diff --git a/benchmark/experiments/select_rows_presize.dart b/benchmark/experiments/select_rows_presize.dart index 05ecf892..fedd684d 100644 --- a/benchmark/experiments/select_rows_presize.dart +++ b/benchmark/experiments/select_rows_presize.dart @@ -1,49 +1,55 @@ // ignore_for_file: avoid_print // -// Focused A/B harness for [EXP-260]: should `decodeQuery`'s result buffer be -// sized from the row count the same SQL last returned? +// Focused A/B harness for the `select()` result buffer: how it is sized at +// allocation and how it grows ([EXP-260], [EXP-264]). // -// `decodeQuery` allocates `List.filled(colCount * 256, ...)` and -// doubles it whenever a result outgrows it. Doubling copies the whole buffer -// each time, so a result that overshoots the initial size by 2^k pays roughly -// one extra full-buffer copy in total — element by element, with a store -// barrier per slot, into a fresh multi-megabyte array. [EXP-251] put Dart -// result construction at 39-63% of worker wall on large reads without -// splitting out how much of it was that growth. +// `decodeQuery` allocates `List.filled(colCount * 256, ...)` and doubles +// it whenever a result outgrows it. Both ends cost real time. Doubling copies the +// whole buffer each time, element by element with a store barrier per slot, so a +// result that overshoots by 2^k pays roughly one extra full-buffer copy. And the +// fixed 256-row allocation is mostly waste for a small result — a one-row read of +// a 21-column table zero-fills 5,376 slots to keep 21. // -// The candidate remembers, on the main isolate, how many rows each SQL has -// been returning, and sends that with the request so the worker's *first* -// growth jumps straight to the right size instead of doubling its way there. -// The initial allocation is untouched ([EXP-067] measured that shrinking it -// regresses small queries), so a result that never overflows it runs exactly -// the code it runs today. +// Sizing either end from a per-SQL memory of past row counts is what the lanes +// below gate. The roles invert between the two ends, so each lane is labelled for +// both: // -// Lanes: +// int20-10k / int4-5k Integer shapes where the buffer is pure Smi slots +// and growth dominates the Dart-side cost. Primary for growth; control for +// the initial allocation, which clamps to the same 256 rows in both arms. +// mixed6-10k / mixed6-1k The canonical 6-column product row, overshooting +// the initial allocation by 39x and 4x. Same roles. +// mixed6-200 Fits inside the initial buffer, and sizes to 250 +// rows against 256 — inert for both ends, so it reads the harness floor. +// Batched 20x per sample: one 200-row read lands near 50 us, where a 1 us +// tick is 2% and cannot resolve a sub-microsecond effect. +// point1 / point1-wide20 One row, at 6 and 21 columns. Primary for the +// initial allocation, where the waste scales with projection width; control +// for growth, which they never reach. Batched 200x per sample. +// mixed6-20 A 20-row page, the shape a paged list view reads. +// mispredict-shrink / -mid A `LIMIT ?` statement whose row count swings +// between 8,000 and a small leg. Guards that a swinging statement is neither +// over-allocated at the growth step nor sized down at the initial one. +// undershoot-jump / -mid A statement that returns thousands after a burst +// of 20-row executions. Guards the initial allocation's failure mode. The two +// sit on opposite sides of the doubling chain's landing point (from 25 rows +// 5,000 lands on 6,400 where 256 lands on 8,192, so the shrunken arm wins; +// 3,300 lands on 6,400 against 4,096, so it loses), which is what stops the +// pair reporting whichever alignment happens to flatter. +// hint-thrash-fits / -overflows +// The same read behind 20 or 40 never-before-seen SQL strings, which claim +// slots in the pool's 32-entry row-size memory. The only lanes exercising +// more distinct statements than the pool can remember; every other lane uses +// a handful, so a per-SQL memory can stop working and nothing moves. 20 fits +// inside the capacity and 40 does not, so the pair separates an eviction +// policy problem from a capacity one. // -// int20-10k / int4-5k — PRIMARY. [EXP-251]'s integer shapes, where the -// buffer is pure Smi slots and growth is the dominant Dart-side cost. -// mixed6-10k / mixed6-1k — PRIMARY. The repo's canonical 6-column product -// row, at a row count that overshoots the initial allocation by 39x and by -// 4x respectively. -// mixed6-200 / point1 — CONTROL. Both return fewer rows than the initial -// buffer holds, so neither ever reaches the changed growth path and the -// decode loop runs byte-identical code in both arms. What they still carry -// is the pool's per-request bookkeeping, which is the whole cost a small -// query pays for this. Per the JOURNAL lesson from exp 248 these lanes are -// the harness's own floor; per exp 254's, a same-sign move across the order -// flip means the two binaries carry a layout offset and no lane is -// trustworthy. `point1` times 200 executions per sample because a single -// point read is a handful of microseconds, where a 1 us stopwatch tick -// swamps the effect being measured. -// mispredict-shrink / mispredict-mid — GUARDS. The hint's failure mode is -// over-allocation: a SQL whose row count swings between executions sizes -// its buffer for the larger result and throws the excess away. Both lanes -// run the same `LIMIT ?` statement at 8000 rows (untimed) before each timed -// sample. `mispredict-shrink` times a 50-row execution behind six 8000-row -// ones — small enough never to overflow the initial buffer, so it proves -// the hint cannot inflate a small result no matter how saturated it is. -// `mispredict-mid` times a 300-row execution in strict alternation — large -// enough that the hint *is* consulted, so it tests the rule that picks it. +// Two shape constraints worth keeping. Every timed statement stays below +// `sacrificeSlotThreshold` (32,768 slots) unless the lane is deliberately +// measuring the sacrifice path, because crossing it respawns a reader worker and +// swamps everything else. And any lane whose per-read cost is dominated by other +// allocation — `mixed6-20`'s 80 Strings, say — cannot resolve a fraction of a +// microsecond, so a small effect there is drift, not a result. // // Usage: // dart run benchmark/experiments/select_rows_presize.dart \ @@ -67,16 +73,21 @@ const _defaultPoisonWidth = 1; final class _Lane { /// A lane whose table is `id INTEGER PRIMARY KEY` plus [columns] generated /// columns all of one affinity — the synthetic width/row-count sweeps. - const _Lane(this.label, this.columns, this.rows, this.cell) - : createSql = null, - insertSql = null, - row = null, - selectSql = 'SELECT * FROM items', - selectParams = const [], - poisonParams = null, - poisonWidth = _defaultPoisonWidth, - expectRows = null, - repeats = 1; + const _Lane( + this.label, + this.columns, + this.rows, + this.cell, { + this.selectSql = 'SELECT * FROM items', + this.selectParams = const [], + this.expectRows, + this.repeats = 1, + this.thrashWidth = 0, + }) : createSql = null, + insertSql = null, + row = null, + poisonParams = null, + poisonWidth = _defaultPoisonWidth; /// A lane that declares its own schema verbatim, so it can reproduce a /// canonical shape rather than approximate one. @@ -93,7 +104,8 @@ final class _Lane { this.expectRows, this.repeats = 1, }) : columns = 0, - cell = null; + cell = null, + thrashWidth = 0; final String label; final int columns; @@ -130,6 +142,16 @@ final class _Lane { /// puts the control lane's resolution on the same footing as the others. /// Reported medians are per sample, not per execution. final int repeats; + + /// Distinct *SQL strings* executed, untimed, before each timed sample. + /// + /// Unlike [poisonWidth], which re-executes [selectSql] with different + /// parameters, each of these is a fresh SQL string that has never been seen + /// before, so it claims a new slot in `ReaderPool._rowHints` (capacity 32). + /// This is the only thing in the suite that exercises having more distinct + /// statements in play than the pool can remember — the gap that let exp 264 + /// widen eviction pressure on exp 260's growth hint without any lane noticing. + final int thrashWidth; } // The repo's canonical mixed row: 6 columns total (`id INTEGER PRIMARY KEY`, @@ -185,8 +207,10 @@ final _lanes = <_Lane>[ createSql: _standardCreate, insertSql: _standardInsert, row: _standardRow, + // See the header: 50 us per read cannot resolve a sub-microsecond effect. + repeats: 20, ), - // CONTROL: a point read, the shape most sensitive to per-request overhead. + // The shape most sensitive to per-request overhead. _Lane.explicit( 'point1', 2000, @@ -198,6 +222,29 @@ final _lanes = <_Lane>[ expectRows: 1, repeats: 200, ), + // The widest projection the harness carries, so the largest saving available: + // a one-row result wastes `colCount * 255` slots. + _Lane( + 'point1-wide20', + 20, + 2000, + (r, c) => r * 31 + c, + selectSql: 'SELECT * FROM items WHERE id = ?', + selectParams: [17], + expectRows: 1, + repeats: 200, + ), + // The shape a paged list view and most reactive streams read. + _Lane.explicit( + 'mixed6-20', + 2000, + createSql: _standardCreate, + insertSql: _standardInsert, + row: _standardRow, + selectSql: 'SELECT * FROM items LIMIT 20', + expectRows: 20, + repeats: 50, + ), // GUARD: the hint is left pointing at 10000 rows before every timed 50-row // execution of the same statement. 50 rows never overflow the initial buffer, // so a saturated hint must still cost nothing. @@ -228,8 +275,72 @@ final _lanes = <_Lane>[ poisonParams: [8000], expectRows: 300, ), + // Eight untimed 20-row executions before each timed sample leave the pool's + // memory sized for 25 rows; the timed execution then returns thousands and has + // to double up from there. The pool's growth hint cannot soften it either — it + // takes the smaller of the last two row counts, which the alternation pins at + // the 20-row leg. See the header for why there are two of these. + _Lane.explicit( + 'undershoot-jump', + 10000, + createSql: _standardCreate, + insertSql: _standardInsert, + row: _standardRow, + selectSql: 'SELECT * FROM items LIMIT ?', + selectParams: [5000], + poisonParams: [20], + poisonWidth: 8, + expectRows: 5000, + ), + _Lane.explicit( + 'undershoot-mid', + 10000, + createSql: _standardCreate, + insertSql: _standardInsert, + row: _standardRow, + selectSql: 'SELECT * FROM items LIMIT ?', + selectParams: [3300], + poisonParams: [20], + poisonWidth: 8, + expectRows: 3300, + ), + // Timed statement is exp 260's int4-5k shape at 25,000 slots, deliberately + // below `sacrificeSlotThreshold` so a worker respawn cannot swamp the effect. + // The growth hint is worth ~40% of this read, which is what the filler + // statements can take away. See the header for what the two widths separate. + _Lane( + 'hint-thrash-fits', + 4, + 5000, + (r, c) => r * 31 + c, + expectRows: 5000, + thrashWidth: 20, + ), + _Lane( + 'hint-thrash-overflows', + 4, + 5000, + (r, c) => r * 31 + c, + expectRows: 5000, + thrashWidth: 40, + ), ]; +/// Monotonic counter making every thrash filler statement a distinct SQL string. +/// A trailing comment changes the text without changing the plan, so a filler +/// costs a prepare and a slot and nothing else. +int _thrashSeq = 0; + +/// Execute [width] never-before-seen SQL strings, untimed, so they claim slots +/// in `ReaderPool._rowHints`. +Future _thrash(resqlite.Database db, int width) async { + for (var i = 0; i < width; i++) { + await db.select('SELECT id FROM items WHERE id = ? -- f${_thrashSeq++}', [ + 1, + ]); + } +} + Future main(List args) async { var warmup = _defaultWarmup; var samples = _defaultSamples; @@ -305,6 +416,7 @@ Future _runLane( final expect = lane.expectRows ?? lane.rows; for (var i = 0; i < warmup; i++) { await _poison(db, lane); + await _thrash(db, lane.thrashWidth); await db.select(lane.selectSql, lane.selectParams); } @@ -317,6 +429,7 @@ Future _runLane( final values = []; for (var i = 0; i < samples; i++) { await _poison(db, lane); + await _thrash(db, lane.thrashWidth); final sw = Stopwatch()..start(); for (var n = 0; n < lane.repeats; n++) { final result = await db.select(lane.selectSql, lane.selectParams); diff --git a/benchmark/results/2026-08-06T11-40-00Z-exp264-initial-alloc-size-memory.md b/benchmark/results/2026-08-06T11-40-00Z-exp264-initial-alloc-size-memory.md new file mode 100644 index 00000000..98d4353c --- /dev/null +++ b/benchmark/results/2026-08-06T11-40-00Z-exp264-initial-alloc-size-memory.md @@ -0,0 +1,248 @@ +# Experiment 264: initial result-buffer sizing + +Collected 2026-08-06 on arm64 macOS 26.2 (Apple M1 Pro) with Dart 3.12.2. +Baseline is `origin/main` at `c351422`; candidate is the same tree plus the +initial-allocation high-water mark on `RowSizeMemory`, the `initialRowHint` field +on `ReadRequest`, and the two new harness lanes. Both arms were built as +native-asset-aware AOT CLI bundles so the decode path is AOT-compiled (exp 193's +requirement for any `Row`/decode change): + +```console +dart build cli --target=bin/select_rows_presize.dart --output= +/bundle/bin/select_rows_presize --lane= --warmup=12 --samples=61 +``` + +The harness source is +[`benchmark/experiments/select_rows_presize.dart`](../experiments/select_rows_presize.dart); +`bin/` is only where `dart build cli` requires the entry point to live. The +baseline arm was built from the candidate's copy of the harness, so the source is +identical in both arms and only `lib/` differs. + +Every lane is **lane-isolated** — one fresh process per lane per arm — and four +passes were collected in alternating collection order (baseline-first, +candidate-first, baseline-first, candidate-first) rather than the usual two, because +the first two passes disagreed on `mixed6-20` and two orderings could not tell a +small effect from drift. Values are microseconds; `point1` and `point1-wide20` +time 200 executions per sample and `mixed6-200` times 20, so their medians are per +sample, not per execution. + +**A release-suite run reaches scenario 14 of 16 and then dies in the peer.** The +suite runs cleanly through Select→Maps, Select→Bytes, Schema Shapes, Scaling, +Concurrent Reads, Point Query, Parameterized, Writes, both Streaming groups, and +the four app-shaped workloads, then aborts inside the sqlite_async peer at +`[15/16] Memory` — the pre-existing #282 crash that also stopped exps 260 and 261, +and which additionally wedges the parent process rather than exiting. Nothing in +this experiment's diff is linked into that library. + +Exp 262's per-scenario persistence did its job: the killed run still wrote an +artifact with 14 scenarios and 169 metrics, correctly self-marked +`partial: true`, `scenariosCompleted: 14`, `repeatCount: 0`. It is the first time +that mechanism has actually preserved anything, since exps 260 and 261 were killed +inside repeat 1 before it existed. + +It is **not committed**, and it is not evidence for this experiment. `repeatCount: +0` is the repo's own marker for a single-sample run, which the trend charts drop +by design, and there is no paired baseline. What it is good for is a sanity check +that nothing broke: point-query throughput 160,798 qps, 1,000-row `select()` 0.349 +ms, batch insert of 1,000 rows 0.389 ms, stream invalidation latency 0.058 ms — +all at or better than the figures published in `README.md`, none regressed. + +## Lanes + +| lane | shape | role for exp 264 | +|---|---|---| +| `point1` | `WHERE id = ?` on the canonical 6-column row, 1 row | primary — the shape the fixed allocation wastes most of | +| `point1-wide20` | `WHERE id = ?` on a 21-column INTEGER row, 1 row | primary — widest projection, so the largest waste | +| `mixed6-20` | `LIMIT 20` on the canonical row | primary — a paged list view | +| `mixed6-200` | 200 rows of the canonical row | control — sizes to 250 rows against 256, effectively inert | +| `mixed6-1k` / `mixed6-10k` / `int4-5k` / `int20-10k` | exp 260's primaries | control — all clamp to the 256-row default, so the initial allocation is unchanged | +| `mispredict-shrink` | 50 rows timed behind six 8,000-row executions | guard — a statement that swings must not be sized down | +| `mispredict-mid` | 300 rows alternating with 8,000 | guard — same, where the growth hint is also consulted | +| `undershoot-jump` | 5,000 rows behind eight 20-row executions | guard — favourable doubling alignment | +| `undershoot-mid` | 3,300 rows behind eight 20-row executions | guard — **adverse** doubling alignment; the lane that rejected the sliding-window rule | + +## Timing + +| lane | pass | baseline median | candidate median | Δ | baseline CV | candidate CV | +|---|---|---:|---:|---:|---:|---:| +| `int20-10k` | 1 (baseline first) | 4457 | 4549 | +2.1% | 2.4% | 3.3% | +| `int20-10k` | 2 (candidate first) | 4611 | 4459 | -3.3% | 3.6% | 2.6% | +| `int20-10k` | 3 (baseline first) | 4565 | 4448 | -2.6% | 2.7% | 2.4% | +| `int20-10k` | 4 (candidate first) | 4536 | 4412 | -2.7% | 3.9% | 2.4% | +| `int4-5k` | 1 (baseline first) | 546 | 539 | -1.3% | 7.0% | 11.9% | +| `int4-5k` | 2 (candidate first) | 522 | 521 | -0.2% | 9.2% | 8.2% | +| `int4-5k` | 3 (baseline first) | 534 | 518 | -3.0% | 9.9% | 8.3% | +| `int4-5k` | 4 (candidate first) | 522 | 520 | -0.4% | 8.8% | 9.3% | +| `mixed6-10k` | 1 (baseline first) | 2501 | 2482 | -0.8% | 17.8% | 15.6% | +| `mixed6-10k` | 2 (candidate first) | 2390 | 2483 | +3.9% | 17.4% | 16.0% | +| `mixed6-10k` | 3 (baseline first) | 2475 | 2390 | -3.4% | 16.7% | 15.9% | +| `mixed6-10k` | 4 (candidate first) | 2405 | 2424 | +0.8% | 17.9% | 14.7% | +| `mixed6-1k` | 1 (baseline first) | 219 | 223 | +1.8% | 13.7% | 14.6% | +| `mixed6-1k` | 2 (candidate first) | 218 | 213 | -2.3% | 14.8% | 14.4% | +| `mixed6-1k` | 3 (baseline first) | 219 | 215 | -1.8% | 16.3% | 16.6% | +| `mixed6-1k` | 4 (candidate first) | 214 | 219 | +2.3% | 17.7% | 13.6% | +| `mixed6-200` | 1 (baseline first) | 1001 | 974 | -2.7% | 5.2% | 9.7% | +| `mixed6-200` | 2 (candidate first) | 1057 | 992 | -6.1% | 4.9% | 5.5% | +| `mixed6-200` | 3 (baseline first) | 997 | 969 | -2.8% | 5.4% | 6.2% | +| `mixed6-200` | 4 (candidate first) | 1052 | 1000 | -4.9% | 3.9% | 6.3% | +| `point1` | 1 (baseline first) | 1103 | 1000 | -9.3% | 19.1% | 15.9% | +| `point1` | 2 (candidate first) | 1103 | 1059 | -4.0% | 17.7% | 16.4% | +| `point1` | 3 (baseline first) | 1094 | 1010 | -7.7% | 16.1% | 15.9% | +| `point1` | 4 (candidate first) | 1106 | 1013 | -8.4% | 16.8% | 18.3% | +| `point1-wide20` | 1 (baseline first) | 1593 | 1109 | -30.4% | 11.3% | 16.2% | +| `point1-wide20` | 2 (candidate first) | 1525 | 1127 | -26.1% | 11.9% | 13.3% | +| `point1-wide20` | 3 (baseline first) | 1515 | 1111 | -26.7% | 10.1% | 13.7% | +| `point1-wide20` | 4 (candidate first) | 1505 | 1106 | -26.5% | 9.9% | 13.7% | +| `mixed6-20` | 1 (baseline first) | 488 | 538 | +10.2% | 16.1% | 14.8% | +| `mixed6-20` | 2 (candidate first) | 497 | 463 | -6.8% | 15.8% | 16.2% | +| `mixed6-20` | 3 (baseline first) | 569 | 593 | +4.2% | 15.0% | 11.2% | +| `mixed6-20` | 4 (candidate first) | 508 | 465 | -8.5% | 15.7% | 15.5% | +| `mispredict-shrink` | 1 (baseline first) | 74 | 75 | +1.4% | 17.8% | 120.5% | +| `mispredict-shrink` | 2 (candidate first) | 67 | 72 | +7.5% | 22.5% | 17.1% | +| `mispredict-shrink` | 3 (baseline first) | 68 | 75 | +10.3% | 15.8% | 17.1% | +| `mispredict-shrink` | 4 (candidate first) | 70 | 74 | +5.7% | 20.8% | 25.5% | +| `mispredict-mid` | 1 (baseline first) | 113 | 108 | -4.4% | 21.3% | 10.7% | +| `mispredict-mid` | 2 (candidate first) | 113 | 116 | +2.7% | 13.3% | 9.2% | +| `mispredict-mid` | 3 (baseline first) | 114 | 107 | -6.1% | 17.3% | 9.9% | +| `mispredict-mid` | 4 (candidate first) | 117 | 118 | +0.9% | 14.1% | 11.9% | +| `undershoot-jump` | 1 (baseline first) | 1559 | 1521 | -2.4% | 48.1% | 47.7% | +| `undershoot-jump` | 2 (candidate first) | 1524 | 1496 | -1.8% | 48.9% | 54.4% | +| `undershoot-jump` | 3 (baseline first) | 1542 | 1536 | -0.4% | 22.8% | 49.3% | +| `undershoot-jump` | 4 (candidate first) | 1541 | 1555 | +0.9% | 55.5% | 51.7% | +| `undershoot-mid` | 1 (baseline first) | 762 | 750 | -1.6% | 9.8% | 9.7% | +| `undershoot-mid` | 2 (candidate first) | 784 | 753 | -4.0% | 10.2% | 8.5% | +| `undershoot-mid` | 3 (baseline first) | 762 | 761 | -0.1% | 8.3% | 9.5% | +| `undershoot-mid` | 4 (candidate first) | 773 | 778 | +0.6% | 10.3% | 9.9% | + +## Re-measurement on a quieter host + +The timing table above was collected while the host sat at 0.0% CPU idle (see +*Environment* below). The comparison was re-run once it freed up — 52-61% idle, +four alternating passes, lane-isolated, 61 samples per lane, three arms: + +```console +pre = origin/main (c351422) +fifo = exp 264 without the eviction fix (f87584d) +victim = exp 264 as it ships +``` + +**Exp 264 (final) against `origin/main`:** + +| lane | role | pass 1 | pass 2 | pass 3 | pass 4 | mean | verdict | +|---|---|---:|---:|---:|---:|---:|---| +| `point1` | primary | -10.3% | -15.2% | -19.9% | -8.5% | -13.5% | **reproduced** | +| `point1-wide20` | primary | -26.3% | -19.5% | -29.5% | -33.1% | -27.1% | **reproduced** | +| `int20-10k` | control | -0.5% | +0.4% | +1.3% | -0.2% | +0.3% | neutral | +| `mixed6-10k` | control | +0.1% | +0.8% | -0.7% | -1.7% | -0.4% | neutral | +| `hint-thrash-overflows` | guard | -1.3% | +0.9% | -2.6% | +2.2% | -0.2% | neutral | + +**Cost of the eviction fix — exp 264 with it against exp 264 without:** + +| lane | role | pass 1 | pass 2 | pass 3 | pass 4 | mean | verdict | +|---|---|---:|---:|---:|---:|---:|---| +| `point1` | primary | -6.8% | -5.6% | -6.9% | +3.3% | -4.0% | sign-flips | +| `point1-wide20` | primary | -1.7% | +24.5% | -2.2% | +1.5% | +5.5% | sign-flips | +| `int20-10k` | control | +0.3% | +0.4% | -0.8% | +0.3% | +0.0% | neutral | +| `mixed6-10k` | control | -0.5% | +0.0% | +1.5% | -0.6% | +0.1% | neutral | +| `hint-thrash-overflows` | guard | -30.3% | -28.8% | -30.1% | -28.8% | -29.5% | **reproduced** | + +Controls tighten to ±1.7% here from ±4%, so these supersede the twelve-lane table +wherever the two overlap. Absolute medians, pass 1 (us): `point1` 1305 / 1255 / +1170, `point1-wide20` 1722 / 1291 / 1269, `hint-thrash-overflows` 551 / 780 / 544. + +## Environment + +Every figure in the twelve-lane table was collected on a host at **0.0% CPU idle**: +`top` reported 57% user / 43% sys, with an unrelated Virtualization.framework VM at +190% CPU, `fseventsd` at 57%, and other Dart processes at 100% and 44%; load average +33/39/50 on ten cores; under 500 MB free on a 460 GB volume; and six +`run_release.dart` processes from earlier sessions resident, wedged 1-4 days at 0.0% +CPU. It surfaced when a confirmation pass read +50.6% on `int20-10k`, a lane the +candidate cannot reach. + +The re-measurement above ran at 52-61% idle with the VM gone (`fseventsd` and +`triald_system` still active, so not pristine). Comparing the two passes bounds what +the saturation cost: six points on the narrow point read (-7.4% → -13.5%), nothing +on the wide one (-27.4% → -27.1%). + +## Verdicts + +`dart run benchmark/ab_drift_check.dart --input=... --markdown`, pooling passes +1+3 as pass 1 and passes 2+4 as pass 2: + +| scenario | verdict | pass 1 Δ | pass 2 Δ | worst flagged CV | +|---|---|---:|---:|---:| +| `int20-10k` | inconclusive / neutral | 0.0% | −2.8% | 3.7% | +| `int4-5k` | inconclusive / neutral | −4.2% | −0.4% | 8.9% | +| `mixed6-10k` | inconclusive / neutral | −1.8% | +2.2% | 17.2% | +| `mixed6-1k` | inconclusive / neutral | 0.0% | −0.7% | 16.2% | +| `mixed6-200` | inconclusive / neutral | −2.9% | −5.4% | 5.3% | +| `point1` | **REPRODUCED (real effect)** | −8.7% | −5.8% | 17.7% | +| `point1-wide20` | **REPRODUCED (real effect)** | −28.3% | −26.7% | 10.9% | +| `mixed6-20` | drift-suspected | +10.3% | −7.7% | 15.6% | +| `mispredict-shrink` | drift-suspected | +4.2% | +4.3% | 91.1% | +| `mispredict-mid` | inconclusive / neutral | −5.3% | +1.3% | 19.2% | +| `undershoot-jump` | inconclusive / neutral | −1.8% | −0.5% | 51.9% | +| `undershoot-mid` | inconclusive / neutral | −1.0% | −0.8% | 10.2% | + +## First-jump cost + +With the high-water rule, `undershoot-mid` raises the mark during warmup and is +inert for every timed sample, so the one-time cost of a statement's first large +execution had to be measured in fresh processes: + +```console +/bundle/bin/select_rows_presize --lane=undershoot-mid --warmup=0 --samples=1 --no-memory +``` + +50 processes per arm, halves collected in each order. The single timed sample is +the first 3,300-row execution the pool has ever seen, behind eight 20-row +executions that have already set the mark to 20. + +| arm | median | p10 | p90 | +|---|---:|---:|---:| +| baseline | 927.5 us | 883 | 1064 | +| candidate | 942.5 us | 872 | 1066 | + +**+1.6%**, about 15 us, inside the p10–p90 spread of either arm. Arithmetic +account: four more growths, ~15,200 more slot copies, ~13,800 more slots +zero-filled. + +## Peak RSS + +`ProcessInfo.maxRss` per lane-isolated process (exp 261's instrument), all four +passes, baseline/candidate: + +| lane | pass 1 | pass 2 | pass 3 | pass 4 | +|---|---:|---:|---:|---:| +| `int20-10k` | 64.6 / 64.5 | 64.8 / 64.5 | 64.7 / 64.6 | 64.4 / 64.7 | +| `int4-5k` | 48.2 / 40.5 | 40.9 / 40.9 | 40.7 / 40.7 | 40.8 / 40.7 | +| `mixed6-10k` | 95.5 / 95.6 | 95.6 / 95.6 | 97.5 / 95.7 | 95.5 / 95.5 | +| `mixed6-1k` | 29.6 / 29.7 | 29.7 / 29.6 | 29.7 / 29.6 | 29.4 / 29.7 | +| `mixed6-200` | 24.6 / 29.5 | 25.0 / 23.5 | 24.3 / 23.5 | 24.9 / 23.5 | +| `point1` | 29.5 / 29.6 | 29.5 / 29.8 | 29.6 / 29.7 | 29.7 / 29.3 | +| `point1-wide20` | 24.0 / 23.8 | 23.7 / 23.7 | 23.6 / 23.9 | 23.7 / 23.8 | +| `mixed6-20` | 29.6 / 29.5 | 29.7 / 29.7 | 29.6 / 29.8 | 29.5 / 29.8 | +| `mispredict-shrink` | 185.3 / 101.8 | 220.3 / 228.9 | 99.3 / 99.4 | 251.8 / 207.9 | +| `mispredict-mid` | 106.6 / 106.8 | 106.8 / 106.8 | 106.9 / 106.8 | 106.7 / 106.8 | +| `undershoot-jump` | 105.8 / 92.8 | 105.7 / 105.6 | 90.3 / 105.9 | 105.8 / 105.8 | +| `undershoot-mid` | 55.7 / 56.1 | 55.4 / 55.5 | 55.1 / 57.8 | 55.5 / 58.4 | + +Every lane that holds one read live at a time is flat within about 1 MB, and +that is the expected reading rather than a surprise: Dart releases a truncated +growable list's backing store, so the fixed allocation was never *retained*. This +change removes transient allocation work and garbage, not footprint — the same +conclusion exp 263 reached about the row representation from the other direction. + +Two lanes carry no memory signal at all and should not be read as one. +`mispredict-shrink` and `undershoot-jump` issue their poison through +`Future.wait`, so peak RSS is set by how many 8,000-row reads happen to be live +together; `mispredict-shrink`'s **baseline** alone reads 185.3, 220.3, 99.3 and +251.8 MB across the four passes. Any candidate-vs-baseline difference on those two +is scheduling, not allocation. + +`undershoot-mid` is up 2.3-2.9 MB in two of four passes. `maxRss` is a +process-lifetime high-water and includes warmup, where the candidate pays its +one-time doubling chain to a 38,400-slot buffer against the baseline's 24,576 — +about 110 KB of extra live buffer plus its transient predecessors. It is the +mispredict showing up in memory as well as in time, once per process. diff --git a/experiments/264-initial-alloc-size-memory.md b/experiments/264-initial-alloc-size-memory.md new file mode 100644 index 00000000..00885ef5 --- /dev/null +++ b/experiments/264-initial-alloc-size-memory.md @@ -0,0 +1,367 @@ +# Experiment 264: size the initial result buffer from what the SQL has ever returned + +**Date:** 2026-08-06 +**Status:** Accepted +**Direction:** `result-transfer-shape` +**Benchmark Run:** none — focused AOT A/B, four alternating-order lane-isolated + passes of + [`benchmark/experiments/select_rows_presize.dart`](../benchmark/experiments/select_rows_presize.dart) + at 61 samples per lane per pass; receipt in + [`benchmark/results/2026-08-06T11-40-00Z-exp264-initial-alloc-size-memory.md`](../benchmark/results/2026-08-06T11-40-00Z-exp264-initial-alloc-size-memory.md). + No release-suite lane resolves a sub-microsecond per-read allocation, so the + focused harness is the durable gate. + +## Problem + +`decodeQuery` opens every read by allocating +`List.filled(colCount * 256, null, growable: true)` — room for 256 rows, +whatever the statement is about to return. A point read then writes one row into +it and truncates. 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 it is 5,376 to +keep 21. + +[Exp 067](067-shrink-initial-allocation.md) tried to shrink the constant in 2026 +and was rejected. It changed `colCount * 256` to `colCount * 4` for *every* +query, so any result larger than four rows had to double its way up from there, +and four small-query workloads regressed 40-44%. Its stated mechanism was that +the VM has a zero-fill fast path making one large null-filled list *cheaper per +slot* than a small one, and that the constant was therefore "well-tuned." + +Two things make that conclusion worth re-testing now. The per-slot claim is true +and decision-irrelevant — a standalone AOT reproduction of the allocation shape +measures 1,536 slots at 0.28 ns/slot against 12 slots at 1.8 ns/slot, so the +large allocation is indeed cheaper per slot and 20x more expensive in total. +And [exp 260](260-result-list-presize.md) has since given the decoder something +exp 067 did not have: a per-SQL memory of how many rows each statement returns. +Shrinking no longer has to be a guess applied to every query. + +This is the same defect class exp 260 found in [exp 059](059-row-count-hint.md): +a mechanism claim that is locally true, measured against an unconditional change, +closing a direction that a *conditional* change reopens. + +## Hypothesis + +Size the initial allocation from the per-SQL row-size memory instead of a +constant — but only ever *downward*, clamped at the existing 256 rows. A +statement that has shown it returns few rows allocates for few rows; a statement +that has returned more, or that the pool has no opinion about yet, allocates +exactly what it allocates today. + +The clamp is what separates this from what exp 260 explicitly rejected. Exp 260 +tried applying its hint to the initial allocation and measured a `LIMIT ?` +statement's 50-row leg going 2.8x slower (68 → 198 us) once the hint saturated +at the large leg: zero-filling 60,000 unused slots costs more than the doubling +it removes. A hint that can only shrink cannot do that — the worst it can do is +make a result double its way back up. + +Primary gate: at least 10% faster median wall on point reads, reproduced in both +collection orders. Kill conditions: any lane whose result is larger than the +initial buffer regresses outside the harness floor, or the cost of a statement +whose row count jumps from small to large is not bounded and one-off. + +## Approach + +`RowSizeMemory` — exp 260's two-execution record of a SQL's result size — gains a +second output. The two ends of the buffer's life want opposite statistics, and +each takes the one whose mistakes are cheap: + +- `hint` (exp 260, unchanged) steers **growth** and takes the *smaller* of the + last two row counts. Over-sizing is the expensive mistake there. +- `initialRows` (new) sizes the **initial allocation** and takes the *largest row + count ever seen*, plus 25% headroom, clamped into `1 ..= 256` rows. + Under-sizing is the expensive mistake here. + +`decodeQuery` and `decodeQueryWithInitialHash` take an `initialRowHint`, and the +value comes from the **main isolate**: `ReaderPool` already keeps a +`RowSizeMemory` per SQL and stamps exp 260's growth hint onto each request, so +this rides along beside it. `ReaderPool._record` now creates an entry for small +results too — exp 260 skipped those because a small result cannot reach the +growth path, and it now has a consumer only small results can reach. The writer +isolate, which no pool serves, keeps using its own schema-cache memory. + +Everything else stays on the old path. A statement in its first two executions, +one whose entry has been evicted from the pool's 32-entry memory, and every +`selectBytes` call (which builds no Dart buffer at all) allocate exactly what +they allocate today. + +### Two rules, and the order they were found in + +The first implementation had neither of the two rules that make this safe, and +one guard lane produced both — the first time by being misread. + +The lane is `undershoot-mid`: a `LIMIT ?` statement returning 3,300 rows behind a +burst of eight 20-row executions. Against the first implementation — which sized +the initial allocation from the larger of the *last two* row counts, by symmetry +with exp 260's smaller-of-two — it measured **+40% in all four order-flipped +passes**. + +**A pool worker must not answer this question about itself.** This was the first +diagnosis, and it was reasoned rather than measured: the first implementation read +the *worker-local* memory, on the argument that exp 260 only needed the main +isolate because `Isolate.exit` destroys the worker that decodes a large result, +which cannot happen to a mark about small ones. That argument is sound as far as +it goes and it misses two others. A high-water mark is only worth the observations +feeding it: a four-worker pool hands each worker a sample, so three of four can +still believe a statement is small after the fourth has decoded a large result, +and a worker that *does* decode a large result is destroyed and its replacement +starts over. So the mark moved to the main isolate, which sees every execution and +outlives every worker. + +That was the right change and it did not move the lane at all — still +40%, all +four passes. The move fixed a real defect that was not the one being measured. + +**The statistic must be a high-water mark, not a sliding window.** The actual +cause is independent of where the memory lives. A window of length two is defeated +by any burst longer than two: wherever it is kept, the two observations before the +large execution are both 20 rows, so the large result is sized for 20 rows and +doubles its way up — *every time*, not once. Only the statistic could fix that. +A high-water mark cannot repeat: the first large result raises it for good, and +every later one is sized from the fixed default. What it gives up is a statement +that was once large and is now permanently small, which keeps today's allocation +forever — no win, but no tax either. With that change the lane reads −1.3%. + +## Results + +Four alternating-order passes (baseline-first, candidate-first, baseline-first, +candidate-first), lane-isolated, 61 samples per lane per pass. Both arms are +native-asset-aware AOT CLI bundles built from the same harness source, per exp +193's requirement for any decode-path result. + +| lane | role | p1 (B1) | p2 (C1) | p3 (B1) | p4 (C1) | verdict | +|---|---|---:|---:|---:|---:|---| +| `point1-wide20` | primary | −30.4% | −26.1% | −26.7% | −26.5% | **reproduced** | +| `point1` | primary | −9.3% | −4.0% | −7.7% | −8.4% | **reproduced** | +| `mixed6-20` | primary | +10.2% | −6.8% | +4.2% | −8.5% | drift-suspected | +| `mixed6-200` | control | −2.7% | −6.1% | −2.8% | −4.9% | neutral | +| `mixed6-1k` | control | +1.8% | −2.3% | −1.8% | +2.3% | neutral | +| `mixed6-10k` | control | −0.8% | +3.9% | −3.4% | +0.8% | neutral | +| `int4-5k` | control | −1.3% | −0.2% | −3.0% | −0.4% | neutral | +| `int20-10k` | control | +2.1% | −3.3% | −2.6% | −2.7% | neutral | +| `mispredict-shrink` | guard | +1.4% | +7.5% | +10.3% | +5.7% | drift-suspected (CV 91%) | +| `mispredict-mid` | guard | −4.4% | +2.7% | −6.1% | +0.9% | neutral | +| `undershoot-jump` | guard | −2.4% | −1.8% | −0.4% | +0.9% | neutral | +| `undershoot-mid` | guard | −1.6% | −4.0% | −0.1% | +0.6% | neutral | + +Verdicts are `benchmark/ab_drift_check.dart`'s, not eyeballed. + +**Point reads get 7-27% faster, and the win scales with projection width.** The +six-column point read moves −7.4% on average and the twenty-one-column one +−27.4% — the ratio the mechanism predicts, because what a one-row result wastes +is `colCount * 255` slots. In absolute terms that is roughly 0.5 us and 2.2 us of +worker time per read. This is a real fraction of a point read: the whole +operation is about 5-8 us, most of which is the isolate round trip, so the +allocation was one of the larger single items left in it. + +**Every lane whose result outgrows the initial buffer is neutral**, which is what +the clamp guarantees rather than something the measurement had to discover: all +five control lanes size to 256 rows in both arms and read the harness floor +(±4%). `mixed6-20` should win by the same mechanism and does not resolve — its +per-read cost is dominated by 80 String allocations, and the sign flips across +the order flip, so it is reported as drift-suspected rather than as a small win. + +**The cost of a first jump from small to large is +1.6%, once.** With the +high-water rule the `undershoot-mid` lane goes inert after its first sample, so +the one-time cost was measured separately: 50 fresh processes per arm, +`--warmup=0 --samples=1`, so the timed 3,300-row read is the first large +execution the pool has ever seen. Median 927.5 us baseline against 942.5 us +candidate — about 15 us, well inside the p10–p90 spread of either arm, and +consistent with an arithmetic account of the extra doublings (four more growths, +15,200 more slot copies, 13,800 more slots zero-filled). It is paid once per +statement per pool-memory lifetime. + +**Peak RSS is flat on every lane that holds one read live** — within about 1 MB +across all four passes, including 24.0 → 23.8 MB on `point1-wide20`. Dart +releases a truncated growable list's backing store, so the fixed allocation was +never *retained*; this removes transient allocation work and garbage, not +footprint, which is the same conclusion exp 263 reached about `select`'s row +representation from the other direction. The two lanes that issue their poison +through `Future.wait` carry no memory signal in either arm — `mispredict-shrink`'s +baseline alone reads 99-252 MB across the four passes, set by how many 8,000-row +reads are live together. `undershoot-mid` is up 2.3-2.9 MB in two of four passes, +which is the one-time mispredict chain showing up in a process-lifetime +high-water. + +**A release-suite run reaches scenario 14 of 16 and then dies in the peer.** The +suite runs cleanly through Select→Maps, Select→Bytes, Schema Shapes, Scaling, +Concurrent Reads, Point Query, Parameterized, Writes, both Streaming groups, and +the four app-shaped workloads, then aborts inside the sqlite_async peer at +`[15/16] Memory` — the pre-existing #282 crash that also stopped exps 260 and 261, +and which additionally wedges the parent process rather than exiting. Nothing in +this experiment's diff is linked into that library. + +Exp 262's per-scenario persistence did its job: the killed run still wrote an +artifact with 14 scenarios and 169 metrics, correctly self-marked +`partial: true`, `scenariosCompleted: 14`, `repeatCount: 0`. It is the first time +that mechanism has actually preserved anything, since exps 260 and 261 were killed +inside repeat 1 before it existed. + +It is **not committed**, and it is not evidence for this experiment. `repeatCount: +0` is the repo's own marker for a single-sample run, which the trend charts drop +by design, and there is no paired baseline. What it is good for is a sanity check +that nothing broke: point-query throughput 160,798 qps, 1,000-row `select()` 0.349 +ms, batch insert of 1,000 rows 0.389 ms, stream invalidation latency 0.058 ms — +all at or better than the figures published in `README.md`, none regressed. + +### The change put exp 260's hint at risk, and the fix is capacity, not order + +Removing exp 260's `rowCount <= initialResultRows` insert guard is what lets a +small statement be remembered at all — and it also lets every point read take one +of the pool's 32 `_rowHints` slots. Exp 260 had those slots to itself: only a +statement that had returned more rows than the initial buffer holds was ever +inserted, so nothing competed with the large-result statements its growth hint +serves. After exp 264 a report query that runs constantly is evicted by point-read +churn and loses its hint. + +Nothing in the suite could see this, because every existing lane uses a handful of +SQL strings. Two new lanes fix that gap by running never-before-seen statements +between timed reads: + +| lane | pre-264 | exp 264 | + LRU promotion | + small-victim eviction | +|---|---:|---:|---:|---:| +| `hint-thrash-overflows` (40 one-offs) | 597 | 826 | 976 | **565** | +| `hint-thrash-fits` (20 one-offs) | 592 | 579 | 597 | 566 | + +The regression is real and systematic — on the overflow lane the slower arm's +*fastest* sample beat the faster arm's *slowest*, so it is not a tail effect. + +**Least-recently-used promotion does not fix it.** That was the first fix +attempted, on the reasoning that a hot statement should not be aged out; it +measured no improvement, and it costs the main isolate a map remove-and-reinsert +on every read. The problem is capacity, not order: once more distinct hot +statements are in play than the map holds, no ordering keeps the one that matters. +Raising `_rowHintMax` to 128 does fix it (620 us), which is what identified the +mechanism. + +What shipped instead is an eviction preference: on overflow, drop an entry whose +`highWater` has never exceeded the initial buffer before dropping one that has. +That restores exp 260's exclusive tenure without picking a new magic capacity, +small statements still use whatever slots are left, and the O(32) scan runs only +on an overflowing miss — never on the per-read path, so it cannot erode the point +read this experiment exists to speed up. When every entry has proven large it +falls back to insertion order, which is exactly the pre-264 behaviour. + +The property is gated in `test/reader_pool_test.dart`, not in the lane. Whether a +hint is still armed is a deterministic consequence of the eviction policy, and the +test fails against insertion-order eviction and passes with the preference — a +cleaner signal than a benchmark that needed three passes to read. Note that +*presence* is the wrong assertion: an evicted statement is re-inserted the next +time it runs, so a membership check passes under any policy. What eviction costs is +the learned hint. + +### Re-measured on a quieter host + +Everything above was collected while the machine was saturated (see below), so the +whole comparison was re-run once it freed up — 52-61% CPU idle, four alternating +passes, lane-isolated, 61 samples per lane, three arms: `origin/main`, exp 264 +without the eviction fix, and exp 264 as it ships. The controls are tight here +(±1.7%, against ±4% before), so these are the numbers to quote. + +**Exp 264 (final) against `origin/main`:** + +| lane | role | pass 1 | pass 2 | pass 3 | pass 4 | mean | verdict | +|---|---|---:|---:|---:|---:|---:|---| +| `point1` | primary | -10.3% | -15.2% | -19.9% | -8.5% | -13.5% | **reproduced** | +| `point1-wide20` | primary | -26.3% | -19.5% | -29.5% | -33.1% | -27.1% | **reproduced** | +| `int20-10k` | control | -0.5% | +0.4% | +1.3% | -0.2% | +0.3% | neutral | +| `mixed6-10k` | control | +0.1% | +0.8% | -0.7% | -1.7% | -0.4% | neutral | +| `hint-thrash-overflows` | guard | -1.3% | +0.9% | -2.6% | +2.2% | -0.2% | neutral | + +**Cost of the eviction fix — exp 264 with it against exp 264 without:** + +| lane | role | pass 1 | pass 2 | pass 3 | pass 4 | mean | verdict | +|---|---|---:|---:|---:|---:|---:|---| +| `point1` | primary | -6.8% | -5.6% | -6.9% | +3.3% | -4.0% | sign-flips | +| `point1-wide20` | primary | -1.7% | +24.5% | -2.2% | +1.5% | +5.5% | sign-flips | +| `int20-10k` | control | +0.3% | +0.4% | -0.8% | +0.3% | +0.0% | neutral | +| `mixed6-10k` | control | -0.5% | +0.0% | +1.5% | -0.6% | +0.1% | neutral | +| `hint-thrash-overflows` | guard | -30.3% | -28.8% | -30.1% | -28.8% | -29.5% | **reproduced** | + +The win holds and the narrow point read is *better* than the saturated run +suggested — −13.5% against the −7.4% first measured, while the wide lane reproduces +almost exactly (−27.1% against −27.4%). The earlier figure was the host, not the +code. + +The eviction fix costs nothing on the hot path, which is what the structure +predicts: `_evictionVictim` is only reachable when the memory overflows its 32 +entries, and a lane running one statement never gets there. Both `point1` lanes +sign-flip across the order flip and both controls sit at ±0.1%, while the thrash +guard moves −29.5% in all four passes. And `hint-thrash-overflows` is now level with +`origin/main` (−0.2%), so exp 260's reach is fully restored rather than merely +improved: without the fix that same lane runs +41.6% slower than pre-264. + +### The host was saturated for the first pass, which is how the figures moved + +The twelve-lane table and the guard measurements above were collected on a machine +at **0.0% CPU idle** — +`top` reported 57% user / 43% sys with an unrelated Virtualization.framework VM at +190% CPU, FSEvents at 57%, and other Dart processes at 100% and 44%. The same host +had under 500 MB free on a 460 GB volume, and six `run_release.dart` processes from +earlier sessions were resident, wedged 1-4 days at 0.0% CPU. + +The direction and mechanism survive that: the design is order-flipped over four +passes, the controls held to ±2%, the two primaries reproduced 4/4 with the +column-count scaling the mechanism predicts, and a standalone allocation probe with +no isolates and no SQLite measures the same effect at 423 ns against 21.5 ns per +call — independent corroboration of both sign and magnitude. The precise +percentages do not survive it. A per-read microsecond measurement on a saturated +host is dominated by reader-isolate scheduling latency, and the confirmation pass +that exposed the problem read +50.6% on `int20-10k`, a lane the candidate cannot +reach. + +That is why the three-arm re-measurement above exists, and it is the authoritative +one wherever the two overlap. The four-pass twelve-lane table is retained because it +is the only pass covering the guards and the full lane set, and its verdicts — +which lanes reproduce and which flip — held up. + +The episode is a gap in the tooling rather than bad luck: `run_release.dart` +stamps `gitDirty` and the charts drop untrusted runs, but a focused AOT harness +records nothing about its host, so there was nothing to notice until an inert lane +moved by 50%. + +## Decision + +**Accepted.** Point reads are a reproduced **13.5% faster on the canonical +six-column row and 27.1% on a twenty-one-column one**, measured on a quiet host +across four alternating passes with controls inside ±1.7%. The two properties that +make the change safe are structural rather than tuned: the clamp means a result +larger than 256 rows runs today's code, and the high-water mark means a mispredict +is one-off rather than periodic. + +The change also required a fix to the pool's eviction preference, without which it +silently narrows exp 260's reach by 40-46% on any application with more than 32 +distinct hot statements. That fix ships here rather than as a follow-up, because +this experiment is what creates the need for it. + +Exp 067's rejection stands for what it tested — an unconditional shrink is still +wrong, and its regressed workloads would still regress. What it got wrong was the +generalisation: it read a true per-slot fact as evidence that the constant was +well-tuned, when the constant was only well-tuned in the absence of any +per-statement knowledge. Exp 260 supplied that knowledge and this consumes it. + +Would reopen if the pool's 32-entry memory turns out to thrash on a real +application's statement mix, since an evicted entry loses the mark and pays the +first-jump cost again. The discriminating measurement is cheap: count distinct +SQL strings per second in a representative app trace against `_rowHintMax`. + +## Test plan + +- [x] `dart run build_runner build --delete-conflicting-outputs` — required in a + fresh worktree; `benchmark/drift/*.g.dart` is gitignored and CI generates it + before analyze and test +- [x] `dart analyze --fatal-infos` — no issues +- [x] `dart test --timeout 60s` — 450 tests, all passing +- [x] `dart test test/result_buffer_sizing_test.dart` — 21 tests, including the + high-water rule, the caller-over-local precedence, a statement that jumps + from tiny to large, and an empty result that then grows +- [x] focused AOT A/B, four alternating orders, 61 samples per lane, lane-isolated; + verdicts from `benchmark/ab_drift_check.dart` +- [x] 50-process first-jump measurement for the one-time mispredict cost +- [x] `dart run benchmark/finalize_experiment.dart` — green +- [x] `dart run benchmark/check_knowledge_links.dart` — clean (79 claims); + `check_experiment_dispositions.dart` — no stranded in-review +- [x] CI green on PR #289. The first run aborted (SIGABRT) inside the sqlite_async + peer's `ConnectionLease.notifyUpdates` while + `benchmark_keyed_pk_subscriptions_test.dart` was running; re-running the + identical commit passed, and six local repetitions of the same three peer + workload tests passed. This is the #282 peer-instability family surfacing in + the test job rather than the benchmark run. diff --git a/experiments/JOURNAL.md b/experiments/JOURNAL.md index 4b363ccd..39495966 100644 --- a/experiments/JOURNAL.md +++ b/experiments/JOURNAL.md @@ -891,6 +891,142 @@ A guard whose sensitivity is unknown is not yet a guard. If setup dominates the measured quantity, say what fraction, or the number reads as more protective than it is.* +### Run drift codegen in a fresh worktree before reading any failure as pre-existing + +`benchmark/drift/*.g.dart` is gitignored and produced by `dart run build_runner +build --delete-conflicting-outputs`, which CI runs before analyze and test. A +worktree that has only had `dart pub get` is missing them, and the symptom is +alarming and misleading: `dart analyze` reports ~77 issues and nine +`benchmark_*_test.dart` files fail to load, all of it in peer drift scaffolding +that looks like it has drifted out of sync with the pinned `drift` version. +[Exp 264](264-initial-alloc-size-memory.md) read that as a pre-existing repo +breakage, checked it reproduced on `origin/main` — it did, for the same reason — +and wrote it up as a blocker before noticing the CI step that generates them. With +codegen run, `dart analyze --fatal-infos` is clean and all 450 tests pass. + +*Reapplies to every new experiment worktree: run codegen immediately after +`dart pub get`. More generally, "it reproduces on `origin/main`" only rules out +your diff; it does not establish that the repo is broken, because a missing +build step reproduces everywhere. Before reporting infrastructure as broken, +check what CI does that you did not.* + +### Check the host before trusting a focused benchmark + +`run_release.dart` stamps `gitDirty` and the experiments chart drops a dirty or +single-sample run, but a focused AOT harness records nothing about the machine it +ran on. [Exp 264](264-initial-alloc-size-memory.md) collected an entire +experiment — four order-flipped passes, twelve lanes — on a host at **0.0% CPU +idle**, with an unrelated VM at 190% CPU, under 500 MB free on a 460 GB volume, +and six `run_release.dart` processes from earlier sessions wedged 1-4 days at 0.0% +CPU. Nothing surfaced it until a late confirmation pass read +50.6% on a lane the +candidate provably cannot reach. + +What survived: the direction and mechanism, because the design was order-flipped, +the controls held to ±2%, the effect scaled with column count as predicted, and a +standalone probe with no isolates and no SQLite measured the same magnitudes. What +did not: the percentages. + +*Reapplies before every focused run. `top -l 1 | grep "CPU usage"` and `df -h` cost +nothing. Treat a same-signed move in a mechanically-inert lane as a host problem +first and a code-layout offset second — the layout reading is the one exp 254 +established, and it quietly assumes the host is idle. Also reap wedged +`run_release.dart` processes first: the #282 crash leaves the parent blocked +forever rather than exiting, they accumulate across sessions, and they sit at 0.0% +CPU so they never look like contention.* + +### Fixing the eviction order is not fixing the capacity + +Exp 264 gave the reader pool's 32-entry per-SQL memory a second consumer, so point +reads began competing for slots that had belonged exclusively to the large-result +statements exp 260's growth hint serves. A hot report query then lost its hint to +point-read churn, measured at +40-46% on a 5,000-row read. + +The obvious fix — promote on use, so a hot entry is not aged out — measured **no +improvement at all**, and cost the main isolate a map remove-and-reinsert per read. +Raising the capacity did fix it, which located the mechanism: once more distinct hot +statements are in play than the map holds, no ordering keeps the one that matters. +What shipped was an eviction *preference* (drop an entry that has never returned a +large result before one that has), which restores the original tenure without +choosing a new magic number and costs nothing per read. + +*Reapplies to any bounded cache that gains a second population of keys. Ask what +fraction of capacity the new population will occupy before reaching for a smarter +replacement policy — LRU, LFU and CLOCK all reorder the same too-small set. And +when a cache serves two consumers with different value densities, priority by value +beats recency.* + +### A per-slot cost is not a per-call cost + +[Exp 067](067-shrink-initial-allocation.md) rejected shrinking `decodeQuery`'s +fixed 256-row initial allocation and explained the rejection with a real VM +property: `List.filled(n, null)` is cheaper *per slot* when `n` is large. +[Exp 264](264-initial-alloc-size-memory.md) measured the same shape per call — +423 ns for 1,536 slots against 21.5 ns for 12 — so the large allocation is about +six times cheaper per slot and twenty times more expensive per query. The stated +mechanism was true; the decision it supported was not. + +This is the second rejection in this direction closed by a locally-true +mechanism claim. [Exp 059](059-row-count-hint.md) counted *growths* rather than +slots copied and concluded list growth was already cheap; exp 260 found the same +defect there. + +*Reapplies whenever a rejection's reasoning is a rate — per slot, per byte, per +row, per call. Multiply it back out by the count the caller actually pays before +treating it as a reason. And when a rejection tested an unconditional change, +what it establishes is that the change is wrong unconditionally; a later +experiment with per-case knowledge is not repeating it.* + +### An inert lane is only a noise gauge once it can resolve the effect + +A lane where the candidate is mechanically inert reads the harness floor, so a +same-signed move across the order flip means no lane is trustworthy (exp 254). +[Exp 264](264-initial-alloc-size-memory.md) found the precondition that rule +needs. Its `mixed6-200` control timed one 200-row read per sample at ~50 us, +where a single stopwatch tick is 2%, and reported +11.3% then +9.8% — a +reproduced, same-signed regression in a lane the candidate provably could not +reach. Batching 20 executions per sample took the same lane to -1.4% / +2.4%. + +*Reapplies before trusting any control lane. Check that its resolution exceeds +the effect being hunted; below that threshold a floor gauge manufactures exactly +the signal it exists to detect. The same run also needed four alternating-order +passes rather than two, because a lane whose per-read cost was dominated by other +allocation agreed with itself twice and then reversed.* + +### Re-run the guard after the fix, even when the fix is obviously right + +[Exp 264](264-initial-alloc-size-memory.md)'s guard lane fired at +40%. The first +diagnosis was that the size memory was worker-local and a four-worker pool gives +each worker a biased sample — which is true, is a real defect, and was worth +fixing on its own. It moved the memory to the main isolate and the lane read ++40% again, in all four passes. The actual cause was the statistic, not its +location. + +*Reapplies whenever a plausible defect is found while chasing a measured one. A +correct fix for a real problem is not evidence that it was the problem you +measured; the guard is. If the writeup had shipped after the reasoning instead of +after the re-run, it would have documented a mechanism the numbers never +supported — and shipped the +40%.* + +### A hint that can under-predict needs a high-water mark, not a window + +Exp 260 sized result-buffer *growth* from the smaller of a SQL's last two row +counts, because over-sizing is the expensive mistake there. +[Exp 264](264-initial-alloc-size-memory.md) sized the *initial* allocation and +inherited the window by symmetry, taking the larger of the last two — and +measured +40% in all four order-flipped passes on a statement returning 3,300 +rows behind bursts of eight 20-row reads. A window of length k is defeated by any +burst longer than k: every observation before the large execution is small, so +the penalty recurs instead of amortising. A high-water mark is raised once and +never falls, which converts a periodic cost into a one-time one (measured at ++1.6%). + +*Reapplies to any adaptive sizing, capacity or threshold whose two error +directions are not symmetric. Identify which direction is the expensive mistake, +then pick a statistic that cannot be talked out of guarding against it by a run +of cheap observations. Pair it with the exp 260 lesson above: put the hint where +a wrong answer costs nothing, and where it cannot, make the wrong answer +unrepeatable.* + ## How to add to this file Add an entry when an experiment surfaces a transferable lesson — something a diff --git a/experiments/index/264.json b/experiments/index/264.json new file mode 100644 index 00000000..27fbf4ed --- /dev/null +++ b/experiments/index/264.json @@ -0,0 +1,7 @@ +{ + "file": "264-initial-alloc-size-memory.md", + "title": "Size the initial result buffer from the SQL's high-water row count", + "impact": "`decodeQuery` allocated room for 256 rows on every read regardless of what the statement returns, so a one-row point read allocated and zero-filled `colCount * 256` slots to keep `colCount`. Exp 067 rejected shrinking the constant in 2026, but it shrank it unconditionally and read a true per-slot fact (a large null-filled list is cheaper *per slot*) as evidence the constant was well-tuned; exp 260 has since supplied the per-SQL row-size memory that makes the shrink conditional. Sizing the initial allocation from the largest row count a SQL has ever returned, clamped so it can only shrink below the existing default, is a reproduced 13.5% on the canonical 6-column point read and 27.1% on a 21-column one — the win scales with projection width, as the mechanism predicts. Every lane whose result outgrows the initial buffer is neutral by construction. Two guard lanes wrote the rule: `undershoot-mid` measured +40% in all four order-flipped passes against a sliding window of two, because the two executions before a large one are both small, which is what forced the statistic to be a high-water mark; the one-time cost of a statement's first jump from small to large is +1.6%. Two follow-on findings from the same run: exp 264's own change to `ReaderPool._record` lets point reads evict exp 260's growth hint from the pool's 32-entry memory, costing a measured +40-46% on a 5,000-row read once more than 32 distinct statements are in play — fixed here by preferring an eviction victim that has never returned a large result (LRU promotion was tried first and measured no improvement; the problem is capacity, not order). The first measurement pass ran on a host at 0.0% CPU idle with under 500 MB free disk and was re-run once it freed up; the narrow point read moved from -7.4% to -13.5% and the wide one held at -27.1%, with controls tightening to +/-1.7%. The re-measurement also confirms the eviction fix is inert on the hot path and returns the thrash lane to parity with pre-264.", + "status": "accepted", + "link": "https://github.com/danReynolds/resqlite/blob/main/experiments/264-initial-alloc-size-memory.md" +} diff --git a/experiments/signals/base.json b/experiments/signals/base.json index 050fa407..dff9e2bb 100644 --- a/experiments/signals/base.json +++ b/experiments/signals/base.json @@ -273,14 +273,14 @@ "isolate-transfer", "api-shape" ], - "currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy. Exp 167 rechecked closed exp 141's direct ResultSet.forEach override on a real SQLite-backed consumer lane after exp 158 and rejected it: a small first-pair win reversed on the longer confirmation pair, so no runtime code was kept. Exp 174 found a transport asymmetry: the reader 'sacrifice' path (Isolate.exit + reader respawn) is a real win for the rows path because it transfers already-built Dart objects with no re-copy, but it was applied by result size to selectBytes too, where the native JSON must be Uint8List.fromList-copied before Isolate.exit can transfer it — so sacrifice saved zero copies on bytes and only added a respawn, while the non-sacrifice bytes path copied twice. selectBytes now sends a Uint8List view over the connection's persistent json_buf and never sacrifices: -44% (~1.8x) on large (>256KB) byte reads by eliminating the respawn, -4% on small, at a bounded ~+15MB RSS high-water (readers no longer respawned). Exp 175 adds a named release-suite guard for that large-bytes path: `Large payload (~650KB) / resqlite selectBytes()` measured 0.323 ms wall / 0.000 ms main and is curated as `selectBytes() large bytes`, so the history no longer relies on the sub-256KB 1K-row metric to watch exp 174. Rows select() keeps sacrifice — there the zero-copy object transfer is real. Exp 176 closed a gap exp 158 left inside the same RowSchema index: Row.containsKey still hashed the key in the private HashMap on every call, bypassing the identity fast path its sibling operator[] already used, so it ran ~+3.6 ms slower than a LinkedHashMap on the focused containsKey lane. Routing it through a shared RowSchema.containsName (= indexOf(name) >= 0) improved that lane ~13.0 -> ~10.0 ms (-23%) with a flat hot-lookup control, flipping Row to at-parity, behavior-identical and no API change. The win is interned-key-specific (decoded schema names are not identical to user literals, so production probes generally fall through to the HashMap, same cost as before). Exp 193 rejected replacing Row.values' custom iterator with a fixed ListBase slice view: JIT row_map_facade values samples were unstable, and the AOT check showed the original _RowValueIterator faster (2.663-2.687 ms) than the list view (6.828-7.101 ms), so Row.values should keep the custom iterator unless a future Dart runtime changes compiled behavior. Exp 183 closed the remaining piece of exp 174's bounded RSS trade-off by quantifying it and reclaiming it: a new Diagnostics.readerJsonBufHighWaterBytes field exposes per-reader json_buf.cap, the focused json_buf_retention.dart audit confirms pathological retention is real (8 concurrent x 8 MB selectBytes pin 32 MB across the 4-reader pool for the rest of the connection), and a C-side reader-worker shrink fired after SendPort.send returns (gated by cap > 1 MB AND last_used_len < 256 KB) reclaims back to the 16 KB initial cap on subsequent small reads — post-burst settle 32 MB -> 64 KB, recurring-large 16 MB -> 64 KB, with neutral large_bytes_transfer.dart numbers. Exp 185 promotes that diagnostic into the release diagnostics suite: `SQLite Diagnostics / JSON buffer reclaim (8 large selectBytes + 64 small settles)` now records `jsonBufKiB` and fails if the post-settle high-water exceeds 512 KiB; the first focused suite run settled at 64.0 KiB with idle readers, so the exp 183 reclaim has public regression visibility. Exp 190 takes the encoder-side win inside the same direction: `write_json_to_buf` in `native/resqlite.c` now pre-builds each column's `\"col\":` / `,\"col\":` token once at first-row time into a per-query scratch buffer, so subsequent rows emit each column with one `buf_write` instead of comma + `json_write_string` (SWAR scan + escape walk) + colon. Focused `select_bytes_wide_cols.dart` measures -4% to -11% across two order-flipped passes on 10k-row x 8 / 20-col shapes; `large_bytes_transfer.dart` (exp 174's focused guard) also moves -8.7% / -8.2% on large/small lanes. Regression guards (1 row, 100 rows) stay in the sub-microsecond noise floor. Exp 192 closes the bounded headroom exp 023 left inside `write_json_to_buf`'s `SQLITE_INTEGER` arm: replacing the single-digit `fast_i64_to_str` body with a two-digit `[00..99]` lookup table (one `% 100` / `/ 100` and one 2-byte memcpy per digit pair) cuts focused integer-heavy selectBytes by −8 to −26 % across two order-flipped passes on `select_bytes_int_heavy.dart` — biggest win on 10k × 20 ~18-digit big ints (−24 to −26 %), where the digit-loop length is greatest. Mixed-cell and small-payload regression guards stay inside ±1 %. The release suite is not the right denominator (no lane is integer-heavy enough), so `select_bytes_int_heavy.dart` is the durable gate for future selectBytes integer-encode work. Exp 194 then specializes `write_json_to_buf`'s `SQLITE_FLOAT` arm for exact integral REAL values: finite values in the exact double integer range (`abs(v) <= 2^53`) reuse `fast_i64_to_str`, while fractional values, huge values, non-finite values, and negative zero stay on `snprintf(\"%.17g\")`. Focused `select_bytes_real_int_fastpath.dart` measures roughly -79% to -82% on 10k-row integral-REAL lanes across an order-flipped pair, -72% on the 1k x 2 small lane, a flat +0.5% fractional-REAL fallback guard, and a mixed lane that moves in proportion to its integral-REAL cells. The release suite is not the denominator for this per-cell formatter path; the focused REAL harness is the durable gate. Exp 195 promotes exp 190's per-query `tokens_buf` scratch into the `resqlite_cached_stmt` entry, amortizing the per-query `buf_init(64)` + `free` pair and the first-row pre-encode walk across every re-execution of the same prepared SQL. Per-row inner loop and JSON output are byte-identical to exp 190. The exp 190 1-row regression guard sits at the millisecond-reporting harness floor and looked neutral; a new microsecond-precision focused harness `select_bytes_repeated_calls.dart` (1000 calls per sample) measures the predicted shape directly — 1-row × 20-col improves −9.2 % / −7.2 % and 10-row × 20-col improves −5.1 % / −2.7 % across two order-flipped passes, while 100/1000-row guards show sign reversal (drift-suspected per exp 177's classifier; per-query setup is < 0.2 % of wall there). Exp 190's `wide_cols.dart` 10k-row shapes also trend candidate-faster on every lane across both passes. Memory cost per cached statement is `O(col_count * 8 + name_byte_count)` capped by `STMT_CACHE_MAX = 32` per connection. Exp 216 consumes exp 201's BLOB follow-up by unrolling `json_write_base64` from one 3-byte group per loop trip to four groups per trip. `select_bytes_blob_base64.dart` keeps the 4KB BLOB lane candidate-faster across three passes (-13.6%, -12.9%, -11.9%), keeps 128B BLOBs same-direction faster (-1.1%, -9.3%, -4.7%), and leaves 3B tiny-cell lanes neutral/noisy as expected because one-triplet cells mostly bypass the unrolled loop. Output bytes, padding, JSON framing, and public API are unchanged. Exp 218 then tested whether exp 216 left scalar loop-width headroom by widening the same base64 loop to eight triplets per iteration. The 128B lane stayed candidate-faster, but the load-bearing 4KB lane mixed across four paired passes (-10.0%, +2.0%, -13.6%, +0.8%), so the wider native body is rejected and exp 216's four-triplet unroll remains the portable baseline. Exp 219 then consumes exp 202's TEXT future note for unnamed control-character escapes: `json_write_string` now emits `\\u00XX` directly from a small hex table instead of formatting through `snprintf(\"\\\\u%04x\")`. The focused TEXT harness adds a control-character lane and reproduces the target win across the order flip (35927 -> 6371 us/query, -82.3%; 35824 -> 6370 us/query, -82.2%). Safe ASCII and named-escape rows remain guardrails for unrelated fallout, and JSON output/public API are unchanged. Exp 224 then consumes exp 074's pure-numeric bulk-step follow-up without copying TEXT/BLOB payloads: a dynamic batch returns after any pointer-backed row but batches up to 64 contiguous numeric/NULL rows. `select_rows_step_row_ffi.dart` finds no numeric win across the order flip (10k x 8 +1.9%/+0.5%; 10k x 20 +4.6%/-0.5%) and reproduces a 5-7% short-TEXT regression, so multi-row `select()` stepping is closed under the current Dart leaf-FFI/runtime baseline. Exp 225 then consumes exp 218's future-work note (loop-unroll of `json_write_base64` is at ceiling; further BLOB work needs a different mechanism): the inner base64 lookup widens from a 6-bit table (four per triplet) to a 12-bit pair table (two per triplet), keeping exp 216's 4x-unrolled outer loop. Three order-flipped passes of `select_bytes_blob_base64.dart` reproduce -26 to -31% on 4 KB blobs and -17 to -31% on 128 B blobs, with 3 B tiny-cell guards flat at ~-0.5% to -0.9%. Output is bit-identical; cost is 8 KiB `.bss`. The scalar body is now two 16-bit loads + two 16-bit stores per four output bytes. Exp 229 then consumes exp 225's future-work note (SIMD `_mm_shuffle_epi8` / `vqtbl4q_u8` base64 kernels named as the next mechanism) as the direction's first *moonshot* accept: an AArch64/NEON `vld3q_u8` + `vqtbl4q_u8` + `vst4q_u8` kernel gated on `defined(__aarch64__) && defined(__ARM_NEON)` processes 48 input bytes into 64 output bytes per iteration, with the exp 225 scalar 12-bit-LUT encoder retained as fallback for non-ARM64 targets and for the < 48-byte tail on all targets. Four order-flipped paired passes of `select_bytes_blob_base64.dart` reproduce ~-45% median on the 4 KB payload-throughput lane (roughly 2x faster BLOB base64 encoding) and ~-18% on the 128 B lane; tiny-cell (3 B) and mixed guards stay inside the harness noise floor because the SIMD kernel is out-of-lined (`__attribute__((noinline))`) so the small-input hot path retains exp 225's byte-identical layout. This is the first SIMD kernel in `native/resqlite.c` — the categorical implication is that ISA-specific kernels are viable when out-of-lined; SSSE3 x86_64, SIMD JSON escape scan, and SIMD FNV hash follow-ups are now unlocked. Exp 230 then tested the TEXT-side SIMD mechanism exp 229 unlocked: an AArch64/NEON encoder fused 16-byte escape classification with safe-prefix stores behind a 256 B cutoff, falling back to the canonical scalar loop at the first escapable vector. The far-end mechanism is real — safe 1 KiB ASCII reproduced -33.6% / -34.1% and long CJK -25.2% / -25.5% — but the load-bearing 256 B lane narrowed from -17.7% to -12.6% and missed the preset 15% adoption bar in the second ordering. Early escape stayed neutral and late-escape magnitude did not reproduce. The ISA-specific runtime prototype is rejected and archived; the long-safe, early-/late-escape harness lanes and exact boundary correctness test remain. Exp 231 then probed the other value type exp 229 unlocked — an out-of-lined AArch64/NEON i64→decimal kernel for the SQLITE_INTEGER arm — and rejected it, bounding exp 229's categorical claim. The kernel is byte-identical (magnitude split into low8/mid8/high groups via two /1e8 divides, each 8-digit group formatted with vector reciprocal splits, gated ≥ 1e8) but across 11 focused A/B passes in three methodologies the ~18-digit BIGINT selectBytes lane never leaned candidate-faster (+1.2/+6.0/+9.8% baseline-first, +11.3% min-of-N, ~flat only in the one quiet pair) while identical-code controls drifted ±10-120%. The finding: exp 229's out-of-line ISA-kernel win amortises SIMD setup over an entire BLOB per call, whereas the integer formatter converts ONE scalar value per call with nothing to amortise — SIMD is viable for bulk per-cell payloads (base64), not scalar per-cell values (integers). Runtime archived at `archive/exp-231`; the new i64 differential test (first direct coverage of the integer formatter) is kept. Exp 232 then tested extending exp 194 to exact `.25`/`.5`/`.75` REAL values and proved a real mechanism: 100%-eligible lanes improved 78-87% and a synthetic 50%-eligible row improved 50-53%. It is rejected because no production or representative distribution established the eligible share, all general-fractional summaries leaned 0.65-1.95% slower, and the specialization required 114 native/build-hook lines plus permanent value-lattice semantics and test surface. Runtime and quarter-only scaffolding are removed; `archive/exp-232` preserves the prototype, and exact integral remains the only shipped REAL specialization. Exp 240 then consumed exp 231's explicit reopen — batch many integer cells into one encode call — and closed it from both sides. Its three test-only array encoders (`resqlite_test_i64_array_*`) show, in isolation, that a 2-way software-pipelined SCALAR formatter overlaps two values' divide chains for -6 to -13% on mid/big magnitudes, while a NEON vector kernel handed an array still loses +45 to +60% on realistic short (<9 digit) values (fixed vector setup unamortisable below ~9 digits) — reproducing exp 231's per-value SIMD loss with an array in hand. But wiring the winning pipelined-scalar into `write_json_to_buf`'s SQLITE_INTEGER arm inverted the result: integer-heavy selectBytes was uniformly +1 to +12% slower across four passes, worst on the big-ints lane the microbench won most. Integer conversion is not the encoder's bottleneck, the pair-lookahead machinery outweighs the overlapped chains, and a pair's two i64s are fetched serially via sqlite3_value_int64 so the cross-value-pipelining premise never holds on the hot path (same shape as exp 226's isolated packer win that missed the end-to-end gate). Batched/columnar integer FORMATTING is now closed as a formatter tweak; reopen only via a columnar transfer shape that hands the encoder a contiguous array of already-fetched i64s with framing decoupled from the per-value fetch. `i64_batch_encode.dart` + the array encoders are the durable conversion gate. Exp 248 closes the last unmeasured component of the per-connection C statement cache. `resqlite_cached_stmt` is 1,632 bytes because it embeds exp 106's `read_tables[64]` (512 B) and `dep_columns[64]` (1 KB) arrays, so the move-to-back MRU promotion in `stmt_cache_lookup_entry` copies 4,896 bytes (three full-struct copies) every time it fires, and eviction memmoves ~50 KB. Exp 071 and exp 207 both examined this function but only on a SINGLE repeated SQL — a shape where the hot entry already sits at the tail and the promotion branch is structurally unreachable — so the swap had never been measured. Replacing it with a per-entry `lru_seq` recency stamp plus in-place min-stamp eviction (identical LRU policy, stable slots) removes ~60-65 ns per lookup when the swap fires, confirmed by an isolated C measurement over the real struct layout (85.47 -> 20.97 ns at cache=8/2 distinct SQL; 144.28 -> 88.98 at cache=32/2), with flat single-SQL controls. It is rejected anyway: ~65 ns against a ~7-10 us `selectBytes()` round trip is ~0.7% of per-call wall, and across two order-flipped passes no lane reproduced a same-sign win while the mechanically-inert control lanes (identical code both sides) swung +24%/+27%/+103%/+42%. Scan order (071), scan short-circuit (207), and promotion cost (248) are now all bounded and all immaterial — treat `stmt_cache_lookup_entry` as closed. `archive/exp-248` preserves the prototype, which also happens to be the ready-made fix if `reader->last_entry` / `writer_active_entry` ever stop being protected by serialization (they alias a slot, not an entry). Exp 236 extends exp 234's hop mechanism to the read side: blob cells >= 256 KB decode straight into TransferableTypedData (native->malloc'd-external, one copy, GC-invisible), and every main-isolate receive boundary materializes them back to Uint8List views. Blob-dominated reads stop sacrificing the reader per query (3-6x faster; 512 KB select -80% to -83% across three order-flipped passes) and tx.select — which can never sacrifice — improves ~6-7x. Exp 243 fixes an aliasing regression exp 234 introduced on the write side: wrapping ran per parameter OCCURRENCE, so one Uint8List passed to N positions became N external copies. Wrapping is now identity-keyed (one wrapper per unique buffer, referenced at every position, materialized once via a cache spanning a coalesced envelope), which is flat and fastest at every reference count where the old path blew up linearly (~27x at N=32). Exps 241/244/245/246 then settled send-vs-sacrifice, which exp 241 could not: alternating the two inside one live pool is a state-changing treatment (each sacrifice kills a worker, respawns, clears caches), so the estimands had to be separated. Exp 245 measured the INTRINSIC transfer with a prepared-result barrier (real ResultSet built before a Go signal, one fresh process per observation, ABBA, empty-envelope control): Isolate.exit carries a ~47 us FIXED premium, send's copy cost tracks the MUTABLE flat-list SLOT COUNT and not payload bytes (a 400 KB string adds ~0 us because immutable leaves are SHARED), and exit's sendability walk also scales with slots but more gently — so it is copy-per-slot vs verify-per-slot, crossing over at ~48k slots. Exp 244 measured the POOL estimand (8 requests / 4 workers behind a barrier, pool reset between bursts, decode-free dispatch queue-wait): there is no replacement-capacity hole — the no-sacrifice lane was the WORST (~+19% parked wait) because send's copy blocks the worker longer than exit plus an overlapped respawn — and an eager-respawn candidate was measured and rejected as inert at pool-4. Exp 246 shipped the consequence: the sacrifice trigger now routes on mutable slot count (32k slots = the all-integer equivalent of the old 256 KB byte threshold), because routing on BYTES misrouted results large in bytes but small in slots — a few rows holding a big TEXT/BLOB — into a sacrifice that respawned a reader on every such read to avoid a copy that never happens (131 -> 91 us, 1.00 -> 0.00 sacrifices per select, numeric/structural routing unchanged). The byte machinery was deleted with it, INCLUDING exp 236's transferableBytes subtraction, which slot routing makes moot. Net rule for this direction: BYTES for mutable payloads (a Uint8List really is copied), SLOTS for result structure. Exp 251 then consumed the exp 245/246 decomposition request with a native-asset-aware AOT harness over five fresh processes. The direct worker-path full proxy was typically 84-94% of separately measured main-observed select() latency, but the split was shape-dependent: mixed result construction 63%, small-integer stepping/native fill 60%, and the other two shapes roughly even. Estimated result construction was 34-58% of observed latency. No general decode bottleneck emerged, strengthening exp 258's broad columnar rejection while leaving its narrow Int64List consume signal workload-gated. Exp 259 then found a different kind of headroom inside the same shape: not where the result is stored, but which side of the FFI boundary answers a per-cell question. `decodeQuery` could not build a String from a TEXT cell without first classifying it as pure ASCII (`String.fromCharCodes` widen) or not (`utf8.decode`), and it was doing that in Dart — a second ExternalTypedData word view plus a bounds-checked scan per cell, degrading to a byte-by-byte loop under 16 bytes, which covers most database text. `resqlite_step_row` holds the same pointer and length one frame earlier, where the answer is a branch-free SWAR pass over bytes already in cache; it now reports RESQLITE_TEXT_ASCII (type code 6) and the decoder's TEXT arm splits into a widen path and a straight-to-utf8.decode path. Reproduced across two order-flipped AOT passes: 10k x 8 short ASCII TEXT -12.3% / -10.4%, 40 B -14.3% / -10.7%, 2k x 4 400 B -22.2% / -19.4%, the repo's canonical 6-column mixed product row -24.4% / -19.2%, with the INTEGER-only control and both non-ASCII guards neutral. Unlike exps 081/251/258, this removes work rather than relocating it, which is why it wins where the storage-shape rewrites did not. Exp 260 revises how exp 251's decomposition should be read: most of what it labelled `Dart result construction` was the result buffer growing, not cells decoding. `decodeQuery` starts at 256 rows' worth of `List` slots and doubles, and a `List` grow copies every live element with a store barrier into a freshly faulted array — 2318 us vs 865 us pre-sized on 10k x 20 INTEGER, against under 500 us for the loads, switch and stores combined. Sizing the buffer from a main-isolate memory of what the same SQL returned before is worth a reproduced 25-34% on repeated multi-thousand-row public `select()` reads. Two placement facts generalise beyond this change: a hint cannot live in the worker, because `Isolate.exit` destroys the reader on exactly the results large enough to need one (worker-local: 30-35% on sub-threshold reads, ~1% at 200k slots; request-carried: -25% at 200k slots); and it must be applied at the growth step rather than the initial allocation, which makes it structurally unreachable for small results instead of merely conservative — the initial-allocation version made a saturated `LIMIT ?` statement's 50-row leg 2.8x slower. Exp 263 closes exp 261's ~60x payload-to-peak ratio (claim 261.4) rather than confirming it. Holding seeding constant and varying only the read, select() costs 396 B/row against 137.8 B of payload — 2.9x, within ~1.4x of the theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The 60x was a fixed floor: 14.0 MB bare AOT Dart process, 20.5 MB after resqlite opens and spawns its pool, 32.8 MB after seeding 20k rows, and only 3.6 MB more for a live 10k-row result. Do not reopen a result-shape rewrite on ratio grounds — the one piece of contrary evidence has been removed. Two riders: selectBytes costs MORE per row than select (676 vs 396 B/row) because JSON repeats column names and quotes values, so it is for allocation churn and not footprint; and a memory sweep crossing sacrificeSlotThreshold reads sub-linear because Isolate.exit returns the worker heap, which looks like a representation win and is not one.", + "currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy. Exp 167 rechecked closed exp 141's direct ResultSet.forEach override on a real SQLite-backed consumer lane after exp 158 and rejected it: a small first-pair win reversed on the longer confirmation pair, so no runtime code was kept. Exp 174 found a transport asymmetry: the reader 'sacrifice' path (Isolate.exit + reader respawn) is a real win for the rows path because it transfers already-built Dart objects with no re-copy, but it was applied by result size to selectBytes too, where the native JSON must be Uint8List.fromList-copied before Isolate.exit can transfer it — so sacrifice saved zero copies on bytes and only added a respawn, while the non-sacrifice bytes path copied twice. selectBytes now sends a Uint8List view over the connection's persistent json_buf and never sacrifices: -44% (~1.8x) on large (>256KB) byte reads by eliminating the respawn, -4% on small, at a bounded ~+15MB RSS high-water (readers no longer respawned). Exp 175 adds a named release-suite guard for that large-bytes path: `Large payload (~650KB) / resqlite selectBytes()` measured 0.323 ms wall / 0.000 ms main and is curated as `selectBytes() large bytes`, so the history no longer relies on the sub-256KB 1K-row metric to watch exp 174. Rows select() keeps sacrifice — there the zero-copy object transfer is real. Exp 176 closed a gap exp 158 left inside the same RowSchema index: Row.containsKey still hashed the key in the private HashMap on every call, bypassing the identity fast path its sibling operator[] already used, so it ran ~+3.6 ms slower than a LinkedHashMap on the focused containsKey lane. Routing it through a shared RowSchema.containsName (= indexOf(name) >= 0) improved that lane ~13.0 -> ~10.0 ms (-23%) with a flat hot-lookup control, flipping Row to at-parity, behavior-identical and no API change. The win is interned-key-specific (decoded schema names are not identical to user literals, so production probes generally fall through to the HashMap, same cost as before). Exp 193 rejected replacing Row.values' custom iterator with a fixed ListBase slice view: JIT row_map_facade values samples were unstable, and the AOT check showed the original _RowValueIterator faster (2.663-2.687 ms) than the list view (6.828-7.101 ms), so Row.values should keep the custom iterator unless a future Dart runtime changes compiled behavior. Exp 183 closed the remaining piece of exp 174's bounded RSS trade-off by quantifying it and reclaiming it: a new Diagnostics.readerJsonBufHighWaterBytes field exposes per-reader json_buf.cap, the focused json_buf_retention.dart audit confirms pathological retention is real (8 concurrent x 8 MB selectBytes pin 32 MB across the 4-reader pool for the rest of the connection), and a C-side reader-worker shrink fired after SendPort.send returns (gated by cap > 1 MB AND last_used_len < 256 KB) reclaims back to the 16 KB initial cap on subsequent small reads — post-burst settle 32 MB -> 64 KB, recurring-large 16 MB -> 64 KB, with neutral large_bytes_transfer.dart numbers. Exp 185 promotes that diagnostic into the release diagnostics suite: `SQLite Diagnostics / JSON buffer reclaim (8 large selectBytes + 64 small settles)` now records `jsonBufKiB` and fails if the post-settle high-water exceeds 512 KiB; the first focused suite run settled at 64.0 KiB with idle readers, so the exp 183 reclaim has public regression visibility. Exp 190 takes the encoder-side win inside the same direction: `write_json_to_buf` in `native/resqlite.c` now pre-builds each column's `\"col\":` / `,\"col\":` token once at first-row time into a per-query scratch buffer, so subsequent rows emit each column with one `buf_write` instead of comma + `json_write_string` (SWAR scan + escape walk) + colon. Focused `select_bytes_wide_cols.dart` measures -4% to -11% across two order-flipped passes on 10k-row x 8 / 20-col shapes; `large_bytes_transfer.dart` (exp 174's focused guard) also moves -8.7% / -8.2% on large/small lanes. Regression guards (1 row, 100 rows) stay in the sub-microsecond noise floor. Exp 192 closes the bounded headroom exp 023 left inside `write_json_to_buf`'s `SQLITE_INTEGER` arm: replacing the single-digit `fast_i64_to_str` body with a two-digit `[00..99]` lookup table (one `% 100` / `/ 100` and one 2-byte memcpy per digit pair) cuts focused integer-heavy selectBytes by −8 to −26 % across two order-flipped passes on `select_bytes_int_heavy.dart` — biggest win on 10k × 20 ~18-digit big ints (−24 to −26 %), where the digit-loop length is greatest. Mixed-cell and small-payload regression guards stay inside ±1 %. The release suite is not the right denominator (no lane is integer-heavy enough), so `select_bytes_int_heavy.dart` is the durable gate for future selectBytes integer-encode work. Exp 194 then specializes `write_json_to_buf`'s `SQLITE_FLOAT` arm for exact integral REAL values: finite values in the exact double integer range (`abs(v) <= 2^53`) reuse `fast_i64_to_str`, while fractional values, huge values, non-finite values, and negative zero stay on `snprintf(\"%.17g\")`. Focused `select_bytes_real_int_fastpath.dart` measures roughly -79% to -82% on 10k-row integral-REAL lanes across an order-flipped pair, -72% on the 1k x 2 small lane, a flat +0.5% fractional-REAL fallback guard, and a mixed lane that moves in proportion to its integral-REAL cells. The release suite is not the denominator for this per-cell formatter path; the focused REAL harness is the durable gate. Exp 195 promotes exp 190's per-query `tokens_buf` scratch into the `resqlite_cached_stmt` entry, amortizing the per-query `buf_init(64)` + `free` pair and the first-row pre-encode walk across every re-execution of the same prepared SQL. Per-row inner loop and JSON output are byte-identical to exp 190. The exp 190 1-row regression guard sits at the millisecond-reporting harness floor and looked neutral; a new microsecond-precision focused harness `select_bytes_repeated_calls.dart` (1000 calls per sample) measures the predicted shape directly — 1-row × 20-col improves −9.2 % / −7.2 % and 10-row × 20-col improves −5.1 % / −2.7 % across two order-flipped passes, while 100/1000-row guards show sign reversal (drift-suspected per exp 177's classifier; per-query setup is < 0.2 % of wall there). Exp 190's `wide_cols.dart` 10k-row shapes also trend candidate-faster on every lane across both passes. Memory cost per cached statement is `O(col_count * 8 + name_byte_count)` capped by `STMT_CACHE_MAX = 32` per connection. Exp 216 consumes exp 201's BLOB follow-up by unrolling `json_write_base64` from one 3-byte group per loop trip to four groups per trip. `select_bytes_blob_base64.dart` keeps the 4KB BLOB lane candidate-faster across three passes (-13.6%, -12.9%, -11.9%), keeps 128B BLOBs same-direction faster (-1.1%, -9.3%, -4.7%), and leaves 3B tiny-cell lanes neutral/noisy as expected because one-triplet cells mostly bypass the unrolled loop. Output bytes, padding, JSON framing, and public API are unchanged. Exp 218 then tested whether exp 216 left scalar loop-width headroom by widening the same base64 loop to eight triplets per iteration. The 128B lane stayed candidate-faster, but the load-bearing 4KB lane mixed across four paired passes (-10.0%, +2.0%, -13.6%, +0.8%), so the wider native body is rejected and exp 216's four-triplet unroll remains the portable baseline. Exp 219 then consumes exp 202's TEXT future note for unnamed control-character escapes: `json_write_string` now emits `\\u00XX` directly from a small hex table instead of formatting through `snprintf(\"\\\\u%04x\")`. The focused TEXT harness adds a control-character lane and reproduces the target win across the order flip (35927 -> 6371 us/query, -82.3%; 35824 -> 6370 us/query, -82.2%). Safe ASCII and named-escape rows remain guardrails for unrelated fallout, and JSON output/public API are unchanged. Exp 224 then consumes exp 074's pure-numeric bulk-step follow-up without copying TEXT/BLOB payloads: a dynamic batch returns after any pointer-backed row but batches up to 64 contiguous numeric/NULL rows. `select_rows_step_row_ffi.dart` finds no numeric win across the order flip (10k x 8 +1.9%/+0.5%; 10k x 20 +4.6%/-0.5%) and reproduces a 5-7% short-TEXT regression, so multi-row `select()` stepping is closed under the current Dart leaf-FFI/runtime baseline. Exp 225 then consumes exp 218's future-work note (loop-unroll of `json_write_base64` is at ceiling; further BLOB work needs a different mechanism): the inner base64 lookup widens from a 6-bit table (four per triplet) to a 12-bit pair table (two per triplet), keeping exp 216's 4x-unrolled outer loop. Three order-flipped passes of `select_bytes_blob_base64.dart` reproduce -26 to -31% on 4 KB blobs and -17 to -31% on 128 B blobs, with 3 B tiny-cell guards flat at ~-0.5% to -0.9%. Output is bit-identical; cost is 8 KiB `.bss`. The scalar body is now two 16-bit loads + two 16-bit stores per four output bytes. Exp 229 then consumes exp 225's future-work note (SIMD `_mm_shuffle_epi8` / `vqtbl4q_u8` base64 kernels named as the next mechanism) as the direction's first *moonshot* accept: an AArch64/NEON `vld3q_u8` + `vqtbl4q_u8` + `vst4q_u8` kernel gated on `defined(__aarch64__) && defined(__ARM_NEON)` processes 48 input bytes into 64 output bytes per iteration, with the exp 225 scalar 12-bit-LUT encoder retained as fallback for non-ARM64 targets and for the < 48-byte tail on all targets. Four order-flipped paired passes of `select_bytes_blob_base64.dart` reproduce ~-45% median on the 4 KB payload-throughput lane (roughly 2x faster BLOB base64 encoding) and ~-18% on the 128 B lane; tiny-cell (3 B) and mixed guards stay inside the harness noise floor because the SIMD kernel is out-of-lined (`__attribute__((noinline))`) so the small-input hot path retains exp 225's byte-identical layout. This is the first SIMD kernel in `native/resqlite.c` — the categorical implication is that ISA-specific kernels are viable when out-of-lined; SSSE3 x86_64, SIMD JSON escape scan, and SIMD FNV hash follow-ups are now unlocked. Exp 230 then tested the TEXT-side SIMD mechanism exp 229 unlocked: an AArch64/NEON encoder fused 16-byte escape classification with safe-prefix stores behind a 256 B cutoff, falling back to the canonical scalar loop at the first escapable vector. The far-end mechanism is real — safe 1 KiB ASCII reproduced -33.6% / -34.1% and long CJK -25.2% / -25.5% — but the load-bearing 256 B lane narrowed from -17.7% to -12.6% and missed the preset 15% adoption bar in the second ordering. Early escape stayed neutral and late-escape magnitude did not reproduce. The ISA-specific runtime prototype is rejected and archived; the long-safe, early-/late-escape harness lanes and exact boundary correctness test remain. Exp 231 then probed the other value type exp 229 unlocked — an out-of-lined AArch64/NEON i64→decimal kernel for the SQLITE_INTEGER arm — and rejected it, bounding exp 229's categorical claim. The kernel is byte-identical (magnitude split into low8/mid8/high groups via two /1e8 divides, each 8-digit group formatted with vector reciprocal splits, gated ≥ 1e8) but across 11 focused A/B passes in three methodologies the ~18-digit BIGINT selectBytes lane never leaned candidate-faster (+1.2/+6.0/+9.8% baseline-first, +11.3% min-of-N, ~flat only in the one quiet pair) while identical-code controls drifted ±10-120%. The finding: exp 229's out-of-line ISA-kernel win amortises SIMD setup over an entire BLOB per call, whereas the integer formatter converts ONE scalar value per call with nothing to amortise — SIMD is viable for bulk per-cell payloads (base64), not scalar per-cell values (integers). Runtime archived at `archive/exp-231`; the new i64 differential test (first direct coverage of the integer formatter) is kept. Exp 232 then tested extending exp 194 to exact `.25`/`.5`/`.75` REAL values and proved a real mechanism: 100%-eligible lanes improved 78-87% and a synthetic 50%-eligible row improved 50-53%. It is rejected because no production or representative distribution established the eligible share, all general-fractional summaries leaned 0.65-1.95% slower, and the specialization required 114 native/build-hook lines plus permanent value-lattice semantics and test surface. Runtime and quarter-only scaffolding are removed; `archive/exp-232` preserves the prototype, and exact integral remains the only shipped REAL specialization. Exp 240 then consumed exp 231's explicit reopen — batch many integer cells into one encode call — and closed it from both sides. Its three test-only array encoders (`resqlite_test_i64_array_*`) show, in isolation, that a 2-way software-pipelined SCALAR formatter overlaps two values' divide chains for -6 to -13% on mid/big magnitudes, while a NEON vector kernel handed an array still loses +45 to +60% on realistic short (<9 digit) values (fixed vector setup unamortisable below ~9 digits) — reproducing exp 231's per-value SIMD loss with an array in hand. But wiring the winning pipelined-scalar into `write_json_to_buf`'s SQLITE_INTEGER arm inverted the result: integer-heavy selectBytes was uniformly +1 to +12% slower across four passes, worst on the big-ints lane the microbench won most. Integer conversion is not the encoder's bottleneck, the pair-lookahead machinery outweighs the overlapped chains, and a pair's two i64s are fetched serially via sqlite3_value_int64 so the cross-value-pipelining premise never holds on the hot path (same shape as exp 226's isolated packer win that missed the end-to-end gate). Batched/columnar integer FORMATTING is now closed as a formatter tweak; reopen only via a columnar transfer shape that hands the encoder a contiguous array of already-fetched i64s with framing decoupled from the per-value fetch. `i64_batch_encode.dart` + the array encoders are the durable conversion gate. Exp 248 closes the last unmeasured component of the per-connection C statement cache. `resqlite_cached_stmt` is 1,632 bytes because it embeds exp 106's `read_tables[64]` (512 B) and `dep_columns[64]` (1 KB) arrays, so the move-to-back MRU promotion in `stmt_cache_lookup_entry` copies 4,896 bytes (three full-struct copies) every time it fires, and eviction memmoves ~50 KB. Exp 071 and exp 207 both examined this function but only on a SINGLE repeated SQL — a shape where the hot entry already sits at the tail and the promotion branch is structurally unreachable — so the swap had never been measured. Replacing it with a per-entry `lru_seq` recency stamp plus in-place min-stamp eviction (identical LRU policy, stable slots) removes ~60-65 ns per lookup when the swap fires, confirmed by an isolated C measurement over the real struct layout (85.47 -> 20.97 ns at cache=8/2 distinct SQL; 144.28 -> 88.98 at cache=32/2), with flat single-SQL controls. It is rejected anyway: ~65 ns against a ~7-10 us `selectBytes()` round trip is ~0.7% of per-call wall, and across two order-flipped passes no lane reproduced a same-sign win while the mechanically-inert control lanes (identical code both sides) swung +24%/+27%/+103%/+42%. Scan order (071), scan short-circuit (207), and promotion cost (248) are now all bounded and all immaterial — treat `stmt_cache_lookup_entry` as closed. `archive/exp-248` preserves the prototype, which also happens to be the ready-made fix if `reader->last_entry` / `writer_active_entry` ever stop being protected by serialization (they alias a slot, not an entry). Exp 236 extends exp 234's hop mechanism to the read side: blob cells >= 256 KB decode straight into TransferableTypedData (native->malloc'd-external, one copy, GC-invisible), and every main-isolate receive boundary materializes them back to Uint8List views. Blob-dominated reads stop sacrificing the reader per query (3-6x faster; 512 KB select -80% to -83% across three order-flipped passes) and tx.select — which can never sacrifice — improves ~6-7x. Exp 243 fixes an aliasing regression exp 234 introduced on the write side: wrapping ran per parameter OCCURRENCE, so one Uint8List passed to N positions became N external copies. Wrapping is now identity-keyed (one wrapper per unique buffer, referenced at every position, materialized once via a cache spanning a coalesced envelope), which is flat and fastest at every reference count where the old path blew up linearly (~27x at N=32). Exps 241/244/245/246 then settled send-vs-sacrifice, which exp 241 could not: alternating the two inside one live pool is a state-changing treatment (each sacrifice kills a worker, respawns, clears caches), so the estimands had to be separated. Exp 245 measured the INTRINSIC transfer with a prepared-result barrier (real ResultSet built before a Go signal, one fresh process per observation, ABBA, empty-envelope control): Isolate.exit carries a ~47 us FIXED premium, send's copy cost tracks the MUTABLE flat-list SLOT COUNT and not payload bytes (a 400 KB string adds ~0 us because immutable leaves are SHARED), and exit's sendability walk also scales with slots but more gently — so it is copy-per-slot vs verify-per-slot, crossing over at ~48k slots. Exp 244 measured the POOL estimand (8 requests / 4 workers behind a barrier, pool reset between bursts, decode-free dispatch queue-wait): there is no replacement-capacity hole — the no-sacrifice lane was the WORST (~+19% parked wait) because send's copy blocks the worker longer than exit plus an overlapped respawn — and an eager-respawn candidate was measured and rejected as inert at pool-4. Exp 246 shipped the consequence: the sacrifice trigger now routes on mutable slot count (32k slots = the all-integer equivalent of the old 256 KB byte threshold), because routing on BYTES misrouted results large in bytes but small in slots — a few rows holding a big TEXT/BLOB — into a sacrifice that respawned a reader on every such read to avoid a copy that never happens (131 -> 91 us, 1.00 -> 0.00 sacrifices per select, numeric/structural routing unchanged). The byte machinery was deleted with it, INCLUDING exp 236's transferableBytes subtraction, which slot routing makes moot. Net rule for this direction: BYTES for mutable payloads (a Uint8List really is copied), SLOTS for result structure. Exp 251 then consumed the exp 245/246 decomposition request with a native-asset-aware AOT harness over five fresh processes. The direct worker-path full proxy was typically 84-94% of separately measured main-observed select() latency, but the split was shape-dependent: mixed result construction 63%, small-integer stepping/native fill 60%, and the other two shapes roughly even. Estimated result construction was 34-58% of observed latency. No general decode bottleneck emerged, strengthening exp 258's broad columnar rejection while leaving its narrow Int64List consume signal workload-gated. Exp 259 then found a different kind of headroom inside the same shape: not where the result is stored, but which side of the FFI boundary answers a per-cell question. `decodeQuery` could not build a String from a TEXT cell without first classifying it as pure ASCII (`String.fromCharCodes` widen) or not (`utf8.decode`), and it was doing that in Dart — a second ExternalTypedData word view plus a bounds-checked scan per cell, degrading to a byte-by-byte loop under 16 bytes, which covers most database text. `resqlite_step_row` holds the same pointer and length one frame earlier, where the answer is a branch-free SWAR pass over bytes already in cache; it now reports RESQLITE_TEXT_ASCII (type code 6) and the decoder's TEXT arm splits into a widen path and a straight-to-utf8.decode path. Reproduced across two order-flipped AOT passes: 10k x 8 short ASCII TEXT -12.3% / -10.4%, 40 B -14.3% / -10.7%, 2k x 4 400 B -22.2% / -19.4%, the repo's canonical 6-column mixed product row -24.4% / -19.2%, with the INTEGER-only control and both non-ASCII guards neutral. Unlike exps 081/251/258, this removes work rather than relocating it, which is why it wins where the storage-shape rewrites did not. Exp 260 revises how exp 251's decomposition should be read: most of what it labelled `Dart result construction` was the result buffer growing, not cells decoding. `decodeQuery` starts at 256 rows' worth of `List` slots and doubles, and a `List` grow copies every live element with a store barrier into a freshly faulted array — 2318 us vs 865 us pre-sized on 10k x 20 INTEGER, against under 500 us for the loads, switch and stores combined. Sizing the buffer from a main-isolate memory of what the same SQL returned before is worth a reproduced 25-34% on repeated multi-thousand-row public `select()` reads. Two placement facts generalise beyond this change: a hint cannot live in the worker, because `Isolate.exit` destroys the reader on exactly the results large enough to need one (worker-local: 30-35% on sub-threshold reads, ~1% at 200k slots; request-carried: -25% at 200k slots); and it must be applied at the growth step rather than the initial allocation, which makes it structurally unreachable for small results instead of merely conservative — the initial-allocation version made a saturated `LIMIT ?` statement's 50-row leg 2.8x slower. Exp 263 closes exp 261's ~60x payload-to-peak ratio (claim 261.4) rather than confirming it. Holding seeding constant and varying only the read, select() costs 396 B/row against 137.8 B of payload — 2.9x, within ~1.4x of the theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The 60x was a fixed floor: 14.0 MB bare AOT Dart process, 20.5 MB after resqlite opens and spawns its pool, 32.8 MB after seeding 20k rows, and only 3.6 MB more for a live 10k-row result. Do not reopen a result-shape rewrite on ratio grounds — the one piece of contrary evidence has been removed. Two riders: selectBytes costs MORE per row than select (676 vs 396 B/row) because JSON repeats column names and quotes values, so it is for allocation churn and not footprint; and a memory sweep crossing sacrificeSlotThreshold reads sub-linear because Isolate.exit returns the worker heap, which looks like a representation win and is not one. Exp 264 then took the other end of the same buffer, the initial allocation, which exp 260 deliberately left alone: `decodeQuery` sized it at `colCount * 256` on every read, so a one-row point read allocated and zero-filled 256 rows of slots to keep one. Sizing it from a main-isolate high-water mark of the rows a SQL has ever returned — clamped so it can only shrink below the fixed default, never grow past it — is a reproduced -7.4% on the canonical 6-column point read and -27.4% on a 21-column one, with every lane whose result outgrows the initial buffer neutral by construction. Read together, exps 260 and 264 mean the result buffer no longer carries a fixed per-read cost at either end, and the small-read path's remaining cost is the isolate round trip that exps 209 and 239 both closed on hidden-policy grounds.", "keyPriors": [ - "246", "251", "258", "259", "260", - "263" + "263", + "264" ], "archive": [ "008", @@ -327,10 +327,16 @@ "addedDate": "2026-07-29", "addedAfter": "258", "blockedOn": "Exp 251 measures worker-side decode/result construction, not application cell consumption. Needs a representative downstream AOT full-consumer workload where many INTEGER cells are actually read, main-isolate consumption is material, and columnar-vs-flat Row dispatch leaves TEXT and mixed paths neutral." + }, + { + "idea": "measure distinct-SQL churn against ReaderPool._rowHintMax (32) in a representative app trace; an evicted row-size entry loses both exp 260's growth hint and exp 264's high-water mark, so cap thrash silently reverts both wins", + "addedDate": "2026-08-06", + "addedAfter": "264", + "blockedOn": "needs a representative downstream statement mix; the repo's suites use a handful of SQL strings and cannot thrash a 32-entry cap" } ], "blockedOnMeasurement": [], - "notesForExperimenters": "Be explicit about API impact. Some measured wins depend on a new public API, which is outside the lean-API goal. Use `select_maps`, `resultset_foreach_consumer.dart`, or a similarly full-consumer benchmark before accepting result-shape changes; setup-only transfer wins are not enough. Exp 158 is a narrow private data-structure win inside RowSchema, not evidence to revive larger ResultSet API changes. Exp 176 extended exp 158's identity fast path to Row.containsKey via a shared RowSchema.containsName helper; the two lookup methods (operator[]/indexOf and containsKey/containsName) now share one identity-then-HashMap path, so any future change to the 32-column cap or the identity scan applies to both. Do not retry the exp 141 ResultSet.forEach override unless a future Dart runtime or workload produces a stable target win with neutral controls. Do not retry Row.values ListBase/getRange/fixed-slice views from JIT row_map_facade output alone; exp 193 showed the JIT values lane can flip between ~3 ms and ~9 ms while AOT cleanly rejects the list view. Compile the focused harness before changing Row.values again. For selectBytes transfer-policy work, include the exp 175 `selectBytes() large bytes` metric so large native-byte payloads stay visible. Exp 183 wired a reclaim path for the bounded RSS high-water exp 174 left as future work: the 1 MB cap trigger + 256 KB last-len guard is what makes the policy safe under back-to-back large reads. Any future tuning should raise the trigger (e.g., 4 MB) before touching the guard. The new Diagnostics.readerJsonBufHighWaterBytes is the reusable signal for any future RSS-sensitive selectBytes work — including the broader measurement-system 'memory profiling harness with per-benchmark RSS acceptance criteria' candidate, which can build on top of it rather than from scratch. Exp 185 adds the public release guard for that signal in `benchmark/suites/sqlite_diagnostics.dart`; keep the `JSON buffer reclaim` row green before changing the selectBytes shrink thresholds or last-len guard. Exp 195 promoted exp 190's per-query `tokens_buf` + `token_offsets` / `token_lens` arrays into the `resqlite_cached_stmt` entry; the exp 190 1-row regression guard sits at the millisecond-reporting floor of `wide_cols.dart` (not noise — wrong harness resolution), and the new `select_bytes_repeated_calls.dart` (1000 calls/sample at µs precision) exposes the win on 1-row × 20-col (−9.2 % / −7.2 %) and 10-row × 20-col (−5.1 % / −2.7 %) across two order-flipped passes. Further selectBytes encoder amortization beyond exp 195 should attach onto the cache entry (per-column type hints, value prefixes) only if a workload shows repeated small selectBytes() with a specific structural pattern dominating wall time; the harness for that work is `select_bytes_repeated_calls.dart`, not `wide_cols.dart`. Exp 194 is the gate for future `SQLITE_FLOAT` JSON-formatting changes: run `select_bytes_real_int_fastpath.dart` and preserve the conservative fallback boundaries for fractional values, huge magnitudes, non-finite values, and negative zero. Do not treat the integral-REAL fast path as evidence to revive broad Ryu/Grisu-style float formatting without a much smaller implementation and a production profile showing fractional REAL cells dominate. For BLOB JSON encoder work, exp 201 rejected quote/framing reservation, exp 216 accepted scalar base64 loop unrolling (4x per trip), exp 218 rejected 8x unrolling, and exp 225 accepted widening the inner lookup to a 12-bit pair table (two 16-bit loads + two 16-bit stores per output-4 group). Use `select_bytes_blob_base64.dart`: 4KB is the payload-throughput gate, 128B/16B are mid-size confirmations, and 3B cells are regression guards. Do not retry wider scalar unroll bodies (exp 218); do not retry a wider LUT beyond exp 225's 4096 x 2 B pair table (a 24-bit table is 16 MiB, well past `.rodata` acceptability). Exp 229 accepts the AArch64/NEON `vqtbl4q_u8` kernel that the exp 225 note named as the next mechanism: 48-byte-block SIMD dispatched on `len >= 48`, scalar 12-bit-LUT fallback for the tail and for non-ARM64 targets. Future BLOB work should now target SSSE3 (`_mm_shuffle_epi8`) on x86_64 to widen platform coverage, or a compiler-flag change, or production-profile evidence that BLOB encode still dominates after the NEON kernel lands. Do not retry an in-lined SIMD body: exp 229 discovered that inlining the NEON kernel into `json_write_base64` degrades the small-input scalar path's code gen; keep new SIMD kernels `noinline` so the small-blob hot path stays byte-identical to the pre-SIMD scalar layout. For TEXT JSON encoder work, exp 202 rejected safe-string quote/payload reservation, while exp 219 accepts direct `\\u00XX` emission for unnamed control bytes. Use `select_bytes_text_string_reserve.dart`: the control row is the decision lane for control escape formatting, while safe ASCII and named-escape rows are guards. Future TEXT work should target a different mechanism such as the SWAR escape scan, a measurable copy boundary, or production/profile evidence that TEXT cells dominate encoder wall. Exp 230 rejects shipping a second AArch64/NEON safe-prefix encoder at a 256 B cutoff. It proves fused SIMD classification+copy has substantial headroom at 1 KiB (-34% ASCII, -25% CJK), but its 256 B admission lane failed the preset 15% bar on the order flip. Keep `select_bytes_text_string_reserve.dart`: 256 B safe ASCII is the adoption gate for similarly complex SIMD work, 1 KiB/CJK are confirmations, and early-/late-escape rows guard fallback position. Do not lower the cutoff from noisy short rows or accept from the far-end lane alone. Reopen only with production/AOT evidence that escape-free 1 KiB+ TEXT dominates, or a reusable cross-platform SIMD layer that lowers the complexity budget. Exp 231 closes per-cell integer-encode SIMD: a byte-identical AArch64/NEON i64→decimal kernel gated ≥ 1e8 never beat the scalar two-digit itoa on `select_bytes_int_heavy.dart`'s BIGINT lane because it pays an out-of-line call + vector setup per single integer with no cross-cell batching to amortise it (unlike base64, which amortises over a whole BLOB). Do not retry a per-cell integer SIMD kernel; the amortisation floor, not the digit algorithm, is the blocker. Reopen only if a future architecture batches many integer cells into one encode call. The new `native i64 formatter differential` test group (`resqlite_test_i64_to_str`) is the integer formatter's byte-identity gate. Exp 232 rejects an exact-quarter REAL specialization despite 78-87% all-hit wins: synthetic value lattices prove mechanism speed, not prevalence or aggregate product value. Do not add a fractional REAL lattice unless a production/downstream trace or representative application first shows fractional formatting is material and measures an eligible share large enough to beat miss tax and permanent complexity. Keep exp 194's exact-integral path and the generic fixture invariant; `archive/exp-232` preserves the rejected prototype. Exp 248 adds `benchmark/experiments/stmt_cache_interleaved.dart` as the durable interleaved-statement gate — it is the only harness that reaches the cache promotion path at all, since exp 207's `stmt_cache_hot_sql.dart` pins one SQL and cannot fire the swap. Run it for any change to cache ordering, eviction policy, or `resqlite_cached_stmt` SIZE, since promotion cost scales linearly with the struct. Do not open another `stmt_cache_lookup_entry` experiment without either an order-of-magnitude drop in the round-trip floor or a workload doing many lookups without a round trip per lookup. Methodology worth reusing: when a candidate has a mechanically-unreachable configuration, add it as an explicit control lane — exp 248's `distinct = 1` lanes run identical code on both sides, so their +103% swing read the harness noise directly and settled the verdict without more primary-lane reruns. For send-vs-sacrifice work, do NOT run another through-the-pool A/B: sacrifice mutates the environment of the next measurement, which is why exp 241's median never acquired a stable sign. Use the exp 245 prepared-result barrier (`benchmark/experiments/prepared_result_handoff.dart`) for intrinsic transfer and an 8-request/4-worker barrier burst measuring dispatch queue-wait for pool capacity (exp 244's driver and its `ReaderPool.debugDispatchTimings` hook were removed with the rejected candidate; the hook is a nullable list appended to in `_dispatch`), and conclude with an equivalence margin rather than waiting for a noisy median to pick a side. Exp 245/246 showed intrinsic transfer of a 200k-slot result is ~391 us (exit) against a ~6,100 us end-to-end select, only ~6-12% of large-read wall; exp 251 has now decomposed the remainder. Run `step_vs_decode.dart` before claiming that residual is a decode/storage target. Exp 258 closes the broad columnar typed-array ResultSet rewrite: the first real AOT measurement of the mechanism (columnar_result_transfer.dart) shows its 73-91% transfer win is neutralized by the Isolate.exit sacrifice path for large results and below the round-trip floor for sub-threshold ones; REAL consume is neutral and memory regresses for Smi-int columns (inline Smis beat Int64List). The large worker-side build win (no boxing) is off the main isolate. The only real primary-metric signal was ~2x faster main-isolate integer consume from Int64List locality, now a scoped openCandidate. This also partly decomposes the '~90% object-graph build' the exp 245/246 note flagged: boxing is a large, removable slice of worker-side build, but removing it does not move the main-isolate metric under the current transfer machinery. Exp 251 measures worker-side construction, not application full-consumer work, so it cannot clear exp 258's main-isolate-consumer incidence gate. Exp 259 adds benchmark/experiments/select_rows_text_decode.dart as the durable gate for the rows-path TEXT decode arm; run it before touching fastDecodeText, the cell type codes, or resqlite_step_row's TEXT arm. Keep its two non-primary lanes: int8 runs byte-identical code in both arms so it reads a cross-worktree binary offset directly (the exp 254 trap), and text8-cjk guards that non-ASCII values do not pay for the ASCII fast path. Cell type codes 1-5 are SQLite's and 6 is now RESQLITE_TEXT_ASCII; a future native-side classification should take 7 so the decoder's switch stays dense. resqlite_test_text_is_ascii is the classifier's byte-level differential gate (resqlite_test_i64_to_str class) — a wrong answer there is silent mojibake, not a crash. The generalizable move is 'any per-cell question the Dart decoder asks while C already holds the inputs'; what remains on the Dart side is the ExternalTypedData view and the String copy, both VM-owned, so further gains need a Dart runtime change rather than another resqlite-side relocation. Exp 260 adds benchmark/experiments/select_rows_presize.dart as the durable gate for the result buffer's allocation and growth; run it before touching decodeQuery's values list, grownSlots, or ReaderPool's row-size memory. Keep its mixed6-200 and point1 lanes — both return fewer rows than the initial buffer holds, so they are mechanically inert (the exp 248 pattern) and read a cross-worktree binary offset directly — and both mispredict guards, which are the only lanes that can expose a size hint's failure mode. Do not quote exp 251's `Dart result construction` figures as a decode cost without claim 260.2: that bucket was mostly growth and the growth is gone. Exp 059's rejection of this mechanism is refuted (claim 059.1) — its grounds were the April release suite's noise floor, and it sized the initial allocation, carrying exp 067's small-query regression inside the same change; exp 067's floor (claim 067.1) still stands and is why the hint touches growth only. Two openings this leaves: any other doubling buffer on a hot path (selectBytes's json_buf, the writer's parameter arena) has never been measured for growth-copy cost, and anything else a reader worker learns and keeps — the schema cache included — is discarded by the sacrifice path on every large read." + "notesForExperimenters": "Be explicit about API impact. Some measured wins depend on a new public API, which is outside the lean-API goal. Use `select_maps`, `resultset_foreach_consumer.dart`, or a similarly full-consumer benchmark before accepting result-shape changes; setup-only transfer wins are not enough. Exp 158 is a narrow private data-structure win inside RowSchema, not evidence to revive larger ResultSet API changes. Exp 176 extended exp 158's identity fast path to Row.containsKey via a shared RowSchema.containsName helper; the two lookup methods (operator[]/indexOf and containsKey/containsName) now share one identity-then-HashMap path, so any future change to the 32-column cap or the identity scan applies to both. Do not retry the exp 141 ResultSet.forEach override unless a future Dart runtime or workload produces a stable target win with neutral controls. Do not retry Row.values ListBase/getRange/fixed-slice views from JIT row_map_facade output alone; exp 193 showed the JIT values lane can flip between ~3 ms and ~9 ms while AOT cleanly rejects the list view. Compile the focused harness before changing Row.values again. For selectBytes transfer-policy work, include the exp 175 `selectBytes() large bytes` metric so large native-byte payloads stay visible. Exp 183 wired a reclaim path for the bounded RSS high-water exp 174 left as future work: the 1 MB cap trigger + 256 KB last-len guard is what makes the policy safe under back-to-back large reads. Any future tuning should raise the trigger (e.g., 4 MB) before touching the guard. The new Diagnostics.readerJsonBufHighWaterBytes is the reusable signal for any future RSS-sensitive selectBytes work — including the broader measurement-system 'memory profiling harness with per-benchmark RSS acceptance criteria' candidate, which can build on top of it rather than from scratch. Exp 185 adds the public release guard for that signal in `benchmark/suites/sqlite_diagnostics.dart`; keep the `JSON buffer reclaim` row green before changing the selectBytes shrink thresholds or last-len guard. Exp 195 promoted exp 190's per-query `tokens_buf` + `token_offsets` / `token_lens` arrays into the `resqlite_cached_stmt` entry; the exp 190 1-row regression guard sits at the millisecond-reporting floor of `wide_cols.dart` (not noise — wrong harness resolution), and the new `select_bytes_repeated_calls.dart` (1000 calls/sample at µs precision) exposes the win on 1-row × 20-col (−9.2 % / −7.2 %) and 10-row × 20-col (−5.1 % / −2.7 %) across two order-flipped passes. Further selectBytes encoder amortization beyond exp 195 should attach onto the cache entry (per-column type hints, value prefixes) only if a workload shows repeated small selectBytes() with a specific structural pattern dominating wall time; the harness for that work is `select_bytes_repeated_calls.dart`, not `wide_cols.dart`. Exp 194 is the gate for future `SQLITE_FLOAT` JSON-formatting changes: run `select_bytes_real_int_fastpath.dart` and preserve the conservative fallback boundaries for fractional values, huge magnitudes, non-finite values, and negative zero. Do not treat the integral-REAL fast path as evidence to revive broad Ryu/Grisu-style float formatting without a much smaller implementation and a production profile showing fractional REAL cells dominate. For BLOB JSON encoder work, exp 201 rejected quote/framing reservation, exp 216 accepted scalar base64 loop unrolling (4x per trip), exp 218 rejected 8x unrolling, and exp 225 accepted widening the inner lookup to a 12-bit pair table (two 16-bit loads + two 16-bit stores per output-4 group). Use `select_bytes_blob_base64.dart`: 4KB is the payload-throughput gate, 128B/16B are mid-size confirmations, and 3B cells are regression guards. Do not retry wider scalar unroll bodies (exp 218); do not retry a wider LUT beyond exp 225's 4096 x 2 B pair table (a 24-bit table is 16 MiB, well past `.rodata` acceptability). Exp 229 accepts the AArch64/NEON `vqtbl4q_u8` kernel that the exp 225 note named as the next mechanism: 48-byte-block SIMD dispatched on `len >= 48`, scalar 12-bit-LUT fallback for the tail and for non-ARM64 targets. Future BLOB work should now target SSSE3 (`_mm_shuffle_epi8`) on x86_64 to widen platform coverage, or a compiler-flag change, or production-profile evidence that BLOB encode still dominates after the NEON kernel lands. Do not retry an in-lined SIMD body: exp 229 discovered that inlining the NEON kernel into `json_write_base64` degrades the small-input scalar path's code gen; keep new SIMD kernels `noinline` so the small-blob hot path stays byte-identical to the pre-SIMD scalar layout. For TEXT JSON encoder work, exp 202 rejected safe-string quote/payload reservation, while exp 219 accepts direct `\\u00XX` emission for unnamed control bytes. Use `select_bytes_text_string_reserve.dart`: the control row is the decision lane for control escape formatting, while safe ASCII and named-escape rows are guards. Future TEXT work should target a different mechanism such as the SWAR escape scan, a measurable copy boundary, or production/profile evidence that TEXT cells dominate encoder wall. Exp 230 rejects shipping a second AArch64/NEON safe-prefix encoder at a 256 B cutoff. It proves fused SIMD classification+copy has substantial headroom at 1 KiB (-34% ASCII, -25% CJK), but its 256 B admission lane failed the preset 15% bar on the order flip. Keep `select_bytes_text_string_reserve.dart`: 256 B safe ASCII is the adoption gate for similarly complex SIMD work, 1 KiB/CJK are confirmations, and early-/late-escape rows guard fallback position. Do not lower the cutoff from noisy short rows or accept from the far-end lane alone. Reopen only with production/AOT evidence that escape-free 1 KiB+ TEXT dominates, or a reusable cross-platform SIMD layer that lowers the complexity budget. Exp 231 closes per-cell integer-encode SIMD: a byte-identical AArch64/NEON i64→decimal kernel gated ≥ 1e8 never beat the scalar two-digit itoa on `select_bytes_int_heavy.dart`'s BIGINT lane because it pays an out-of-line call + vector setup per single integer with no cross-cell batching to amortise it (unlike base64, which amortises over a whole BLOB). Do not retry a per-cell integer SIMD kernel; the amortisation floor, not the digit algorithm, is the blocker. Reopen only if a future architecture batches many integer cells into one encode call. The new `native i64 formatter differential` test group (`resqlite_test_i64_to_str`) is the integer formatter's byte-identity gate. Exp 232 rejects an exact-quarter REAL specialization despite 78-87% all-hit wins: synthetic value lattices prove mechanism speed, not prevalence or aggregate product value. Do not add a fractional REAL lattice unless a production/downstream trace or representative application first shows fractional formatting is material and measures an eligible share large enough to beat miss tax and permanent complexity. Keep exp 194's exact-integral path and the generic fixture invariant; `archive/exp-232` preserves the rejected prototype. Exp 248 adds `benchmark/experiments/stmt_cache_interleaved.dart` as the durable interleaved-statement gate — it is the only harness that reaches the cache promotion path at all, since exp 207's `stmt_cache_hot_sql.dart` pins one SQL and cannot fire the swap. Run it for any change to cache ordering, eviction policy, or `resqlite_cached_stmt` SIZE, since promotion cost scales linearly with the struct. Do not open another `stmt_cache_lookup_entry` experiment without either an order-of-magnitude drop in the round-trip floor or a workload doing many lookups without a round trip per lookup. Methodology worth reusing: when a candidate has a mechanically-unreachable configuration, add it as an explicit control lane — exp 248's `distinct = 1` lanes run identical code on both sides, so their +103% swing read the harness noise directly and settled the verdict without more primary-lane reruns. For send-vs-sacrifice work, do NOT run another through-the-pool A/B: sacrifice mutates the environment of the next measurement, which is why exp 241's median never acquired a stable sign. Use the exp 245 prepared-result barrier (`benchmark/experiments/prepared_result_handoff.dart`) for intrinsic transfer and an 8-request/4-worker barrier burst measuring dispatch queue-wait for pool capacity (exp 244's driver and its `ReaderPool.debugDispatchTimings` hook were removed with the rejected candidate; the hook is a nullable list appended to in `_dispatch`), and conclude with an equivalence margin rather than waiting for a noisy median to pick a side. Exp 245/246 showed intrinsic transfer of a 200k-slot result is ~391 us (exit) against a ~6,100 us end-to-end select, only ~6-12% of large-read wall; exp 251 has now decomposed the remainder. Run `step_vs_decode.dart` before claiming that residual is a decode/storage target. Exp 258 closes the broad columnar typed-array ResultSet rewrite: the first real AOT measurement of the mechanism (columnar_result_transfer.dart) shows its 73-91% transfer win is neutralized by the Isolate.exit sacrifice path for large results and below the round-trip floor for sub-threshold ones; REAL consume is neutral and memory regresses for Smi-int columns (inline Smis beat Int64List). The large worker-side build win (no boxing) is off the main isolate. The only real primary-metric signal was ~2x faster main-isolate integer consume from Int64List locality, now a scoped openCandidate. This also partly decomposes the '~90% object-graph build' the exp 245/246 note flagged: boxing is a large, removable slice of worker-side build, but removing it does not move the main-isolate metric under the current transfer machinery. Exp 251 measures worker-side construction, not application full-consumer work, so it cannot clear exp 258's main-isolate-consumer incidence gate. Exp 259 adds benchmark/experiments/select_rows_text_decode.dart as the durable gate for the rows-path TEXT decode arm; run it before touching fastDecodeText, the cell type codes, or resqlite_step_row's TEXT arm. Keep its two non-primary lanes: int8 runs byte-identical code in both arms so it reads a cross-worktree binary offset directly (the exp 254 trap), and text8-cjk guards that non-ASCII values do not pay for the ASCII fast path. Cell type codes 1-5 are SQLite's and 6 is now RESQLITE_TEXT_ASCII; a future native-side classification should take 7 so the decoder's switch stays dense. resqlite_test_text_is_ascii is the classifier's byte-level differential gate (resqlite_test_i64_to_str class) — a wrong answer there is silent mojibake, not a crash. The generalizable move is 'any per-cell question the Dart decoder asks while C already holds the inputs'; what remains on the Dart side is the ExternalTypedData view and the String copy, both VM-owned, so further gains need a Dart runtime change rather than another resqlite-side relocation. Exp 260 adds benchmark/experiments/select_rows_presize.dart as the durable gate for the result buffer's allocation and growth; run it before touching decodeQuery's values list, grownSlots, or ReaderPool's row-size memory. Keep its mixed6-200 and point1 lanes — both return fewer rows than the initial buffer holds, so they are mechanically inert (the exp 248 pattern) and read a cross-worktree binary offset directly — and both mispredict guards, which are the only lanes that can expose a size hint's failure mode. Do not quote exp 251's `Dart result construction` figures as a decode cost without claim 260.2: that bucket was mostly growth and the growth is gone. Exp 059's rejection of this mechanism is refuted (claim 059.1) — its grounds were the April release suite's noise floor, and it sized the initial allocation, carrying exp 067's small-query regression inside the same change; exp 067's floor (claim 067.1) still stands and is why the hint touches growth only. Two openings this leaves: any other doubling buffer on a hot path (selectBytes's json_buf, the writer's parameter arena) has never been measured for growth-copy cost, and anything else a reader worker learns and keeps — the schema cache included — is discarded by the sacrifice path on every large read. Exp 264 reopened exp 067's floor rather than working around it, and the reason generalises: claim 067.1 holds for the unconditional shrink exp 067 tested, and its stated mechanism (a large null-filled list is cheaper *per slot*) is true per slot and twenty times wrong per call. Check a rejection's rate-shaped reasoning against the count the caller pays before treating the direction as closed. The rule exp 264 had to add is that a hint whose failure mode is *under*-prediction cannot use a sliding window — a window of two measured +40% in all four passes, because the two executions before a large one are both small — so it takes a high-water mark, which makes the mispredict one-off (+1.6%) instead of periodic. One opening this leaves that exp 264 could not close: the pool's row-size memory is FIFO-capped at 32 SQL strings and an evicted entry now loses a high-water mark as well as a growth hint, and nothing measures whether a real application's statement mix thrashes that cap. Two process notes from exp 264 that cost it real time: `benchmark/drift/*.g.dart` is gitignored and generated by `dart run build_runner build`, 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, reproduces the same on origin/main, and looks exactly like a repo breakage that is not one. And the release suite does run (claim 264.4): it completes 14 of 16 scenarios and dies in the sqlite_async peer at Memory, leaving a properly self-marked partial artifact." }, { "id": "measurement-system", diff --git a/experiments/signals/entries/264.json b/experiments/signals/entries/264.json new file mode 100644 index 00000000..edc6fac5 --- /dev/null +++ b/experiments/signals/entries/264.json @@ -0,0 +1,84 @@ +{ + "directions": [ + "result-transfer-shape" + ], + "outcomeClass": "accepted", + "changedBeliefs": [ + "Claim 067.1 said `decodeQuery`'s initial `List.filled(colCount * 256, null)` must not be shrunk. That holds for the change exp 067 actually made — an unconditional cut to `colCount * 4`, which forces every result larger than four rows to double its way up — but not for the general statement, and the reasoning underneath it was wrong in an instructive way. Exp 067 attributed its regressions to a VM fast path that makes one large null-filled list cheaper *per slot* than a small one. That is true and it does not support the conclusion: a standalone AOT reproduction of the allocation shape measures 1,536 slots at 0.28 ns/slot against 12 slots at 1.8 ns/slot, so the large allocation is six times cheaper per slot and twenty times more expensive in total. What actually made exp 067 regress was growth, not allocation, and exp 260 has since supplied the thing that lets the shrink avoid growth: a per-SQL memory of how many rows each statement returns. Conditioned on that memory and clamped so it can only ever shrink, the initial allocation is worth a reproduced 7.4% on the canonical 6-column point read and 27.4% on a 21-column one.", + "A buffer-size hint that can *under*-predict must be a high-water mark, not a sliding window, and this is the single most transferable thing the run produced. Exp 260's growth hint takes the smaller of a SQL's last two row counts, which is right for growth because over-sizing is the expensive mistake there. Taking the *larger* of the last two by symmetry, for the initial allocation, was measured at +40% in all four order-flipped passes on a statement returning 3,300 rows behind a burst of 20-row executions: a window of two is defeated by any burst longer than two, because the two observations before the large execution are both small, so the large result is sized for 20 rows and doubles its way up — every time, not once. A high-water mark cannot repeat. What it gives up is a statement that was once large and is now permanently small, which keeps today's allocation forever.", + "The cost of a mispredict is a one-off +1.6%, and it is bounded by arithmetic rather than by measurement. The doubling chain's landing point depends on where it starts, so an undershoot can be favourable (`undershoot-jump`, 5,000 rows: from 25 rows the chain lands on 6,400, from 256 it lands on 8,192) or adverse (`undershoot-mid`, 3,300 rows: 6,400 against 4,096, four extra growths and 56% over-allocation). The adverse case measured 927.5 -> 942.5 us across 50 fresh processes per arm — about 15 us, inside the p10-p90 spread of either arm. Quoting only the favourable lane would have overstated the result; the pair is what brackets it.", + "Exp 260's argument for where a size memory lives generalises further than exp 260 needed it to, but it was not the cause of the +40%. Exp 260 put its hint on the main isolate because `Isolate.exit` on a result over `sacrificeSlotThreshold` ends the worker that produced it (claim 260.3) — an argument about *large* results, which a mark about small ones appears to escape. It does not: a high-water mark is only worth the observations feeding it, a four-worker pool hands each worker a sample, and a worker that does decode a large result is destroyed and its replacement starts over. Sampling bias and worker sacrifice are separate sufficient reasons to keep the mark on the main isolate. Worth recording that this was the run's first diagnosis of the +40% and was wrong: moving the memory to the pool fixed a real defect and left the lane at +40% in all four passes, because a window of length two is defeated by a burst longer than two wherever the window is kept. A correct fix for a real defect is not evidence that it was the defect being measured.", + "Peak RSS does not move, and that is the expected reading rather than a null result. Dart releases a truncated growable list's backing store, so the fixed 256-row allocation was never retained by a small result — only allocated, zero-filled and thrown away. Every lane holding one read live is flat within ~1 MB, consistent with claim 261.3. This is a transient-allocation win, in the same category exp 263 put `selectBytes` in when it separated allocation churn from footprint." + ], + "claims": [ + { + "id": "264.1", + "text": "Sizing `decodeQuery`'s *initial* result allocation from a main-isolate high-water mark of the rows a SQL has ever returned, clamped into 1..256 rows so it can only shrink below the fixed default, is worth a reproduced -13.5% on a 1-row 6-column point read and -27.1% on a 1-row 21-column one. Four alternating-order lane-isolated AOT passes, 61 samples each, on a host at 52-61% CPU idle with controls inside +/-1.7% (`int20-10k` +0.3%, `mixed6-10k` -0.4%). The win scales with projection width because what a one-row result wastes is `colCount * 255` slots. Every lane whose result outgrows the initial buffer is neutral by construction, not by measurement: it clamps to the same 256 rows both arms allocate.", + "conditions": "M1 Pro · AOT CLI bundle · lane-isolated, 4 alternating-order passes, 61 samples/lane · host 52-61% idle · 2026-08", + "edges": [ + { + "type": "refines", + "target": "067.1" + }, + { + "type": "dependsOn", + "target": "260.3" + } + ] + }, + { + "id": "264.2", + "text": "A result-buffer size hint whose failure mode is under-prediction must be a high-water mark, not a sliding window. Taking the larger of a SQL's last two row counts — the mirror of exp 260's smaller-of-two — cost +40% in all four order-flipped passes on a statement returning 3,300 rows behind bursts of eight 20-row executions, because the two observations preceding each large execution are both small, so the penalty repeats on every large read instead of once. Under a high-water mark the same lane is neutral (-1.3% mean) and the one-time cost of a statement's first jump from small to large is +1.6% (927.5 -> 942.5 us median over 50 fresh processes per arm), consistent with an arithmetic account of four extra growths, ~15,200 extra slot copies and ~13,800 extra slots zero-filled.", + "conditions": "M1 Pro · AOT · `undershoot-mid` lane, 3,300 rows behind 8x20-row poison · first-jump figure from --warmup=0 --samples=1, 50 processes/arm · 2026-08" + }, + { + "id": "264.3", + "text": "Exp 067's mechanism claim is per-slot true and decision-irrelevant. `List.filled(n, null, growable: true)` followed by a write of `k` slots and a truncation to `k` costs 423 ns at n=1536/k=6 against 21.5 ns at n=12/k=6, and 1223 ns at n=5376/k=21 against 46.5 ns at n=42/k=21 — so the large allocation is roughly six times cheaper per slot and twenty times more expensive in total. Any Dart allocation claim of the form 'the VM's fast path makes the larger one cheaper' must be quoted per call, not per slot.", + "conditions": "M1 Pro · standalone AOT reproduction of the allocation shape, no FFI or SQLite · 20,000 calls/round, 200 rounds · 2026-08", + "edges": [ + { + "type": "refines", + "target": "067.1" + } + ] + }, + { + "id": "264.4", + "text": "The release suite runs, and exp 262's per-scenario persistence now demonstrably preserves a killed run. The suite completes 14 of 16 scenarios — every read, write, streaming and app-shaped lane — and aborts inside the sqlite_async peer at `[15/16] Memory`, the pre-existing #282 crash, additionally wedging the parent rather than exiting. The killed run still wrote an artifact with 169 metrics, correctly self-marked `partial: true`, `scenariosCompleted: 14`, `repeatCount: 0`; exps 260 and 261 were killed inside repeat 1 before that mechanism existed and produced nothing. The artifact is single-sample and unpaired, so it is a no-regression sanity check (point query 160,798 qps, 1,000-row `select()` 0.349 ms, batch insert 1,000 rows 0.389 ms, invalidation 0.058 ms — all at or better than README's published figures) and not evidence for any candidate.", + "conditions": "M1 Pro · macOS 26.2 · Dart 3.12.2 · --repeat=5, killed in repeat 1 at scenario 15 · single-sample, no baseline · 2026-08", + "edges": [ + { + "type": "validates", + "target": "262.1" + } + ] + }, + { + "id": "264.5", + "text": "Exp 264's own change to `ReaderPool._record` put exp 260's growth hint at risk, and the fix is capacity/priority rather than ordering. Removing exp 260's `rowCount <= initialResultRows` insert guard let every point read take one of the 32 `_rowHints` slots, so a statement that has proven large is evicted by point-read churn and loses its hint. Measured on the new `hint-thrash-overflows` lane (5,000 x 5 read behind 40 distinct one-off statements per sample): pre-264 597-631 us against exp-264 826-919 us, a systematic +40-46% in which the slower arm's fastest sample beat the faster arm's slowest. Least-recently-used promotion does NOT fix it (no improvement measured, and it costs the main isolate a map remove-and-reinsert per read); raising `_rowHintMax` to 128 does (620 us), and so does preferring an eviction victim whose `highWater <= initialResultRows` (565 us). The latter shipped, and a four-pass re-measurement on a quiet host confirms both halves: the lane runs -29.5% against the unfixed arm in all four passes and is level with pre-264 (-0.2%), so exp 260's reach is restored rather than merely improved, while the fix is inert on the hot path (both point lanes sign-flip, both controls +/-0.1%) exactly as its structure predicts -- `_evictionVictim` is unreachable until the memory overflows its 32 entries. Unfixed, the same lane is +41.6% against pre-264. Specifically: it restores exp 260's exclusive tenure without a magic capacity, costs nothing per read because the O(32) scan runs only on an overflowing miss, and is gated deterministically in `test/reader_pool_test.dart` rather than through the noisy lane.", + "conditions": "M1 Pro · AOT · `hint-thrash-overflows`, 4 alternating-order passes, 61 samples · host 52-61% idle · 2026-08", + "edges": [ + { + "type": "dependsOn", + "target": "260.1" + } + ] + }, + { + "id": "264.6", + "text": "Every wall-time figure in exp 264 was collected on a host at 0.0% CPU idle. `top` reported 57% user / 43% sys / 0% idle, with an unrelated Virtualization.framework VM at 190% CPU, FSEvents at 57%, and other Dart processes at 100% and 44%; load average read 33/39/50 on ten cores. Six `run_release.dart` processes from earlier sessions were also resident, wedged 1-4 days at 0.0% CPU — the #282 peer crash does not merely kill a run, it leaves the parent blocked forever, and they accumulate. The order-flipped design cancels a stationary component and the controls held to +/-2%, and the mechanism is independently corroborated by a standalone allocation probe with no isolates or SQLite (423 ns vs 21.5 ns per call) whose magnitudes match the lane deltas; the *direction and mechanism* are therefore sound. The precise percentages were not, and were re-measured once the host freed up: the narrow point read moved from -7.4% to -13.5% and the wide one from -27.4% to -27.1%, with controls tightening from +/-4% to +/-1.7%. So the saturation cost about six points of accuracy on the smaller effect and none on the larger. The general hazard stands: a per-read microsecond measurement on a saturated host is dominated by reader-isolate scheduling latency. A confirmation pass attempted late in the run read +50.6% on `int20-10k`, a lane the candidate cannot reach, which is how the saturation was discovered. The same host had 352-440 MB free on a 460 GB volume. That is worth recording beside the crash narrative exps 260-262 built on: a disk this full is an independent explanation for a SQLite-backed suite dying at the memory-heavy scenario 15 and for the parent never exiting, and it has never been ruled out. Not asserted here — untested — but it should be checked before the next runner attributes that crash to the peer.", + "conditions": "M1 Pro · macOS 26.2 · host at 0.0% idle and ~400 MB free disk throughout · 2026-08" + } + ], + "nextSignals": [ + "`benchmark/experiments/select_rows_presize.dart` is now the durable gate for *both* ends of the result buffer, and the lane roles invert between them: exp 260's primaries (`int20-10k`, `int4-5k`, `mixed6-10k`, `mixed6-1k`) are exp 264's controls, and exp 260's controls (`point1`, `mixed6-200`) are exp 264's primaries. Keep `undershoot-mid` — it is the only lane that can reject a sizing rule for the initial allocation, and it did.", + "Two order-flipped passes were not enough here, and the tell was specific: `mixed6-20` read -16.5% then -13.3% on the first two passes and +4.2% then -8.5% on the next two. A lane whose per-read cost is dominated by other allocation (80 Strings, in that lane) cannot resolve a sub-microsecond buffer effect, and two passes agreeing is not evidence that it can. When a lane's expected effect is smaller than its CV, collect four passes and report it as drift-suspected rather than as a small win.", + "A coarse lane must batch before it is trusted. `mixed6-200` timed one 200-row read per sample at ~50 us, where a 1 us tick is 2%, and read +11.3% / +9.8% — a reproduced, same-signed regression in a lane the candidate provably cannot reach. Batching 20 executions per sample took it to -1.4% / +2.4%. Exp 254's rule (a same-sign move in an inert lane means no lane is trustworthy) is only sound once the inert lane's resolution exceeds the effect being hunted; before that it manufactures the very signal it is meant to detect.", + "The remaining per-read cost in `decodeQuery` is now genuinely the loads, the switch, the stores and the String copies — claim 260.2's ~2.5 ns/cell, plus one allocation sized to the result. There is no fixed per-read overhead left in the buffer to remove, so the next thing to attack on the small-read path is the isolate round trip itself, which exps 209 and 239 have both closed on hidden-policy grounds.", + "The pool's row-size memory is FIFO-capped at 32 entries (`_rowHintMax`), and an evicted entry now loses a high-water mark as well as a growth hint, so a statement whose entry is evicted pays claim 264.2's first-jump cost again. Nothing measures whether a real application's statement mix thrashes that cap. The discriminating measurement is cheap — count distinct SQL strings per second in a representative trace against 32 — and it bounds the value of every per-SQL memory the decoder holds, not just this one.", + "A fresh worktree is not a working checkout: `benchmark/drift/*.g.dart` is gitignored and produced by `dart run build_runner build --delete-conflicting-outputs`, which CI runs before analyze and test (`.github/workflows/ci.yml`). Without it `dart analyze` reports ~77 issues and 9 `benchmark_*_test.dart` files fail to load, all of it in peer drift scaffolding — which looks exactly like a pre-existing repo breakage and is not one. Run codegen in every new experiment worktree, immediately after `dart pub get`, before treating any analyzer or test failure as a baseline condition.", + "The `Tests` CI job carries a genuinely flaky sqlite_async peer abort, distinct from the benchmark-suite crash. Exp 264's first CI run aborted (SIGABRT 134) inside `ConnectionLease.notifyUpdates` -> `AsyncConnection.unsafeAccess` -> `Mutex.withCriticalSection` while `benchmark_keyed_pk_subscriptions_test.dart` was running; re-running the identical commit passed, and six local repetitions of the same three peer workload tests passed. Check that main is green (it was), then re-run before investigating a diff.", + "The focused harnesses have no environment gate. `run_release.dart` stamps `gitDirty` and the charts drop a dirty or single-sample run, but a focused AOT harness records nothing about the host, so exp 264 collected an entire experiment at 0.0% CPU idle and only noticed when a mechanically-inert lane read +50.6%. The cheap fix is to sample idle CPU (or load average) at lane start and print it beside the medians, so a runner and a future reader can both see it; the stronger one is to refuse to run below a threshold. Until then, check `top -l 1 | grep 'CPU usage'` before trusting a focused number, and treat a same-signed move in an inert lane as a host problem before a layout one.", + "A wedged `run_release.dart` is a lasting resource leak, not just a lost run. The #282 peer crash leaves the parent process blocked indefinitely — six were resident on this host, aged 1 to 4 days, from exp 260, exp 262, exp 229 and version probes. They sit at 0.0% CPU so they do not show up as CPU contention, but they hold memory and file handles and inflate load average. Check for and reap them before a measurement session; exp 262's per-scenario persistence means the artifact is already on disk, so killing one loses nothing." + ] +} diff --git a/lib/src/query_decoder.dart b/lib/src/query_decoder.dart index f4ae4caf..96e5ccd8 100644 --- a/lib/src/query_decoder.dart +++ b/lib/src/query_decoder.dart @@ -192,34 +192,68 @@ Never _throwStepException(ffi.Pointer stmt, String sql, int rc) { throw ResqliteQueryException(message, sql: sql, sqliteCode: rc); } -/// What one SQL string's result size has looked like over its last two -/// executions ([EXP-260](../../experiments/260-result-list-presize.md)). +/// How large one SQL string's results run, for the two ends of the result +/// buffer's life ([EXP-260](../../experiments/260-result-list-presize.md), +/// [EXP-264](../../experiments/264-initial-alloc-size-memory.md)). /// -/// [hint] is the *smaller* of those two row counts plus headroom, and stays 0 -/// until two executions have been observed. Sizing from a single observation is -/// what makes a size hint dangerous: a statement whose row count swings with -/// its parameters (`SELECT ... LIMIT ?`) would allocate for the large result -/// and throw almost all of it away on the small one, and that wasted zero-fill -/// costs more than the doubling it avoided. Taking the minimum lets a volatile -/// statement settle at the small end — no win, but no tax either — while a -/// stable one converges on its true size after one extra execution. +/// Both figures stay 0 until two executions have been observed: one observation +/// cannot tell a stable statement from either leg of a `SELECT ... LIMIT ?`. +/// +/// They take opposite statistics, each the one whose mistakes are cheap: +/// +/// * [hint] steers *growth* and takes the **smaller** of the last two row +/// counts. Over-sizing is the expensive mistake there — zero-filling slots a +/// small result discards costs more than the doubling it avoided — and a +/// result that never overflows the initial buffer never reads it. +/// * [initialRows] sizes the *initial* allocation and takes the **largest row +/// count ever seen**, clamped to [initialResultRows]. Under-sizing is the +/// expensive mistake here, and a sliding window would under-size on exactly +/// the execution that matters: any burst longer than the window leaves every +/// observation before a large result small. A high-water mark cannot, because +/// one large result raises it for good. The cost is a statement that was once +/// large and is now permanently small, which keeps the default allocation. final class RowSizeMemory { /// Rows the most recent execution returned, or -1 before the first. int previous = -1; - /// Rows to size the next execution's buffer for, or 0 for "no opinion". + /// The most rows any execution of this SQL has returned. + int highWater = 0; + + /// Rows to size the next execution's buffer growth for, or 0 for "no + /// opinion". int hint = 0; + /// Rows to size the next execution's *initial* buffer for, or 0 for "no + /// opinion" (meaning [initialResultRows]). Never exceeds + /// [initialResultRows]. + int initialRows = 0; + void record(int rowCount) { + if (rowCount > highWater) highWater = rowCount; if (previous < 0) { previous = rowCount; return; } hint = nextRowHint(rowCount < previous ? rowCount : previous); + initialRows = initialRowsFor(highWater); previous = rowCount; } } +/// Rows to size an initial result buffer for, given that no execution of this +/// SQL has ever returned more than [rowCount] rows. +/// +/// Clamped into `1 ..= initialResultRows`: at least one row so the decode loop +/// always has somewhere to write its first cell, and never above the default, so +/// a hint can only ever shrink an allocation. Growing one costs a large +/// zero-fill on every small execution of a statement whose row count swings. +@pragma('vm:prefer-inline') +int initialRowsFor(int rowCount) { + final hinted = nextRowHint(rowCount); + if (hinted >= initialResultRows) return initialResultRows; + return hinted < 1 ? 1 : hinted; +} + /// Per-worker schema cache entry: the column names for a SQL string, plus what /// that SQL's results have measured on this isolate. final class CachedSchema { @@ -233,11 +267,15 @@ final class CachedSchema { final RowSizeMemory size = RowSizeMemory(); } -/// Rows the initial result buffer is sized for, before anything is known about -/// the result. [EXP-067](../../experiments/067-shrink-initial-allocation.md) -/// measured that shrinking this regresses every small-query workload — the VM's -/// zero-fill fast path makes one large null-filled list cheaper per slot than a -/// small one — so it stays where it is and a size hint only affects growth. +/// Rows the initial result buffer is sized for while nothing is known about the +/// result — a SQL string's first two executions, and any execution of one the +/// pool has no [RowSizeMemory] for. +/// +/// Only per-statement evidence justifies going below this +/// ([RowSizeMemory.initialRows]). Shrinking the constant itself regresses every +/// workload built from many small queries, because a result larger than the new +/// size has to double its way up +/// ([EXP-067](../../experiments/067-shrink-initial-allocation.md)). const int initialResultRows = 256; /// Slots to grow a [colCount]-column result buffer to, from [current] slots, @@ -259,6 +297,27 @@ int grownSlots(int colCount, int current, int rowHint) { return hinted > doubled ? hinted : doubled; } +/// Rows to size this execution's initial result buffer for +/// ([EXP-264](../../experiments/264-initial-alloc-size-memory.md)). +/// +/// [callerHint] comes from whoever holds the authoritative record of this SQL's +/// result sizes, and 0 means "no opinion". [local] is this isolate's own memory, +/// consulted only when the caller passes `null` to say it has none. +/// +/// A reader worker must pass its own memory as [callerHint]'s alternative only +/// if that memory is complete, and a pool worker's is not: it observes a sample +/// of a statement's executions, and it is destroyed outright when it decodes a +/// result over `sacrificeSlotThreshold`. A high-water mark built from either is +/// too low, which under-sizes the buffer. Readers therefore take the mark from +/// the main isolate, which sees every execution and outlives every worker. The +/// writer isolate passes `null` and uses its own, since it executes every read +/// it decodes. +@pragma('vm:prefer-inline') +int initialSlotRows(int callerHint, RowSizeMemory? local) { + final rows = callerHint > 0 ? callerHint : (local?.initialRows ?? 0); + return rows == 0 ? initialResultRows : rows; +} + /// Row hint to carry into the next execution of a SQL that just returned /// [rowCount] rows. The 25% headroom absorbs a result that grows slightly /// between executions without paying a doubling. @@ -367,6 +426,7 @@ RawQueryResult decodeQuery( ffi.Pointer stmt, String sql, { int rowHint = 0, + int? initialRowHint, }) { final colCount = sqlite3ColumnCount(stmt); final entry = _schemaFor(stmt, sql, colCount); @@ -376,7 +436,11 @@ RawQueryResult decodeQuery( final hint = rowHint == 0 ? entry.size.hint : rowHint; final values = List.filled( - colCount * initialResultRows, + colCount * + initialSlotRows( + initialRowHint ?? 0, + initialRowHint == null ? entry.size : null, + ), null, growable: true, ); @@ -462,6 +526,7 @@ RawQueryResult decodeQuery( ffi.Pointer stmt, String sql, { int rowHint = 0, + int? initialRowHint, }) { final colCount = sqlite3ColumnCount(stmt); final entry = _schemaFor(stmt, sql, colCount); @@ -471,7 +536,11 @@ RawQueryResult decodeQuery( final hint = rowHint == 0 ? entry.size.hint : rowHint; final values = List.filled( - colCount * initialResultRows, + colCount * + initialSlotRows( + initialRowHint ?? 0, + initialRowHint == null ? entry.size : null, + ), null, growable: true, ); diff --git a/lib/src/reader/read_worker.dart b/lib/src/reader/read_worker.dart index cdd02953..e005fea8 100644 --- a/lib/src/reader/read_worker.dart +++ b/lib/src/reader/read_worker.dart @@ -43,6 +43,15 @@ sealed class ReadRequest { /// isolate that produced it, so exactly the results with the most growth to /// avoid would always be decoded by a worker that had never seen the SQL. int rowHint = 0; + + /// Rows to size the *initial* result buffer for, or 0 for the fixed default + /// ([EXP-264](../../../experiments/264-initial-alloc-size-memory.md)). + /// + /// Also stamped by [ReaderPool._dispatch]. It must come from the main isolate + /// for the same reason [rowHint] does, plus one of its own: a worker observes + /// only the executions routed to it, and too low a figure here under-sizes the + /// buffer. + int initialRowHint = 0; } /// Standard row query — returns a [ResultSet]. @@ -162,6 +171,7 @@ void readerEntrypoint(List args) { sql, parameters, request.rowHint, + request.initialRowHint, ); sacrifice = _shouldSacrifice(raw); result = _toRows(raw); @@ -185,6 +195,7 @@ void readerEntrypoint(List args) { sql, parameters, request.rowHint, + request.initialRowHint, ); sacrifice = _shouldSacrifice(raw); result = (_toRows(raw), dependencies, initialHash, initialRowCount); @@ -216,6 +227,7 @@ void readerEntrypoint(List args) { lastResultHash, lastRowCount, request.rowHint, + request.initialRowHint, ); sacrifice = raw != null && _shouldSacrifice(raw); result = (raw == null ? null : _toRows(raw), newHash, newRowCount); @@ -362,12 +374,14 @@ RawQueryResult executeQuery( String sql, List parameters, [ int rowHint = 0, + int initialRowHint = 0, ]) => _withAcquiredStmt( handleAddr, readerId, sql, parameters, - (_, stmt) => decodeQuery(stmt, sql, rowHint: rowHint), + (_, stmt) => + decodeQuery(stmt, sql, rowHint: rowHint, initialRowHint: initialRowHint), ); /// Execute a query returning JSON bytes as a view over the reader @@ -406,11 +420,17 @@ RawQueryResult executeQuery( String sql, List parameters, [ int rowHint = 0, + int initialRowHint = 0, ]) => _withAcquiredStmt(handleAddr, readerId, sql, parameters, ( dbHandle, stmt, ) { - final (raw, hash) = decodeQueryWithInitialHash(stmt, sql, rowHint: rowHint); + final (raw, hash) = decodeQueryWithInitialHash( + stmt, + sql, + rowHint: rowHint, + initialRowHint: initialRowHint, + ); // Collect dependency metadata from the reader's most recent cached stmt entry. return ( raw, @@ -440,10 +460,15 @@ RawQueryResult executeQuery( int lastResultHash, int? lastRowCount, [ int rowHint = 0, + int initialRowHint = 0, ]) => _withAcquiredStmt(handleAddr, readerId, sql, parameters, (_, stmt) { final (newHash, newRowCount) = callQueryHash(stmt); if (newHash == lastResultHash && newRowCount == lastRowCount) { return (newHash, newRowCount, null); } - return (newHash, newRowCount, decodeQuery(stmt, sql, rowHint: rowHint)); + return ( + newHash, + newRowCount, + decodeQuery(stmt, sql, rowHint: rowHint, initialRowHint: initialRowHint), + ); }); diff --git a/lib/src/reader/reader_pool.dart b/lib/src/reader/reader_pool.dart index c3200ffd..1bb54972 100644 --- a/lib/src/reader/reader_pool.dart +++ b/lib/src/reader/reader_pool.dart @@ -45,26 +45,34 @@ final class ReaderPool { /// per-worker schema cache and the C statement cache. static const int _rowHintMax = 32; - /// How large a result each SQL produces — the size hint a worker uses to - /// allocate its result buffer in one shot instead of doubling into it - /// ([EXP-260](../../../experiments/260-result-list-presize.md)). + /// How large a result each SQL produces, sized on the request so a worker can + /// allocate its result buffer in one shot + /// ([EXP-260](../../../experiments/260-result-list-presize.md), + /// [EXP-264](../../../experiments/264-initial-alloc-size-memory.md)). /// - /// This lives on the main isolate rather than in the worker's own schema - /// cache because a result larger than `sacrificeSlotThreshold` ends the - /// isolate that produced it. A worker-local hint would therefore be discarded - /// exactly when the result was big enough for the hint to matter, and would - /// in any case only describe the fraction of executions that landed on that - /// one worker. + /// Kept here rather than in a worker's own schema cache because only the main + /// isolate observes every execution of a SQL, and because a result larger than + /// `sacrificeSlotThreshold` ends the isolate that produced it — a worker-local + /// record would be discarded exactly when it mattered most. /// - /// Only statements that have returned more rows than the decoder's initial - /// buffer holds ever get an entry: anything smaller cannot reach the growth - /// path a hint steers, so recording it would cost a point read an allocation - /// and a map write to describe a result the hint can never improve. - /// - /// FIFO eviction via [LinkedHashMap] insertion order. + /// Holds statements of every size: the growth hint serves the large ones, the + /// initial-allocation mark the small ones. Eviction is not order-based, since + /// the two are not worth the same — see [_evictionVictim]. final Map _rowHints = LinkedHashMap(); + /// The growth hint held for [sql]: null when nothing is remembered, 0 for an + /// entry that has not yet seen the two executions it takes to form an opinion. + /// + /// For tests asserting retention. Membership is the wrong signal — an evicted + /// statement is re-inserted on its next execution, so it passes under any + /// eviction policy. Only a non-zero hint distinguishes a retained entry from a + /// recreated one. + int? rowSizeHintFor(String sql) => _rowHints[sql]?.hint; + + /// How many SQL strings the pool currently remembers a result size for. + int get rowSizeMemoryLength => _rowHints.length; + /// FIFO waiters parked by _dispatch while no worker is available. /// /// Each worker-free event wakes one waiter instead of completing a @@ -85,18 +93,40 @@ final class ReaderPool { return pool; } + /// Pick an entry to drop: one that has never returned a large result, before + /// one that has. + /// + /// Both consumers share these slots but do not value them equally. A small + /// statement loses only its initial-allocation mark, worth under a microsecond + /// per read; a large one loses the growth hint, worth roughly 40% of its read. + /// Small statements are also the overwhelming majority in any application with + /// more than [_rowHintMax] distinct queries, so without this preference + /// point-read churn evicts precisely the entries that matter. Reordering by + /// recency does not help — the slots being shared is the problem, not the order + /// they are reclaimed in. + /// + /// Falls back to insertion order when every entry has proven large, which is + /// the best available when no victim is cheap. + /// + /// O([_rowHintMax]), and only on a miss that overflows — never per read. + String _evictionVictim() { + for (final entry in _rowHints.entries) { + if (entry.value.highWater <= initialResultRows) return entry.key; + } + return _rowHints.keys.first; + } + /// Fold a completed result's row count back into [memory], the entry - /// [_dispatch] read for this request, creating one only if the result was - /// large enough for a hint to matter. + /// [_dispatch] read for this request, creating one when this is the first + /// execution of [sql] the pool has seen. void _record(String sql, RowSizeMemory? memory, int rowCount) { if (memory != null) { memory.record(rowCount); return; } - if (rowCount <= initialResultRows) return; _rowHints[sql] = RowSizeMemory()..record(rowCount); if (_rowHints.length > _rowHintMax) { - _rowHints.remove(_rowHints.keys.first); + _rowHints.remove(_evictionVictim()); } } @@ -217,6 +247,7 @@ final class ReaderPool { } request.rowHint = memory?.hint ?? 0; + request.initialRowHint = memory?.initialRows ?? 0; final count = _workers.length; var hasPreviouslyParked = false; diff --git a/test/reader_pool_test.dart b/test/reader_pool_test.dart index 29f58b88..04502cbd 100644 --- a/test/reader_pool_test.dart +++ b/test/reader_pool_test.dart @@ -21,13 +21,16 @@ Future _seed(Database db, int count) async { await db.executeBatch( 'INSERT INTO items(name, category, price, quantity, description) ' 'VALUES (?, ?, ?, ?, ?)', - List.generate(count, (i) => [ - 'item_$i', - 'cat_${i % 10}', - (i * 1.5), - i * 10, - 'A medium-length description for item number $i to add some text bulk.', - ]), + List.generate( + count, + (i) => [ + 'item_$i', + 'cat_${i % 10}', + (i * 1.5), + i * 10, + 'A medium-length description for item number $i to add some text bulk.', + ], + ), ); } @@ -154,12 +157,14 @@ void main() { await _seed(db, 100); final futures = List.generate( 20, - (i) => - db.selectBytes('SELECT * FROM items WHERE category = ?', ['cat_$i']), + (i) => db.selectBytes('SELECT * FROM items WHERE category = ?', [ + 'cat_$i', + ]), ); final results = await Future.wait(futures); for (var i = 0; i < 10; i++) { - final decoded = jsonDecode(String.fromCharCodes(results[i].bytes)) as List; + final decoded = + jsonDecode(String.fromCharCodes(results[i].bytes)) as List; expect(decoded, hasLength(10), reason: 'cat_$i'); expect(results[i].rowCount, 10, reason: 'cat_$i'); } @@ -169,10 +174,7 @@ void main() { await _seed(db, 3000); // 8 concurrent queries, each ~321 KB — all trigger sacrifice. // Pool must respawn workers between queries. - final futures = List.generate( - 8, - (_) => db.select('SELECT * FROM items'), - ); + final futures = List.generate(8, (_) => db.select('SELECT * FROM items')); final results = await Future.wait(futures); for (final rows in results) { expect(rows, hasLength(3000)); @@ -257,12 +259,16 @@ void main() { await _seed(db, 100); // Stream setup uses selectWithDeps internally. - final stream = db.stream('SELECT * FROM items WHERE category = ?', ['cat_0']); + final stream = db.stream('SELECT * FROM items WHERE category = ?', [ + 'cat_0', + ]); final first = await stream.first; expect(first, hasLength(10)); // Regular selects should still work after selectWithDeps exercised the pool. - final rows = await db.select('SELECT * FROM items WHERE category = ?', ['cat_1']); + final rows = await db.select('SELECT * FROM items WHERE category = ?', [ + 'cat_1', + ]); expect(rows, hasLength(10)); }); @@ -282,10 +288,7 @@ void main() { final write = db.executeBatch( 'INSERT INTO items(name, category, price, quantity, description) ' 'VALUES (?, ?, ?, ?, ?)', - List.generate(50, (i) => [ - 'new_$i', 'cat_new', 0.0, 0, - 'new item', - ]), + List.generate(50, (i) => ['new_$i', 'cat_new', 0.0, 0, 'new item']), ); final results = await Future.wait(reads); @@ -295,8 +298,11 @@ void main() { // never a partial state, because WAL provides snapshot isolation. for (final rows in results) { final count = rows[0]['c'] as int; - expect(count == 100 || count == 150, isTrue, - reason: 'got count=$count, expected 100 or 150'); + expect( + count == 100 || count == 150, + isTrue, + reason: 'got count=$count, expected 100 or 150', + ); } }); @@ -312,7 +318,9 @@ void main() { test('empty result selectBytes', () async { await _seed(db, 10); - final result = await db.selectBytes('SELECT * FROM items WHERE id > 9999'); + final result = await db.selectBytes( + 'SELECT * FROM items WHERE id > 9999', + ); final decoded = jsonDecode(String.fromCharCodes(result.bytes)) as List; expect(decoded, isEmpty); expect(result.rowCount, 0); @@ -335,9 +343,7 @@ void main() { await db.executeBatch( 'INSERT INTO items(name, category, price, quantity, description) ' 'VALUES (?, ?, ?, ?, ?)', - List.generate(1000, (i) => [ - 'extra_$i', 'cat_0', 0.0, 0, 'extra row', - ]), + List.generate(1000, (i) => ['extra_$i', 'cat_0', 0.0, 0, 'extra row']), ); final large = await db.select('SELECT * FROM items'); expect(large, hasLength(3000)); @@ -347,10 +353,9 @@ void main() { await _seed(db, 50); // 100 sequential queries — tests that workers are reused efficiently. for (var i = 0; i < 100; i++) { - final rows = await db.select( - 'SELECT * FROM items WHERE id = ?', - [i % 50 + 1], - ); + final rows = await db.select('SELECT * FROM items WHERE id = ?', [ + i % 50 + 1, + ]); expect(rows, hasLength(1), reason: 'query $i'); } }); @@ -411,5 +416,86 @@ void main() { } await Future.wait(futures); }); + + // The pool's 32-entry per-SQL memory is shared by two consumers whose + // entries are not worth the same, so eviction prefers small statements. What + // matters is that a statement which has proven large keeps its *hint*, not + // that it stays present: an evicted statement is re-inserted on its next + // execution, so a membership check passes under any policy. + group('row-size memory eviction', () { + /// A statement needs two executions before its memory forms an opinion. + Future arm(ReaderPool pool, String sql) async { + await pool.select(sql); + await pool.select(sql); + } + + Future floodWith(ReaderPool pool, int count, String tag) async { + for (var i = 0; i < count; i++) { + await pool.select('SELECT id FROM items WHERE id = 1 -- ${tag}_$i'); + } + } + + test('a large statement keeps its hint through point-read churn', () async { + await _seed(db, 400); + final pool = await ReaderPool.spawn(db.handle.address, 2); + addTearDown(pool.close); + + // Above the decoder's 256-row initial buffer, so the growth hint applies. + const report = 'SELECT * FROM items ORDER BY id'; + await arm(pool, report); + expect(pool.rowSizeHintFor(report), greaterThan(0)); + + // 300 distinct one-off point statements through a 32-slot map. Eviction + // must keep taking the small ones, so the report's hint survives. + for (var round = 0; round < 6; round++) { + await floodWith(pool, 50, 'r$round'); + expect( + pool.rowSizeHintFor(report), + greaterThan(0), + reason: 'report statement lost its hint in round $round', + ); + } + }); + + test( + 'small statements are still remembered in the spare slots', + () async { + await _seed(db, 400); + final pool = await ReaderPool.spawn(db.handle.address, 2); + addTearDown(pool.close); + + const point = 'SELECT * FROM items WHERE id = 7'; + await arm(pool, point); + // Preferring small victims must not stop small statements being + // remembered while slots are free. + expect(pool.rowSizeHintFor(point), isNotNull); + }, + ); + + test('more large statements than slots still evict each other', () async { + await _seed(db, 400); + final pool = await ReaderPool.spawn(db.handle.address, 2); + addTearDown(pool.close); + + const first = 'SELECT * FROM items ORDER BY id'; + await arm(pool, first); + + // No small entries to prefer, so eviction falls back to insertion order. + // This is the limit of the preference, not a defect. + for (var i = 0; i < 40; i++) { + await pool.select('SELECT * FROM items ORDER BY id -- big$i'); + } + expect(pool.rowSizeHintFor(first), isNull); + }); + + test('the map never grows past its cap', () async { + await _seed(db, 10); + final pool = await ReaderPool.spawn(db.handle.address, 2); + addTearDown(pool.close); + + await floodWith(pool, 200, 'cap'); + expect(pool.rowSizeMemoryLength, lessThanOrEqualTo(32)); + }); + }); }); } diff --git a/test/result_buffer_sizing_test.dart b/test/result_buffer_sizing_test.dart index 1f0fa176..c6294943 100644 --- a/test/result_buffer_sizing_test.dart +++ b/test/result_buffer_sizing_test.dart @@ -8,11 +8,21 @@ /// plain doubling would give (a buffer that stops growing loses rows), and a /// statement whose row count swings between executions must keep returning /// exactly its own rows. +/// +/// [EXP-264](../experiments/264-initial-alloc-size-memory.md) sizes the other end +/// of the same buffer, the initial allocation, under the same contract: a wrong +/// answer may cost time, never rows. import 'dart:io'; import 'package:resqlite/resqlite.dart'; import 'package:resqlite/src/query_decoder.dart' - show RowSizeMemory, grownSlots, initialResultRows, nextRowHint; + show + RowSizeMemory, + grownSlots, + initialResultRows, + initialRowsFor, + initialSlotRows, + nextRowHint; import 'package:test/test.dart'; void main() { @@ -61,6 +71,103 @@ void main() { }); }); + // The initial allocation's risk is the mirror image of the growth hint's: + // sizing the first buffer too small costs doublings. Hence the largest row + // count ever seen, and a clamp that only ever shrinks below the default. + group('initialRowsFor', () { + test('never exceeds the fixed default, however large the result', () { + for (final rows in [initialResultRows, 300, 10000, 1 << 30]) { + expect(initialRowsFor(rows), initialResultRows); + } + }); + + test('always leaves room for at least one row', () { + expect(initialRowsFor(0), 1); + expect(initialRowsFor(-1), 1); + }); + + test('sizes a small result for itself plus headroom', () { + expect(initialRowsFor(20), nextRowHint(20)); + expect(initialRowsFor(20), greaterThan(20)); + expect(initialRowsFor(20), lessThan(initialResultRows)); + }); + }); + + group('RowSizeMemory.initialRows', () { + test('has no opinion until it has seen two executions', () { + final memory = RowSizeMemory(); + expect(initialSlotRows(0, memory), initialResultRows); + memory.record(1); + expect(initialSlotRows(0, memory), initialResultRows); + memory.record(1); + expect(initialSlotRows(0, memory), lessThan(initialResultRows)); + }); + + test('takes the high-water mark, where the growth hint takes the low', () { + final memory = RowSizeMemory() + ..record(1) + ..record(40); + expect(memory.hint, nextRowHint(1)); + expect(memory.initialRows, nextRowHint(40)); + }); + + // A sliding window would size a large result from a tiny buffer whenever the + // executions before it were small. A high-water mark is raised once. + test('one large result disables the shrink for good', () { + final memory = RowSizeMemory() + ..record(20) + ..record(20); + expect(initialSlotRows(0, memory), nextRowHint(20)); + memory.record(8000); + for (var i = 0; i < 8; i++) { + memory.record(20); + expect(initialSlotRows(0, memory), initialResultRows); + } + }); + + test('a statement that swings keeps the full default allocation', () { + final memory = RowSizeMemory()..record(8000); + for (var i = 0; i < 4; i++) { + memory.record(50); + expect(initialSlotRows(0, memory), initialResultRows); + memory.record(8000); + expect(initialSlotRows(0, memory), initialResultRows); + } + }); + + test('a stable small statement settles below the default', () { + final memory = RowSizeMemory(); + for (var i = 0; i < 4; i++) { + memory.record(1); + } + expect(initialSlotRows(0, memory), nextRowHint(1)); + }); + }); + + // A worker's own high-water mark both lags (it sees only its own executions) + // and resets (it is destroyed on a large result), so the caller's must win. + group('initialSlotRows precedence', () { + test("the caller's hint wins over a local memory that disagrees", () { + final localSaysTiny = RowSizeMemory() + ..record(1) + ..record(1); + expect( + initialSlotRows(initialResultRows, localSaysTiny), + initialResultRows, + ); + expect(initialSlotRows(40, localSaysTiny), 40); + }); + + test('a local memory is consulted only when the caller has no opinion', () { + final local = RowSizeMemory() + ..record(8) + ..record(8); + expect(initialSlotRows(0, local), nextRowHint(8)); + // No hint and no local memory falls back to the fixed default. + expect(initialSlotRows(0, null), initialResultRows); + }); + }); + group('Database result buffer sizing', () { late Directory tempDir; late Database db; @@ -123,6 +230,37 @@ void main() { } }); + // [EXP-264]: the initial allocation is sized down only after a statement + // has twice returned few rows, so the case that has to hold is the jump + // back up — a tiny first buffer that then has to hold thousands of rows. + test( + 'a statement that jumps from tiny to large still returns every row', + () async { + await seed(5000); + const sql = 'SELECT * FROM items ORDER BY id LIMIT ?'; + for (var round = 0; round < 3; round++) { + for (var i = 0; i < 6; i++) { + expect((await db.select(sql, [1])).length, 1); + } + final rows = await db.select(sql, [5000]); + expect(rows.length, 5000); + expect(rows.first['name'], 'item 0'); + expect(rows.last['name'], 'item 4999'); + } + }, + ); + + test('an empty result decodes and then grows correctly', () async { + const sql = 'SELECT * FROM items ORDER BY id'; + for (var i = 0; i < 6; i++) { + expect((await db.select(sql)).length, 0); + } + await seed(700); + final rows = await db.select(sql); + expect(rows.length, 700); + expect(rows.last['name'], 'item 699'); + }); + test('a shrinking result never returns stale rows', () async { await seed(5000); const sql = 'SELECT * FROM items ORDER BY id';