SQLite vs. ObjectBox vs. Isar
ente.io
ente.io
We had a test database of 1,000,000 images, each with large amounts (several hundred) of metadata items - EXIF and the like, all of which could be queried. We had a target time of 2 seconds to get any query back. Most of the time, we hit that mark using the raw SQLite database approach.
So this was:
- On far older hardware, we still used spinning rust for $deity sake...
- Seems like much more data
- Seems like much more metadata
And it was still very fast (relatively speaking - a couple of seconds is still a long wait-time, and then there was all the UI overhead on top).
I think the author might be missing a trick or two when running SQLite, as mentioned in their write-ups, we found it faster than the filesystem as a storage medium...
As a long-term Aperture user, I concur with the above sentiments. It was an impressive real-world use of SQLite by Adobe.
Hence I agree with the conclusion that if SQLite is "slow" its usually a PBCAK error.
My bad, apologies.
Although in my defence, Lightroom also uses SQLite as backend, although clearly I'm not sure how extensive it is compared to Apple Aperture.
There isn't anything obviously wrong with how the lib is used - the writes are properly batched in a single transaction, but the performance is much slower than expected.
From a quick look, garbage collection appears to be a big overhead - it could be related to how data is passed between isolates. In this case, it may actually be faster to do writes in smaller batches, since the current approach may use a lot of memory for the batch. I'll have to test further to see whether that helps, or whether there are other optimizations for this case.
With larger batch sizes, memory usage becomes a problem with the current sqlite_async batch implementation. There can probably be improved in the library, but a workaround is to split the writes into smaller batches.
Overall, you should be able to get very similar performance between Isar and SQLite, but there is something to say for Isar providing better performance for the default/obvious way of doing things in this case.
[1]: https://github.com/ente-io/edge-db-benchmarks/blob/43273607d...
Edit: Updated the permalink
Edit: You've changed the link after I wrote this reply. Did you fix the code? Is the blog post using the original code or this updated code?
Most people won't be inserting large amounts in a single transaction. Ex webserver with many concurrent clients inserting.
We are executing the entire write in a single batch query.
From what we observed, and have documented, it is the serialization that is causing the bottleneck, not the write itself.
Also, more than writes, it is the latency associated with deserializing the read data into models that pushed us to look at options outside SQLite.
[1]: https://github.com/ente-io/edge-db-benchmarks/blob/43273607d...
But as an end consumer, the overall throughput while using SQLite as the DB engine was not sufficient to serve our use case of reading 100k embeddings.
Either of those would not be an interesting problem except that your title is calling out a particular and beloved database as being the culprit. Which feels a lot like defamation.
Even if deserialization is the bottleneck, bottlenecks are relative and don’t have to be singular. You can have multiple bottlenecks and being blind to one because of PEBCAK can still skew results and thus conclusions.
The people you’re responding to want you to fix your code and run it again. I think that’s reasonable. And maybe deserves a follow up post or a rework of the existing one.
I have nothing against SQLite, but I unfortunately haven't yet understood how to write and read Lists more efficiently. Once I do, I'll definitely post a follow up :)
TL;DR: Removing Protobuf and directly serializing the list to a blob helped!
Your comment reads a bit like saying you don't like a Toyota car for transporting apples, because it takes too long to bake a pie out of them first.
BTW, if Isar's data format really is "very close to in-memory representation", then why aren't you simply packing the 512 floats into an array (which I guess is how this data is represented anyhow), and use this continuous block of memory directly as the serialized value?
```
[log] SQLite: 100000 embeddings inserted in 2635 ms
[log] SQLite: 100000 embeddings retrieved in 561 ms
```
Isar is still ~2x faster, but there's room for optimization.
Thanks a bunch for sharing this, I will update the post.
[1]: https://github.com/ente-io/edge-db-benchmarks/commit/51ec496...
They even seem to be doubly json encoded at some point:
https://github.com/ente-io/clip-ggml/blob/main/lib/clip_ggml...
In my opinion, they should probably just be a memory buffer representing the raw floats all the way down: from the output of the model to the database. They should never be encoded, neither in json, nor as a dart List<double>.
We'll take another look.
And on the cpp side, remove the json encoding and just return a raw buffer.
``` I/scudo ( 641): Stats: SizeClassAllocator64: 572M mapped (0M rss) in 11986660 allocations; remains 257629 I/scudo ( 641): 00 ( 64): mapped: 1024K popped: 506106 pushed: 491660 inuse: 14446 total: 15044 rss: 0K releases: 0 last released: 0K region: 0x7ceae87000 (0x7ceae86000) I/scudo ( 641): 01 ( 32): mapped: 1024K popped: 92137 pushed: 73047 inuse: 19090 total: 26708 rss: 0K releases: 0 last released: 0K region: 0x7cfae8c000 (0x7cfae86000) ```
I think this is because during the proto encoding/decoding stage the protobuf lib ended up creating a bunch of objects to support the process
``` "Class","Library","Total Instances","Total Size","Total Dart Heap Size","Total External Size","New Space Instances","New Space Size","New Space Dart Heap Size","New Space External Size","Old Space Instances","Old Space Size","Old Space Dart Heap Size","Old Space External Size" _FieldSet,package:protobuf/protobuf.dart,100010,4800480,4800480,0,0,0,0,0,100010,4800480,4800480,0 PbList,package:protobuf/protobuf.dart,108536,3473152,3473152,0,0,0,0,0,108536,3473152,3473152,0 Embedding,package:edge_db_benchmarks/models/embedding.dart,108535,3473120,3473120,0,0,0,0,0,108535,3473120,3473120,0 EmbeddingProto,package:edge_db_benchmarks/models/embedding.pb.dart,100010,1600160,1600160,0,0,0,0,0,100010,1600160,1600160,0 ```
What's missing here is that these have to be copied over to the database isolate as well.
Looking at the library, it seems it's just using prepared statements outside of a transaction...
https://github.com/powersync-ja/sqlite_async.dart/blob/f994e...
Seems the other commenter is correct that wrapping that line in a tx will fix it.
https://github.com/powersync-ja/sqlite_async.dart/blob/f994e...
I think you may be looking too deeply. It does look like that object will wrap in a tx.
If you use one definition while everyone else is using the other, there’s going to be a loud and confused argument.
Since we are talking about databases, we’d all better be using the former, but I suspect maybe the author is not.
What does this have to do with SQLite?
The original link highlighted this snippet:
Future<void> insertEmbedding(EmbeddingProto embedding) async {
final db = await _database;
await db.writeTransaction((tx) async {
await tx.execute('INSERT INTO $tableName ($columnEmbedding) values(?)',
[embedding.writeToBuffer()]);
});
}
Now highlighted: Future<void> insertMultipleEmbeddings(List<EmbeddingProto> embeddings) async {
final db = await _database;
final inputs = embeddings.map((e) => [e.writeToBuffer()]).toList();
await db.executeBatch(
'INSERT INTO $tableName ($columnEmbedding) values(?)', inputs);
}
Did you post the wrong link the first time and corrected your mistake? Or did you change the snippet because @electroly was right about the code being inefficient?[1]: https://github.com/ente-io/edge-db-benchmarks/blob/43273607d...
The code we used for running the benchmarks is available here[1].
If you have suggestions on how we could speed up the deserialization during reads, please do share. We'd be happy to go back to SQLite.
> It was clear that serialization was the culprit, so we switched to Protobuf instead of stringifying the embeddings. This resulted in considerable gains. For 100,000 embeddings, writing took 6.2 seconds, reading 12.6 seconds and the disk space consumed dropped to 440 MB.
Doing what though? What was their schema like? What are their indices? Primary keys? Column types? Query structure?
It's lacking so little information I find this article not useful and not interesting to me. Which is a bit disappointing since I was hoping to learn something relevant.
SELECT $columnEmbedding FROM $tableName LIMIT 10000 OFFSET $offset
They're not using any indexes, because they're doing offset pagination [1] in a loop. And offset pagination is causing them to scan 550K total rows [2] instead of 100K.OP, change your code. You're probably wrong about SQLite being slower than Isar, or at least it must be very close.
[1] https://use-the-index-luke.com/no-offset
[2] 10K + 20K + 30K + 40K + 50K + 60K + 70K + 80K + 90K + 100K = 550K
I was playing around with the offsets incorrectly, while attempting to reduce the memory footprint on a low-end Android device.
I've updated the code[1] to read all embeddings in one shot, and I'm now measuring the time to read and the time to deserialize separately.
On an iPhone simulator running on an Apple M3 Pro, this is what it says:
```
[log] SqliteDB fetch all took: 1572 ms
[log] SqliteDB deserialization took: 4268 ms
...
[log] Isar: 100000 embeddings retrieved in 616 ms
```
[1]: https://github.com/ente-io/edge-db-benchmarks/commit/e02c3d3...
Here you go: https://github.com/ente-io/edge-db-benchmarks/blob/43273607d...
What your article is really saying is, "SQLite isn't a good fit for our data that we didn't want to serialize differently to be efficient with SQLite." And that's fine, it looks like you found the right tool for your data and requirements, but the title is click-baity and isn't really about SQLite.
It's the first time I've heard of Isar[1] though. I'm always surprised at how many solid-but-underused Apache projects are out there, chugging along.
[0] https://phiresky.github.io/blog/2020/sqlite-performance-tuni...
Also, even if it is done right, inserting 100,000 of 512 byte objects will be around 50MiB, which will be the point to trigger checkpointing WAL file into the main db, which can further slow down SQLite.
https://www.vldb.org/pvldb/vol15/p3535-gaffney.pdf
“To accelerate searching the WAL, SQLite creates a WAL index in shared memory. This improves the performance of read transactions, but the use of shared memory requires that all readers must be on the same machine [and OS instance]. Thus, WAL mode does not work on a network filesystem.”
“It is not possible to change the page size after entering WAL mode.”
“In addition, WAL mode comes with the added complexity of checkpoint operations and additional files to store the WAL and the WAL index.”
https://www.sqlite.org/lang_attach.html
“SQLite does not guarantee ACID consistency with ATTACH DATABASE in WAL mode. “Transactions involving multiple attached databases are atomic, assuming that the main database is not ":memory:" and the journal_mode is not WAL. If the main database is ":memory:" or if the journal_mode is WAL, then transactions continue to be atomic within each individual database file. But if the host computer crashes in the middle of a COMMIT where two or more database files are updated, some of those files might get the changes where others might not.”
Still dont think this is sqlite's fault. Serialization is something outside sqlite and a cost you have to pay no matter what you are using.
Within reads the most expensive part was the re-construction of models from the rows returned by SQLite.
While deserialization is not the responsibility of the database, the overall throughput with SQLite was too low for it to serve our usecase.
If this is how you are doing reads, this is your problem. Limit/offset reads are slow.
You'll get much better results if you do a proper pagination implementation.
This article describes the problem and solution that'd give you much better read results.
https://use-the-index-luke.com/sql/partial-results/fetch-nex...
(don't forget page 2 https://use-the-index-luke.com/sql/partial-results/window-fu... )
Removing the offsets has unfortunately not sped things up, since it's the step post reading the entries (deserialization) that is the bottleneck[1].
Since SQLite does not support Lists, Protobufs came across as the best way to serialize the data at hand.
If there's more native way to solve this problem within SQLite, please let me know!
Snippets are available @ https://github.com/ente-io/edge-db-benchmarks/blob/main/lib/...
https://github.com/ente-io/edge-db-benchmarks/blob/main/lib/...
Any reason for that?
https://flutterawesome.com/high-performance-asynchronous-int...
You could store a dense mapping to integers in (1) and then represent (2) as a separate file that is literally just packed floats. Photo number n has its embeddings start at file offset n * 512 * sizeof(float). Updating any given photo's embeddings is an easy seek+write.
Given that the use case of similarity search is to stream through the embeddings and not any complex seeking/rewriting/indexing/etc, feels like a relatively simple bit of code.
The post talks about serializing 100k photos. With 32-bit floats each embedding vector is 4*512=2048 bytes, so that's ~205mb of data, much less than the >500mb quoted in the post.
Definitely disagree, having fast search makes a huge difference. Apple Photo's search is fast. Especially since CLIP embeddings aren't perfect, you may need to try a few different keywords.
> For 100,000 embeddings, writing took 6.2 seconds, reading 12.6 seconds and the disk space consumed dropped to 440 MB.
Something sounds so wrong here, I read/write a 100-500 GiBs of data from Python, and it's much faster than this.
In fact I'm seeing ~1.3s to write, and ~700ms to read from trivial Python:
import pickle
import sqlite3
import numpy as np
IMAGES = 100_000
EMBEDDING_DIMENSIONS = 1024
### write
db = sqlite3.connect("images.db")
db.execute("CREATE TABLE IF NOT EXISTS image_embeddings (id INTEGER PRIMARY KEY, embedding BLOB)")
# generate synthetic embeddings
embeddings = np.random.normal(size=(IMAGES, EMBEDDING_DIMENSIONS)) # 1.61 s
# serialize embeddings for storage
serialized_embeddings = [(pickle.dumps(embedding),) for embedding in embeddings] # 626 ms
# insert into db
db.executemany("INSERT INTO image_embeddings (embedding) VALUES (?)", serialized_embeddings) # 679 ms
db.commit() # 95.3 ms
db.close()
#### read
db = sqlite3.connect("images.db")
serialized_embeddings = db.execute("SELECT embedding FROM image_embeddings").fetchall() # 370 ms
embeddings = [pickle.loads(x[0]) for x in serialized_embeddings] # 322 msKind of a misleading title, TBH.
[1] https://github.com/powersync-ja/sqlite_async.dart
Reference to an HN comment that clarifies this - https://news.ycombinator.com/item?id=39290411
WTF? Are you running a separate transaction per embedding? Using a heavy weight ORM incorrectly? SQLite can handle inserts very, very fast; but if you put lots of layers on top of it, (like an ORM,) they usually slow things down.
> It was clear that serialization was the culprit, so we switched to Protobuf instead of stringifying the embeddings. This resulted in considerable gains.
Why are you serializing and then inserting into a SQL database? You should have columns for your fields, and directly query on those columns.
Anyway, I think I understand why this article is (unfairly) flagged: You are criticizing SQLite, and clearly do not know how to program with a SQL database.
For working with embeddings on desktop, would you recommend using a vector DB? Or can we get away with SQLite (or Isar)?
So your embeddings are an index? That's kind of the point of an index.