Skip to content

perf(zql): reduce key and merge overhead in FlippedJoin batched fetch - #6436

Draft
Karavil wants to merge 2 commits into
rocicorp:mainfrom
goblinshq:capy/flipped-join-batch-cost
Draft

perf(zql): reduce key and merge overhead in FlippedJoin batched fetch#6436
Karavil wants to merge 2 commits into
rocicorp:mainfrom
goblinshq:capy/flipped-join-batch-cost

Conversation

@Karavil

@Karavil Karavil commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

One allocation-focused change to packages/zql/src/ivm/flipped-join.ts, plus one new benchmark file. Behavior-preserving on every shape the suites and the equality harness cover. Base is 7fb2c78bb.

Problem

FlippedJoin.#fetchBatched pays three allocations on every row it touches: a tagged canonical key string per child row and per returned parent row, a number[] of child indexes per distinct key, and a fresh idxs.map(...) array per emitted parent. #yieldParentWithOverlay was a generator invoked once per parent, so each parent also allocated a generator object and paid yield* delegation.

Mechanism

  • Single-column joins -- the common shape -- key the dedupe/lookup Map on the value itself for numbers, bigints and booleans. Map already keys 1, 1n and true apart, so those three need no tag string at all. null and undefined still share one bucket, matching the compound path and the existing tests. Strings and JSON values keep their tags, because JSON values render as strings and Map would otherwise key them by identity. Compound keys are unchanged.
  • The map holds the grouped child Node[] directly, so the per-parent idxs.map(i => childNodes[i]) array disappears. The group is only read by #parentWithOverlay, which copies before mutating (filter / spread).
  • #yieldParentWithOverlay yielded zero or one node, so it became #parentWithOverlay, a plain method returning Node | undefined.

Numbers

Machine-local, warm, same process shape on both sides; every comparison is interleaved A/B with the file swapped between runs. The ratios are the portable result.

Roster specimen (primary)

The post-auth assignment.roster AST from the workload behind #6438 -- the same query #6432 commits as ROSTER_AST in zql-benchmarks -- hydrated warm against a replica initial-synced from that workload's fixture: 2 warmups, 120 measured iterations, 1,118 rows per hydration, 10 processes per side, run once in each A/B order:

Order base median-of-medians opt median-of-medians Delta
base first 33.23 ms 32.42 ms −2.4%
opt first 33.55 ms 32.35 ms −3.7%

CPU profile of the same loop (500 µs sampling, 120 iterations):

Frame (self time) base opt
#fetchBatched 3.2% 1.7%
#yieldParentWithOverlay#parentWithOverlay 1.5% 1.2%
fetch 1.0% 0.8%
FlippedJoin JS body total 5.7% 3.7%
FlippedJoin.fetch inclusive 30.3% 28.4%

That loop still spends ~19% in native prepare and ~33% in the planner, so the 2.0 points removed from FlippedJoin's own body are a larger share of what remains once planner caching (#6432) lands.

Tracker specimen (secondary -- dominated by other work)

The workload's tracker query: no end-to-end delta outside noise (medians 270/275 ms base vs 275/270 ms opt across four profiled processes). The profile shows the code path itself got cheaper -- FlippedJoin JS self time 2.8% → 2.2% -- but the tracker loop is dominated by a per-tracker authorization N+1 under the join (which #6434 addresses separately), so 0.6 points is not measurable end to end. This specimen is not evidence for or against the change.

Repo benchmarks

pnpm --filter zql-benchmarks bench, 5 rounds per side, comparing the fastest iteration of each case (bench medians are noisy at 5–25 samples):

Case base min opt min Delta
keys: 1000 unique string keys 5.403 ms 5.019 ms −7.1%
keys: 50 string keys × 20 children 4.976 ms 4.547 ms −8.6%
keys: 1000 compound (string,string) 7.131 ms 7.150 ms +0.3%
batching: 1,000 / 2,500 / 5,000 / 10,000 rows -- -- −1.7% … −5.6%
merge: 100 … 10,000 rows -- -- −3.8% … −10.7%

flipped-join-keys.bench.ts is new in this branch: the two existing flipped-join benches only cover integer keys at a 1:1 ratio, so neither exercises string keys, duplicate parent keys, or the compound-key path.

Targeted microbench (join isolated, same replica)

