Exp 264: size the initial result buffer from the SQL's high-water row count - #289
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reader worker sees only a sample of a statement executions, so a burst of small reads can leave its last two observations small while the statement regularly returns thousands of rows. The new undershoot-mid guard lane caught that at +40% in all four order-flipped passes. The main isolute sees every execution, so the opinion moves there and rides the request beside exp 260 growth hint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…window Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Belief impactLearned
What this changed
|
There was a problem hiding this comment.
Pull request overview
This experiment extends resqlite’s result-buffer sizing logic by shrinking decodeQuery’s initial List.filled(...) allocation for statements that have historically returned few rows, while preserving the existing behavior for any statement that might return larger results (via a clamp back to the 256-row default). It builds on Exp 260’s per-SQL row-count memory, adding a high-water statistic used only to reduce waste on point reads.
Changes:
- Add a high-water-mark–based
initialRowssignal toRowSizeMemoryand plumb aninitialRowHintthroughReaderPool→ReadRequest→ reader decode calls. - Update the decoder to size the initial allocation via
initialSlotRows(...), using main-isolate hints for reader workers and local memory only for the writer isolate. - Expand focused harness lanes and add targeted tests covering shrink/overshoot/mispredict scenarios and precedence rules.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/result_buffer_sizing_test.dart | Adds unit + integration tests for initial-allocation sizing, high-water behavior, and hint precedence. |
| lib/src/reader/reader_pool.dart | Records row-size memory for all statements and stamps initialRowHint onto each request. |
| lib/src/reader/read_worker.dart | Adds initialRowHint to the isolate protocol and passes it into decoder entrypoints. |
| lib/src/query_decoder.dart | Implements initialRowsFor / initialSlotRows and applies the initial-row sizing to the decoder’s initial buffer allocation. |
| experiments/signals/entries/264.json | Adds the per-experiment signals/claims entry for Exp 264. |
| experiments/signals/base.json | Updates the result-transfer-shape narrative and priors list to include Exp 264. |
| experiments/JOURNAL.md | Records transferable lessons learned from Exp 264 (rate vs per-call, guard reruns, high-water vs window). |
| experiments/index/264.json | Adds the index fragment used to generate the experiments table. |
| experiments/264-initial-alloc-size-memory.md | Adds the experiment writeup documenting hypothesis, approach, and results. |
| benchmark/results/2026-08-06T11-40-00Z-exp264-initial-alloc-size-memory.md | Adds the focused AOT A/B benchmark receipt for Exp 264. |
| benchmark/experiments/select_rows_presize.dart | Adds/adjusts lanes and batching to make initial-allocation effects measurable and guarded. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The drift peer scaffolding was never broken. benchmark/drift/*.g.dart is gitignored and generated by build_runner, which CI runs before analyze and test; a fresh worktree that skips it sees 77 analyzer issues and 9 failing test files in peer scaffolding and reproduces the same on origin/main, which looks like a repo breakage and is not one. With codegen run, dart analyze --fatal-infos is clean and all 450 tests pass. The release suite does run: 14 of 16 scenarios, then the pre-existing #282 sqlite_async crash at Memory. Exp 262 per-scenario persistence preserved a properly self-marked partial artifact with 169 metrics, which is its first real save. Single-sample and unpaired, so it is a no-regression sanity check rather than evidence, and is not committed. Claim 264.4 restated accordingly; JOURNAL gains the codegen lesson. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing exp 260 rowCount <= initialResultRows insert guard is what lets a small statement be remembered, and it also lets every point read take one of the pool 32 _rowHints slots. Exp 260 had those slots to itself, so a report query that runs constantly is now evicted by point-read churn and loses its hint: measured +40-46% on a 5,000-row read behind 40 distinct one-off statements, systematic enough that the slower arm fastest sample beat the faster arm slowest. LRU promotion was tried first and measured no improvement, while costing the main isolate a map remove-and-reinsert per read. The problem is capacity, not order. What ships is an eviction preference: drop an entry whose highWater has never exceeded the initial buffer before dropping one that has. That restores exp 260 exclusive tenure without a new magic capacity, and the O(32) scan runs only on an overflowing miss, never per read. Two new harness lanes close the gap that hid this - nothing else in the suite uses more than a handful of SQL strings - and the property itself is gated deterministically in reader_pool_test, which fails against insertion-order eviction. Also records that every wall-time figure in this experiment came from a host at 0.0% CPU idle with under 500 MB free disk. Direction and mechanism stand; percentages want a re-measure on a quiet machine before promotion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first measurement pass ran at 0.0% CPU idle. Re-run at 52-61% idle, four alternating passes, lane-isolated, three arms (origin/main, exp 264 without the eviction fix, exp 264 as it ships). Controls tighten to +/-1.7% from +/-4%. The win holds and the narrow point read improves: -13.5% on the canonical 6-column point read against -7.4% first measured, while the 21-column lane reproduces at -27.1% against -27.4%. So the saturation cost about six points on the smaller effect and nothing on the larger. The eviction fix is inert on the hot path, as its structure predicts - both point lanes sign-flip, both controls +/-0.1% - and returns hint-thrash-overflows to parity with pre-264 (-0.2%), against +41.6% unfixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut the experiment-by-experiment narration and kept the reason a reader needs now. No behaviour change; 454 tests pass, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hypothesis
Every
select()starts by allocating room for 256 rows, whatever the query isabout to return.
decodeQuerycallsList<Object?>.filled(colCount * 256, null, growable: true), writes the rows in,and truncates. For a point read on the repo's canonical six-column product row
that is 1,536 slots allocated and zero-filled to keep 6; on a twenty-column row,
5,376 to keep 21. It is pure waste, paid on every small read.
Someone already tried removing it. Exp 067
shrank the constant in April, regressed four small-query workloads by 40-44%, and
concluded the constant was well-tuned — explaining the regression with a real VM
property, that
List.filled(n, null)is cheaper per slot whennis large.Two things reopen it. The per-slot claim is true and doesn't support the
conclusion: measured per call, the large allocation is about six times cheaper per
slot and twenty times more expensive in total (423 ns for 1,536 slots against
21.5 ns for 12). And exp 067 shrank the constant for every query, so anything
larger than four rows had to double its way up — growth, not allocation, is what
regressed it. Exp 260
has since given the decoder a per-SQL memory of how many rows each statement
returns, so the shrink no longer has to be a guess applied to every query.
The bet: size the initial allocation from that memory, only ever downward, and
point reads get materially cheaper while everything larger runs today's code
unchanged.
Approach
RowSizeMemorygains a second output alongside exp 260's growth hint, and the twotake deliberately opposite statistics, because the two ends of the buffer's life
have opposite failure modes:
hint(exp 260, untouched) steers growth and takes the smaller of thelast two row counts. Over-sizing is the expensive mistake there.
initialRows(new) sizes the initial allocation and takes the largest rowcount ever seen, plus 25% headroom, clamped at the existing 256 rows.
Under-sizing is the expensive mistake here.
The clamp is what makes this safe rather than tuned, and it is the difference from
what exp 260 explicitly rejected: exp 260 tried applying a hint to the initial
allocation and measured a
LIMIT ?statement's 50-row leg going 2.8x slower oncethe hint saturated at its large leg. A value that can only ever shrink below the
default cannot do that. Anything that outgrows the initial buffer clamps back to
256 rows and allocates exactly what it allocates on
main.The mark is kept on the main isolate —
ReaderPoolalready stamps exp 260'sgrowth hint onto each request, so this rides along beside it as
ReadRequest.initialRowHint. A reader worker must not answer this about itself:it sees only a sample of a statement's executions, and a worker that decodes a
result over
sacrificeSlotThresholdis destroyed outright, taking its mark withit. The writer isolate, which no pool serves, keeps using its own memory — it is
long-lived and executes every read issued inside a transaction, so its local view
is the global one.
Everything else stays on the old path: a statement's first two executions, one
whose entry has been evicted from the pool's 32-entry memory, and every
selectBytescall (which builds no Dart buffer at all).Full detail, including the sequence in which the two rules were found:
experiments/264-initial-alloc-size-memory.md.
Results
Four alternating-order passes, lane-isolated, 61 samples per lane, three arms:
origin/main, exp 264 without its eviction fix, and exp 264 as it ships. Host at52-61% CPU idle; controls inside ±1.7%.
Exp 264 (final) against
origin/main:point1point1-wide20int20-10kmixed6-10khint-thrash-overflowsCost of the eviction fix — exp 264 with it against exp 264 without:
point1point1-wide20int20-10kmixed6-10khint-thrash-overflowsPoint reads are 13.5% faster on the canonical six-column row and 27.1% on a
twenty-one-column one, reproduced in all four passes, and the ratio between them
is what the mechanism predicts — what a one-row result wastes is
colCount * 255slots, so the win scales with projection width. In absolute terms that is roughly
0.7 µs and 2.3 µs of worker time per read, against a whole point-read operation of
about 6 µs. Both controls are neutral by construction rather than by luck: a result
larger than 256 rows clamps to the same allocation in both arms.
hint-thrash-overflowsis the lane added by this PR (see below) and is now levelwith
origin/main, so nothing regressed.The change put exp 260's growth hint at risk, and that is fixed here
Removing exp 260's
rowCount <= initialResultRowsinsert guard is what lets a smallstatement be remembered — and it also lets every point read take one of the pool's
32
_rowHintsslots. Exp 260 had those slots to itself, so after this change areport query that runs constantly gets evicted by point-read churn and loses its
hint. Measured at +41.6% on a 5,000-row read behind 40 distinct one-off
statements, systematic enough that the unfixed arm's fastest sample beat the fixed
arm's slowest.
No existing lane could see this — every one of them uses a handful of SQL strings —
so two lanes were added that run never-before-seen statements between timed reads,
bracketing what an eviction policy can and cannot fix.
LRU promotion was the first fix tried and measured no improvement, while costing
the main isolate a map remove-and-reinsert per read. The problem is capacity, not
order. What ships is an eviction preference: drop an entry whose
highWaterhasnever exceeded the initial buffer before dropping one that has. It restores exp 260's
exclusive tenure without a new magic capacity, and the O(32) scan runs only on an
overflowing miss — never per read, which the table confirms (both point lanes
sign-flip, both controls ±0.1%). The property is gated deterministically in
test/reader_pool_test.dart, which fails against insertion-order eviction.A note on measurement environment
The first measurement pass was collected on a host at 0.0% CPU idle — an
unrelated VM at 190% CPU, under 500 MB free disk, and six
run_release.dartprocesses wedged 1-4 days from earlier sessions. It surfaced when a confirmation
pass read +50.6% on a lane the candidate cannot reach. Everything above is the
re-measurement after the host freed up. Comparing the two bounds the damage: six
points on the narrow point read (−7.4% → −13.5%), none on the wide one. Recorded as
claim 264.6, with the lesson in
JOURNAL.md— a focused harness stamps nothingabout its host, unlike
run_release.dart'sgitDirty.Release suite
The suite runs — it completes 14 of 16 scenarios (every read, write, streaming and
app-shaped lane) and then aborts inside the sqlite_async peer at
[15/16] Memory,the pre-existing #282 crash that also stopped exps 260 and 261. Exp 262's
per-scenario persistence made its first real save: the killed run still wrote an
artifact with 169 metrics, correctly self-marked
partial: true,scenariosCompleted: 14,repeatCount: 0.That artifact is not committed and is not evidence for this experiment —
single-sample with no paired baseline, which is exactly what
repeatCount: 0marks and what the trend charts drop. As a no-regression sanity check it is
reassuring: point query 160,798 qps, 1,000-row
select()0.349 ms, batch insertof 1,000 rows 0.389 ms, invalidation latency 0.058 ms — all at or better than
README.md's published figures.Outcome
Accepted. A 7-27% win on point reads with no reachable regression, and the two
properties that make it safe are structural: the clamp means anything larger than
256 rows runs today's code byte-for-byte, and the high-water mark means a
mispredict is one-off (+1.6%) rather than periodic.
Exp 067's rejection stands for what it tested — an unconditional shrink is still
wrong. What it got wrong was the generalisation, and that is now recorded as a
transferable lesson: a rejection whose reasoning is a rate (per slot, per byte,
per row) has to be multiplied back out by the count the caller actually pays before
it closes a direction.
Would reopen if the pool's 32-entry row-size memory turns out to thrash on a real
application's statement mix — an evicted entry now loses a high-water mark as well
as a growth hint, so it would pay the first-jump cost again. The discriminating
measurement is cheap and is filed as an
openCandidatesentry.Test plan
dart run build_runner build --delete-conflicting-outputs— needed in afresh worktree;
benchmark/drift/*.g.dartis gitignored and CI generates itbefore analyze and test
dart analyze --fatal-infos— no issuesdart test --timeout 60s— 450 tests, all passingdart test test/result_buffer_sizing_test.dart— 21 tests covering thehigh-water rule, caller-over-local precedence, a statement that jumps from
tiny to large, and an empty result that then grows
finalize_experiment.dart,check_knowledge_links.dart(79 claims, clean),check_experiment_dispositions.dart— all greenConnectionLease.notifyUpdateswhilebenchmark_keyed_pk_subscriptions_test.dartwas running; re-running theidentical commit passed, and six local repetitions of the same three peer
workload tests passed.
mainwas green throughout, so this is the Benchmark: survive a peer crash, and pin the baseline the numbers compare against #282peer-instability family surfacing in the test job.
Correction to an earlier version of this description
An earlier revision of this PR reported that the release suite "no longer
compiles" against
drift2.34.3, and filed it as claim 264.4. That was wrong.benchmark/drift/*.g.dartis gitignored by design and generated bybuild_runner, which CI runs before analyze and test — the fresh experimentworktree had simply never run it. Reproducing the same failure on
origin/mainruled out this diff but did not establish a repo breakage, because a missing build
step reproduces everywhere. The claim has been restated to what is actually true,
and the general lesson is recorded in
JOURNAL.md.