Recently Xu Yang (aka Fatbobman, iOS Developer & Blogger) published an article called "SwiftData: Optimization Starts with Modeling". In this article he shared data gathered using a benchmark he wrote. Naturally I got curious how Vein would perform in the same benchmark. He was so nice to share it with me.
I recommend reading his article first, but it's not necessary to understand mine.
What all this is about:
Once a dataset grows, SwiftData populated Lists start to stutter. This is only partially due SwiftUI. The enormous memory footprint as well as initial fetch times matter a lot. SwiftData populates every field on creation of the model instance with no way to only load certain ones on demand.
A common way to write models:
@Model
final class Note {
var title: String
var createdAt: Date
var isPinned: Bool
var body: String // only needed in a detail view
var attachment: Data // only needed in a detail view
}
Model from Xu Yang's article
How to solve that in SwiftData?
As he outlined in his article, relationships are lazy loaded. So you can solve this in SwiftData, by splitting up the models, moving large fields to separate models.
@Model
final class Note {
var title: String
var createdAt: Date
var isPinned: Bool
var preview: String? // summary for the list, deliberately duplicated
var body: NoteBody?
var attachment: NoteAttachment?
}
@Model
final class NoteBody {
var text: String
var note: Note?
}
@Model
final class NoteAttachment {
var data: Data // thumbnail
var original: AttachmentOriginal?
var note: Note?
}
@Model
final class AttachmentOriginal {
@Attribute(.externalStorage)
var data: Data // full-size image
var attachment: NoteAttachment?
}
Split up Model from Xu Yang's article
How about Vein?
Vein is designed to be similar to SwiftData. I really like how easy it is to use. But there are issues, like the one I just described. Vein gives you more control - if you want it. By default Vein behaves very similar to SwiftData: every field is eager loaded, relationships are lazy. You don't even need to have encountered an issue with this during your own use, it's pretty logical that loading large chunks of data that you don't even need a lot of the time is generally not the best idea. Doing it correctly is actually very easy in Vein. You just slap @LazyField on a property you only want to load on access, and it happens.
@Model
final class Note {
var title: String
var createdAt: Date
var isPinned: Bool
@LazyField
var body: String // only needed in a detail view
@LazyField
var attachment: Data // only needed in a detail view
}
The numbers
The benchmark creates 3000 records:
- title: ~48 bytes
- body: 8KB
- attachment: 32KB
Each run uses a fresh model with cleared in memory cache. Seeding is not measured. I will mostly not go into memory usage as I only have data about the deltas of memory usage, not the isolated operations and the delta is never negative. All numbers are from the same run on my M3 Pro and should only be compared to each other, not Xu Yang's results due to potentially different testing machines. These numbers are the result of only one test run and therefore shouldn't be taken as absolutes. They only serve to demonstrate that we achieve our goal.
For Vein commit d0ba408 was used.
Fetching all rows, touching nothing:
Framework | Model Version | Duration |
|---|---|---|
SwiftData | original | 0.312s |
Vein | original | 0.110s |
Vein | with | 0.070s |
Fetching all rows, touching only title:
Framework | Model Version | Duration |
|---|---|---|
SwiftData | original | 0.326s |
SwiftData | split up | 0.035s |
Vein | original | 0.100s |
Vein | with | 0.070s |
Vein | split up | 0.065s |
We can clearly see both LazyField and splitting up doing what we intended: improving performance (and memory footprint is significantly smaller, by over 100MB for both SwiftData and Vein on the optimized versions).
Fetching all rows, touching everything:
Framework | Model Version | Duration |
|---|---|---|
SwiftData | original | 0.559s |
SwiftData | split up | 0.472s |
Vein | original | 0.130s |
Vein | with | 0.336s |
Vein | split up* | 0.321s |
I'm a bit irritated by SwiftData's original model being measurably slower than the optimized one here while Vein's optimized one gets slower. Vein's @LazyField solution being slow here is a classic N+1 query problem, or more accurately 2N+1 in this case, as each lazy field triggers its own fetch and there are two of them per row. While @LazyField saves time during initial loads, accessing every property sequentially forces the engine to make a round-trip to the db for every lazy field in every row. And one of Vein's biggest features is transparency. If I add predictive pre-fetching at some point, it will be explicitly opt in.
Conclusion
Optimizing is worth it. Vein provides a second, very easy, straightforward option to optimize using a builtin, intended way, instead of what feels more like a workaround with SwiftData.
What I take from this for Vein
Adding @LazyField was the right decision and it works as intended, saving RAM and time when larger data isn't needed.
Baseline performance is already really good, way better than I expected and SwiftData is somehow significantly slower than Vein pre optimization. I'm very happy with this result. Either the Observation framework adds a huge amount of overhead on SwiftData, or my serialization is just plain a lot simpler.
The optimized Vein models not improving by as much as SwiftData makes me think there is a baseline amount of time spent on something that didn't go away by improving the model. I will put profiling this onto my todo list for 1.2.
Vein being so surprisingly fast when eager loading everything tells me that there should be a way to eager load @LazyField declared properties via a parameter on a fetch. That way you could keep the benefits for the UI without making potentially still existing cases where you need them on every row slower (no N+1, yay).
Thanks again to Xu Yang for providing the benchmark and encouraging me to write an article about it.
If you're interested in Vein please check it out and maybe leave a star: Amethyst Vein
You can find the full benchmark results here: SwiftData | Vein | Combined
Until next time ~ Mia