Large music libraries have a quiet failure mode: the first pages scroll fine, but jump to a letter deep in the index, page far into the list, or run a global search, and the spinner appears. We pushed Pure Music Player onto a 500,500-song test library, timed every read path, and then built a new acceleration layer for Pro users: a high-performance read mirror.
📌 Availability note: Version 6.0 already shipped a round of performance work, but under this benchmark we still weren’t satisfied — which is what led to the acceleration layer described here. This feature targets 7.0 and is currently in internal testing; it is not yet in the shipping release, and all numbers come from internal builds, offered as a preview.
The mechanism itself is simple. We keep a read-only derived copy of your library in local GRDB/SQLite, precomputing at build time the things read paths recalculate constantly: sort keys, filter values, A-Z anchor letters. SwiftData remains the single source of truth; the mirror serves reads only and can be deleted and rebuilt wholesale.
📊 Measured numbers
Same M4 Mac mini, same dataset, and every figure below passed field-by-field parity assertions against the original path:
| Read path | Scale | SwiftData | Mirror | Speedup |
|---|---|---|---|---|
| Song list snapshot build | 10k | 3030 ms | 21 ms | ~143× |
| List paging (filtered) | 10k | 1984 ms | 8.2 ms | ~242× |
| A-Z anchor jump | 10k | ~2050 ms | 0.4–7.6 ms | 274–5100× |
| Deep page (offset 450k) | 500k | ~28 min/page | ~0.9 s | ~1800× |
| “Any visible rows?” zero-hit scan | 100k | 98.1 s | 46 ms | ~2100× |
| Smart-playlist multi-condition candidates | 100k | 6.9 s | 1.27 s | ~5.4× |
| Playlist page / artist drill-down / first paint | 50k | – | – | 4.5–7.3× |
The 500k deep-page number deserves a closer look. The original implementation is offset-insensitive: every page pays a near half-hour full scan. Fetching one page around song 400,000 took ~1668 seconds at baseline. The mirror brings the same page to 0.8–1.4 seconds, at roughly the same price shallow or deep.
🏷 Sort tags: toggle without rebuilding
Classical and jazz listeners often enable sort tags, filing “The Beatles” under “Beatles, The”. The mirror materializes two sets of sort keys for each of the five tag-aware fields, so toggling sort tags just picks a different column at query time. No index rebuild, and the A-Z bar follows along.
The album, artist, and composer aggregate pages are accelerated too, with one exception: when sort tags are active, those three pages fall back to the original path, because tag values live on entity records that aggregate rows can’t reproduce. The results stay identical; those pages simply lose the speedup for now. Write-side aggregate tables are on the roadmap.
⚖️ The costs, stated plainly
Acceleration isn’t free. Three bills:
- First-time index build: measured at about 32 minutes for a 500k library, as a background task with lists usable in standard mode throughout. A 50k library builds in ~35 seconds.
- Storage: the 500k mirror takes about 2.1GB. The settings page discloses the real footprint; turning High-Performance Mode off keeps the index for instant re-enable, and you can purge it manually.
- Write overhead: saving song metadata updates the mirror in step, about +20ms per committed page in our measurements.
So the feature ships off by default, enabled manually in Settings. Small libraries, or anyone who doesn’t need instant responses, lose nothing by leaving it off.
🔍 Why it’s fast
- No entity materialization. The original path runs every row through the SwiftData object lifecycle; the mirror reads raw SQLite rows and returns lightweight values.
- Predicates pushed down. Filtering, sorting, and deduplication happen inside SQL, instead of hauling whole tables into memory to filter them there.
- Precomputed sort keys. Normalized sort keys and A-Z section letters are computed once at build time, so queries are index scans plus keyset pagination, and deep pages no longer mean counting out the first N rows.
🧩 The hard parts
Building it was easy. Building it correctly was not. A mirror only counts if it matches the original implementation field-for-field, so we gate everything on a parity harness: 19 sort columns × ascending/descending × sort tags on/off × every filter, asserting identical song sequences. That harness caught several real bugs:
- The whitespace trap. The sort-tag rule is “use the tag if present, fall back to the display name.” Swift’s
??falls back only on nil; SQLCOALESCEswallows blank strings too. Translate directly and blank tags silently diverge. We ended up materializing the tag key and the display key as separate columns, choosing explicitly at query time instead of letting SQL decide. - “Duration” is text. The library sorts duration by string comparison: “120.0” sorts before “90.0” lexicographically. A numeric index alone would break parity, so text columns get their own materialized text keys and only true numeric columns get numeric indexes.
- Even “empty” had to be aligned. Swift’s
whitespacesAndNewlinesand SQLtrim()use different character sets (\t,\n, full-width spaces), so emptiness checks diverged by one byte. We added a set of write-side emptiness flags to make both sides agree bit-for-bit. - Superlinear build at 500k. A full build took a measured 32 minutes, 5.5× slower than linear extrapolation, mostly because the FTS5 full-text triggers write two indexes per row. We disclose “the first build takes tens of minutes” and made it a resumable, chunked background task rather than hiding the cost.
- Out-of-order write arbitration. While the index rebuilds, the user may still be editing metadata: a late-arriving build row must never overwrite a newer online write. Every write carries a monotonic sequence number, and an
ON CONFLICTclause applies “newest wins” updates.
All numbers are Debug-build measurements; Release should be better. The full report, methodology and raw logs included, ships with the codebase.
