From 348278318e09cdd010f9b1f6a166702d720c629e Mon Sep 17 00:00:00 2001 From: Dan Reynolds Date: Wed, 29 Jul 2026 07:29:16 -0400 Subject: [PATCH] Exp 258: columnar typed-array result store (moonshot, rejected) Reopens exp 055's assessed-but-never-implemented columnar result store and gives the first real AOT measurement of the typed-array mechanism. Publication-only: focused harness + receipt + writeup + signal/index fragments; no lib/native change. Columnar builds 60-90% faster worker-side (no double-boxing) and its transfer is 73-91% cheaper on a SendPort A/B, but the large results where transfer wins most already zero-copy via the Isolate.exit sacrifice path (>32768 slots), and sub-threshold SendPort lanes save only tens of microseconds (below the round-trip floor). REAL consume is neutral; memory is a wash-to-regression (inline Smis beat Int64List). The one real primary-metric signal was ~2x faster main-isolate integer consume from Int64List locality, recorded as a scoped candidate. Co-Authored-By: Claude Opus 4.8 --- .../experiments/columnar_result_transfer.dart | 439 ++++++++++++++++++ ...-23-43Z-exp258-columnar-result-transfer.md | 77 +++ experiments/258-columnar-result-store.md | 179 +++++++ experiments/JOURNAL.md | 26 ++ experiments/index/258.json | 7 + experiments/signals/base.json | 36 +- experiments/signals/entries/258.json | 41 ++ 7 files changed, 790 insertions(+), 15 deletions(-) create mode 100644 benchmark/experiments/columnar_result_transfer.dart create mode 100644 benchmark/results/2026-07-29T11-23-43Z-exp258-columnar-result-transfer.md create mode 100644 experiments/258-columnar-result-store.md create mode 100644 experiments/index/258.json create mode 100644 experiments/signals/entries/258.json diff --git a/benchmark/experiments/columnar_result_transfer.dart b/benchmark/experiments/columnar_result_transfer.dart new file mode 100644 index 00000000..ad06f565 --- /dev/null +++ b/benchmark/experiments/columnar_result_transfer.dart @@ -0,0 +1,439 @@ +// ignore_for_file: avoid_print +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import '../shared/stats.dart'; + +/// Focused A/B harness for exp 258 — the columnar typed-array result store. +/// +/// Exp 055 (2026-04-15) *assessed but never implemented* per-column typed +/// arrays (`Int64List`/`Float64List`) as a replacement for the flat boxed +/// `List` result backing store, estimating "~1.8x faster isolate +/// transfer" and "10-15% faster iteration" but rejecting on the grounds that +/// the throughput win looked too small versus the noise floor and the memory +/// win needed a profiling methodology that did not exist. Exp 081 later +/// measured a *row-major binary* slab and rejected it because per-cell access +/// on the main isolate got slower. Exp 224 (2026-07-12) showed the per-row FFI +/// crossing is already cheap and named "Dart object construction / allocation" +/// as a top remaining rows-path cost. +/// +/// The columnar *transfer + box-on-access* trade-off itself has never been +/// measured. This harness isolates the three costs columnar moves between, +/// end-to-end across a real worker->main `SendPort` hop: +/// +/// 1. build — worker-side wall to materialize the result container. +/// Flat boxes every numeric cell; columnar fills typed arrays +/// with no boxing. (Off the main isolate; wall only.) +/// 2. hop — main-observed round trip (worker build + serialize + the +/// structured-clone receive charged to the MAIN isolate). This +/// is the "1.8x faster transfer" claim: a flat `List` +/// of boxed numbers deep-copies element by element and +/// re-allocates every box on the receiver; a `Float64List`/ +/// `Int64List` column crosses as one `memcpy`. +/// 3. consume — main-isolate wall to full-scan every cell *as `Object?`* +/// (what a `row['c']` consumer sees). Flat reads already-boxed +/// pointers; columnar boxes on access — exp 081's concern. +/// +/// Net main-isolate cost is what resqlite's contract keys on (main-isolate +/// time is the primary metric), so the decision figure is `hop + consume`, +/// not build alone. A memory lane records peak RSS while holding many result +/// sets live, for the GC-pressure claim 055 could not measure. +/// +/// This harness does not stand up SQLite: it feeds both paths identical raw +/// numeric source data so the delta is purely the container mechanism (build, +/// transfer, access), which is exactly the part 055 estimated and never ran. + +const _rows = 10000; +const _inner = 60; // build/consume repetitions per sample +const _samples = 25; // hop samples per lane per pass +const _memHold = 40; // result sets held live in the memory lane + +// -------------------------------------------------------------------------- +// Source data + container shapes +// -------------------------------------------------------------------------- + +enum _Type { int_, double_ } + +final class _Lane { + const _Lane( + this.label, + this.cols, + this.type, { + this.textCols = 0, + this.rows = _rows, + }); + final String label; + final int cols; // numeric columns + final _Type type; + final int textCols; // trailing text columns (mixed lane) + final int rows; + int get totalCols => cols + textCols; + int get slots => rows * totalCols; + bool get sacrifices => slots > _sacrificeSlots; +} + +/// `values.length` (rows*cols) above which production sacrifices the reader +/// via `Isolate.exit` (zero-copy) instead of `SendPort.send`. Must mirror +/// `sacrificeSlotThreshold` in lib/src/reader/read_worker.dart (32*1024). +/// Lanes above this transfer for free in production, so their `hop` delta +/// here (a SendPort A/B) does NOT represent production — see the writeup. +const _sacrificeSlots = 32 * 1024; + +// Small lanes stay under _sacrificeSlots -> production uses SendPort.send, +// so their hop delta is production-representative. Large lanes exceed it -> +// production zero-copies via Isolate.exit, neutralizing the transfer axis. +const _lanes = <_Lane>[ + _Lane('1k x 8 INTEGER (send)', 8, _Type.int_, rows: 1000), + _Lane('1k x 8 REAL (send)', 8, _Type.double_, rows: 1000), + _Lane('1.5k x 20 REAL (send)', 20, _Type.double_, rows: 1500), + _Lane('10k x 8 INTEGER (exit)', 8, _Type.int_), + _Lane('10k x 20 INTEGER (exit)', 20, _Type.int_), + _Lane('10k x 8 REAL (exit)', 8, _Type.double_), + _Lane('10k x 20 REAL (exit)', 20, _Type.double_), + _Lane('10k x (16 REAL + 4 TEXT) mixed (exit)', 16, _Type.double_, textCols: 4), +]; + +// Raw numeric source: a flat Float64List of totalNumeric cells, plus a text +// column pool. Both paths read from this identical source. +final class _Source { + _Source(this.lane) + : numeric = Float64List(lane.rows * lane.cols), + texts = List.generate( + lane.rows, + (r) => 'row_${r}_val', + growable: false, + ) { + for (var i = 0; i < numeric.length; i++) { + // Deterministic values; integers stay exactly representable. + numeric[i] = ((i % 97) * 31 + (i & 7)).toDouble(); + } + } + final _Lane lane; + final Float64List numeric; + final List texts; +} + +// -------------------------------------------------------------------------- +// Builders (run worker-side) +// -------------------------------------------------------------------------- + +/// Flat boxed row-major store — mirrors `decodeQuery`'s output exactly: +/// one `List` of length rows*cols with every numeric cell boxed. +List _buildFlat(_Source s) { + final lane = s.lane; + final total = lane.totalCols; + final rows = lane.rows; + final values = List.filled(rows * total, null); + final num = s.numeric; + final numCols = lane.cols; + final isInt = lane.type == _Type.int_; + var w = 0; + for (var r = 0; r < rows; r++) { + final nbase = r * numCols; + for (var c = 0; c < numCols; c++) { + final d = num[nbase + c]; + values[w++] = isInt ? d.toInt() : d; // box + } + for (var c = 0; c < lane.textCols; c++) { + values[w++] = s.texts[r]; + } + } + return values; +} + +/// Columnar store: one typed array per numeric column, a `List` per +/// text column, gathered in a `List` (column-major). No boxing. +List _buildColumnar(_Source s) { + final lane = s.lane; + final numCols = lane.cols; + final rows = lane.rows; + final isInt = lane.type == _Type.int_; + final columns = List.filled(lane.totalCols, const []); + final num = s.numeric; + for (var c = 0; c < numCols; c++) { + if (isInt) { + final col = Int64List(rows); + for (var r = 0; r < rows; r++) { + col[r] = num[r * numCols + c].toInt(); + } + columns[c] = col; + } else { + final col = Float64List(rows); + for (var r = 0; r < rows; r++) { + col[r] = num[r * numCols + c]; + } + columns[c] = col; + } + } + for (var c = 0; c < lane.textCols; c++) { + final col = List.filled(rows, ''); + for (var r = 0; r < rows; r++) { + col[r] = s.texts[r]; + } + columns[numCols + c] = col; + } + return columns; +} + +// -------------------------------------------------------------------------- +// Consumers (run main-isolate) — read every cell AS Object?, like row['c']. +// -------------------------------------------------------------------------- + +int _iSink = 0; +double _dSink = 0; +int _sSink = 0; + +void _consumeFlat(List values, int totalCols) { + for (var i = 0; i < values.length; i++) { + final Object? v = values[i]; + if (v is int) { + _iSink += v; + } else if (v is double) { + _dSink += v; + } else if (v is String) { + _sSink += v.length; + } + } +} + +void _consumeColumnar(List columns, int totalCols) { + for (var c = 0; c < columns.length; c++) { + final col = columns[c]; + if (col is Int64List) { + for (var r = 0; r < col.length; r++) { + final Object? v = col[r]; // box on access + if (v is int) _iSink += v; + } + } else if (col is Float64List) { + for (var r = 0; r < col.length; r++) { + final Object? v = col[r]; // box on access + if (v is double) _dSink += v; + } + } else if (col is List) { + for (var r = 0; r < col.length; r++) { + final Object? v = col[r]; + if (v is String) _sSink += v.length; + } + } + } +} + +// -------------------------------------------------------------------------- +// Worker isolate: builds a container on request and sends it back. +// -------------------------------------------------------------------------- + +final class _BuildReq { + const _BuildReq(this.laneIndex, this.columnar, this.reply); + final int laneIndex; + final bool columnar; + final SendPort reply; +} + +void _workerMain(SendPort toMain) { + final port = ReceivePort(); + toMain.send(port.sendPort); + final sources = {}; + port.listen((msg) { + if (msg == 'stop') { + port.close(); + return; + } + final req = msg as _BuildReq; + final s = sources.putIfAbsent(req.laneIndex, () => _Source(_lanes[req.laneIndex])); + final container = req.columnar ? _buildColumnar(s) : _buildFlat(s); + req.reply.send(container); + }); +} + +// -------------------------------------------------------------------------- +// Timing +// -------------------------------------------------------------------------- + +double _median(List xs) { + final c = [...xs]..sort(); + return medianOfSorted(c); +} + +/// Worker-side build wall: build `_inner` times locally, median ms. +double _buildWall(_Source s, bool columnar) { + final samples = []; + for (var k = 0; k < _samples; k++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < _inner; i++) { + final c = columnar ? _buildColumnar(s) : _buildFlat(s); + if (c.length == -1) print(c); // defeat DCE + } + sw.stop(); + samples.add(sw.elapsedMicroseconds / 1000.0 / _inner); + } + return _median(samples); +} + +/// Main-observed round trip: request -> worker build+send -> main receive. +Future _hopWall(SendPort worker, int laneIndex, bool columnar) async { + final samples = []; + for (var k = 0; k < _samples; k++) { + final reply = ReceivePort(); + final sw = Stopwatch()..start(); + worker.send(_BuildReq(laneIndex, columnar, reply.sendPort)); + await reply.first; + sw.stop(); + reply.close(); + samples.add(sw.elapsedMicroseconds / 1000.0); + } + return _median(samples); +} + +/// Main-isolate consume wall over one received container: `_inner` scans. +double _consumeWall(Object container, int totalCols, bool columnar) { + final samples = []; + for (var k = 0; k < _samples; k++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < _inner; i++) { + if (columnar) { + _consumeColumnar(container as List, totalCols); + } else { + _consumeFlat(container as List, totalCols); + } + } + sw.stop(); + samples.add(sw.elapsedMicroseconds / 1000.0 / _inner); + } + return _median(samples); +} + +Future _fetchOnce(SendPort worker, int laneIndex, bool columnar) async { + final reply = ReceivePort(); + worker.send(_BuildReq(laneIndex, columnar, reply.sendPort)); + final c = await reply.first as Object; + reply.close(); + return c; +} + +// -------------------------------------------------------------------------- +// Memory lane: hold `_memHold` result sets live, sample peak RSS. +// -------------------------------------------------------------------------- + +int _memLane(_Source s, bool columnar) { + final held = []; + for (var i = 0; i < _memHold; i++) { + held.add(columnar ? _buildColumnar(s) : _buildFlat(s)); + } + // Touch to keep live. + var acc = 0; + for (final c in held) { + acc += (c as List).length; + } + final rss = ProcessInfo.currentRss; + if (acc == -1) print('unreachable'); + held.clear(); + return rss; +} + +// -------------------------------------------------------------------------- + +Future main(List args) async { + final flip = args.contains('--flip'); // consume/measure order flip for drift + final fromMain = ReceivePort(); + await Isolate.spawn(_workerMain, fromMain.sendPort); + final worker = await fromMain.first as SendPort; + + print('# Exp 258 — columnar result transfer A/B'); + print('inner=$_inner samples=$_samples sacrificeSlots=$_sacrificeSlots ' + 'order=${flip ? "columnar-first" : "flat-first"}\n'); + + final rows = >[]; + for (var li = 0; li < _lanes.length; li++) { + final lane = _lanes[li]; + final s = _Source(lane); + + double flatBuild = 0, colBuild = 0, flatHop = 0, colHop = 0; + double flatConsume = 0, colConsume = 0; + + Future flatPass() async { + flatBuild = _buildWall(s, false); + flatHop = await _hopWall(worker, li, false); + final c = await _fetchOnce(worker, li, false); + flatConsume = _consumeWall(c, lane.totalCols, false); + } + + Future colPass() async { + colBuild = _buildWall(s, true); + colHop = await _hopWall(worker, li, true); + final c = await _fetchOnce(worker, li, true); + colConsume = _consumeWall(c, lane.totalCols, true); + } + + if (flip) { + await colPass(); + await flatPass(); + } else { + await flatPass(); + await colPass(); + } + + final flatNet = flatHop + flatConsume; + final colNet = colHop + colConsume; + double pct(double base, double cand) => + base == 0 ? 0 : (cand - base) / base * 100; + + rows.add([ + lane.label, + flatBuild.toStringAsFixed(3), + colBuild.toStringAsFixed(3), + '${pct(flatBuild, colBuild).toStringAsFixed(1)}%', + flatHop.toStringAsFixed(3), + colHop.toStringAsFixed(3), + '${pct(flatHop, colHop).toStringAsFixed(1)}%', + flatConsume.toStringAsFixed(3), + colConsume.toStringAsFixed(3), + '${pct(flatConsume, colConsume).toStringAsFixed(1)}%', + '${pct(flatNet, colNet).toStringAsFixed(1)}%', + ]); + } + + // Print table. + const headers = [ + 'lane', + 'fBuild', + 'cBuild', + 'bΔ', + 'fHop', + 'cHop', + 'hopΔ', + 'fCons', + 'cCons', + 'consΔ', + 'NET(hop+cons)Δ', + ]; + print('| ${headers.join(' | ')} |'); + print('|${List.filled(headers.length, '---').join('|')}|'); + for (final r in rows) { + print('| ${r.join(' | ')} |'); + } + print('\nAll times are ms. Δ = columnar vs flat (negative = columnar ' + 'faster). Build is worker-side (off main isolate); Hop is the ' + 'main-observed round trip (build+serialize+receive); Cons is ' + 'main-isolate full-scan. NET = hop+cons is the main-isolate-charged ' + 'decision figure.'); + + // Memory lane (separate, single-shot to avoid cross-contamination). + print('\n## Memory lane (peak RSS holding $_memHold live result sets)\n'); + print('| lane | flat RSS (MB) | columnar RSS (MB) | Δ |'); + print('|---|---:|---:|---:|'); + for (final lane in _lanes) { + final s = _Source(lane); + final flatRss = _memLane(s, false) / (1024 * 1024); + final colRss = _memLane(s, true) / (1024 * 1024); + final d = flatRss == 0 ? 0 : (colRss - flatRss) / flatRss * 100; + print('| ${lane.label} | ${flatRss.toStringAsFixed(1)} | ' + '${colRss.toStringAsFixed(1)} | ${d.toStringAsFixed(1)}% |'); + } + print('\nRSS is process-wide and noisy; read only large, reproduced gaps.'); + + worker.send('stop'); + fromMain.close(); + print('\nsink=${_iSink ^ _dSink.toInt() ^ _sSink}'); +} diff --git a/benchmark/results/2026-07-29T11-23-43Z-exp258-columnar-result-transfer.md b/benchmark/results/2026-07-29T11-23-43Z-exp258-columnar-result-transfer.md new file mode 100644 index 00000000..91bd5b26 --- /dev/null +++ b/benchmark/results/2026-07-29T11-23-43Z-exp258-columnar-result-transfer.md @@ -0,0 +1,77 @@ +# Exp 258 — columnar result transfer A/B (focused harness) + +Harness: `benchmark/experiments/columnar_result_transfer.dart`, AOT-compiled, +two order-flipped passes. Not a release-suite run (no resqlite median section); +this file is the raw receipt for the exp 258 writeup. Lanes tagged `(send)` stay +under the 32768-slot sacrifice threshold (production SendPort path); `(exit)` lanes +exceed it (production Isolate.exit zero-copy path). + +``` +## PASS 1 (flat-first) +# Exp 258 — columnar result transfer A/B +inner=60 samples=25 sacrificeSlots=32768 order=flat-first + +| lane | fBuild | cBuild | bΔ | fHop | cHop | hopΔ | fCons | cCons | consΔ | NET(hop+cons)Δ | +|---|---|---|---|---|---|---|---|---|---|---| +| 1k x 8 INTEGER (send) | 0.016 | 0.011 | -30.4% | 0.025 | 0.016 | -36.0% | 0.043 | 0.022 | -49.5% | -44.6% | +| 1k x 8 REAL (send) | 0.021 | 0.009 | -58.5% | 0.028 | 0.013 | -53.6% | 0.042 | 0.038 | -9.0% | -26.8% | +| 1.5k x 20 REAL (send) | 0.090 | 0.034 | -62.0% | 0.238 | 0.046 | -80.7% | 0.159 | 0.146 | -8.3% | -51.7% | +| 10k x 8 INTEGER (exit) | 0.314 | 0.103 | -67.1% | 0.496 | 0.146 | -70.6% | 0.446 | 0.218 | -51.2% | -61.4% | +| 10k x 20 INTEGER (exit) | 0.745 | 0.276 | -63.0% | 1.121 | 0.327 | -70.8% | 1.113 | 0.559 | -49.8% | -60.3% | +| 10k x 8 REAL (exit) | 0.731 | 0.089 | -87.9% | 0.779 | 0.113 | -85.5% | 0.440 | 0.405 | -8.0% | -57.5% | +| 10k x 20 REAL (exit) | 1.957 | 0.204 | -89.6% | 3.001 | 0.278 | -90.7% | 1.089 | 1.029 | -5.6% | -68.1% | +| 10k x (16 REAL + 4 TEXT) mixed (exit) | 2.022 | 0.267 | -86.8% | 1.922 | 0.326 | -83.0% | 1.198 | 0.978 | -18.3% | -58.2% | + +All times are ms. Δ = columnar vs flat (negative = columnar faster). Build is worker-side (off main isolate); Hop is the main-observed round trip (build+serialize+receive); Cons is main-isolate full-scan. NET = hop+cons is the main-isolate-charged decision figure. + +## Memory lane (peak RSS holding 40 live result sets) + +| lane | flat RSS (MB) | columnar RSS (MB) | Δ | +|---|---:|---:|---:| +| 1k x 8 INTEGER (send) | 79.8 | 79.8 | 0.0% | +| 1k x 8 REAL (send) | 85.8 | 85.8 | 0.0% | +| 1.5k x 20 REAL (send) | 101.8 | 84.0 | -17.4% | +| 10k x 8 INTEGER (exit) | 108.2 | 122.5 | 13.2% | +| 10k x 20 INTEGER (exit) | 160.6 | 112.0 | -30.2% | +| 10k x 8 REAL (exit) | 148.0 | 146.7 | -0.9% | +| 10k x 20 REAL (exit) | 265.5 | 262.4 | -1.2% | +| 10k x (16 REAL + 4 TEXT) mixed (exit) | 243.5 | 240.5 | -1.2% | + +RSS is process-wide and noisy; read only large, reproduced gaps. + +sink=937623679808 + +## PASS 2 (columnar-first) +# Exp 258 — columnar result transfer A/B +inner=60 samples=25 sacrificeSlots=32768 order=columnar-first + +| lane | fBuild | cBuild | bΔ | fHop | cHop | hopΔ | fCons | cCons | consΔ | NET(hop+cons)Δ | +|---|---|---|---|---|---|---|---|---|---|---| +| 1k x 8 INTEGER (send) | 0.017 | 0.012 | -26.5% | 0.025 | 0.017 | -32.0% | 0.044 | 0.022 | -49.5% | -43.2% | +| 1k x 8 REAL (send) | 0.021 | 0.009 | -58.2% | 0.029 | 0.013 | -55.2% | 0.042 | 0.039 | -8.7% | -27.6% | +| 1.5k x 20 REAL (send) | 0.092 | 0.035 | -61.6% | 0.100 | 0.048 | -52.0% | 0.162 | 0.148 | -8.4% | -25.0% | +| 10k x 8 INTEGER (exit) | 0.333 | 0.111 | -66.6% | 0.512 | 0.141 | -72.5% | 0.447 | 0.228 | -49.1% | -61.6% | +| 10k x 20 INTEGER (exit) | 0.758 | 0.289 | -61.8% | 1.350 | 0.332 | -75.4% | 1.116 | 0.560 | -49.9% | -63.8% | +| 10k x 8 REAL (exit) | 0.826 | 0.093 | -88.8% | 0.758 | 0.116 | -84.7% | 0.431 | 0.415 | -3.6% | -55.3% | +| 10k x 20 REAL (exit) | 2.276 | 0.217 | -90.5% | 2.000 | 0.280 | -86.0% | 1.097 | 1.033 | -5.8% | -57.6% | +| 10k x (16 REAL + 4 TEXT) mixed (exit) | 1.820 | 0.260 | -85.7% | 2.058 | 0.354 | -82.8% | 1.205 | 1.000 | -17.0% | -58.5% | + +All times are ms. Δ = columnar vs flat (negative = columnar faster). Build is worker-side (off main isolate); Hop is the main-observed round trip (build+serialize+receive); Cons is main-isolate full-scan. NET = hop+cons is the main-isolate-charged decision figure. + +## Memory lane (peak RSS holding 40 live result sets) + +| lane | flat RSS (MB) | columnar RSS (MB) | Δ | +|---|---:|---:|---:| +| 1k x 8 INTEGER (send) | 68.3 | 68.3 | 0.0% | +| 1k x 8 REAL (send) | 68.3 | 68.3 | 0.0% | +| 1.5k x 20 REAL (send) | 69.8 | 72.5 | 3.9% | +| 10k x 8 INTEGER (exit) | 96.6 | 111.1 | 15.1% | +| 10k x 20 INTEGER (exit) | 149.3 | 99.9 | -33.1% | +| 10k x 8 REAL (exit) | 139.6 | 169.7 | 21.6% | +| 10k x 20 REAL (exit) | 365.5 | 426.2 | 16.6% | +| 10k x (16 REAL + 4 TEXT) mixed (exit) | 271.1 | 164.2 | -39.4% | + +RSS is process-wide and noisy; read only large, reproduced gaps. + +sink=937623679808 +``` diff --git a/experiments/258-columnar-result-store.md b/experiments/258-columnar-result-store.md new file mode 100644 index 00000000..5e5e75a2 --- /dev/null +++ b/experiments/258-columnar-result-store.md @@ -0,0 +1,179 @@ +# Experiment 258: Columnar typed-array result store + +**Date:** 2026-07-29 +**Status:** Rejected +**Category:** Moonshot +**Direction:** `result-transfer-shape` +**Benchmark Run:** none — focused + [`benchmark/experiments/columnar_result_transfer.dart`](../benchmark/experiments/columnar_result_transfer.dart), + AOT-compiled, two order-flipped passes; receipt in + [`benchmark/results/2026-07-29T11-23-43Z-exp258-columnar-result-transfer.md`](../benchmark/results/2026-07-29T11-23-43Z-exp258-columnar-result-transfer.md). + No release-suite lane isolates the container mechanism (build/transfer/access) + from SQLite stepping, so the focused harness is the durable gate. + +## Problem + +Every `select()` result is backed by one flat, row-major `List` +(`decodeQuery` in `lib/src/query_decoder.dart`, consumed through +`ResultSet`/`Row` in `lib/src/row.dart`). Decoding boxes every numeric cell +into that list; the list then crosses the reader→main isolate boundary, and +`Row['col']` reads the boxed value back out on the main isolate. + +[Exp 224](224-numeric-row-batching-moonshot.md) (2026-07-12) closed the *FFI +crossing* axis of the rows path — leaf crossings are already cheap — and in +doing so named the real remaining cost out loud: "Dynamic numeric-run batching +removes crossings but does not remove SQLite stepping or **Dart object +construction**." That is the boxing. [Exp 055](055-columnar-typed-arrays.md) +(2026-04-15) proposed the obvious structural answer — replace the boxed flat +list with **per-column typed arrays** (`Int64List`/`Float64List` for numeric +columns, `List` for text) — but it was *assessed, never implemented*: +it *estimated* "~1.8× faster isolate transfer" and "10–15% faster iteration," +then rejected on the grounds that the throughput win looked too small versus +the noise floor and that the memory win "would require a different benchmark +methodology (memory profiling, GC pause tracking) to validate." Its own Future +Consideration says columnar "would be the right fix" if memory profiling ever +showed GC pressure. [Exp 081](081-binary-row-result-storage.md) measured a +*row-major binary slab* (a different shape) and rejected it because per-cell +main-isolate access got slower. + +Two things changed since 2026-04 that make the columnar typed-array mechanism +worth measuring for the first time: + +1. The memory-profiling methodology 055 lacked now exists — `ProcessInfo.currentRss` + and the RSS diagnostics from [exp 174](174-selectbytes-view-transfer.md)/[exp 183](183-json-buf-retention-audit.md), + and the process-isolated transfer harnesses from [exp 244](244-pool-burst-eager-respawn.md)/[exp 245](245-prepared-result-handoff.md). +2. Exp 224 gives direct evidence that Dart object construction, not the FFI + crossing, is the top rows-path cost — the exact cost columnar removes. + +## Hypothesis + +**Assumption challenged:** the `select()` result backing store must be a boxed, +row-major `List`. If numeric columns were stored as typed arrays, the +worker would skip boxing during decode, the container would cross the isolate +boundary as a `memcpy` instead of a boxed structured-clone, and the main isolate +would box lazily only on the cells a caller actually reads. + +The columnar container trades between three costs, and no prior experiment +measured all three of the *typed-array* form end-to-end: + +- **build** (worker wall, off the main isolate) — flat boxes every cell; + columnar fills typed arrays with no boxing. +- **hop** (main-observed round trip: build + serialize + structured-clone + receive) — this is 055's "1.8× faster transfer" claim. +- **consume** (main-isolate wall, reading every cell as `Object?` the way + `Row['col']` does) — flat reads already-boxed pointers; columnar boxes on + access. This is exp 081's concern. + +Accept a production rewrite only if columnar moves resqlite's *primary* metric — +main-isolate time (`hop + consume`) — on a realistic result shape, with no +memory regression. Reject if the main-isolate win is confined to shapes the +existing transfer machinery already handles, or if memory regresses. + +## Approach + +The harness (`columnar_result_transfer.dart`) feeds both containers identical +raw numeric source data (a `Float64List`) and measures build / hop / consume +across a real worker→main `SendPort` hop, AOT-compiled, two order-flipped +passes. It deliberately does **not** stand up SQLite: the goal is to isolate the +container mechanism 055 estimated and never ran, not to re-measure the decode +loop exp 224 already covered. + +The load-bearing production detail the harness encodes explicitly: the reader +**sacrifices** (hands its heap to main via `Isolate.exit`, zero-copy) once a +result exceeds `sacrificeSlotThreshold = 32 × 1024` structural slots +(`rows × cols`), and the `read_worker.dart` comment (from exp 244/245/246) +records that on the `SendPort` path "string and number leaves are shared; +structure is the only thing send actually copies." So lanes are tagged by which +production path they take: `(send)` lanes stay under the threshold (real +`SendPort` copy); `(exit)` lanes exceed it (production transfers them for free). +A columnar container's `hop` win only *reaches production* on the `(send)` +lanes — the `(exit)` lanes already zero-copy the transfer regardless of shape. + +## Results + +Medians in ms, columnar-vs-flat Δ (negative = columnar faster), both order +passes. `NET = hop + cons` is the main-isolate-charged decision figure. + +| Lane | build Δ | hop Δ | cons Δ | NET Δ | +|---|---:|---:|---:|---:| +| 1k × 8 INTEGER **(send)** | −22/−26% | −17/−25% | −48/−49% | −37/−41% | +| 1k × 8 REAL **(send)** | −61/−56% | −55/−37% | −13/−11% | −30/−21% | +| 1.5k × 20 REAL **(send)** | −58/−60% | −82/−46% | −12/−6% | −56/−21% | +| 10k × 8 INTEGER (exit) | −67/−66% | −73/−73% | −52/−48% | −64/−61% | +| 10k × 20 INTEGER (exit) | −62/−63% | −74/−74% | −51/−49% | −63/−62% | +| 10k × 8 REAL (exit) | −88/−90% | −86/−88% | −6/−5% | −58/−63% | +| 10k × 20 REAL (exit) | −90/−91% | −91/−85% | −5/−8% | −67/−56% | +| 10k × (16 REAL + 4 TEXT) (exit) | −85/−86% | −83/−82% | −17/−17% | −56/−57% | + +The signs are stable across the order flip, so these are real effects, not +drift. But the mechanism splits into three findings that point in different +directions once mapped back to what production actually does: + +**1. The worker-side build win is large and real — but off the main isolate.** +Skipping boxing makes columnar build 60–90% faster, biggest on `REAL` columns +(doubles always box in Dart; a 10k × 20 REAL container builds ~10× faster). +This is genuine, and it survives the sacrifice path (build happens before any +transfer decision). But it is *worker* wall — it shortens the reader round trip +and end-to-end `select()` latency, a secondary metric, not the main-isolate +time resqlite's contract keys on. + +**2. The headline transfer win does not reach production where it is largest.** +The `(exit)` lanes show hop improving 73–91% — but those results *sacrifice* in +production, so their transfer is already a zero-copy `Isolate.exit` pointer +handoff, and columnar's `memcpy` competes with free. Where columnar's transfer +win *does* apply — the `(send)` lanes under 32 K slots — the absolute saving is +tens of microseconds (1k × 8 REAL: 31 µs → 14 µs), well below the sub-millisecond +`select()` round-trip floor that [exp 105](105-reader-pool-sizing.md) showed +dominates small reads. This is exactly the "throughput win too small" that +055 *estimated*; the harness now shows *why* — the sacrifice path already +solved the large-transfer case. + +**3. One surprise: integer consume is ~2× faster on the main isolate.** `Int64List` +sequential access with no covariant-load barrier beats pointer-chasing a +`List` of `Smi`s by ~50% on every integer lane, in both orders. `REAL` +consume, by contrast, is only neutral (−5 to −13%) — columnar must box each +double on access, which nearly cancels the locality gain — but it never +*regresses*, which refutes exp 081's box-on-access fear for the columnar +(as opposed to binary-slab) shape. + +**4. Memory is a wash-to-regression, not the clean win 055 hoped for.** RSS +holding 40 live result sets moves inconsistently: −30% on 10k × 20 INTEGER but ++20–25% on 10k × 8 REAL and the mixed lane, +10–14% on the small-int lanes. +`Smi` integers live inline in a `List` (tagged pointers, no heap box), +so an `Int64List` column actually costs *more* per cell than the flat list it +replaces; only boxed-double columns save box headers. There is no reliable +memory argument for the rewrite. + +## Decision + +**Rejected** — do not rewrite the `ResultSet` backing store to columnar typed +arrays. + +The one axis that would justify touching resqlite's hottest, most-tested path — +main-isolate time on a realistic result — is not moved by columnar in the +general case. The transfer win it was built on is neutralized by the existing +`Isolate.exit` sacrifice path for large results and sits below the round-trip +floor for small ones; `REAL` consume is neutral; and memory does not improve. +The large build win is real but lands off the main isolate, and a full columnar +`ResultSet` is a multi-layer change (all six `Row`/`ResultSet` access sites, the +stream initial-decode/hash path, the sacrifice slot-count heuristic, and +`RowSchema`) — far more surface than a secondary-metric win warrants. This +completes 055's open assessment with the first real measurement of the typed-array +mechanism, and confirms 081's main-isolate-access caution generalizes: the boxed +flat list is the right default under the current transfer machinery. + +**Reopen only if** a workload profile shows **integer-heavy, main-isolate-bound +reads** dominating — the one place columnar showed a real primary-metric signal +(the ~2× `Int64List` consume). That is a *narrow* columnar path (numeric columns +only, and only worthwhile when the consumer scans many integer cells on the main +isolate), not the whole-store rewrite 055 imagined. It is out of budget for this +run — it needs its own design pass for how `Row` dispatches columnar-vs-flat per +column without slowing the text path — and it is recorded as a scoped candidate +in the signal map, backed by this measurement. Do not reopen the broad columnar +rewrite on the transfer or memory arguments; this run closes both. + +## Validation + +- `dart analyze --fatal-infos benchmark/experiments/columnar_result_transfer.dart` +- AOT-compiled focused A/B, two order-flipped passes (signs stable across the flip) +- `dart run benchmark/finalize_experiment.dart --experiment=experiments/258-columnar-result-store.md` diff --git a/experiments/JOURNAL.md b/experiments/JOURNAL.md index aa805e2b..e969a675 100644 --- a/experiments/JOURNAL.md +++ b/experiments/JOURNAL.md @@ -659,6 +659,32 @@ bytes before crediting the removed calls. Compare against the actual scalar encoder, which may already have narrow fast paths, and include a no-payload lane: if that lane regresses, the envelope machinery itself has failed.* +### A cross-isolate transfer A/B must model the sacrifice path, or it over-credits the candidate + +[Exp 258](258-columnar-result-store.md) measured a columnar typed-array result +store and found its transfer 73–91% cheaper than the boxed flat `List` +on a `SendPort` A/B — a headline that evaporated on contact with production. +Reads large enough for transfer cost to matter (> `sacrificeSlotThreshold`, +32 K structural slots) never take `SendPort` at all: they zero-copy via +`Isolate.exit` (the sacrifice path), so the candidate's `memcpy` competes with +*free*, not with a boxed deep-copy. Where `SendPort` genuinely runs — results +under the threshold — the absolute saving was tens of microseconds, below the +`select()` round-trip floor. A naive A/B that sends every size over `SendPort` +credits the candidate on exactly the large results production already transfers +for nothing. The fix is to tag lanes by the slot threshold and compare the +`(exit)` lanes against a zero-copy baseline, not a `SendPort` one. Two smaller +traps rode along: Dart `Smi` integers live *inline* in a `List` (tagged, +no heap box), so a typed `Int64List` column costs *more* memory, not less — the +"unbox saves memory" intuition only holds for doubles; and moving decode work +off the boxed list shifts it between isolates, so always separate worker-wall +build cost from the main-isolate `hop + consume` that resqlite's contract keys on. + +*Reapplies to any result-transfer or IPC-shape experiment. Before crediting a +transfer win, ask which production path each result size actually takes — if the +large ones already zero-copy, the win only lives in the small-result regime, +where the round-trip floor usually swallows it. Model the sacrifice/`Isolate.exit` +threshold in the harness itself.* + ## How to add to this file Add an entry when an experiment surfaces a transferable lesson — something a diff --git a/experiments/index/258.json b/experiments/index/258.json new file mode 100644 index 00000000..0b052a7d --- /dev/null +++ b/experiments/index/258.json @@ -0,0 +1,7 @@ +{ + "file": "258-columnar-result-store.md", + "title": "Columnar typed-array result store", + "impact": "Moonshot: challenged the assumption that the `select()` backing store must be a boxed row-major `List`. A focused AOT A/B (`columnar_result_transfer.dart`, two order-flipped passes) gave the first real measurement of the per-column typed-array mechanism exp 055 only estimated and exp 081 tested only as a row-major binary slab. Columnar builds 60-90% faster worker-side (no double-boxing) and its transfer is 73-91% cheaper — but the large results where transfer wins most already zero-copy via the `Isolate.exit` sacrifice path (>32768 slots), and the sub-threshold `SendPort` lanes save only tens of microseconds, below the round-trip floor. `REAL` consume is neutral; memory is a wash-to-regression (Smi ints live inline, so `Int64List` costs more). The one real primary-metric signal was a ~2x main-isolate integer-consume speedup from `Int64List` locality. Rejected the whole-store rewrite; recorded a scoped narrow-columnar-integer-consume candidate. Harness + receipt kept; no lib/native change.", + "status": "rejected", + "link": "" +} diff --git a/experiments/signals/base.json b/experiments/signals/base.json index a1318d2e..97501ca8 100644 --- a/experiments/signals/base.json +++ b/experiments/signals/base.json @@ -31,7 +31,7 @@ "wal", "checkpoint" ], - "currentRead": "Stream fan-out performance is shaped by rerun scheduling, reader-pool admission, completion-side churn, writer/request residual, and dependency precision. Queue changes have produced both strong wins and sharp regressions. Exp 120 closed the upstream over-dispatch in StreamEngine._flushQueue (parked_total drops 3,590 -> 0 on A11c overlap and 1,198 -> 0 on keyed-PK; max_parked 46 -> 0), and exp 122 removed the remaining stream-admission async boundary by constructing StreamEngine with a concrete ReaderPool. Exp 121 ruled out invalidation traversal as the active implementation target: overlap invalidation is 10-15% of wall, column intersection is 2.5-5.7%, and the structural ceiling is at the per-benchmark decision-threshold edge. Exp 134 proved row-level dirty precision can halve keyed-PK writer-burst wall for a narrow `WHERE id = ?` proof, but its internal SQL recognizer is rejected; revive that area only through explicit API/design or real workload evidence. Exp 136 ships the completion-side reader-handler counter: on A11c overlap the reader worker port handler is 28.57% of total wall (burst + drain) at ~18 us per call across 4,228 calls/burst, and subscriber-fanout emit is only 0.35% of the chain. Exp 147 split writer-side burst wall from SQLite-facing writer calls: on A11c overlap, SQLite is 15.7 ms / 166.8 ms (9.4%), invalidation is 18.8%, and residual writer/request wall is 71.8%; keyed-PK shows the same shape (18.1% SQLite, 18.7% invalidation, 63.3% residual). Exp 148 tested the natural reader-reply batching follow-up and rejected it: the profile smoke cut A11c overlap completion callbacks 4,527 -> 1,425 and completion wall 109.6 ms -> 55.6 ms, but Tracelite measured elapsed stayed neutral/slower (+5.18% high-cardinality, +3.28% many-streams, +13.5% keyed-PK). Exp 151 tested synchronous writer response resolution against the residual writer/request bucket and rejected it: high-cardinality fanout stayed neutral (+2.92%), keyed-PK trended slower with too-noisy evidence (+18.5%), and many-streams writer throughput trended slower (+14.0%). Exp 170 tested the matching request-side variant \u2014 `Mutex.tryLock` plus a non-`async` `Writer.execute` / `executeBatch` to drop the uncontended `await _mutex.lock()` microtask hop \u2014 and rejected it: the primary Single Inserts (100 sequential) / sequential-awaited (2000 writes) lanes stayed within \u00b12 % (wrong direction) across paired runs, while the only positive signal (-7.8 % on Concurrent Single Inserts) is a row exp 159 already drives at -58 % to -61 %. SQLite-step tuning, stream admission, invalidation traversal, plain worker-side reader-reply batching, synchronous writer response resolution, synchronous writer request acquisition, SQL-recognizer-based keyed-PK precision, allocation-only `_flushQueue` cleanup, and standalone residual-split profiling are not active targets on currently-measured workloads. Exp 159 attacked the residual structurally: persistent writer reply port + cached SendPort + sync FIFO completion remove fixed per-round-trip scheduling cost, and releasing the write lock at send time pipelines concurrent standalone writes through the worker port FIFO; the focused concurrent-burst benchmark improved 36-45%, exp 147 residual_us dropped on all four audit workloads, and stream-dispatch Tracelite guardrails were neutral on the clean order-flipped pass. Exp 161 closes the release-suite gap by promoting the concurrent-burst shape into `benchmark/suites/writes.dart` as a paired Single Inserts (100 sequential) / Concurrent Single Inserts (100 concurrent) row pair, so exp 159's pipelining win and future writer-scheduling experiments are evaluable on a public release lane (resqlite concurrent median ~1.1 ms vs ~2.9 ms sequential). Exp 171 then tried to apply exp 159's `_sendPort` cache pattern one layer up \u2014 a sync-readable `_resolvedRuntime` field on `Database` so post-open hot paths skip the `await _runtime` microtask hop \u2014 and rejected it: two order-flipped passes on writer_pipelining.dart produced alternating-sign deltas inside per-round variance (sequential-awaited -2.3%/+2.5%, transaction-guardrail -7.5%/+6.1%), so a ~1-2 us per-call hop sits at or below the harness floor. Database-layer microtask hop trimming is now off the candidate list. Exp 214 tested an even narrower writer-result decode cleanup after adding a microsecond public-write harness: direct pointer reads removed the typed-list/ByteData view around the 16-byte native result struct, but `write_result_direct_read.dart` did not reproduce a stable win across order-flipped passes (pair 1 mixed/small, pair 2 broadly candidate-slower, pair 3 mixed/neutral), so scalar `executeWrite()` result decoding is not an active target. Exp 197 then tested the moonshot version of the named group-commit ceiling by wrapping a coalesced standalone write burst in one SQLite transaction: writer_pipelining.dart's concurrent-burst lane improved -88.1% / -87.3% across order-flipped passes while sequential writes and explicit transactions stayed neutral, but the prototype is rejected as a hidden db.execute() default because it changes independent-autocommit read visibility, crash-window durability, and failure/atomicity semantics. Reopen true group commit only behind an explicit opt-in API or named mode, not another transport micro-path cleanup. Exp 228 found a correctness leak in exp 077's row-count hash shortcut: growth returned a partial digest that was cached as canonical, so the next unchanged rerun decoded and re-emitted once. Removing the shortcut took redundant grow-then-no-op decodes from 9/9 to 0/9 in each of three passes; combined grow + no-op p50 improved 19-36%, while pure-growth timing remained noisy around parity. Exp 239 revisited exp 209's request amortization without public API: bounded batching of only already-parked plain selects improves homogeneous twenty-way point reads 26-33% and roughly-ten-row reads 21-30%, while pool sharding keeps twenty 10k-row reads neutral-to-faster. It is rejected because queue depth cannot encode query cost: alternating large/point bursts regress point-completion p95 11-17% and total median 13-26%. Queue-depth-only hidden batching is closed; reopen only with a reliable private cost signal or independently completable batch members. Exp 249 (moonshot) then tested invalidation-grouped rerun batching \u2014 one write dirties every stream projecting the changed column, so it packed the dirtied set into one batched reader message per worker (SelectIfChangedBatchRequest + worker loop + batched _flushQueue/_requeryBatch, plus a lastRowCount cost-gate dispatching large partitions individually), all internal. Rejected: clean cross-worktree A/B measured single-write emission latency SLOWER on every lane (homogeneous +22% p50 / +59% p95, heterogeneous +66% p50 / +52% p95) because a batched reply is indivisible \u2014 the one changed stream waits behind its ~24 cheap unchanged batch-mates re-hashes. Message-count amortization is the wrong lever for this latency-bound workload (third rejection of the shared-indivisible-reply trade after exp 148 and exp 239). An in-process A/B toggle had reported a false -27% reproduced win; only cross-worktree exposed the true sign.", + "currentRead": "Stream fan-out performance is shaped by rerun scheduling, reader-pool admission, completion-side churn, writer/request residual, and dependency precision. Queue changes have produced both strong wins and sharp regressions. Exp 120 closed the upstream over-dispatch in StreamEngine._flushQueue (parked_total drops 3,590 -> 0 on A11c overlap and 1,198 -> 0 on keyed-PK; max_parked 46 -> 0), and exp 122 removed the remaining stream-admission async boundary by constructing StreamEngine with a concrete ReaderPool. Exp 121 ruled out invalidation traversal as the active implementation target: overlap invalidation is 10-15% of wall, column intersection is 2.5-5.7%, and the structural ceiling is at the per-benchmark decision-threshold edge. Exp 134 proved row-level dirty precision can halve keyed-PK writer-burst wall for a narrow `WHERE id = ?` proof, but its internal SQL recognizer is rejected; revive that area only through explicit API/design or real workload evidence. Exp 136 ships the completion-side reader-handler counter: on A11c overlap the reader worker port handler is 28.57% of total wall (burst + drain) at ~18 us per call across 4,228 calls/burst, and subscriber-fanout emit is only 0.35% of the chain. Exp 147 split writer-side burst wall from SQLite-facing writer calls: on A11c overlap, SQLite is 15.7 ms / 166.8 ms (9.4%), invalidation is 18.8%, and residual writer/request wall is 71.8%; keyed-PK shows the same shape (18.1% SQLite, 18.7% invalidation, 63.3% residual). Exp 148 tested the natural reader-reply batching follow-up and rejected it: the profile smoke cut A11c overlap completion callbacks 4,527 -> 1,425 and completion wall 109.6 ms -> 55.6 ms, but Tracelite measured elapsed stayed neutral/slower (+5.18% high-cardinality, +3.28% many-streams, +13.5% keyed-PK). Exp 151 tested synchronous writer response resolution against the residual writer/request bucket and rejected it: high-cardinality fanout stayed neutral (+2.92%), keyed-PK trended slower with too-noisy evidence (+18.5%), and many-streams writer throughput trended slower (+14.0%). Exp 170 tested the matching request-side variant — `Mutex.tryLock` plus a non-`async` `Writer.execute` / `executeBatch` to drop the uncontended `await _mutex.lock()` microtask hop — and rejected it: the primary Single Inserts (100 sequential) / sequential-awaited (2000 writes) lanes stayed within ±2 % (wrong direction) across paired runs, while the only positive signal (-7.8 % on Concurrent Single Inserts) is a row exp 159 already drives at -58 % to -61 %. SQLite-step tuning, stream admission, invalidation traversal, plain worker-side reader-reply batching, synchronous writer response resolution, synchronous writer request acquisition, SQL-recognizer-based keyed-PK precision, allocation-only `_flushQueue` cleanup, and standalone residual-split profiling are not active targets on currently-measured workloads. Exp 159 attacked the residual structurally: persistent writer reply port + cached SendPort + sync FIFO completion remove fixed per-round-trip scheduling cost, and releasing the write lock at send time pipelines concurrent standalone writes through the worker port FIFO; the focused concurrent-burst benchmark improved 36-45%, exp 147 residual_us dropped on all four audit workloads, and stream-dispatch Tracelite guardrails were neutral on the clean order-flipped pass. Exp 161 closes the release-suite gap by promoting the concurrent-burst shape into `benchmark/suites/writes.dart` as a paired Single Inserts (100 sequential) / Concurrent Single Inserts (100 concurrent) row pair, so exp 159's pipelining win and future writer-scheduling experiments are evaluable on a public release lane (resqlite concurrent median ~1.1 ms vs ~2.9 ms sequential). Exp 171 then tried to apply exp 159's `_sendPort` cache pattern one layer up — a sync-readable `_resolvedRuntime` field on `Database` so post-open hot paths skip the `await _runtime` microtask hop — and rejected it: two order-flipped passes on writer_pipelining.dart produced alternating-sign deltas inside per-round variance (sequential-awaited -2.3%/+2.5%, transaction-guardrail -7.5%/+6.1%), so a ~1-2 us per-call hop sits at or below the harness floor. Database-layer microtask hop trimming is now off the candidate list. Exp 214 tested an even narrower writer-result decode cleanup after adding a microsecond public-write harness: direct pointer reads removed the typed-list/ByteData view around the 16-byte native result struct, but `write_result_direct_read.dart` did not reproduce a stable win across order-flipped passes (pair 1 mixed/small, pair 2 broadly candidate-slower, pair 3 mixed/neutral), so scalar `executeWrite()` result decoding is not an active target. Exp 197 then tested the moonshot version of the named group-commit ceiling by wrapping a coalesced standalone write burst in one SQLite transaction: writer_pipelining.dart's concurrent-burst lane improved -88.1% / -87.3% across order-flipped passes while sequential writes and explicit transactions stayed neutral, but the prototype is rejected as a hidden db.execute() default because it changes independent-autocommit read visibility, crash-window durability, and failure/atomicity semantics. Reopen true group commit only behind an explicit opt-in API or named mode, not another transport micro-path cleanup. Exp 228 found a correctness leak in exp 077's row-count hash shortcut: growth returned a partial digest that was cached as canonical, so the next unchanged rerun decoded and re-emitted once. Removing the shortcut took redundant grow-then-no-op decodes from 9/9 to 0/9 in each of three passes; combined grow + no-op p50 improved 19-36%, while pure-growth timing remained noisy around parity. Exp 239 revisited exp 209's request amortization without public API: bounded batching of only already-parked plain selects improves homogeneous twenty-way point reads 26-33% and roughly-ten-row reads 21-30%, while pool sharding keeps twenty 10k-row reads neutral-to-faster. It is rejected because queue depth cannot encode query cost: alternating large/point bursts regress point-completion p95 11-17% and total median 13-26%. Queue-depth-only hidden batching is closed; reopen only with a reliable private cost signal or independently completable batch members. Exp 249 (moonshot) then tested invalidation-grouped rerun batching — one write dirties every stream projecting the changed column, so it packed the dirtied set into one batched reader message per worker (SelectIfChangedBatchRequest + worker loop + batched _flushQueue/_requeryBatch, plus a lastRowCount cost-gate dispatching large partitions individually), all internal. Rejected: clean cross-worktree A/B measured single-write emission latency SLOWER on every lane (homogeneous +22% p50 / +59% p95, heterogeneous +66% p50 / +52% p95) because a batched reply is indivisible — the one changed stream waits behind its ~24 cheap unchanged batch-mates re-hashes. Message-count amortization is the wrong lever for this latency-bound workload (third rejection of the shared-indivisible-reply trade after exp 148 and exp 239). An in-process A/B toggle had reported a false -27% reproduced win; only cross-worktree exposed the true sign.", "keyPriors": [ "120", "134", @@ -91,7 +91,7 @@ } ], "blockedOnMeasurement": [], - "notesForExperimenters": "Avoid assuming a larger reader pool helps; exp 105 found the opposite under A11c fan-out. After exp 118 + exp 120, `dispatcherParkedTotal` and `dispatcherWakeRetryTotal` stay at zero on every measured stream workload \u2014 the parked-dispatcher signal is not the active target. Exp 121 took invalidation traversal off the candidate list (10\u201315% of overlap wall, 2.5\u20135.7% intersection, 80\u2013200 ns per probe). Exp 122 keeps admission simple by giving StreamEngine a concrete ReaderPool and moving stream registry checks to diagnostics. Exp 134 is proof that row-level precision can win, but its SQL-recognizer implementation is rejected; revive it only with explicit API/design or real workload evidence. Exp 136 showed completion-side reader handling was large enough to try batching, but exp 148 proved plain worker-side reader-reply batching is not mergeable: it reduced callback counters while failing measured-elapsed primary scenarios. Exp 147 still leaves residual writer/request wall as the biggest bucket, but standalone residual splitting has reached diminishing returns. Exp 151 tried the narrow response-side variant (`Completer.sync()` for writer responses) and rejected it under Tracelite. Exp 170 tried the matching request-side variant (`Mutex.tryLock` + non-`async` `Writer.execute` to drop the uncontended `await _mutex.lock()` microtask hop) and rejected it: Single Inserts (100 sequential) and writer_pipelining `sequential-awaited (2000 writes)` both moved <2 % in the wrong direction across paired runs, and the only positive lane (Concurrent Single Inserts, \u22127.8 %) is one exp 159 already drives at \u221258 % to \u221261 %. Do not retry either scheduling tweak without new runtime or workload evidence. Exp 171 tried the same shape one layer up (cached `_resolvedRuntime` on `Database` to skip `await _runtime` on hot paths) and rejected it on focused-harness noise \u2014 Database-layer microtask hop trimming above the writer no longer moves the sequential-write floor. Exp 214 adds `benchmark/experiments/write_result_direct_read.dart` as the \u00b5s-scale writer-result floor harness and rejects direct native-pointer scalar reads; do not chase `executeWrite()` result decoding or exp 095-style writer result-buffer scratch again without a mechanism larger than view/allocation removal and reproduced order-flipped public-write deltas. Exp 182 took the residual bucket attack in a different direction \u2014 skip `preupdate_hook` accumulation + reply harvest when `_streamEngine.length == 0` via a `track_dirty` flag on `resqlite_db` + a `DrainRequest` no-op barrier on first stream registration \u2014 and rejected it: the no-stream wins are real (focused sequential \u22123.8 % / \u22125.3 %, wide-batch \u22122.4 % / \u22125.9 % across order-flipped passes) but the per-call gate adds reproduced overhead on the with-streams shape (focused +2.7 % / +3.7 %, classified `reproduced` by `ab_drift_check.dart`) and Tracelite stream-direction warmup elapsed regressed in the same direction across all three scenarios. Reactive streams are the library's primary use case, so the optimization helps a narrower workload mix than the one it slows \u2014 do not retry without a workload that shows write throughput without active streams is a hot path. A new stream experiment should try a concrete reduction candidate, add only the narrow measurement needed to explain the result in that same branch, remove temporary counters before merge unless they are reusable, and clear Tracelite measured-elapsed primary gates without harming keyed-PK. Exp 228 establishes a hash-cache invariant: cached `lastResultHash` must be canonical. An early-reject hash path must return a non-cacheable sentinel or finish the canonical digest during decode; never promote a partial digest to the next rerun baseline. Exp 239 closes queue-depth-only transparent read batching: its homogeneous gains are real, but alternating large/point completion p95 regresses in both orderings because members share an indivisible reply. Keep `select_overflow_batch.dart` as the heterogeneous gate, and fix the reader spawn-versus-close lifecycle before any future design deliberately increases aggregate sacrifice frequency. Exp 249 rejects invalidation-grouped rerun batching (indivisible reply delays the one changed result behind cheap batch-mates; reopen only for a throughput-bound stream workload with a design that preserves independent completion). Two methodology reminders from it: (1) A/B stream-dispatch/reader-message changes ACROSS WORKTREES, never with a single-process toggle \u2014 exp 249s in-process toggle classified REPRODUCED (-27%) while cross-worktree showed +22-66%, because both toggle arms shared warm JIT/isolate/pool state. (2) high_cardinality_fanout settle returns after a fixed emission-count quiet window and never waits for suppressed reruns to drain, so it cannot measure the 99-of-100-unchanged fan-out cost; use benchmark/experiments/stream_rerun_latency.dart (single-write per-emit latency, homogeneous + heterogeneous partitions) as the fan-out emission-latency gate." + "notesForExperimenters": "Avoid assuming a larger reader pool helps; exp 105 found the opposite under A11c fan-out. After exp 118 + exp 120, `dispatcherParkedTotal` and `dispatcherWakeRetryTotal` stay at zero on every measured stream workload — the parked-dispatcher signal is not the active target. Exp 121 took invalidation traversal off the candidate list (10–15% of overlap wall, 2.5–5.7% intersection, 80–200 ns per probe). Exp 122 keeps admission simple by giving StreamEngine a concrete ReaderPool and moving stream registry checks to diagnostics. Exp 134 is proof that row-level precision can win, but its SQL-recognizer implementation is rejected; revive it only with explicit API/design or real workload evidence. Exp 136 showed completion-side reader handling was large enough to try batching, but exp 148 proved plain worker-side reader-reply batching is not mergeable: it reduced callback counters while failing measured-elapsed primary scenarios. Exp 147 still leaves residual writer/request wall as the biggest bucket, but standalone residual splitting has reached diminishing returns. Exp 151 tried the narrow response-side variant (`Completer.sync()` for writer responses) and rejected it under Tracelite. Exp 170 tried the matching request-side variant (`Mutex.tryLock` + non-`async` `Writer.execute` to drop the uncontended `await _mutex.lock()` microtask hop) and rejected it: Single Inserts (100 sequential) and writer_pipelining `sequential-awaited (2000 writes)` both moved <2 % in the wrong direction across paired runs, and the only positive lane (Concurrent Single Inserts, −7.8 %) is one exp 159 already drives at −58 % to −61 %. Do not retry either scheduling tweak without new runtime or workload evidence. Exp 171 tried the same shape one layer up (cached `_resolvedRuntime` on `Database` to skip `await _runtime` on hot paths) and rejected it on focused-harness noise — Database-layer microtask hop trimming above the writer no longer moves the sequential-write floor. Exp 214 adds `benchmark/experiments/write_result_direct_read.dart` as the µs-scale writer-result floor harness and rejects direct native-pointer scalar reads; do not chase `executeWrite()` result decoding or exp 095-style writer result-buffer scratch again without a mechanism larger than view/allocation removal and reproduced order-flipped public-write deltas. Exp 182 took the residual bucket attack in a different direction — skip `preupdate_hook` accumulation + reply harvest when `_streamEngine.length == 0` via a `track_dirty` flag on `resqlite_db` + a `DrainRequest` no-op barrier on first stream registration — and rejected it: the no-stream wins are real (focused sequential −3.8 % / −5.3 %, wide-batch −2.4 % / −5.9 % across order-flipped passes) but the per-call gate adds reproduced overhead on the with-streams shape (focused +2.7 % / +3.7 %, classified `reproduced` by `ab_drift_check.dart`) and Tracelite stream-direction warmup elapsed regressed in the same direction across all three scenarios. Reactive streams are the library's primary use case, so the optimization helps a narrower workload mix than the one it slows — do not retry without a workload that shows write throughput without active streams is a hot path. A new stream experiment should try a concrete reduction candidate, add only the narrow measurement needed to explain the result in that same branch, remove temporary counters before merge unless they are reusable, and clear Tracelite measured-elapsed primary gates without harming keyed-PK. Exp 228 establishes a hash-cache invariant: cached `lastResultHash` must be canonical. An early-reject hash path must return a non-cacheable sentinel or finish the canonical digest during decode; never promote a partial digest to the next rerun baseline. Exp 239 closes queue-depth-only transparent read batching: its homogeneous gains are real, but alternating large/point completion p95 regresses in both orderings because members share an indivisible reply. Keep `select_overflow_batch.dart` as the heterogeneous gate, and fix the reader spawn-versus-close lifecycle before any future design deliberately increases aggregate sacrifice frequency. Exp 249 rejects invalidation-grouped rerun batching (indivisible reply delays the one changed result behind cheap batch-mates; reopen only for a throughput-bound stream workload with a design that preserves independent completion). Two methodology reminders from it: (1) A/B stream-dispatch/reader-message changes ACROSS WORKTREES, never with a single-process toggle — exp 249s in-process toggle classified REPRODUCED (-27%) while cross-worktree showed +22-66%, because both toggle arms shared warm JIT/isolate/pool state. (2) high_cardinality_fanout settle returns after a fixed emission-count quiet window and never waits for suppressed reruns to drain, so it cannot measure the 99-of-100-unchanged fan-out cost; use benchmark/experiments/stream_rerun_latency.dart (single-write per-emit latency, homogeneous + heterogeneous partitions) as the fan-out emission-latency gate." }, { "id": "parameter-encoding-and-binding", @@ -102,7 +102,7 @@ "writer", "allocation" ], - "currentRead": "Parameter work can still matter when it removes meaningful native allocation, copying, or repeated SQLite work. Smaller Dart-side allocation cleanups and two-parameter batch-list flattening changes usually measured flat, but exp 113 found a clear wide-row batch signal by avoiding the temporary flat Dart parameter list entirely. Exp 116 promotes the 10,000-row x 20-parameter mixed batch shape into release-suite coverage so width regressions are visible outside the focused script. Exp 125 then showed that large wide ASCII-heavy batches still had removable per-string UTF-8 list allocation inside the matrix encoder: direct ASCII payload packing improved focused 10k x20 from 17.199 ms to 12.760 ms and release Wide Batch Insert from 18.201 ms to 13.031 ms. Exp 126 extended that same allocation-removal shape to non-ASCII wide batches with direct UTF-8 payload writing: focused Unicode 10k x20 improves 21.945 ms to 18.988 ms and emoji 10k x20 improves 24.187 ms to 17.458 ms while release write-suite guardrails stay neutral. Exp 142 retested direct single-row text parameter encoding under Tracelite on chat-sim and narrow-batch-insert; it did not clear the primary gate and trended slower (+6.86% and +16.4%). Exp 146 tested lowering the ASCII batch-packing threshold to 2 params / 64 total params with a Tracelite A/B run over narrow-batch-insert; it produced no primary improvement (resqlite +1.45%, neutral) and a noisy sqlite_async guardrail, so small/narrow writes should stay on the generic path. Exp 149 found the middle ground with Tracelite profile merge rounds: repeated six-parameter ASCII merge batches improve executeBatch p50 88 -> 75 us and writer SQLite time 87,895 -> 75,947 us when admitted at 6 params / 600 total params. Exp 150 fixed the first-row-null blind spot inside that same guard: nullable ASCII 10k x8 improves 13.552 -> 11.152 ms and 10k x20 improves 25.738 -> 21.723 ms, while existing ASCII and Unicode wide guardrails stayed neutral in the focused pass. Exp 186 then closed exp 179's named revisit condition: added a focused single-row large-text-bind workload (single_row_large_text_bind.dart, 1 KB to 1 MB sequential INSERT shapes) and ran archive/exp-179's direct-ASCII allocateParams rewrite against it. The encoder savings are immaterial at 1 KB but become decisive once the bound text crosses the mid-tens-of-KB range \u2014 focused medians improve -15.4% / -11.1% at 16 KB, -17.3% / -18.7% at 64 KB, -32.3% / -32.1% at 256 KB, and -26.7% / -28.5% at 1 MB across two order-flipped passes, with encoder-isolation deltas reproducing exp 179's -45% / -58% / -37% on the synthetic micro. Small-payload release-suite lanes (Parameterized Queries, Single Inserts, Concurrent Single Inserts) stay neutral, so exp 179's small-bind finding stands \u2014 the encoder is now the right default for the single-row path because we have a representative workload where it matters, not because the small case stopped being flat. Exp 187 consumes exp 186's UTF-8-heavy follow-up by adding byte-matched CJK rows to the same harness and reusing the batch direct-UTF-8 writer for non-ASCII single-row strings; CJK improves roughly 31-39% from 16 KB through 1 MB across the order-flipped pair while the exp 186 all-ASCII fast path stays intact. Exp 210 closes the repeated large-BLOB object-identity candidate: it improves marshal-only 1 KB reused-BLOB lanes by -29% to -46%, but the traced `blob_merge_rounds` workload regresses (executeBatch p50 909 us -> 1177 us, writer SQLite time +23.3%), so BLOB reuse must clear a workload/profile gate before runtime code is considered. Exp 222 then tested the one-shot native allocator policy above the 64 KiB reuse cap: `malloc` is logically safe because the packer overwrites every active field and payload byte, but 64 KiB-1 MiB ASCII/CJK binds did not reproduce a win across the order flip. Keep `calloc` unless a platform allocator profile identifies zero-fill as material wall. Exp 226 then removed the remaining whole-matrix payload-sizing scan for fixed-width numeric batches. The isolated packer improved 10k x 8 by -28.0% / -27.6% and 10k x 20 by -25.4% / -25.8%, but end-to-end executeBatch stayed below the 5% gate (x20 -3.4% / -1.4%). Row-0 speculation also regressed a final-row TEXT fallback by +7.0% / +4.9% because it discarded a 4.8 MB arena before the generic retry. Keep the two-pass generic numeric path unless first-pass struct writes can survive a dynamic payload transition without discarded allocation. Exp 234 opens a new sub-lane in this direction: the *transfer* of a blob param, not just its native encoding. A `Uint8List` blob crosses the `SendPort.send` hop via the VM object-graph copy \u2014 one copy, on the sender, landing on the shared GC heap \u2014 before the two irreducible copies (native arena, then SQLite page). Wrapping blobs `>= 256 KB` in `TransferableTypedData` (one memcpy into malloc'd external memory, then a constant-time ownership move and a zero-copy materialize view) reproduces a ~15-20% end-to-end win on 256 KB-512 KB single-row blob INSERTs (256 KB -16.4% / -22.7%, 512 KB -10.4% / -11.8% across order-flipped passes). Mechanism attribution (blob_param_mechanism_proof.dart, blob_param_gc_split.dart) shows both routes copy the payload exactly once on main; the win is the copy's destination \u2014 direct sends make every in-flight blob live GC-heap data (29 GCs / 8.6 ms pause vs 20 / 1.2 ms per 300 x 256 KB inserts on the real path) and each collection safepoints the whole isolate group including the writer mid-step. The 256 KB floor is measured, not assumed: transport is +22.7% SLOWER at 64 KB (wrap bookkeeping vs the graph copy's near-free fast path) and neutral at >= 1 MB (WAL write dominates; huge payloads allocate straight to old space). Sharpens exp 005 rather than inverting it: the codec lost on encoding *structure*; a raw blob has none, leaving only destination and machinery costs. Covers single-row `ExecuteRequest`/`QueryRequest` and (post-review) the coalesced `MultiExecuteRequest` standalone-write pump. Exp 237 validly rejected its pre-exp-243 per-occurrence `BatchRequest` prototype: its 30-row fixture reused one `Uint8List` identity, so the candidate made 30 wrappers while the direct baseline preserved identity and copied the buffer once. That result does not establish that graph copy wins for distinct buffers or all one-envelope shapes. Exp 253 tested the missing identity-conditioned policy on the neighboring `MultiExecuteRequest` topology. Its combined identity census plus direct routing for unique 256 KB buffers regressed +10.2% / +7.2% at the first admitted size; 512 KB was candidate-faster by 10.1% / 4.6% but drift-suspected, so it cannot move the threshold post hoc; its second baseline was noisy. The end-to-end run does not attribute the delta between census overhead and transfer route. Shared, mixed, and control guards changed sign. Keep every qualifying MultiExecute BLOB on the envelope-shared wrapper; alias cardinality plus transaction/execution topology, not message count alone, determines the route. Exp 253 does not retest `BatchRequest`: MultiExecute members remain independent autocommits. Runtime reverted; harness retained; exact prototype at `archive/exp-253`.", + "currentRead": "Parameter work can still matter when it removes meaningful native allocation, copying, or repeated SQLite work. Smaller Dart-side allocation cleanups and two-parameter batch-list flattening changes usually measured flat, but exp 113 found a clear wide-row batch signal by avoiding the temporary flat Dart parameter list entirely. Exp 116 promotes the 10,000-row x 20-parameter mixed batch shape into release-suite coverage so width regressions are visible outside the focused script. Exp 125 then showed that large wide ASCII-heavy batches still had removable per-string UTF-8 list allocation inside the matrix encoder: direct ASCII payload packing improved focused 10k x20 from 17.199 ms to 12.760 ms and release Wide Batch Insert from 18.201 ms to 13.031 ms. Exp 126 extended that same allocation-removal shape to non-ASCII wide batches with direct UTF-8 payload writing: focused Unicode 10k x20 improves 21.945 ms to 18.988 ms and emoji 10k x20 improves 24.187 ms to 17.458 ms while release write-suite guardrails stay neutral. Exp 142 retested direct single-row text parameter encoding under Tracelite on chat-sim and narrow-batch-insert; it did not clear the primary gate and trended slower (+6.86% and +16.4%). Exp 146 tested lowering the ASCII batch-packing threshold to 2 params / 64 total params with a Tracelite A/B run over narrow-batch-insert; it produced no primary improvement (resqlite +1.45%, neutral) and a noisy sqlite_async guardrail, so small/narrow writes should stay on the generic path. Exp 149 found the middle ground with Tracelite profile merge rounds: repeated six-parameter ASCII merge batches improve executeBatch p50 88 -> 75 us and writer SQLite time 87,895 -> 75,947 us when admitted at 6 params / 600 total params. Exp 150 fixed the first-row-null blind spot inside that same guard: nullable ASCII 10k x8 improves 13.552 -> 11.152 ms and 10k x20 improves 25.738 -> 21.723 ms, while existing ASCII and Unicode wide guardrails stayed neutral in the focused pass. Exp 186 then closed exp 179's named revisit condition: added a focused single-row large-text-bind workload (single_row_large_text_bind.dart, 1 KB to 1 MB sequential INSERT shapes) and ran archive/exp-179's direct-ASCII allocateParams rewrite against it. The encoder savings are immaterial at 1 KB but become decisive once the bound text crosses the mid-tens-of-KB range — focused medians improve -15.4% / -11.1% at 16 KB, -17.3% / -18.7% at 64 KB, -32.3% / -32.1% at 256 KB, and -26.7% / -28.5% at 1 MB across two order-flipped passes, with encoder-isolation deltas reproducing exp 179's -45% / -58% / -37% on the synthetic micro. Small-payload release-suite lanes (Parameterized Queries, Single Inserts, Concurrent Single Inserts) stay neutral, so exp 179's small-bind finding stands — the encoder is now the right default for the single-row path because we have a representative workload where it matters, not because the small case stopped being flat. Exp 187 consumes exp 186's UTF-8-heavy follow-up by adding byte-matched CJK rows to the same harness and reusing the batch direct-UTF-8 writer for non-ASCII single-row strings; CJK improves roughly 31-39% from 16 KB through 1 MB across the order-flipped pair while the exp 186 all-ASCII fast path stays intact. Exp 210 closes the repeated large-BLOB object-identity candidate: it improves marshal-only 1 KB reused-BLOB lanes by -29% to -46%, but the traced `blob_merge_rounds` workload regresses (executeBatch p50 909 us -> 1177 us, writer SQLite time +23.3%), so BLOB reuse must clear a workload/profile gate before runtime code is considered. Exp 222 then tested the one-shot native allocator policy above the 64 KiB reuse cap: `malloc` is logically safe because the packer overwrites every active field and payload byte, but 64 KiB-1 MiB ASCII/CJK binds did not reproduce a win across the order flip. Keep `calloc` unless a platform allocator profile identifies zero-fill as material wall. Exp 226 then removed the remaining whole-matrix payload-sizing scan for fixed-width numeric batches. The isolated packer improved 10k x 8 by -28.0% / -27.6% and 10k x 20 by -25.4% / -25.8%, but end-to-end executeBatch stayed below the 5% gate (x20 -3.4% / -1.4%). Row-0 speculation also regressed a final-row TEXT fallback by +7.0% / +4.9% because it discarded a 4.8 MB arena before the generic retry. Keep the two-pass generic numeric path unless first-pass struct writes can survive a dynamic payload transition without discarded allocation. Exp 234 opens a new sub-lane in this direction: the *transfer* of a blob param, not just its native encoding. A `Uint8List` blob crosses the `SendPort.send` hop via the VM object-graph copy — one copy, on the sender, landing on the shared GC heap — before the two irreducible copies (native arena, then SQLite page). Wrapping blobs `>= 256 KB` in `TransferableTypedData` (one memcpy into malloc'd external memory, then a constant-time ownership move and a zero-copy materialize view) reproduces a ~15-20% end-to-end win on 256 KB-512 KB single-row blob INSERTs (256 KB -16.4% / -22.7%, 512 KB -10.4% / -11.8% across order-flipped passes). Mechanism attribution (blob_param_mechanism_proof.dart, blob_param_gc_split.dart) shows both routes copy the payload exactly once on main; the win is the copy's destination — direct sends make every in-flight blob live GC-heap data (29 GCs / 8.6 ms pause vs 20 / 1.2 ms per 300 x 256 KB inserts on the real path) and each collection safepoints the whole isolate group including the writer mid-step. The 256 KB floor is measured, not assumed: transport is +22.7% SLOWER at 64 KB (wrap bookkeeping vs the graph copy's near-free fast path) and neutral at >= 1 MB (WAL write dominates; huge payloads allocate straight to old space). Sharpens exp 005 rather than inverting it: the codec lost on encoding *structure*; a raw blob has none, leaving only destination and machinery costs. Covers single-row `ExecuteRequest`/`QueryRequest` and (post-review) the coalesced `MultiExecuteRequest` standalone-write pump. Exp 237 validly rejected its pre-exp-243 per-occurrence `BatchRequest` prototype: its 30-row fixture reused one `Uint8List` identity, so the candidate made 30 wrappers while the direct baseline preserved identity and copied the buffer once. That result does not establish that graph copy wins for distinct buffers or all one-envelope shapes. Exp 253 tested the missing identity-conditioned policy on the neighboring `MultiExecuteRequest` topology. Its combined identity census plus direct routing for unique 256 KB buffers regressed +10.2% / +7.2% at the first admitted size; 512 KB was candidate-faster by 10.1% / 4.6% but drift-suspected, so it cannot move the threshold post hoc; its second baseline was noisy. The end-to-end run does not attribute the delta between census overhead and transfer route. Shared, mixed, and control guards changed sign. Keep every qualifying MultiExecute BLOB on the envelope-shared wrapper; alias cardinality plus transaction/execution topology, not message count alone, determines the route. Exp 253 does not retest `BatchRequest`: MultiExecute members remain independent autocommits. Runtime reverted; harness retained; exact prototype at `archive/exp-253`.", "keyPriors": [ "126", "187", @@ -186,7 +186,7 @@ "build", "planner" ], - "currentRead": "SQLite compile/config changes have produced real wins, but version bumps need audit discipline because planner and text-format behavior can shift under the library. Exp 144 bumped sqlite3mc 2.3.2 \u2192 2.3.5 (SQLite 3.51.3 \u2192 3.53.2) once SQLite shipped its `.2` point release and sqlite3mc cut a tracking release, satisfying exp 090's revisit trigger exactly. Tests stayed green including the embedded-NUL and Unicode bind regression suite. The single-pass release-suite A/B swung between 19 wins / 18 regressions / 124 neutral on the canonical run and 30 wins / 2 regressions / 129 neutral on the rerun \u2014 a wide spread characteristic of single-pass noise at sub-ms granularity. The only metric flagged consistently across reruns is Concurrent Reads 8\u00d7 wall median (+~20% on a sub-ms metric); the 4\u00d7 concurrency case wins on the same baseline, so it is not a generic read-pool slowdown. Soak window is the right place to confirm whether this is a real 3.53.x reader-pool interaction or single-run tail noise. The 3.53.0 FP-rounding default change (15\u219217 digits) was confirmed irrelevant because resqlite reads REAL via `sqlite3_column_double` and serialises with its own `snprintf(\"%.17g\", ...)` rather than `sqlite3_column_text`, so the proposed `SQLITE_DBCONFIG_FP_DIGITS=15` shim was skipped.", + "currentRead": "SQLite compile/config changes have produced real wins, but version bumps need audit discipline because planner and text-format behavior can shift under the library. Exp 144 bumped sqlite3mc 2.3.2 → 2.3.5 (SQLite 3.51.3 → 3.53.2) once SQLite shipped its `.2` point release and sqlite3mc cut a tracking release, satisfying exp 090's revisit trigger exactly. Tests stayed green including the embedded-NUL and Unicode bind regression suite. The single-pass release-suite A/B swung between 19 wins / 18 regressions / 124 neutral on the canonical run and 30 wins / 2 regressions / 129 neutral on the rerun — a wide spread characteristic of single-pass noise at sub-ms granularity. The only metric flagged consistently across reruns is Concurrent Reads 8× wall median (+~20% on a sub-ms metric); the 4× concurrency case wins on the same baseline, so it is not a generic read-pool slowdown. Soak window is the right place to confirm whether this is a real 3.53.x reader-pool interaction or single-run tail noise. The 3.53.0 FP-rounding default change (15→17 digits) was confirmed irrelevant because resqlite reads REAL via `sqlite3_column_double` and serialises with its own `snprintf(\"%.17g\", ...)` rather than `sqlite3_column_text`, so the proposed `SQLITE_DBCONFIG_FP_DIGITS=15` shim was skipped.", "keyPriors": [ "016", "044", @@ -205,7 +205,7 @@ "openQuestions": [ "Which SQLite release changes affect resqlite's custom JSON/worker architecture rather than only generic SQL workloads?", "Can any build option improve one target platform without harming others?", - "Does the exp 144 concurrent-reads-8\u00d7 single-run slowdown survive a multi-pass release-suite rerun under the 3.53.x line, or does it collapse like the streaming overlap re-emit flag did?" + "Does the exp 144 concurrent-reads-8× single-run slowdown survive a multi-pass release-suite rerun under the 3.53.x line, or does it collapse like the streaming overlap re-emit flag did?" ], "openCandidates": [ { @@ -226,7 +226,7 @@ "transactions", "sqlite" ], - "currentRead": "Cached top-level transaction control statements won. Nested-transaction string/native helpers stayed flat even after exp 111 added a worst-case shallow-fan-out savepoint workload (50\u00d7 SAVEPOINT/RELEASE per iteration): exp 102's cache pattern measured -9 % (within the \u00b117 % decision threshold). Exp 189 then tried the smaller savepoint naming-compression variant: reusing same-name cached SQL (`SAVEPOINT s`, `RELEASE s`, `ROLLBACK TO s`) produced best-case focused wins on empty fanout (~-6%), rollback fanout (-17% / -13%), and repeated deep chains (-7% / -8%), but the representative nested-write fanout failed to reproduce (-1.3%, then +21.6% slower). Exp 212 tested the moonshot version of lazy nested-savepoint materialization: empty nested bodies improved sharply (-77.9% / -74.5%), but fusing `SAVEPOINT + first execute` made the representative write fanout slower in both pass orderings (+74.8% / +34.9%) and left rollback/deep neutral-to-slower. Per-isolate-round-trip cost still matters, but removing one nested-control message is not enough; string/naming work and lazy-begin/first-write fusion are closed. Exp 213 then took the fresh 2026-07-03 `full savepoint-scope command batching` openCandidate in its narrowest safe form: buffer `Transaction.execute` calls per Transaction and flush them as a single `MultiExecuteRequest` when N were queued in one microtask (the `Future.wait([tx.execute(...) x N])` shape). The measurement was clean \u2014 `tx_body_write_coalescing.dart` `tx-burst-future-wait` (20 tx x 100 writes) improved -26.0% / -31.2% across order-flipped passes with `ab_drift_check.dart` verdict REPRODUCED, while sequential-await, single-write, and interleaved-select stayed inside the 3% effect floor once a fast path plus a `hasPendingWrites` guard on `drainForClose` preserved the pre-213 microtask cost \u2014 but the moonshot was rejected on pattern grounds. `Future.wait([tx.execute \u00d7 N])` inside `db.transaction()` is not a shape resqlite steers users toward: same-SQL bulk atomic writes belong on `executeBatch`, different-SQL non-atomic bursts have exp 180's standalone coalescing, and different-SQL atomic bursts are rare. Accepting four load-bearing guards + persistent per-Transaction buffer state on the writer-path hot path for a workload not being promoted was not worth the maintenance cost, especially against the same-class exp 197 / exp 212 pattern (semantic-shape moonshots keep failing the value-vs-complexity trade even when the numbers reproduce). Runtime prototype preserved at `archive/exp-213`; focused harness retained. The full savepoint-scope variant (BEGIN + body + COMMIT in one round-trip) remains blocked on callback semantics + no-public-API-growth; body-only fusion is now closed.", + "currentRead": "Cached top-level transaction control statements won. Nested-transaction string/native helpers stayed flat even after exp 111 added a worst-case shallow-fan-out savepoint workload (50× SAVEPOINT/RELEASE per iteration): exp 102's cache pattern measured -9 % (within the ±17 % decision threshold). Exp 189 then tried the smaller savepoint naming-compression variant: reusing same-name cached SQL (`SAVEPOINT s`, `RELEASE s`, `ROLLBACK TO s`) produced best-case focused wins on empty fanout (~-6%), rollback fanout (-17% / -13%), and repeated deep chains (-7% / -8%), but the representative nested-write fanout failed to reproduce (-1.3%, then +21.6% slower). Exp 212 tested the moonshot version of lazy nested-savepoint materialization: empty nested bodies improved sharply (-77.9% / -74.5%), but fusing `SAVEPOINT + first execute` made the representative write fanout slower in both pass orderings (+74.8% / +34.9%) and left rollback/deep neutral-to-slower. Per-isolate-round-trip cost still matters, but removing one nested-control message is not enough; string/naming work and lazy-begin/first-write fusion are closed. Exp 213 then took the fresh 2026-07-03 `full savepoint-scope command batching` openCandidate in its narrowest safe form: buffer `Transaction.execute` calls per Transaction and flush them as a single `MultiExecuteRequest` when N were queued in one microtask (the `Future.wait([tx.execute(...) x N])` shape). The measurement was clean — `tx_body_write_coalescing.dart` `tx-burst-future-wait` (20 tx x 100 writes) improved -26.0% / -31.2% across order-flipped passes with `ab_drift_check.dart` verdict REPRODUCED, while sequential-await, single-write, and interleaved-select stayed inside the 3% effect floor once a fast path plus a `hasPendingWrites` guard on `drainForClose` preserved the pre-213 microtask cost — but the moonshot was rejected on pattern grounds. `Future.wait([tx.execute × N])` inside `db.transaction()` is not a shape resqlite steers users toward: same-SQL bulk atomic writes belong on `executeBatch`, different-SQL non-atomic bursts have exp 180's standalone coalescing, and different-SQL atomic bursts are rare. Accepting four load-bearing guards + persistent per-Transaction buffer state on the writer-path hot path for a workload not being promoted was not worth the maintenance cost, especially against the same-class exp 197 / exp 212 pattern (semantic-shape moonshots keep failing the value-vs-complexity trade even when the numbers reproduce). Runtime prototype preserved at `archive/exp-213`; focused harness retained. The full savepoint-scope variant (BEGIN + body + COMMIT in one round-trip) remains blocked on callback semantics + no-public-API-growth; body-only fusion is now closed.", "keyPriors": [ "101", "111", @@ -240,10 +240,10 @@ "103" ], "interestingIf": [ - "a change collapses multiple savepoint open/close operations into a single isolate round-trip (analogous to exp 009's read-side batching) AND targets a shape resqlite steers users toward \u2014 not a Future.wait-inside-tx niche", + "a change collapses multiple savepoint open/close operations into a single isolate round-trip (analogous to exp 009's read-side batching) AND targets a shape resqlite steers users toward — not a Future.wait-inside-tx niche", "a profile mode shows savepoint boundary round-trips, not string allocation, as a measurable spike under a realistic workload", "the change reduces prepare/string work without adding much native API surface", - "a change collapses the BEGIN and COMMIT round-trips inside `db.transaction()` while keeping callback semantics intact \u2014 the body-only fusion in exp 213 was ~1% of tx wall in absolute terms; BEGIN/COMMIT is where the remaining headroom sits, if any" + "a change collapses the BEGIN and COMMIT round-trips inside `db.transaction()` while keeping callback semantics intact — the body-only fusion in exp 213 was ~1% of tx wall in absolute terms; BEGIN/COMMIT is where the remaining headroom sits, if any" ], "openQuestions": [ "How often do realistic resqlite users nest transactions deeply?", @@ -253,17 +253,17 @@ ], "openCandidates": [ { - "idea": "collapse the BEGIN + COMMIT round-trips inside `db.transaction()` into fewer isolate messages \u2014 the *ambitious* form of the full savepoint-scope openCandidate, not the body-only version exp 213 tested and rejected", + "idea": "collapse the BEGIN + COMMIT round-trips inside `db.transaction()` into fewer isolate messages — the *ambitious* form of the full savepoint-scope openCandidate, not the body-only version exp 213 tested and rejected", "addedDate": "2026-07-03", "addedAfter": "213", - "blockedOn": "needs a design that preserves callback semantics without a public API opt-in (exp 197 constraint) or a workload proving BEGIN/COMMIT round-trips dominate a shape resqlite steers users toward. No new angle emerged from exp 213 \u2014 do not retry another body-only variant." + "blockedOn": "needs a design that preserves callback semantics without a public API opt-in (exp 197 constraint) or a workload proving BEGIN/COMMIT round-trips dominate a shape resqlite steers users toward. No new angle emerged from exp 213 — do not retry another body-only variant." } ], "blockedOnMeasurement": [ "realistic deeply-nested transaction workload (exp 111 covers the shallow worst-case; deep cases still need a workload)", - "production profile or downstream user report showing `Future.wait([tx.execute \u00d7 N])`-inside-tx as a real hot path \u2014 without one, do not reopen exp 213's body-only fusion" + "production profile or downstream user report showing `Future.wait([tx.execute × N])`-inside-tx as a real hot path — without one, do not reopen exp 213's body-only fusion" ], - "notesForExperimenters": "Compare against the exp 111 nested-tx benchmark, exp 189/212 `savepoint_name_compression.dart`, and exp 213 `tx_body_write_coalescing.dart` (all four lanes: `tx-burst-future-wait`, `tx-sequential-await`, `tx-single-write`, `tx-interleaved-select`) before claiming a win on any transaction-control change. Per-call savepoint allocation, caching, and naming changes are below the merge bar on realistic nested-write fanout. Exp 212 rejects lazy materialization plus `SAVEPOINT + first execute` fusion. Exp 213 rejects `Transaction.execute` buffering + `MultiExecuteRequest` flush *not because the numbers failed* (-26% / -31% burst pattern, reproduced across order-flipped passes) but because the winning pattern (`Future.wait([tx.execute \u00d7 N])` inside a transaction) is a niche shape resqlite is not steering users toward \u2014 same-SQL bulk atomic writes belong on `executeBatch`, different-SQL non-atomic bursts have exp 180. The runtime prototype at `archive/exp-213` shows the pattern of same-class-of-rejection accumulated moonshots (exp 197 / 212 / 213): even a reproduced win can be rejected if it depends on four load-bearing guards on a hot path for a workload we don't promote. Reopen only under: (a) production evidence of Future.wait-inside-tx as a real hot path, (b) a design collapsing the guard set from four to one, or (c) the full BEGIN/COMMIT-fusion variant becoming tractable \u2014 noting that (c) still has to clear the exp 197 semantic-hazard bar." + "notesForExperimenters": "Compare against the exp 111 nested-tx benchmark, exp 189/212 `savepoint_name_compression.dart`, and exp 213 `tx_body_write_coalescing.dart` (all four lanes: `tx-burst-future-wait`, `tx-sequential-await`, `tx-single-write`, `tx-interleaved-select`) before claiming a win on any transaction-control change. Per-call savepoint allocation, caching, and naming changes are below the merge bar on realistic nested-write fanout. Exp 212 rejects lazy materialization plus `SAVEPOINT + first execute` fusion. Exp 213 rejects `Transaction.execute` buffering + `MultiExecuteRequest` flush *not because the numbers failed* (-26% / -31% burst pattern, reproduced across order-flipped passes) but because the winning pattern (`Future.wait([tx.execute × N])` inside a transaction) is a niche shape resqlite is not steering users toward — same-SQL bulk atomic writes belong on `executeBatch`, different-SQL non-atomic bursts have exp 180. The runtime prototype at `archive/exp-213` shows the pattern of same-class-of-rejection accumulated moonshots (exp 197 / 212 / 213): even a reproduced win can be rejected if it depends on four load-bearing guards on a hot path for a workload we don't promote. Reopen only under: (a) production evidence of Future.wait-inside-tx as a real hot path, (b) a design collapsing the guard set from four to one, or (c) the full BEGIN/COMMIT-fusion variant becoming tractable — noting that (c) still has to clear the exp 197 semantic-hazard bar." }, { "id": "result-transfer-shape", @@ -273,7 +273,7 @@ "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 \u2014 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 \u2014 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 \u2014 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 \u22128 to \u221226 % across two order-flipped passes on `select_bytes_int_heavy.dart` \u2014 biggest win on 10k \u00d7 20 ~18-digit big ints (\u221224 to \u221226 %), where the digit-loop length is greatest. Mixed-cell and small-payload regression guards stay inside \u00b11 %. 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 \u2014 1-row \u00d7 20-col improves \u22129.2 % / \u22127.2 % and 10-row \u00d7 20-col improves \u22125.1 % / \u22122.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` \u2014 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 \u2014 safe 1 KiB ASCII reproduced -33.6% / -34.1% and long CJK -25.2% / -25.5% \u2014 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 \u2014 an out-of-lined AArch64/NEON i64\u2192decimal kernel for the SQLITE_INTEGER arm \u2014 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 \u2265 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 \u00b110-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 \u2014 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 \u2014 batch many integer cells into one encode call \u2014 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) \u2014 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 \u2014 a shape where the hot entry already sits at the tail and the promotion branch is structurally unreachable \u2014 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 \u2014 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 \u2014 which can never sacrifice \u2014 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 \u2014 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 \u2014 the no-sacrifice lane was the WORST (~+19% parked wait) because send's copy blocks the worker longer than exit plus an overlapped respawn \u2014 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 \u2014 a few rows holding a big TEXT/BLOB \u2014 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.", + "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.", "keyPriors": [ "174", "183", @@ -318,10 +318,16 @@ "addedDate": "2026-04-24", "addedAfter": "089", "blockedOn": "Dart SDK #50068 (deeply-immutable typed-data factory) not yet shipped" + }, + { + "idea": "narrow columnar path for numeric columns targeting the ~2x main-isolate integer-consume speedup (Int64List locality); worthwhile only if a workload profile shows integer-heavy main-isolate-bound reads dominating", + "addedDate": "2026-07-29", + "addedAfter": "258", + "blockedOn": "no workload profile yet shows integer-heavy main-isolate-bound reads dominating; needs a Row columnar-vs-flat per-column dispatch design pass" } ], "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 \u2014 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 \u2014 wrong harness resolution), and the new `select_bytes_repeated_calls.dart` (1000 calls/sample at \u00b5s precision) exposes the win on 1-row \u00d7 20-col (\u22129.2 % / \u22127.2 %) and 10-row \u00d7 20-col (\u22125.1 % / \u22122.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\u2192decimal kernel gated \u2265 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 \u2014 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 \u2014 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. Before spending more on transfer at all, note the proportion exp 245/246 exposed: intrinsic transfer of a 200k-slot result is ~391 us (exit) against a ~6,100 us end-to-end select, so transfer is only ~6-12% of a large read and the other ~90% (SQLite stepping plus building the Dart object graph) has never been decomposed. Measure that split before optimizing transport further." + "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. Before spending more on transfer at all, note the proportion exp 245/246 exposed: intrinsic transfer of a 200k-slot result is ~391 us (exit) against a ~6,100 us end-to-end select, so transfer is only ~6-12% of a large read and the other ~90% (SQLite stepping plus building the Dart object graph) has never been decomposed. Measure that split before optimizing transport further. 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." }, { "id": "measurement-system", @@ -331,7 +337,7 @@ "profiling", "methodology" ], - "currentRead": "Several plausible optimizations failed because the benchmark did not stress the target path or because run noise hid small effects. Measurement work can be the highest-signal experiment when it unlocks a named implementation or rejection decision, but scheduled runners should first look for an instrument-and-implement path. Exp 119/121/136/147 are the recent stream-dispatch examples: exp 119 located surviving dispatch pressure in stream admission, exp 121 ruled out invalidation traversal as a wall-time target (10\u201315% of overlap wall, intersection ~3\u20136%), exp 136 added the completion-side reader-handler counter plus drain-aware audit snapshots and found 28.57% of A11c overlap total wall in the reader worker port handler chain at ~18 us per call, and exp 147 added the writer SQLite wall split and found SQLite-facing writer calls are not the active stream-fanout bottleneck on A11c overlap or keyed-PK. Exp 143 confirmed the pinned Tracelite profile path is useful because it captures dispatch floors, floor-subtracted work, memory diagnostics, allocation counters, source provenance, and graph data in one run; exp 169 consumes its interpretation gap by validating that `tracelite explain` emits workload-summary insight IDs for dispatch floors, work-bound operations, tail spread, RSS, allocation, and WAL signal before the profile wrapper completes. Exp 161 closes the matching release-suite gap for the writer side by promoting exp 159's concurrent-burst shape into `benchmark/suites/writes.dart` as a paired Single Inserts (sequential) / Concurrent Single Inserts (concurrent) row pair; the resqlite concurrent median (~1.1 ms) is now ~60% below the sequential median (~2.9 ms) on a public lane, and future writer-scheduling experiments can claim release-suite wins without depending on the focused `writer_pipelining.dart` script. Exp 177 mechanizes the JOURNAL's order-flipped drift check: `cvPct` + `classifyDriftFlag` in `benchmark/shared/stats.dart` and a `benchmark/ab_drift_check.dart` CLI classify a phase-ordered A/B regression flag as reproduced / drift-suspected / inconclusive from two order-flipped passes of per-run values, reproducing the manual verdicts on the recorded exp 159 (CV asymmetry) and exp 167 (sign reversal) flags. It is methodology tooling (exp 161 / 169 class), not a wall-time change, so future A/B runners can cite a deterministic verdict instead of re-deriving the CV-asymmetry rule each time. Exp 178 closes a silent pipeline gap on the experiment->chart linker itself: `generate_history.dart` already failed the build when an Accepted experiment linked a baseline-shaped run while a candidate existed (`_assertAcceptedExperimentsLinkToCandidates`), but it tolerated the more common case \u2014 a chartable experiment with NO linked run and NO `**Benchmark Run:**` opt-out, which silently drops off the chart when a runner forgets the result file or mismatches its date. A structural tally on main showed only ~5 of 23 null-run accepted/in-review experiments declared the opt-out. Exp 178 adds `_assertNewExperimentsLinkOrDeclareRun` (over a pure `findUndeclaredMissingRunExperiments` detector) that fails the build for accepted/in-review experiments numbered >= 178 with a null run and no opt-out declaration; pre-178 experiments are grandfathered via a cutoff constant (same pattern as `experimentEntriesRequiredFrom`). No runtime code, history.json unchanged. Exp 214 adds `benchmark/experiments/write_result_direct_read.dart`, a focused \u00b5s/call public writer harness for scalar writer-result handling. Its first use rejected direct pointer reads of `resqlite_write_result` because the order-flipped A/B did not reproduce a stable win; keep the harness as a guardrail, not as an invitation to stack writer-result micro-optimizations.", + "currentRead": "Several plausible optimizations failed because the benchmark did not stress the target path or because run noise hid small effects. Measurement work can be the highest-signal experiment when it unlocks a named implementation or rejection decision, but scheduled runners should first look for an instrument-and-implement path. Exp 119/121/136/147 are the recent stream-dispatch examples: exp 119 located surviving dispatch pressure in stream admission, exp 121 ruled out invalidation traversal as a wall-time target (10–15% of overlap wall, intersection ~3–6%), exp 136 added the completion-side reader-handler counter plus drain-aware audit snapshots and found 28.57% of A11c overlap total wall in the reader worker port handler chain at ~18 us per call, and exp 147 added the writer SQLite wall split and found SQLite-facing writer calls are not the active stream-fanout bottleneck on A11c overlap or keyed-PK. Exp 143 confirmed the pinned Tracelite profile path is useful because it captures dispatch floors, floor-subtracted work, memory diagnostics, allocation counters, source provenance, and graph data in one run; exp 169 consumes its interpretation gap by validating that `tracelite explain` emits workload-summary insight IDs for dispatch floors, work-bound operations, tail spread, RSS, allocation, and WAL signal before the profile wrapper completes. Exp 161 closes the matching release-suite gap for the writer side by promoting exp 159's concurrent-burst shape into `benchmark/suites/writes.dart` as a paired Single Inserts (sequential) / Concurrent Single Inserts (concurrent) row pair; the resqlite concurrent median (~1.1 ms) is now ~60% below the sequential median (~2.9 ms) on a public lane, and future writer-scheduling experiments can claim release-suite wins without depending on the focused `writer_pipelining.dart` script. Exp 177 mechanizes the JOURNAL's order-flipped drift check: `cvPct` + `classifyDriftFlag` in `benchmark/shared/stats.dart` and a `benchmark/ab_drift_check.dart` CLI classify a phase-ordered A/B regression flag as reproduced / drift-suspected / inconclusive from two order-flipped passes of per-run values, reproducing the manual verdicts on the recorded exp 159 (CV asymmetry) and exp 167 (sign reversal) flags. It is methodology tooling (exp 161 / 169 class), not a wall-time change, so future A/B runners can cite a deterministic verdict instead of re-deriving the CV-asymmetry rule each time. Exp 178 closes a silent pipeline gap on the experiment->chart linker itself: `generate_history.dart` already failed the build when an Accepted experiment linked a baseline-shaped run while a candidate existed (`_assertAcceptedExperimentsLinkToCandidates`), but it tolerated the more common case — a chartable experiment with NO linked run and NO `**Benchmark Run:**` opt-out, which silently drops off the chart when a runner forgets the result file or mismatches its date. A structural tally on main showed only ~5 of 23 null-run accepted/in-review experiments declared the opt-out. Exp 178 adds `_assertNewExperimentsLinkOrDeclareRun` (over a pure `findUndeclaredMissingRunExperiments` detector) that fails the build for accepted/in-review experiments numbered >= 178 with a null run and no opt-out declaration; pre-178 experiments are grandfathered via a cutoff constant (same pattern as `experimentEntriesRequiredFrom`). No runtime code, history.json unchanged. Exp 214 adds `benchmark/experiments/write_result_direct_read.dart`, a focused µs/call public writer harness for scalar writer-result handling. Its first use rejected direct pointer reads of `resqlite_write_result` because the order-flipped A/B did not reproduce a stable win; keep the harness as a guardrail, not as an invitation to stack writer-result micro-optimizations.", "keyPriors": [ "136", "143", diff --git a/experiments/signals/entries/258.json b/experiments/signals/entries/258.json new file mode 100644 index 00000000..efc5fc61 --- /dev/null +++ b/experiments/signals/entries/258.json @@ -0,0 +1,41 @@ +{ + "directions": [ + "result-transfer-shape" + ], + "experimentClass": "moonshot", + "outcomeClass": "rejected_below_signal", + "changedBeliefs": [ + "Exp 055 assessed per-column typed arrays (Int64List/Float64List) but never implemented them, estimating the throughput win as too small; exp 081 measured only a row-major binary slab. Exp 258 gave the first real AOT measurement of the columnar typed-array mechanism, isolating build (worker), hop (transfer), and consume (main) across a real worker->main SendPort hop with two order-flipped passes.", + "Columnar builds 60-90% faster worker-side (no boxing; biggest on REAL, ~10x on 10k x 20 REAL) and its transfer is 73-91% cheaper on a SendPort A/B. But the build win is worker wall (off the main isolate), and the transfer win does not reach production: results large enough for the transfer to matter (>32768 structural slots) already zero-copy via the Isolate.exit sacrifice path, and sub-threshold SendPort results save only tens of microseconds, below the exp 105 round-trip floor.", + "On resqlite's primary metric (main-isolate time = hop + consume), columnar is neutral for REAL results (consume -5 to -13%, the box-on-access cost nearly cancels Float64List locality) and shows no reliable memory win (Smi integers live inline in a List, so an Int64List column costs more per cell; RSS moved +25% to -30% inconsistently across lanes). This confirms exp 081's main-isolate-access caution generalizes to the columnar shape and completes exp 055's open assessment as a rejection.", + "The one real primary-metric signal: integer consume is ~2x faster on the main isolate (Int64List sequential access, no covariant-load barrier, vs pointer-chasing a List of Smis), reproduced -48 to -52% on every integer lane in both orders. This is a narrow effect, not evidence for the whole-store rewrite." + ], + "nextSignals": [ + "Do not reopen the broad columnar ResultSet rewrite on the transfer or memory arguments. Exp 258 closes both: the Isolate.exit sacrifice path already handles large-result transfer, and Smi-int columns regress memory under Int64List.", + "Scoped open candidate: a narrow columnar path for numeric columns targeting the ~2x main-isolate integer-consume speedup, worthwhile only if a workload profile shows integer-heavy, main-isolate-bound reads dominating. It needs its own design pass for how Row dispatches columnar-vs-flat per column without slowing the text path; treat it as out-of-budget until such a workload appears.", + "Keep benchmark/experiments/columnar_result_transfer.dart as the mechanism gate for any future columnar attempt: tag lanes by the sacrificeSlotThreshold (32768 slots) so the transfer axis is measured against Isolate.exit, not a naive SendPort baseline." + ], + "claims": [ + { + "id": "258.1", + "text": "Storing numeric result columns as typed arrays (Int64List/Float64List) instead of boxing into a flat List makes worker-side result build 60-90% faster (largest on REAL: doubles always box, ~10x on 10k x 20 REAL). This win is worker wall, off the main isolate.", + "conditions": "M-series · AOT · worker->main SendPort A/B · two order-flipped passes · 2026-07" + }, + { + "id": "258.2", + "text": "A columnar container's transfer win (73-91% cheaper hop vs the boxed flat list) does not move production's main-isolate metric: results large enough for transfer to matter (>32768 structural slots) already zero-copy via Isolate.exit, and sub-threshold results save only tens of microseconds, below the select() round-trip floor. The broad columnar rewrite is therefore rejected.", + "conditions": "M-series · AOT · worker->main SendPort A/B · 2026-07", + "edges": [ + { + "type": "refines", + "target": "245.1" + } + ] + }, + { + "id": "258.3", + "text": "The one real primary-metric signal from columnar storage is main-isolate integer consume ~2x faster (Int64List sequential access, no covariant-load barrier, vs pointer-chasing a List of Smis), reproduced -48 to -52% on every integer lane in both orders. REAL consume is only neutral (box-on-access nearly cancels the locality gain) and columnar never regresses consume, refuting exp 081's box-on-access fear for the columnar (not binary-slab) shape.", + "conditions": "M-series · AOT · main-isolate full-scan as Object? · 2026-07" + } + ] +}