bench(zero-cache): N-group wave-replay harness and scaling baseline - #6438
Draft
Karavil wants to merge 3 commits into
Draft
bench(zero-cache): N-group wave-replay harness and scaling baseline#6438Karavil wants to merge 3 commits into
Karavil wants to merge 3 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. |
…ot path Rebase the diagnosis instrumentation onto current main so the concurrent client-group baseline can be re-measured on this commit. This is measurement scaffolding, not a behavior change: every addition is a `performance.now()` delta and an `lc.info` line, and nothing on the request path is reordered. Current main already reports `zero.sync.view_syncer_hydration` and `zero.sync.lock-wait-time`, but neither can answer the question the diagnosis needed answered. Hydration covers transform through `pokeEnd` as one number with no group or query attribution, and lock-wait records how long a caller waited to acquire the client-group lock rather than how long the lock stayed held while its work was descheduled. Without splitting the wave into stages you cannot tell an expensive query on an idle worker from an ordinary query queued behind sixteen peers, which is exactly the distinction the scaling law rests on. The probes emit four `zeroEvent` payloads, keyed by `clientGroupID` so the harness can join them to a client sample: * `diagnosis-view-syncer-lock-acquired` / `-lock-released` wrap `#runInLockWithCVR` and carry both `lockWaitMs` and `lockWorkMs`. The released probe fires in the `finally` block, before auth-maintenance scheduling and the actual unlock, so it slightly understates hold time. * `diagnosis-config-update-stages` splits `#updateCVRConfig` into bookkeeping, CVR flush, config poke, and the query-sync branch. * `diagnosis-query-sync-stages` reports transform time accumulated across every custom query in `#syncQueryPipelineSet`, plus the CVR/custom/add/remove counts. * `diagnosis-query-wave-stages` splits `#addAndRemoveQueries` into prepare, `TimeSliceTimer` processing, hydration and bookkeeping, unreferenced-row deletion, CVR flush, client catch-up, and `pokeEnd`, alongside the wave's wall time and query count. The separation between `queryProcessingCpuMs` and `totalMs` is the whole point: `TimeSliceTimer` stops while a ViewSyncer yields to the shared time-slice queue, so the first approximates CPU spent on this group and the second includes the scheduler wait. Their ratio is what makes queueing visible. `pnpm --filter zero-cache check-types` passes and `git diff --check` is clean.
…harness Optimization branches aimed at the sync worker have nothing to prove themselves against. The diagnosis that established the scaling law ran from a scratch directory on one machine, so every later branch would either re-derive the workload or compare against numbers nobody can regenerate. This lands the harness, the fixture, and a measured baseline in the tree so a before/after is a command instead of an argument. ## What it measures N fresh client groups each register the goblins educator assignment page's six desired roots in one same-tick batch, against one sync worker. A fresh client group also hydrates Zero's internal `lmids` and `mutationResults`, so the server sees eight pipeline additions per six-root client wave. The result is a scaling law, not a time. Query-wave wall clock grows near linearly with concurrent groups while the CPU-ish per-group query time stays flat, because every ViewSyncer on a worker shares one cooperative time-slice queue and `TimeSliceTimer` stops while a ViewSyncer yields. Branches should compare ratio columns; the milliseconds belong to whichever machine produced them. ## Measured on this commit 3 runs per N, pooled median over all client samples, 8 vCPU: | N | settle | query wave | per-group cpu | wave / N=1 | diagnosis wave / N=1 | |---:|---:|---:|---:|---:|---:| | 1 | 998 ms | 496.8 ms | 409.9 ms | 1.00x | 1.00x | | 2 | 1,448 ms | 882.4 ms | 380.0 ms | 1.78x | 1.75x | | 4 | 2,504 ms | 1,653.1 ms | 373.2 ms | 3.33x | 3.40x | | 8 | 5,076 ms | 3,735.5 ms | 395.4 ms | 7.52x | 6.86x | | 16 | 9,592 ms | 7,362.1 ms | 395.3 ms | 14.82x | 14.76x | Per-group CPU varies +4.9% to -4.5% around its mean across a 16x load increase. Row counts match the diagnosis exactly (roster 1,118, with_problems 53, trackers 7,768), and `problem_trackers.for_assignment` carries the same `transformationHash` `216mzlo8m5qcv`. One divergence is recorded in `results/baseline-2026-08-28.md`: the tracker graph costs 2.4x less here than in the diagnosis, moving its share of per-group processing from ~73% to ~53%, while the other five roots reproduce within 26%. Two data-shape explanations were tested against the diagnosis replica and ruled out. The diagnosis commit `ac5eb231` is not in this mirror, so the remaining upstream delta cannot be A/B tested yet. ## Shape of the harness * `client/zero-ordered-replay.ts` is one client group. It imports `@goblins/zero`, so `run-replay-concurrency.ts` copies it into the goblins checkout before spawning it; bun resolves workspace packages by walking up from the script's own directory. * `run-replay-concurrency.ts` starts N clients as separate processes, so each gets its own auth token, its own in-memory kv store, and therefore its own client group. It asserts wave integrity on every client: all six roots must complete within 1 ms of each other, because one group poke commits the wave and no root becomes visible before `pokers.end(finalVersion)`. A wider spread means the run is not exercising single-poke semantics, so it exits non-zero rather than reporting a number that looks fine. All 93 samples in the recorded baseline had a 0 ms spread. * `run-matrix.ts` drives the matrix and then the two aggregation scripts. * `seed/apply-seed.sh` applies the 3,365-statement idempotent fixture and mints one session row per concurrent client. The driver sends `emu-session-token-NNN` as its Zero auth token and zero-cache forwards it as a bearer token, so concurrent groups need distinct tokens. An unmatched token fails quietly: the transform endpoint answers 200 with a null userID and the connection is then rejected for a userID mismatch, leaving every root to time out with zero rows. The fixture is entirely synthetic: `emu-*` identifiers, `Emulation Student NNN` display names, and `@goblinsapp.com` addresses. No production data. `bench/wave-replay` is excluded from the zero-cache tsconfig because the drivers are bun scripts, and typechecking them would pull `@types/bun` into a package with no other reason to want it. ## Reruns `README.md` carries the full recipe. `ZERO_NUM_SYNC_WORKERS=1` is the load-bearing setting: normal multiprocess mode with one syncer child, not `SINGLE_PROCESS=1`, so the dispatcher's `taskID/clientGroupID` hash lands every group on the same worker and the same time-slice queue.
The baseline left one loose end: the tracker graph cost 2.4x less here than in the diagnosis while the other five roots reproduced, and the results file attributed the gap to an untestable upstream code delta because the diagnosis commit was not in this mirror. It is fetchable after all, and the hypothesis is wrong. `ac5eb2311`'s parent is exactly `7fb2c78`, the upstream tip fork `main` merges, so the entire delta between the two bases is rocicorp#6425: 35 lines in `litestream3-prometheus-poller.ts` and its test, a metrics poller that never runs in this harness. Both bases were built with the same instrumentation and run side by side, each on its own port, app id and replica against the same database, six measured N=1 replays each, alternating so neither could hold a cache-warmth advantage: | query | main `16019afa4` | `ac5eb2311` | B/A | diagnosis | |---|---:|---:|---:|---:| | `assignment.roster` | 42.5 | 51.4 | 1.21x | 49.8 | | `problem_trackers.for_assignment` | 190.4 | 231.5 | 1.22x | 537.7 | | per-group cpu | 391.2 | 447.8 | 1.14x | 730.6 | The diagnosis commit shows no tracker-specific penalty: its 1.22x sits inside the same band as roster's 1.21x, and the whole column is a flat ~1.14x offset from running two zero-cache instances on one 8-core box. What it does show is the tracker query at 231.5 ms on the exact commit the diagnosis measured at 537.7 ms, 2.3x cheaper on this machine with the same fixture and the same `transformationHash`. The divergence is therefore a machine-dependent per-row constant, not a code change. `problem_trackers.for_assignment` hydrates 7,768 row events against at most 1,118 for any other root, so it is the only one whose cost is dominated by cache residency and allocation rate rather than fixed per-query overhead, and a different host memory subsystem moves it while leaving the small roots roughly in place. This changes what the harness promises. The scaling law and the ratio columns are the portable result; tracker dominance is not, and branches should re-baseline it on their own hardware rather than carrying ~73% or ~53% forward. Per-client replay output for all twelve runs is under `results/raw/ab/`.
Karavil
force-pushed
the
capy/zc-baseline-harness
branch
from
August 28, 2026 17:39
65c7e09 to
0a86f15
Compare
This was referenced Aug 28, 2026
Draft
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.
Benchmark tooling: a concurrent client-group wave-replay harness for zero-cache, a committed synthetic fixture, temporary stage probes on the sync path, and a measured baseline with raw results in the tree.
What it measures. N fresh client groups each register the same six query roots (a classroom app's assignment-detail page) in one same-tick batch, against one sync worker, and the harness records how the query wave scales with N. The reproduced result is a scaling law, not a time: query-wave wall clock grows near-linearly with concurrent client groups while the CPU-ish per-group query time stays flat, because every ViewSyncer on a worker shares one cooperative time-slice queue and
TimeSliceTimerstops while a ViewSyncer yields. Optimization branches should compare ratio columns; the milliseconds belong to whichever machine produced them.The workload is entirely synthetic:
emu-*identifiers,Emulation Student NNNdisplay names, synthetic addresses. No production data. It is production-shaped -- the fixture (136 students, 5 classes, 24 problems, 973 problem trackers, 973 conversations, 957 mastery assessments) and the six roots come from a real educator-facing page in the Goblins app, whose query mix motivated this work. This branch exists so that the perf branches built against this workload (#6432, #6433, #6434, #6435, #6436, #6437 all cite it) have a committed workload definition, fixture, and rerun recipe in the tree, instead of numbers pinned to uncommitted scripts on one machine: the profiling investigation that established the scaling law ran exactly that way. That investigation is referred to as "the diagnosis" in the committed results notes and in the probe event names below; its scripts and replica are not committed anywhere, and its numbers appear here only as comparison columns. The rerun recipe has one external dependency this repo cannot supply -- the source application's transform server and schema -- spelled out under "Reruns, and an honest dependency" below.The commits
1.
perf(zero-cache): add temporary stage probes to the assignment-wave hot path.Measurement scaffolding, not a behavior change: every addition is aperformance.now()delta and anlc.infoline, and nothing on the request path is reordered. Current main already reportszero.sync.view_syncer_hydrationandzero.sync.lock-wait-time, but neither can answer the question here: hydration covers transform throughpokeEndas one number with no group or query attribution, and lock-wait records how long a caller waited to acquire the client-group lock rather than how long the lock stayed held while its work was descheduled. Without splitting the wave into stages you cannot tell an expensive query on an idle worker from an ordinary query queued behind sixteen peers, which is exactly the distinction the scaling law rests on.The probes emit four
zeroEventpayloads, keyed byclientGroupIDso the harness can join them to a client sample:diagnosis-view-syncer-lock-acquired/-lock-releasedwrap#runInLockWithCVRand carry bothlockWaitMsandlockWorkMs. The released probe fires in thefinallyblock, before auth-maintenance scheduling and the actual unlock, so it slightly understates hold time.diagnosis-config-update-stagessplits#updateCVRConfiginto bookkeeping, CVR flush, config poke, and the query-sync branch.diagnosis-query-sync-stagesreports transform time accumulated across every custom query in#syncQueryPipelineSet, plus the CVR/custom/add/remove counts.diagnosis-query-wave-stagessplits#addAndRemoveQueriesinto prepare,TimeSliceTimerprocessing, hydration and bookkeeping, unreferenced-row deletion, CVR flush, client catch-up, andpokeEnd, alongside the wave's wall time and query count.The separation between
queryProcessingCpuMsandtotalMsis the whole point:TimeSliceTimerstops while a ViewSyncer yields to the shared time-slice queue, so the first approximates CPU spent on this group and the second includes the scheduler wait. Their ratio is what makes queueing visible. (#6437 later promotes this distinction to standing OTel metrics; the log probes here are what the recorded baseline was extracted from.)2.
test(zero-cache): add the concurrent assignment-wave replay baseline harness.The harness, the fixture, and the recorded baseline, underpackages/zero-cache/bench/wave-replay/.3.
test(zero-cache): settle the tracker divergence with an ac5eb231 A/B. See "The tracker divergence" below.Measured baseline
3 runs per N, pooled median over all client samples, 8 vCPU. A fresh client group also hydrates Zero's internal
lmidsandmutationResults, so the server sees eight pipeline additions per six-root client wave.Per-group CPU varies +4.9% to -4.5% around its mean across a 16x load increase. Row counts match the prior investigation exactly (roster 1,118, with_problems 53, trackers 7,768), and
problem_trackers.for_assignmentcarries the sametransformationHash216mzlo8m5qcv. Raw per-client samples, per-run summaries and the pooled medians are committed underresults/.The tracker divergence, and what it teaches about portability
The baseline reproduced the earlier investigation's scaling ratios and row counts, with one divergence recorded in
results/baseline-2026-08-28.md: the tracker graph costs 2.4x less here than in that investigation, moving its share of per-group processing from ~73% to ~53%, while the other five roots reproduce within 26%. Two data-shape explanations were tested against the original replica and ruled out. The remaining hypothesis was a code delta, since the investigation ran on upstreamac5eb2311(#6425), which this branch's base predates.An A/B settles it, and the code-delta hypothesis is wrong. Side A is this fork's
main(16019afa4, as recorded inresults/raw/ab/), a merge whose tree differs from upstream7fb2c78bb(#6423) only by a fork CI workflow file that never runs in the harness. Side B is upstreamac5eb2311(#6425), whose parent is exactly7fb2c78bb. The only code that can differ between the two measured servers is therefore #6425: 35 lines inlitestream3-prometheus-poller.tsand its test, a metrics poller that never runs in this harness. Both sides were built with the same instrumentation and run side by side, each on its own port, app id and replica against the same database, six measured N=1 replays each, alternating so neither could hold a cache-warmth advantage:main(16019afa4)ac5eb2311(#6425)assignment.rosterproblem_trackers.for_assignmentThe #6425 commit shows no tracker-specific penalty: its 1.22x sits inside the same band as roster's 1.21x, and the whole column is a flat ~1.14x offset from running two zero-cache instances on one 8-core box. What it does show is the tracker query at 231.5 ms on the exact commit the investigation measured at 537.7 ms -- 2.3x cheaper on this machine with the same fixture and the same
transformationHash.The divergence is therefore not explained by any code delta, which leaves the environment: it behaves like a machine-dependent per-row constant. The plausible mechanism is that
problem_trackers.for_assignmenthydrates 7,768 row events against at most 1,118 for any other root, so it is the only root whose cost is dominated by cache residency and allocation rate rather than fixed per-query overhead, and a different host memory subsystem would move it while leaving the small roots roughly in place. That mechanism is inferred, not isolated; what is established is that the same commit, fixture, andtransformationHashprice the tracker 2.3x apart on two hosts.This changes what the harness promises. The scaling law and the ratio columns are the portable result; tracker dominance is not, and branches should re-baseline it on their own hardware rather than carrying ~73% or ~53% forward. Per-client replay output for all twelve A/B runs is under
results/raw/ab/.Shape of the harness
client/zero-ordered-replay.tsis one client group. It imports the app's Zero client package, sorun-replay-concurrency.tscopies it into that app's checkout before spawning it; bun resolves workspace packages by walking up from the script's own directory.run-replay-concurrency.tsstarts N clients as separate processes, so each gets its own auth token, its own in-memory kv store, and therefore its own client group. It asserts wave integrity on every client: all six roots must complete within 1 ms of each other, because one group poke commits the wave and no root becomes visible beforepokers.end(finalVersion). A wider spread means the run is not exercising single-poke semantics, so it exits non-zero rather than reporting a number that looks fine. All 93 samples in the recorded baseline had a 0 ms spread.run-matrix.tsdrives the matrix and then the two aggregation scripts (extract-stage-events.mjs,summarize-concurrency.mjs).seed/apply-seed.shapplies the 3,365-statement idempotent fixture and mints one session row per concurrent client. The driver sendsemu-session-token-NNNas its Zero auth token and zero-cache forwards it as a bearer token, so concurrent groups need distinct tokens. An unmatched token fails quietly: the transform endpoint answers 200 with a null userID and the connection is then rejected for a userID mismatch, leaving every root to time out with zero rows.bench/wave-replayis excluded from the zero-cache tsconfig because the drivers are bun scripts, and typechecking them would pull@types/buninto a package with no other reason to want it.Reruns, and an honest dependency
README.mdin the harness directory carries the full recipe.ZERO_NUM_SYNC_WORKERS=1is the load-bearing setting: normal multiprocess mode with one syncer child, notSINGLE_PROCESS=1, so the dispatcher'staskID/clientGroupIDhash lands every group on the same worker and the same time-slice queue.One prerequisite an outside runner should know up front: the six roots are named custom queries, so the harness needs the Goblins application's transform server and a Postgres database with that app's schema (the seed SQL is idempotent against it). The README documents the exact setup. The workload data itself is fully committed and synthetic; the transform dependency is what keeps the replayed queries byte-identical to the ones the investigation profiled, rather than an approximation of them.
Validation
pnpm --filter zero-cache check-typespasses andgit diff --checkis clean. The probes are logging-only; the harness and results are new files outside the compiled package.