perf(zql): reduce key and merge overhead in FlippedJoin batched fetch - #6436
Draft
Karavil wants to merge 2 commits into
Draft
perf(zql): reduce key and merge overhead in FlippedJoin batched fetch#6436Karavil wants to merge 2 commits into
Karavil wants to merge 2 commits into
Conversation
|
@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
force-pushed
the
capy/flipped-join-batch-cost
branch
from
August 28, 2026 17:39
330d9fc to
8541fa2
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 is7fb2c78bb.Problem
FlippedJoin.#fetchBatchedpays three allocations on every row it touches: a tagged canonical key string per child row and per returned parent row, anumber[]of child indexes per distinct key, and a freshidxs.map(...)array per emitted parent.#yieldParentWithOverlaywas a generator invoked once per parent, so each parent also allocated a generator object and paidyield*delegation.Mechanism
Mapon the value itself for numbers, bigints and booleans.Mapalready keys1,1nandtrueapart, so those three need no tag string at all.nullandundefinedstill share one bucket, matching the compound path and the existing tests. Strings and JSON values keep their tags, because JSON values render as strings andMapwould otherwise key them by identity. Compound keys are unchanged.Node[]directly, so the per-parentidxs.map(i => childNodes[i])array disappears. The group is only read by#parentWithOverlay, which copies before mutating (filter/ spread).#yieldParentWithOverlayyielded zero or one node, so it became#parentWithOverlay, a plain method returningNode | 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.rosterAST from the workload behind #6438 -- the same query #6432 commits asROSTER_ASTinzql-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:CPU profile of the same loop (500 µs sampling, 120 iterations):
#fetchBatched#yieldParentWithOverlay→#parentWithOverlayfetchFlippedJoin.fetchinclusiveThat loop still spends ~19% in native
prepareand ~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):flipped-join-keys.bench.tsis 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.fetchdriven directly over the replica, 8 rounds per side, median of per-process medians: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, fourconstraintshapes,startat/after, reverse+start) × 3 chunk sizes (64, 256, and one larger than the key set, so both the single-fetch and themergeSortedStreamschunked 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.tspins the key itself, andzqlite/src/flipped-join-json-key.test.tspins the end-to-end join -- ajsonjoin 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 becausecompareValuescannot 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, includingFlip-invariance -- every flip plan of an EXISTS query hydrate-equal over miniand 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.
#parentWithOverlayonly 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
buildJoinConstraintobject for duplicate parent keys (0.0% on the duplicate-key shape).Rerun
The repo benches, including the new key-shape bench, are committed:
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.