FlippedJoin.fetch driven directly over the replica, 8 rounds per side, median of per-process medians:

Shape base opt Delta
tracker × conversation, 973 unique string keys 6.70 ms 6.34 ms −5.5%
tracker × assessment, 957 unique string keys 5.45 ms 5.36 ms −1.5% (min −5.3%)
tracker × conversation by student, 58 keys / 973 children 5.01 ms 4.66 ms −6.9%
tracker × conversation, compound (student_id,id) 7.58 ms 7.47 ms −1.4%
in-memory sources, 973 unique string keys (no SQLite) 3.61 ms 3.19 ms −11.7%
in-memory sources, 58 keys / 973 children (no SQLite) 0.42 ms 0.30 ms −29.6%

Equality evidence

A harness serialized every fetched row, in order, with each relationship expanded, over 5 join shapes (single string key, unique and duplicate; compound key; numeric key) × 8 request variants (plain, reverse, four constraint shapes, start at/after, reverse+start) × 3 chunk sizes (64, 256, and one larger than the key set, so both the single-fetch and the mergeSortedStreams chunked path are covered). Output: 537,144 lines / 97,438,814 bytes, sha256 7eb190909c104b1fe6d9ccc1e59f63e64402277a2d1e0f29744435d9010a3f1a, identical before and after.

The join key stays type-discriminating in both directions: a value's key never crosses into another runtime type's bucket, and the string tag is what keeps a plain 'j{"a":1}' out of the bucket the object {a: 1} renders into. flipped-join.chunked.test.ts pins the key itself, and zqlite/src/flipped-join-json-key.test.ts pins the end-to-end join -- a json join column holding both values joins each child to its own parent, on the fetch path and after a push. That fixture lives on the SQLite source because compareValues cannot order a json column that holds both an object and a string, so MemorySource cannot host it.

Suites, all unmodified and green: pnpm --filter zql test (1,317 passed), pnpm --filter zqlite-zql-test test (1,317 passed), pnpm --filter zqlite test (195 passed), pnpm --filter zql-integration-tests test (1,165 passed, including Flip-invariance -- every flip plan of an EXISTS query hydrate-equal over mini and the random-yield push/hydrate parity fuzzers), pnpm --filter zql check-types, pnpm --filter zero-cache check-types, pnpm --filter zql-benchmarks check-types, lint and format clean.

Risk

The behavior worth naming in review: the grouped child array is now shared by every parent row with the same key instead of being rebuilt per parent. #parentWithOverlay only reads it and copies before mutating, and the relationship closure already returned the same array on repeated calls, but a downstream consumer that mutated a relationship stream in place would now be visible across parents. Nothing in the tree does.

Ideas measured and dropped for being noise: skipping the buildJoinConstraint object for duplicate parent keys (0.0% on the duplicate-key shape).

Rerun

The repo benches, including the new key-shape bench, are committed:

pnpm --filter zql-benchmarks bench
pnpm --filter zql-benchmarks exec vitest run --config vitest.config.bench.ts \
  src/flipped-join-keys.bench.ts
pnpm --filter zql test && pnpm --filter zqlite-zql-test test

The specimen A/B driver and the golden-equality harness are scratch scripts and are not committed; their methods (interleaved A/B with the source file swapped, and the full-cross-product row serialization hash) are described above so the numbers carry their context. The replica they ran against is reproducible from #6438's seed fixture.

@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

@Karavil is attempting to deploy a commit to the Rocicorp Team on Vercel.

A member of the Team first needs to authorize it.

Problem: `#fetchBatched` allocates on every row it touches. Each child row
and each returned parent row builds a tagged canonical key string, each
distinct key keeps a `number[]` of child indexes, and every emitted parent
rebuilds its related-children array through `idxs.map(...)`.
`#yieldParentWithOverlay` was a generator producing zero or one node, so
each parent also allocated a generator and paid `yield*` delegation.

Mechanism: single-column joins now key the dedupe/lookup `Map` on the value
itself, because `Map` already keys `1`, `'1'`, `1n` and `true` apart. `null`
and `undefined` still share one bucket, matching the compound path, and JSON
values keep the tagged string since `Map` would otherwise key them by
identity. Compound keys are untouched. The map holds the grouped child
`Node[]` directly, so the per-parent array is gone, and the overlay helper
became a plain method returning `Node | undefined`.

