|
| 1 | +# Benchmarks |
| 2 | + |
| 3 | +`Sources/ZeroBenchmarks` has ports of the JS client's benchmarks, under the |
| 4 | +same names, and benchmarks of the Swift client's own: |
| 5 | + |
| 6 | +```sh |
| 7 | +cd swift |
| 8 | +swift run -c release ZeroBenchmarks # everything, about a minute |
| 9 | +swift run -c release ZeroBenchmarks hydration "pokes > put 100" --time 3 --json out.json |
| 10 | +``` |
| 11 | + |
| 12 | +A filter is a substring of `group > benchmark`. Each benchmark runs for |
| 13 | +`--time` seconds (default 1) after a warm-up and reports the median time per |
| 14 | +call with the 75th and 99th percentiles, as `packages/shared/src/bench.ts` |
| 15 | +does. |
| 16 | + |
| 17 | +The JS sides: |
| 18 | + |
| 19 | +| Swift group | JS | |
| 20 | +| ------------------------------------- | ------------------------------------------------------------------------------------------- | |
| 21 | +| `zero.bench.ts` | `pnpm --filter zero-client bench` | |
| 22 | +| `btree-set.bench.ts`, `hash.bench.ts` | `pnpm --filter shared bench src/btree-set.bench.ts src/hash.bench.ts` | |
| 23 | +| `memory`, `hydration`, `mutations` | `pnpm --filter zero-conformance bench` (`src/bench/client.bench.ts`) | |
| 24 | +| `pokes`, the round trip | none: the JS client applies pokes on the next frame, so a JS timing measures the scheduling | |
| 25 | + |
| 26 | +The benchmarks don't run in CI; numbers from shared runners vary too much to |
| 27 | +gate on. CI builds them, so they keep compiling. |
| 28 | + |
| 29 | +## Results |
| 30 | + |
| 31 | +Linux, one machine (8-core Intel Xeon at 2.6 GHz), Swift 6.2.4 in release |
| 32 | +mode, Node 22.23. Medians; lower is better. |
| 33 | + |
| 34 | +### The client |
| 35 | + |
| 36 | +| Benchmark | JS | Swift | |
| 37 | +| ------------------------------------------ | ------- | ------- | |
| 38 | +| zero.bench: all 1000 rows x 10 columns | 263 µs | 220 µs | |
| 39 | +| zero.bench: pk = N | 7.72 µs | 7.68 µs | |
| 40 | +| zero.bench: lower 500 rows | 261 µs | 213 µs | |
| 41 | +| materialize 1,000 rows | 282 µs | 135 µs | |
| 42 | +| materialize 10,000 rows | 2.70 ms | 1.69 ms | |
| 43 | +| materialize 100,000 rows | 43.2 ms | 23.2 ms | |
| 44 | +| memory for 100,000 rows and a view of them | 164 MB | 98 MB | |
| 45 | +| optimistic update with a 1000-row view | 178 µs | 21 µs | |
| 46 | + |
| 47 | +Swift only: |
| 48 | + |
| 49 | +| Benchmark | Swift | |
| 50 | +| --------------------------------------------------------- | ------- | |
| 51 | +| materialize 1,000 rows as structs | 552 µs | |
| 52 | +| materialize 10,000 rows as structs | 6.19 ms | |
| 53 | +| materialize 100,000 rows as structs | 85.5 ms | |
| 54 | +| optimistic update with a 1000-row live query of structs | 92 µs | |
| 55 | +| poke putting 1 row, 1000-row view | 28 µs | |
| 56 | +| poke putting 100 rows, 1000-row view | 1.08 ms | |
| 57 | +| poke putting 1000 rows, 1000-row view | 10.7 ms | |
| 58 | +| round trip (update, push, confirming poke), 1000-row view | 79 µs | |
| 59 | + |
| 60 | +JS views hand out their rows; Swift apps read them as structs, which costs |
| 61 | +about four times the materialization. A live query reads only the rows |
| 62 | +that changed after the first read. |
| 63 | + |
| 64 | +### Data structures |
| 65 | + |
| 66 | +| Benchmark | JS | Swift | |
| 67 | +| -------------------------------------- | --------------------------- | ------------------------ | |
| 68 | +| BTreeSet values() full scan (1000) | 3.60 µs | 2.67 µs | |
| 69 | +| BTreeSet valuesFrom() from mid | 2.35 µs | 1.40 µs | |
| 70 | +| BTreeSet valuesReversed() full scan | 4.29 µs | 0.92 µs | |
| 71 | +| BTreeSet valuesFromReversed() from mid | 2.55 µs | 0.59 µs | |
| 72 | +| BTreeSet has() hit (x3) | 67 ns | 199 ns | |
| 73 | +| BTreeSet has() miss (x3) | 63 ns | 91 ns | |
| 74 | +| BTreeSet get() hit (x3) | 70 ns | 193 ns | |
| 75 | +| BTreeSet add() then delete() | 109 ns | 160 ns | |
| 76 | +| BTreeSet add() 100 sequential keys | 6.29 µs | 4.62 µs | |
| 77 | +| BTreeSet add() 1000 sequential keys | 63.6 µs | 71.5 µs | |
| 78 | +| BTreeSet sort, then add() 1000 keys | 104 µs | 42 µs | |
| 79 | +| h32, 64 / 1024 / 8192 chars | 290 ns / 2.78 µs / 23.7 µs | 23 ns / 168 ns / 1.26 µs | |
| 80 | +| h64, 64 / 1024 / 8192 chars | 552 ns / 5.46 µs / 41.3 µs | 35 ns / 318 ns / 2.45 µs | |
| 81 | +| h128, 64 / 1024 / 8192 chars | 1.02 µs / 10.2 µs / 82.0 µs | 54 ns / 628 ns / 5.0 µs | |
| 82 | + |
| 83 | +As `hash.bench.ts` notes, vitest keeps imported functions from being |
| 84 | +inlined, so its JS hash numbers are pessimistic; bundled, they are a few |
| 85 | +times faster. |
| 86 | + |
| 87 | +## What they found |
| 88 | + |
| 89 | +- The mutation tracker looked mutations up by scanning a list, so each |
| 90 | + mutation cost more than the last while mutations were pending (229 µs on |
| 91 | + average over a one-second run, now 21 µs). |
| 92 | +- A live query read every row again on every change (now only the changed |
| 93 | + ones: 92 µs instead of about 640 µs for 1000 rows). |
| 94 | +- B-tree nodes paid for dynamic exclusivity checks on every read, and the |
| 95 | + forward iterator read its leaf's keys through the node for every element |
| 96 | + (a full scan of 1000 took 23 µs, now 2.7 µs). Views and sources iterate |
| 97 | + B-trees, so materializing got faster too. |
| 98 | +- Hydrating a view took its row list out of the view and put it back for |
| 99 | + every row. |
| 100 | +- Comparing strings in UTF-8 order walked them with iterators rather than |
| 101 | + their bytes. |
| 102 | + |
| 103 | +Where Swift is still slower: B-tree lookups call their comparator through |
| 104 | +a closure, which V8 inlines. And a row is a `[String: JSONValue]`, so |
| 105 | +reading a column hashes its name: comparing rows by key and reading rows as |
| 106 | +structs mostly hash. Rows keyed by column index would remove most of that. |
0 commit comments