Numbers, all interleaved A/B with the file swapped between runs. On the
profiler's exact post-auth `assignment.roster` AST (1,118 rows,
`hydrateInternal` loop, 10 processes per side, run in both orders) the loop
drops 2.4% and 3.7%; its CPU profile puts FlippedJoin's own JS self time at
5.7% before and 3.7% after, with `#fetchBatched` self at 3.2% -> 1.7% and
`FlippedJoin.fetch` inclusive at 30.3% -> 28.4%. Repo benches improve 1.7%
to 10.7% on the fastest iteration of every case except compound keys, which
are unchanged by design. A microbench driving the join directly over the
seeded replica shows -5.5% on 973 unique string keys and -6.9% on 58
duplicate keys; with in-memory sources, where no SQLite work dilutes the
result, -11.7% and -29.6%.

Equality: a 97 MB, 537k-line dump of fetch output (rows, order, expanded
relationships) over 5 join shapes x 8 request variants x 3 chunk sizes
hashes identically before and after
(sha256 7eb190909c104b1fe6d9ccc1e59f63e64402277a2d1e0f29744435d9010a3f1a).
`zql`, `zqlite-zql-test` and `zql-integration-tests` pass unmodified,
including the flip-invariance and random-yield parity fuzzers.

Risk: the grouped child array is now shared by every parent with the same
key rather than rebuilt per parent. The overlay helper only reads it and
copies before mutating, and the relationship closure already returned the
same array across calls, but a consumer that mutated a relationship stream
in place would now see that across parents. Nothing in the tree does.

`flipped-join-keys.bench.ts` is new: the two existing flipped-join benches
only cover integer keys at a 1:1 ratio, so nothing exercised string keys,
duplicate parent keys, or the compound-key path.
@Karavil
Karavil force-pushed the capy/flipped-join-batch-cost branch from 330d9fc to 8541fa2 Compare August 28, 2026 17:39
…keys

Dropping the type tag on the single-column key path is safe for `1`, `1n`
and `true`, because a `Map` already keys those apart from each other and
from every string. It is not safe for strings. JSON values still render as
`'j' + JSON.stringify(v)`, and a `json` column can hold a plain string, so a
child row whose join value is the literal `'j{"a":1}'` shared a bucket with
one whose value is the object `{a: 1}`. The two children group together,
the dedupe drops one of the two multi-constraint entries, and the parent
that entry would have matched disappears from the fetch entirely.

Strings key as `'s' + v` again. Numbers, bigints, booleans and null still
key raw, which is where the win comes from -- none of them can collide with
a string in a `Map`. Compound keys are untouched.

`flipped-join.chunked.test.ts` pins the key itself, beside the existing
collision cases. `zqlite/src/flipped-join-json-key.test.ts` pins the join: a
`json` column holding both `{a: 1}` and `'j{"a":1}'` joins each child to its
own parent, on the fetch path and after a push. That fixture lives on the
SQLite source because `compareValues` cannot order a json column holding
both an object and a string, so MemorySource cannot host it. Against the
previous commit both fail -- the parent holding the string vanishes and its
children are grouped under the parent holding the object.

Cost, measured in isolation over 3,000 alternating rounds of 1,000 keys:
9.0 ns per string key, against 7.1 ns untagged and 9.3 ns for the base's
`canonicalValue` call. Number keys are unchanged at 6.3 ns against 6.0 ns,
and 13.4 ns on base. The repo benches do not move: interleaved A/B with the
file swapped, five rounds per side, fastest iteration of each case.

| case                                | untagged | tagged   | delta |
| ----------------------------------- | -------: | -------: | ----: |
| keys: 1000 unique string keys       | 5.128 ms | 5.127 ms | -0.0% |
| keys: 50 string keys x 20 children  | 4.559 ms | 4.556 ms | -0.1% |
| keys: 1000 compound (string,string) | 7.125 ms | 6.983 ms | -2.0% |
| batching: 100 .. 10,000 rows        |       -- |       -- | +2.2% .. -1.4% |
| merge: 100 .. 10,000 rows           |       -- |       -- | +0.2% .. -1.2% |

The string shapes' win in this branch comes from dropping the per-parent
`idxs.map(...)` array and the per-parent generator, not from the key, so
restoring the tag gives up none of it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant