From dd6a52ecec451c340bb6f5ddf89e1584c07e5cc7 Mon Sep 17 00:00:00 2001 From: Dan Reynolds Date: Wed, 5 Aug 2026 10:06:14 -0400 Subject: [PATCH 1/2] Exp 263: the 60x memory ratio was mostly floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exp 261 recorded a ~60x ratio between a 10k-row select()'s peak RSS and its payload as claim 261.4, "never decomposed". Decomposed here by holding seeding constant at 20,000 rows and varying only the timed statement's LIMIT, so the slope is the per-row marginal and the intercept is everything that does not scale. The measurement configuration was itself the first finding. RSS never falls, so exp 261's 5 warmup + 21 timed reads accumulated up to 26 results of retained garbage and reported 99 MB where one read held live reports 36.1 MB. On the single-result measurement select() costs 396 B/row against 137.8 B of payload — 2.9x, within ~1.4x of the theoretical minimum for the row shape. The floor is where the 60x lived: 14.0 MB bare AOT Dart process, 20.5 MB after resqlite opens and spawns its pool, 32.8 MB after seeding, 36.1 MB with a live 10k-row result. The result is the smallest term. Premise refuted; claim 261.4 closed. Two riders worth keeping: selectBytes costs MORE per row than select (676 vs 396 B/row) because JSON repeats column names and quotes values, so it is for allocation churn and not footprint; and a memory sweep crossing sacrificeSlotThreshold reads sub-linear because Isolate.exit returns the worker heap. Co-Authored-By: Claude Opus 5 --- .../select_memory_decomposition.dart | 257 ++++++++++++++++++ ...-00Z-exp263-select-memory-decomposition.md | 88 ++++++ .../263-select-memory-decomposition.md | 130 +++++++++ experiments/index/263.json | 7 + experiments/signals/base.json | 12 +- experiments/signals/entries/263.json | 38 +++ 6 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 benchmark/experiments/select_memory_decomposition.dart create mode 100644 benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md create mode 100644 experiments/263-select-memory-decomposition.md create mode 100644 experiments/index/263.json create mode 100644 experiments/signals/entries/263.json diff --git a/benchmark/experiments/select_memory_decomposition.dart b/benchmark/experiments/select_memory_decomposition.dart new file mode 100644 index 00000000..8b7c22a2 --- /dev/null +++ b/benchmark/experiments/select_memory_decomposition.dart @@ -0,0 +1,257 @@ +// ignore_for_file: avoid_print +// +// Focused memory decomposition for [EXP-263]: where does a `select()`'s +// resident memory actually go? +// +// [EXP-261](../../experiments/261-focused-memory-guard.md) measured the repo's +// canonical 6-column product row at 10k rows peaking at ~95 MB while the table +// holds roughly 1.5 MB of data, and flagged the ~60x ratio as never decomposed. +// Its own instrument cannot decompose it — process RSS cannot resolve anything +// below a doubling, and an AOT binary has no VM service to ask for heap +// composition. +// +// What process RSS *can* do is separate fixed cost from marginal cost, if the +// only thing that varies is the amount read. Every lane here seeds the same +// 20,000-row table and differs only in how many rows the timed statement +// returns, so a fit of peak RSS against row count gives the per-row marginal +// directly, and the intercept is everything that does not scale with the read +// (process, VM, connection, page cache, seeding). +// +// Three modes over the same rows isolate the parts: +// +// select — `select()`, the full Dart object graph (flat values list plus +// the lazy `Row` facade over it). +// bytes — `selectBytes()`, the same rows serialized in C with no Dart +// object graph at all. The difference between this and `select` +// is what the Dart representation costs. +// id — `select()` of the INTEGER primary key alone. Smis live inline in +// the values list, so this is structure without payload. +// +// The `select` sweep crosses `sacrificeSlotThreshold` (32768 structural slots, +// so 5,461 rows at 6 columns) between its 5,000 and 7,500 row lanes. Results +// above it return via `Isolate.exit` and end the worker; results below take a +// `SendPort`. Lanes are tagged with which path they took, because a +// discontinuity there is a transport artifact rather than a representation one +// ([EXP-258](../../experiments/258-columnar-result-store.md)). +// +// Per [EXP-261](../../experiments/261-focused-memory-guard.md): the reported +// figure is `maxRss`, and it is only per-lane clean when the lane had the +// process to itself. Run one lane per process with `--lane=`. +// +// Usage: +// dart run benchmark/experiments/select_memory_decomposition.dart \ +// [--reads=21] [--lane=select-5000] +import 'dart:io'; + +import 'package:resqlite/resqlite.dart' as resqlite; + +import '../shared/memory_probe.dart'; + +/// Rows seeded into every lane's table, held constant so the only variable is +/// how many of them the timed statement returns. +const _seedRows = 20000; + +const _defaultReads = 21; +const _defaultWarmup = 5; + +/// `sacrificeSlotThreshold` in `lib/src/reader/read_worker.dart`. +const _sacrificeSlots = 32 * 1024; + +const _rowCounts = [1000, 2500, 5000, 7500, 10000, 20000]; + +enum _Mode { + select('select', 6), + bytes('bytes', 6), + id('id', 1), + + /// Open the database and read nothing. Isolates the fixed floor — VM, + /// native library, SQLite connections and the reader/writer isolate pool — + /// from anything the result contributes. Ignores the row count. + open('open', 0); + + const _Mode(this.label, this.columns); + final String label; + + /// Structural slots a row of this mode occupies, for the sacrifice estimate. + final int columns; +} + +const _standardCreate = ''' + CREATE TABLE items( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + value REAL NOT NULL, + category TEXT NOT NULL, + created_at TEXT NOT NULL + ) +'''; +const _standardInsert = + 'INSERT INTO items(name, description, value, category, created_at) ' + 'VALUES (?, ?, ?, ?, ?)'; +List _standardRow(int i) => [ + 'Item $i', + 'This is a description for item number $i with some padding text to ' + 'simulate real data', + i * 1.5, + 'category_${i % 10}', + '2026-04-0${(i % 9) + 1}T12:00:00Z', +]; + +/// Bytes of actual cell data in one seeded row, so the report can state the +/// payload the marginal cost is measured against rather than estimating it. +int _payloadBytes(int i) { + final row = _standardRow(i); + var bytes = 8; // id, INTEGER + for (final cell in row) { + bytes += cell is String ? cell.length : 8; + } + return bytes; +} + +Future main(List args) async { + var reads = _defaultReads; + var warmup = _defaultWarmup; + String? only; + for (final arg in args) { + if (arg.startsWith('--reads=')) { + reads = int.parse(arg.substring('--reads='.length)); + } else if (arg.startsWith('--warmup=')) { + warmup = int.parse(arg.substring('--warmup='.length)); + } else if (arg.startsWith('--lane=')) { + only = arg.substring('--lane='.length); + } else { + throw ArgumentError('unknown argument: $arg'); + } + } + + print('=== select() memory decomposition ==='); + print('seed_rows=$_seedRows warmup=$warmup reads_per_lane=$reads'); + if (warmup == 0 && reads == 1) { + print( + 'mode=single-live-result — one read, held alive across the sample, so ' + 'the marginal is one result rather than accumulated retention', + ); + } + final avgPayload = + List.generate(100, _payloadBytes).reduce((a, b) => a + b) / 100; + print('avg_payload_bytes_per_row=${avgPayload.toStringAsFixed(1)}'); + + for (final mode in _Mode.values) { + for (final rows in mode == _Mode.open ? const [0] : _rowCounts) { + final label = mode == _Mode.open ? 'open' : '${mode.label}-$rows'; + if (only != null && label != only) continue; + await _runLane( + mode, + rows, + reads: reads, + warmup: warmup, + laneIsolated: only != null, + ); + } + } +} + +Future _runLane( + _Mode mode, + int rows, { + required int reads, + required int warmup, + required bool laneIsolated, +}) async { + final temp = await Directory.systemTemp.createTemp('bench_memdecomp_'); + try { + final db = await resqlite.Database.open('${temp.path}/test.db'); + await db.execute(_standardCreate); + + if (mode == _Mode.open) { + // One trivial statement so the reader pool has actually spawned; the + // pool is lazy and a floor measured before it exists is not the floor a + // reading workload pays. + await db.select('SELECT id FROM items LIMIT 1'); + final probe = MemoryProbe.start(); + probe.sample(); + final reading = probe.finish(laneIsolated: laneIsolated); + await db.close(); + print( + 'shape=open mode=open rows=0 slots=0 sacrifices=false ' + '${reading.format()}', + ); + return; + } + + const chunk = 500; + for (var start = 0; start < _seedRows; start += chunk) { + final end = start + chunk < _seedRows ? start + chunk : _seedRows; + await db.executeBatch(_standardInsert, [ + for (var r = start; r < end; r++) _standardRow(r), + ]); + } + + final sql = switch (mode) { + _Mode.select => 'SELECT * FROM items ORDER BY id LIMIT ?', + _Mode.bytes => 'SELECT * FROM items ORDER BY id LIMIT ?', + _Mode.id => 'SELECT id FROM items ORDER BY id LIMIT ?', + // Unreachable: the open lane returns above, before any statement. + _Mode.open => throw StateError('open lane has no statement'), + }; + final params = [rows]; + + // The result is held in `live` across the sample. Without that the VM may + // reclaim it before RSS is read, and the lane would measure a result that + // no longer exists. + Object? live; + Future read() async { + if (mode == _Mode.bytes) { + final r = await db.selectBytes(sql, params); + live = r; + return r.rowCount; + } + final r = await db.select(sql, params); + live = r; + // Read a cell from every row so the lazy `Row` facade actually + // materializes. The cell *values* are built by `decodeQuery` either way + // — what this adds is the per-row `Row` object a consumer holds. + if (mode == _Mode.select) { + for (final row in r) { + if (row['name'] == null) throw StateError('null name'); + } + } + return r.length; + } + + for (var i = 0; i < warmup; i++) { + if (await read() != rows) { + throw StateError('lane ${mode.label}-$rows returned the wrong count'); + } + } + live = null; + + final probe = MemoryProbe.start(); + for (var i = 0; i < reads; i++) { + if (await read() != rows) { + throw StateError('lane ${mode.label}-$rows returned the wrong count'); + } + probe.sample(); + } + if (live == null) throw StateError('result was not retained'); + final reading = probe.finish(laneIsolated: laneIsolated); + await db.close(); + + // `selectBytes` never sacrifices — the result is native bytes, so + // `Isolate.exit` would need a copy first and saves nothing. + final slots = rows * mode.columns; + final sacrifices = mode != _Mode.bytes && slots > _sacrificeSlots; + + print( + 'shape=${mode.label}-$rows ' + 'mode=${mode.label} ' + 'rows=$rows ' + 'slots=$slots ' + 'sacrifices=$sacrifices ' + '${reading.format()}', + ); + } finally { + await temp.delete(recursive: true); + } +} diff --git a/benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md b/benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md new file mode 100644 index 00000000..08d3bd71 --- /dev/null +++ b/benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md @@ -0,0 +1,88 @@ +# Experiment 263: where a select()'s memory actually goes + +Collected 2026-08-05 on arm64 macOS 26.2 (Apple M1 Pro) with Dart 3.12.2, from +`main` at `7225903`. Harness: +[`benchmark/experiments/select_memory_decomposition.dart`](../experiments/select_memory_decomposition.dart). + +Every figure is `maxRss` with **one process per lane** (`--lane=`), per exp 261. + +## Method + +Exp 261 flagged the canonical 6-column product row at 10k rows peaking at ~95 MB +against a table holding ~1.5 MB, a ~60x ratio, and noted its own instrument could +not decompose it: process RSS cannot resolve below a doubling, and an AOT binary +has no VM service to ask for heap composition. + +What RSS *can* do is separate fixed from marginal, if the only thing that varies +is the amount read. Every lane seeds the same 20,000-row table and differs only +in the timed statement's `LIMIT`, so the slope against row count is the per-row +marginal and the intercept is everything that does not scale with the read. + +Two configurations, and the difference between them is the experiment's first +finding: + +- `--warmup=5 --reads=21` — the shape exp 261 used. RSS never falls, so 26 reads + accumulate up to 26 results' worth of retained garbage. +- `--warmup=0 --reads=1`, result held live across the sample — one result, + which is what "what does a result cost" actually asks. + +## Single live result (maxRss, MB) + +| rows | `select` | `bytes` | `id` | +|---:|---:|---:|---:| +| 1,000 | 32.7 | 33.0 | 32.8 | +| 2,500 | 33.0 | 33.7 | 33.0 | +| 5,000 | 33.9 | 35.3 | 33.3 | +| 7,500 | 35.1 | 37.0 | 33.6 | +| 10,000 | 36.1 | 38.8 | 34.0 | +| 20,000 | 38.8 | 45.1 | 35.6 | + +Marginal cost per row, over two spans: + +| mode | 1k→10k | 1k→20k | +|---|---:|---:| +| `select` | 396 B/row | 337 B/row | +| `bytes` | 676 B/row | 668 B/row | +| `id` | 140 B/row | 155 B/row | + +Average payload in one seeded row: **137.8 bytes**. + +`select`'s 1k→20k span reads lower than 1k→10k because results above +`sacrificeSlotThreshold` (5,461 rows at 6 columns) return via `Isolate.exit`, +which ends the worker and returns its heap. The sub-linearity is a transport +artifact, not a representation one. + +## Fixed floor + +| stage | maxRss | +|---|---:| +| bare AOT Dart process (measured separately) | 14.0 MB | +| + resqlite open, pool spawned, one trivial read (`open` lane) | 20.5 MB | +| + seeding 20,000 rows via `executeBatch` (`id-1000` lane) | 32.8 MB | +| + one live 10,000-row `select()` result | 36.1 MB | + +## Repeatability + +Three runs per lane, isolated processes, maxRss MB: + +| lane | runs | +|---|---| +| `open` | 20.5, 20.5, 20.5 | +| `select-1000` | 32.8, 32.9, 32.8 | +| `select-10000` | 36.8, 36.4, 36.8 | +| `select-20000` | 38.9, 42.1, 38.9 | + +## Accumulated-retention configuration, for contrast + +The same lanes under `--warmup=5 --reads=21` (26 reads, nothing released): + +| rows | `select` | `bytes` | `id` | +|---:|---:|---:|---:| +| 1,000 | 33.9 | 39.4 | 33.3 | +| 5,000 | 99.4 | 51.3 | 35.6 | +| 10,000 | 99.0 | 74.5 | 38.4 | +| 20,000 | 105.0 | 97.8 | 45.2 | + +This is the configuration exp 261 measured, and it is ~2.7x the single-result +figure at 10,000 rows. It answers a real question — what a process doing +repeated reads holds — but not the one the 60x ratio was posed against. diff --git a/experiments/263-select-memory-decomposition.md b/experiments/263-select-memory-decomposition.md new file mode 100644 index 00000000..1fcf84fa --- /dev/null +++ b/experiments/263-select-memory-decomposition.md @@ -0,0 +1,130 @@ +# Experiment 263: The 60× memory ratio was mostly floor + +**Date:** 2026-08-05 +**Status:** Accepted +**Category:** Measurement +**Direction:** `result-transfer-shape`, `measurement-system` +**Benchmark Run:** none — focused memory decomposition, no release run; harness + [`benchmark/experiments/select_memory_decomposition.dart`](../benchmark/experiments/select_memory_decomposition.dart), + full tables in + [`benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md`](../benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md) + +## Problem + +[Exp 261](261-focused-memory-guard.md) measured the repo's canonical 6-column +product row at 10,000 rows peaking at ~95 MB while the table holds roughly +1.5 MB of data, and recorded the ~60× ratio as claim 261.4 — "never decomposed", +with `List>` overhead named as a partial explanation and +exps 008/032's lazy and facade shapes flagged as having been judged on wall time +rather than on this. + +A 60× representation overhead would be a serious finding. It would put the +result shape squarely back on the table despite exps 081, 251 and 258 all having +closed storage rewrites on their own evidence. + +It is not one. The ratio was an artifact of what was being divided by what. + +## Hypothesis and decision rule + +Process RSS cannot resolve heap composition — exp 261 established that, and an +AOT binary has no VM service to ask. But it can separate *fixed* from *marginal* +cost, if the only thing that varies is how much is read. Hold seeding constant at +20,000 rows and vary only the timed statement's `LIMIT`: the slope against row +count is the per-row marginal, the intercept is everything that does not scale. + +Declared before measuring: + +- If the marginal is near the theoretical minimum for the row shape (~280 B/row: + four `OneByteString`s, one boxed double, six pointer slots, one `Row` facade), + the ratio is fixed cost and the premise is refuted — close claim 261.4. +- If the marginal is several times that, there is real per-row waste and it + becomes an implementation candidate. + +## Approach + +Three modes over the same rows: `select` (the full Dart object graph, with a +cell read from every row so the lazy `Row` facade actually materializes), +`bytes` (`selectBytes`, serialized in C with no Dart object graph), and `id` +(the INTEGER primary key alone — structure without payload, since Smis live +inline). Plus an `open` lane that opens the database, spawns the pool with one +trivial read, and reads nothing else. + +**The measurement configuration turned out to be the first finding.** Run the +way exp 261 ran it — 5 warmup plus 21 timed reads — the 10,000-row lane reports +99 MB. RSS never falls, so 26 reads accumulate up to 26 results' worth of +retained garbage, and the number describes a process doing repeated reads rather +than the cost of a result. Run with one read, held live across the sample, the +same lane reports **36.1 MB**. Both are honest; only the second answers the +question the ratio was posed against. + +## Results + +Single live result, `maxRss`, one process per lane: + +| mode | marginal (1k→10k) | vs 137.8 B payload | +|---|---:|---:| +| `select` | 396 B/row | **2.9×** | +| `bytes` | 676 B/row | 4.9× | +| `id` | 140 B/row | — | + +And the floor, which is where the 60× actually lived: + +| stage | maxRss | +|---|---:| +| bare AOT Dart process | 14.0 MB | +| + resqlite open, pool spawned | 20.5 MB | +| + seeding 20,000 rows | 32.8 MB | +| + one live 10,000-row result | 36.1 MB | + +**The result is the smallest term.** A 10,000-row `select()` holds ~3.6 MB above +a floor of ~32.8 MB, of which 14 MB is the Dart VM before resqlite exists at all, +6.5 MB is resqlite's open connections and isolate pool, and ~12 MB is retained +seeding. Dividing a peak that is ~90% fixed-and-setup cost by the payload is what +produced 60×. + +At 396 B/row against 137.8 B of payload, `select()` carries a **2.9×** +representation overhead — and against the ~280 B/row theoretical minimum for this +row shape it is within about 1.4×. That gap is page granularity and heap slack, +not a structure worth rewriting. + +Two secondary findings worth keeping: + +- **`selectBytes` costs more memory per row than `select`, not less** — 676 B/row + against 396. It avoids Dart *objects*, which is what it has always claimed, but + JSON re-encodes every column name on every row and quotes and escapes every + value, so the bytes are larger than the object graph they replace. Anyone + reaching for `selectBytes` to reduce memory rather than allocation churn is + reaching for the wrong tool. +- **The sacrifice path shows up as sub-linearity, not as a step.** `select`'s + marginal drops from 396 B/row (1k→10k) to 337 (1k→20k) because results above + `sacrificeSlotThreshold` return via `Isolate.exit`, ending the worker and + returning its heap. A memory comparison that spans that threshold is measuring + transport as much as representation (the exp 258 trap, in its memory form). + +## Outcome + +**Accepted as measurement; premise refuted.** Claim 261.4's ~60× is closed: the +marginal cost of a `select()` result is 2.9× its payload, near the floor of what +a `List>` of Dart strings can achieve, and the ratio it was +derived from was dominated by a fixed floor and by retained garbage from repeated +reads. + +Nothing here reopens a result-shape rewrite; it removes the one piece of evidence +that might have. If resqlite's memory footprint is ever a target, the numbers say +where to look, and it is not the row representation: 20.5 MB is resident before a +single row is read, and the isolate pool is most of resqlite's share of it. Exp +105 already established that pool size is throughput-critical, so that is a +trade, not a free win — but it is the term that dominates every small and +medium read. + +Would reopen the representation question only for a workload holding many result +sets alive at once, where the marginal rather than the floor dominates — which is +the opposite of the read-and-discard shape everything here measures. + +## Test plan + +- `dart analyze --fatal-infos` on the harness — clean +- Three runs per key lane, isolated processes: `open` 20.5/20.5/20.5, + `select-1000` 32.8/32.9/32.8, `select-10000` 36.8/36.4/36.8 +- Both measurement configurations run over the full sweep, with the contrast + between them recorded rather than one silently chosen diff --git a/experiments/index/263.json b/experiments/index/263.json new file mode 100644 index 00000000..43ab5ee5 --- /dev/null +++ b/experiments/index/263.json @@ -0,0 +1,7 @@ +{ + "file": "263-select-memory-decomposition.md", + "title": "The 60x memory ratio was mostly floor", + "impact": "Exp 261 recorded a ~60x ratio between a 10k-row `select()`'s peak RSS (~95 MB) and its payload (~1.5 MB) as claim 261.4, `never decomposed`, with `List>` overhead named as a partial cause. Decomposed here by holding seeding constant at 20,000 rows and varying only the timed statement's LIMIT, so the slope is the per-row marginal and the intercept is everything that does not scale. The measurement configuration was itself the first finding: exp 261's 5 warmup + 21 timed reads accumulate up to 26 results of retained garbage because RSS never falls, reporting 99 MB where one read held live reports 36.1 MB. On the single-result measurement `select()` costs 396 B/row against 137.8 B of payload — a 2.9x representation overhead, within ~1.4x of the ~280 B/row theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The floor is where the 60x lived: 14.0 MB is the bare Dart AOT process, 20.5 MB after resqlite opens and spawns its pool, 32.8 MB after seeding, and 36.1 MB with a live 10k-row result — so the result is the smallest term and the ratio divided a ~90%-fixed peak by the payload. Premise refuted; claim 261.4 closed. Two secondary findings: `selectBytes` costs MORE memory per row than `select` (676 vs 396 B/row) because JSON re-encodes every column name per row and quotes every value — it avoids Dart objects, not bytes, so it is the wrong tool for reducing memory as opposed to allocation churn; and the sacrifice path shows up as sub-linearity (396 B/row over 1k-10k vs 337 over 1k-20k) because results above sacrificeSlotThreshold end the worker and return its heap, so any memory comparison spanning that threshold measures transport as much as representation. If footprint is ever a target the numbers say it is the isolate pool, not the row shape — but exp 105 already made pool size throughput-critical, so that is a trade rather than a free win.", + "status": "accepted", + "link": "" +} diff --git a/experiments/signals/base.json b/experiments/signals/base.json index 37b4ea6f..050fa407 100644 --- a/experiments/signals/base.json +++ b/experiments/signals/base.json @@ -273,14 +273,14 @@ "isolate-transfer", "api-shape" ], - "currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy. Exp 167 rechecked closed exp 141's direct ResultSet.forEach override on a real SQLite-backed consumer lane after exp 158 and rejected it: a small first-pair win reversed on the longer confirmation pair, so no runtime code was kept. Exp 174 found a transport asymmetry: the reader 'sacrifice' path (Isolate.exit + reader respawn) is a real win for the rows path because it transfers already-built Dart objects with no re-copy, but it was applied by result size to selectBytes too, where the native JSON must be Uint8List.fromList-copied before Isolate.exit can transfer it — so sacrifice saved zero copies on bytes and only added a respawn, while the non-sacrifice bytes path copied twice. selectBytes now sends a Uint8List view over the connection's persistent json_buf and never sacrifices: -44% (~1.8x) on large (>256KB) byte reads by eliminating the respawn, -4% on small, at a bounded ~+15MB RSS high-water (readers no longer respawned). Exp 175 adds a named release-suite guard for that large-bytes path: `Large payload (~650KB) / resqlite selectBytes()` measured 0.323 ms wall / 0.000 ms main and is curated as `selectBytes() large bytes`, so the history no longer relies on the sub-256KB 1K-row metric to watch exp 174. Rows select() keeps sacrifice — there the zero-copy object transfer is real. Exp 176 closed a gap exp 158 left inside the same RowSchema index: Row.containsKey still hashed the key in the private HashMap on every call, bypassing the identity fast path its sibling operator[] already used, so it ran ~+3.6 ms slower than a LinkedHashMap on the focused containsKey lane. Routing it through a shared RowSchema.containsName (= indexOf(name) >= 0) improved that lane ~13.0 -> ~10.0 ms (-23%) with a flat hot-lookup control, flipping Row to at-parity, behavior-identical and no API change. The win is interned-key-specific (decoded schema names are not identical to user literals, so production probes generally fall through to the HashMap, same cost as before). Exp 193 rejected replacing Row.values' custom iterator with a fixed ListBase slice view: JIT row_map_facade values samples were unstable, and the AOT check showed the original _RowValueIterator faster (2.663-2.687 ms) than the list view (6.828-7.101 ms), so Row.values should keep the custom iterator unless a future Dart runtime changes compiled behavior. Exp 183 closed the remaining piece of exp 174's bounded RSS trade-off by quantifying it and reclaiming it: a new Diagnostics.readerJsonBufHighWaterBytes field exposes per-reader json_buf.cap, the focused json_buf_retention.dart audit confirms pathological retention is real (8 concurrent x 8 MB selectBytes pin 32 MB across the 4-reader pool for the rest of the connection), and a C-side reader-worker shrink fired after SendPort.send returns (gated by cap > 1 MB AND last_used_len < 256 KB) reclaims back to the 16 KB initial cap on subsequent small reads — post-burst settle 32 MB -> 64 KB, recurring-large 16 MB -> 64 KB, with neutral large_bytes_transfer.dart numbers. Exp 185 promotes that diagnostic into the release diagnostics suite: `SQLite Diagnostics / JSON buffer reclaim (8 large selectBytes + 64 small settles)` now records `jsonBufKiB` and fails if the post-settle high-water exceeds 512 KiB; the first focused suite run settled at 64.0 KiB with idle readers, so the exp 183 reclaim has public regression visibility. Exp 190 takes the encoder-side win inside the same direction: `write_json_to_buf` in `native/resqlite.c` now pre-builds each column's `\"col\":` / `,\"col\":` token once at first-row time into a per-query scratch buffer, so subsequent rows emit each column with one `buf_write` instead of comma + `json_write_string` (SWAR scan + escape walk) + colon. Focused `select_bytes_wide_cols.dart` measures -4% to -11% across two order-flipped passes on 10k-row x 8 / 20-col shapes; `large_bytes_transfer.dart` (exp 174's focused guard) also moves -8.7% / -8.2% on large/small lanes. Regression guards (1 row, 100 rows) stay in the sub-microsecond noise floor. Exp 192 closes the bounded headroom exp 023 left inside `write_json_to_buf`'s `SQLITE_INTEGER` arm: replacing the single-digit `fast_i64_to_str` body with a two-digit `[00..99]` lookup table (one `% 100` / `/ 100` and one 2-byte memcpy per digit pair) cuts focused integer-heavy selectBytes by −8 to −26 % across two order-flipped passes on `select_bytes_int_heavy.dart` — biggest win on 10k × 20 ~18-digit big ints (−24 to −26 %), where the digit-loop length is greatest. Mixed-cell and small-payload regression guards stay inside ±1 %. The release suite is not the right denominator (no lane is integer-heavy enough), so `select_bytes_int_heavy.dart` is the durable gate for future selectBytes integer-encode work. Exp 194 then specializes `write_json_to_buf`'s `SQLITE_FLOAT` arm for exact integral REAL values: finite values in the exact double integer range (`abs(v) <= 2^53`) reuse `fast_i64_to_str`, while fractional values, huge values, non-finite values, and negative zero stay on `snprintf(\"%.17g\")`. Focused `select_bytes_real_int_fastpath.dart` measures roughly -79% to -82% on 10k-row integral-REAL lanes across an order-flipped pair, -72% on the 1k x 2 small lane, a flat +0.5% fractional-REAL fallback guard, and a mixed lane that moves in proportion to its integral-REAL cells. The release suite is not the denominator for this per-cell formatter path; the focused REAL harness is the durable gate. Exp 195 promotes exp 190's per-query `tokens_buf` scratch into the `resqlite_cached_stmt` entry, amortizing the per-query `buf_init(64)` + `free` pair and the first-row pre-encode walk across every re-execution of the same prepared SQL. Per-row inner loop and JSON output are byte-identical to exp 190. The exp 190 1-row regression guard sits at the millisecond-reporting harness floor and looked neutral; a new microsecond-precision focused harness `select_bytes_repeated_calls.dart` (1000 calls per sample) measures the predicted shape directly — 1-row × 20-col improves −9.2 % / −7.2 % and 10-row × 20-col improves −5.1 % / −2.7 % across two order-flipped passes, while 100/1000-row guards show sign reversal (drift-suspected per exp 177's classifier; per-query setup is < 0.2 % of wall there). Exp 190's `wide_cols.dart` 10k-row shapes also trend candidate-faster on every lane across both passes. Memory cost per cached statement is `O(col_count * 8 + name_byte_count)` capped by `STMT_CACHE_MAX = 32` per connection. Exp 216 consumes exp 201's BLOB follow-up by unrolling `json_write_base64` from one 3-byte group per loop trip to four groups per trip. `select_bytes_blob_base64.dart` keeps the 4KB BLOB lane candidate-faster across three passes (-13.6%, -12.9%, -11.9%), keeps 128B BLOBs same-direction faster (-1.1%, -9.3%, -4.7%), and leaves 3B tiny-cell lanes neutral/noisy as expected because one-triplet cells mostly bypass the unrolled loop. Output bytes, padding, JSON framing, and public API are unchanged. Exp 218 then tested whether exp 216 left scalar loop-width headroom by widening the same base64 loop to eight triplets per iteration. The 128B lane stayed candidate-faster, but the load-bearing 4KB lane mixed across four paired passes (-10.0%, +2.0%, -13.6%, +0.8%), so the wider native body is rejected and exp 216's four-triplet unroll remains the portable baseline. Exp 219 then consumes exp 202's TEXT future note for unnamed control-character escapes: `json_write_string` now emits `\\u00XX` directly from a small hex table instead of formatting through `snprintf(\"\\\\u%04x\")`. The focused TEXT harness adds a control-character lane and reproduces the target win across the order flip (35927 -> 6371 us/query, -82.3%; 35824 -> 6370 us/query, -82.2%). Safe ASCII and named-escape rows remain guardrails for unrelated fallout, and JSON output/public API are unchanged. Exp 224 then consumes exp 074's pure-numeric bulk-step follow-up without copying TEXT/BLOB payloads: a dynamic batch returns after any pointer-backed row but batches up to 64 contiguous numeric/NULL rows. `select_rows_step_row_ffi.dart` finds no numeric win across the order flip (10k x 8 +1.9%/+0.5%; 10k x 20 +4.6%/-0.5%) and reproduces a 5-7% short-TEXT regression, so multi-row `select()` stepping is closed under the current Dart leaf-FFI/runtime baseline. Exp 225 then consumes exp 218's future-work note (loop-unroll of `json_write_base64` is at ceiling; further BLOB work needs a different mechanism): the inner base64 lookup widens from a 6-bit table (four per triplet) to a 12-bit pair table (two per triplet), keeping exp 216's 4x-unrolled outer loop. Three order-flipped passes of `select_bytes_blob_base64.dart` reproduce -26 to -31% on 4 KB blobs and -17 to -31% on 128 B blobs, with 3 B tiny-cell guards flat at ~-0.5% to -0.9%. Output is bit-identical; cost is 8 KiB `.bss`. The scalar body is now two 16-bit loads + two 16-bit stores per four output bytes. Exp 229 then consumes exp 225's future-work note (SIMD `_mm_shuffle_epi8` / `vqtbl4q_u8` base64 kernels named as the next mechanism) as the direction's first *moonshot* accept: an AArch64/NEON `vld3q_u8` + `vqtbl4q_u8` + `vst4q_u8` kernel gated on `defined(__aarch64__) && defined(__ARM_NEON)` processes 48 input bytes into 64 output bytes per iteration, with the exp 225 scalar 12-bit-LUT encoder retained as fallback for non-ARM64 targets and for the < 48-byte tail on all targets. Four order-flipped paired passes of `select_bytes_blob_base64.dart` reproduce ~-45% median on the 4 KB payload-throughput lane (roughly 2x faster BLOB base64 encoding) and ~-18% on the 128 B lane; tiny-cell (3 B) and mixed guards stay inside the harness noise floor because the SIMD kernel is out-of-lined (`__attribute__((noinline))`) so the small-input hot path retains exp 225's byte-identical layout. This is the first SIMD kernel in `native/resqlite.c` — the categorical implication is that ISA-specific kernels are viable when out-of-lined; SSSE3 x86_64, SIMD JSON escape scan, and SIMD FNV hash follow-ups are now unlocked. Exp 230 then tested the TEXT-side SIMD mechanism exp 229 unlocked: an AArch64/NEON encoder fused 16-byte escape classification with safe-prefix stores behind a 256 B cutoff, falling back to the canonical scalar loop at the first escapable vector. The far-end mechanism is real — safe 1 KiB ASCII reproduced -33.6% / -34.1% and long CJK -25.2% / -25.5% — but the load-bearing 256 B lane narrowed from -17.7% to -12.6% and missed the preset 15% adoption bar in the second ordering. Early escape stayed neutral and late-escape magnitude did not reproduce. The ISA-specific runtime prototype is rejected and archived; the long-safe, early-/late-escape harness lanes and exact boundary correctness test remain. Exp 231 then probed the other value type exp 229 unlocked — an out-of-lined AArch64/NEON i64→decimal kernel for the SQLITE_INTEGER arm — and rejected it, bounding exp 229's categorical claim. The kernel is byte-identical (magnitude split into low8/mid8/high groups via two /1e8 divides, each 8-digit group formatted with vector reciprocal splits, gated ≥ 1e8) but across 11 focused A/B passes in three methodologies the ~18-digit BIGINT selectBytes lane never leaned candidate-faster (+1.2/+6.0/+9.8% baseline-first, +11.3% min-of-N, ~flat only in the one quiet pair) while identical-code controls drifted ±10-120%. The finding: exp 229's out-of-line ISA-kernel win amortises SIMD setup over an entire BLOB per call, whereas the integer formatter converts ONE scalar value per call with nothing to amortise — SIMD is viable for bulk per-cell payloads (base64), not scalar per-cell values (integers). Runtime archived at `archive/exp-231`; the new i64 differential test (first direct coverage of the integer formatter) is kept. Exp 232 then tested extending exp 194 to exact `.25`/`.5`/`.75` REAL values and proved a real mechanism: 100%-eligible lanes improved 78-87% and a synthetic 50%-eligible row improved 50-53%. It is rejected because no production or representative distribution established the eligible share, all general-fractional summaries leaned 0.65-1.95% slower, and the specialization required 114 native/build-hook lines plus permanent value-lattice semantics and test surface. Runtime and quarter-only scaffolding are removed; `archive/exp-232` preserves the prototype, and exact integral remains the only shipped REAL specialization. Exp 240 then consumed exp 231's explicit reopen — batch many integer cells into one encode call — and closed it from both sides. Its three test-only array encoders (`resqlite_test_i64_array_*`) show, in isolation, that a 2-way software-pipelined SCALAR formatter overlaps two values' divide chains for -6 to -13% on mid/big magnitudes, while a NEON vector kernel handed an array still loses +45 to +60% on realistic short (<9 digit) values (fixed vector setup unamortisable below ~9 digits) — reproducing exp 231's per-value SIMD loss with an array in hand. But wiring the winning pipelined-scalar into `write_json_to_buf`'s SQLITE_INTEGER arm inverted the result: integer-heavy selectBytes was uniformly +1 to +12% slower across four passes, worst on the big-ints lane the microbench won most. Integer conversion is not the encoder's bottleneck, the pair-lookahead machinery outweighs the overlapped chains, and a pair's two i64s are fetched serially via sqlite3_value_int64 so the cross-value-pipelining premise never holds on the hot path (same shape as exp 226's isolated packer win that missed the end-to-end gate). Batched/columnar integer FORMATTING is now closed as a formatter tweak; reopen only via a columnar transfer shape that hands the encoder a contiguous array of already-fetched i64s with framing decoupled from the per-value fetch. `i64_batch_encode.dart` + the array encoders are the durable conversion gate. Exp 248 closes the last unmeasured component of the per-connection C statement cache. `resqlite_cached_stmt` is 1,632 bytes because it embeds exp 106's `read_tables[64]` (512 B) and `dep_columns[64]` (1 KB) arrays, so the move-to-back MRU promotion in `stmt_cache_lookup_entry` copies 4,896 bytes (three full-struct copies) every time it fires, and eviction memmoves ~50 KB. Exp 071 and exp 207 both examined this function but only on a SINGLE repeated SQL — a shape where the hot entry already sits at the tail and the promotion branch is structurally unreachable — so the swap had never been measured. Replacing it with a per-entry `lru_seq` recency stamp plus in-place min-stamp eviction (identical LRU policy, stable slots) removes ~60-65 ns per lookup when the swap fires, confirmed by an isolated C measurement over the real struct layout (85.47 -> 20.97 ns at cache=8/2 distinct SQL; 144.28 -> 88.98 at cache=32/2), with flat single-SQL controls. It is rejected anyway: ~65 ns against a ~7-10 us `selectBytes()` round trip is ~0.7% of per-call wall, and across two order-flipped passes no lane reproduced a same-sign win while the mechanically-inert control lanes (identical code both sides) swung +24%/+27%/+103%/+42%. Scan order (071), scan short-circuit (207), and promotion cost (248) are now all bounded and all immaterial — treat `stmt_cache_lookup_entry` as closed. `archive/exp-248` preserves the prototype, which also happens to be the ready-made fix if `reader->last_entry` / `writer_active_entry` ever stop being protected by serialization (they alias a slot, not an entry). Exp 236 extends exp 234's hop mechanism to the read side: blob cells >= 256 KB decode straight into TransferableTypedData (native->malloc'd-external, one copy, GC-invisible), and every main-isolate receive boundary materializes them back to Uint8List views. Blob-dominated reads stop sacrificing the reader per query (3-6x faster; 512 KB select -80% to -83% across three order-flipped passes) and tx.select — which can never sacrifice — improves ~6-7x. Exp 243 fixes an aliasing regression exp 234 introduced on the write side: wrapping ran per parameter OCCURRENCE, so one Uint8List passed to N positions became N external copies. Wrapping is now identity-keyed (one wrapper per unique buffer, referenced at every position, materialized once via a cache spanning a coalesced envelope), which is flat and fastest at every reference count where the old path blew up linearly (~27x at N=32). Exps 241/244/245/246 then settled send-vs-sacrifice, which exp 241 could not: alternating the two inside one live pool is a state-changing treatment (each sacrifice kills a worker, respawns, clears caches), so the estimands had to be separated. Exp 245 measured the INTRINSIC transfer with a prepared-result barrier (real ResultSet built before a Go signal, one fresh process per observation, ABBA, empty-envelope control): Isolate.exit carries a ~47 us FIXED premium, send's copy cost tracks the MUTABLE flat-list SLOT COUNT and not payload bytes (a 400 KB string adds ~0 us because immutable leaves are SHARED), and exit's sendability walk also scales with slots but more gently — so it is copy-per-slot vs verify-per-slot, crossing over at ~48k slots. Exp 244 measured the POOL estimand (8 requests / 4 workers behind a barrier, pool reset between bursts, decode-free dispatch queue-wait): there is no replacement-capacity hole — the no-sacrifice lane was the WORST (~+19% parked wait) because send's copy blocks the worker longer than exit plus an overlapped respawn — and an eager-respawn candidate was measured and rejected as inert at pool-4. Exp 246 shipped the consequence: the sacrifice trigger now routes on mutable slot count (32k slots = the all-integer equivalent of the old 256 KB byte threshold), because routing on BYTES misrouted results large in bytes but small in slots — a few rows holding a big TEXT/BLOB — into a sacrifice that respawned a reader on every such read to avoid a copy that never happens (131 -> 91 us, 1.00 -> 0.00 sacrifices per select, numeric/structural routing unchanged). The byte machinery was deleted with it, INCLUDING exp 236's transferableBytes subtraction, which slot routing makes moot. Net rule for this direction: BYTES for mutable payloads (a Uint8List really is copied), SLOTS for result structure. Exp 251 then consumed the exp 245/246 decomposition request with a native-asset-aware AOT harness over five fresh processes. The direct worker-path full proxy was typically 84-94% of separately measured main-observed select() latency, but the split was shape-dependent: mixed result construction 63%, small-integer stepping/native fill 60%, and the other two shapes roughly even. Estimated result construction was 34-58% of observed latency. No general decode bottleneck emerged, strengthening exp 258's broad columnar rejection while leaving its narrow Int64List consume signal workload-gated. Exp 259 then found a different kind of headroom inside the same shape: not where the result is stored, but which side of the FFI boundary answers a per-cell question. `decodeQuery` could not build a String from a TEXT cell without first classifying it as pure ASCII (`String.fromCharCodes` widen) or not (`utf8.decode`), and it was doing that in Dart — a second ExternalTypedData word view plus a bounds-checked scan per cell, degrading to a byte-by-byte loop under 16 bytes, which covers most database text. `resqlite_step_row` holds the same pointer and length one frame earlier, where the answer is a branch-free SWAR pass over bytes already in cache; it now reports RESQLITE_TEXT_ASCII (type code 6) and the decoder's TEXT arm splits into a widen path and a straight-to-utf8.decode path. Reproduced across two order-flipped AOT passes: 10k x 8 short ASCII TEXT -12.3% / -10.4%, 40 B -14.3% / -10.7%, 2k x 4 400 B -22.2% / -19.4%, the repo's canonical 6-column mixed product row -24.4% / -19.2%, with the INTEGER-only control and both non-ASCII guards neutral. Unlike exps 081/251/258, this removes work rather than relocating it, which is why it wins where the storage-shape rewrites did not. Exp 260 revises how exp 251's decomposition should be read: most of what it labelled `Dart result construction` was the result buffer growing, not cells decoding. `decodeQuery` starts at 256 rows' worth of `List` slots and doubles, and a `List` grow copies every live element with a store barrier into a freshly faulted array — 2318 us vs 865 us pre-sized on 10k x 20 INTEGER, against under 500 us for the loads, switch and stores combined. Sizing the buffer from a main-isolate memory of what the same SQL returned before is worth a reproduced 25-34% on repeated multi-thousand-row public `select()` reads. Two placement facts generalise beyond this change: a hint cannot live in the worker, because `Isolate.exit` destroys the reader on exactly the results large enough to need one (worker-local: 30-35% on sub-threshold reads, ~1% at 200k slots; request-carried: -25% at 200k slots); and it must be applied at the growth step rather than the initial allocation, which makes it structurally unreachable for small results instead of merely conservative — the initial-allocation version made a saturated `LIMIT ?` statement's 50-row leg 2.8x slower.", + "currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy. Exp 167 rechecked closed exp 141's direct ResultSet.forEach override on a real SQLite-backed consumer lane after exp 158 and rejected it: a small first-pair win reversed on the longer confirmation pair, so no runtime code was kept. Exp 174 found a transport asymmetry: the reader 'sacrifice' path (Isolate.exit + reader respawn) is a real win for the rows path because it transfers already-built Dart objects with no re-copy, but it was applied by result size to selectBytes too, where the native JSON must be Uint8List.fromList-copied before Isolate.exit can transfer it — so sacrifice saved zero copies on bytes and only added a respawn, while the non-sacrifice bytes path copied twice. selectBytes now sends a Uint8List view over the connection's persistent json_buf and never sacrifices: -44% (~1.8x) on large (>256KB) byte reads by eliminating the respawn, -4% on small, at a bounded ~+15MB RSS high-water (readers no longer respawned). Exp 175 adds a named release-suite guard for that large-bytes path: `Large payload (~650KB) / resqlite selectBytes()` measured 0.323 ms wall / 0.000 ms main and is curated as `selectBytes() large bytes`, so the history no longer relies on the sub-256KB 1K-row metric to watch exp 174. Rows select() keeps sacrifice — there the zero-copy object transfer is real. Exp 176 closed a gap exp 158 left inside the same RowSchema index: Row.containsKey still hashed the key in the private HashMap on every call, bypassing the identity fast path its sibling operator[] already used, so it ran ~+3.6 ms slower than a LinkedHashMap on the focused containsKey lane. Routing it through a shared RowSchema.containsName (= indexOf(name) >= 0) improved that lane ~13.0 -> ~10.0 ms (-23%) with a flat hot-lookup control, flipping Row to at-parity, behavior-identical and no API change. The win is interned-key-specific (decoded schema names are not identical to user literals, so production probes generally fall through to the HashMap, same cost as before). Exp 193 rejected replacing Row.values' custom iterator with a fixed ListBase slice view: JIT row_map_facade values samples were unstable, and the AOT check showed the original _RowValueIterator faster (2.663-2.687 ms) than the list view (6.828-7.101 ms), so Row.values should keep the custom iterator unless a future Dart runtime changes compiled behavior. Exp 183 closed the remaining piece of exp 174's bounded RSS trade-off by quantifying it and reclaiming it: a new Diagnostics.readerJsonBufHighWaterBytes field exposes per-reader json_buf.cap, the focused json_buf_retention.dart audit confirms pathological retention is real (8 concurrent x 8 MB selectBytes pin 32 MB across the 4-reader pool for the rest of the connection), and a C-side reader-worker shrink fired after SendPort.send returns (gated by cap > 1 MB AND last_used_len < 256 KB) reclaims back to the 16 KB initial cap on subsequent small reads — post-burst settle 32 MB -> 64 KB, recurring-large 16 MB -> 64 KB, with neutral large_bytes_transfer.dart numbers. Exp 185 promotes that diagnostic into the release diagnostics suite: `SQLite Diagnostics / JSON buffer reclaim (8 large selectBytes + 64 small settles)` now records `jsonBufKiB` and fails if the post-settle high-water exceeds 512 KiB; the first focused suite run settled at 64.0 KiB with idle readers, so the exp 183 reclaim has public regression visibility. Exp 190 takes the encoder-side win inside the same direction: `write_json_to_buf` in `native/resqlite.c` now pre-builds each column's `\"col\":` / `,\"col\":` token once at first-row time into a per-query scratch buffer, so subsequent rows emit each column with one `buf_write` instead of comma + `json_write_string` (SWAR scan + escape walk) + colon. Focused `select_bytes_wide_cols.dart` measures -4% to -11% across two order-flipped passes on 10k-row x 8 / 20-col shapes; `large_bytes_transfer.dart` (exp 174's focused guard) also moves -8.7% / -8.2% on large/small lanes. Regression guards (1 row, 100 rows) stay in the sub-microsecond noise floor. Exp 192 closes the bounded headroom exp 023 left inside `write_json_to_buf`'s `SQLITE_INTEGER` arm: replacing the single-digit `fast_i64_to_str` body with a two-digit `[00..99]` lookup table (one `% 100` / `/ 100` and one 2-byte memcpy per digit pair) cuts focused integer-heavy selectBytes by −8 to −26 % across two order-flipped passes on `select_bytes_int_heavy.dart` — biggest win on 10k × 20 ~18-digit big ints (−24 to −26 %), where the digit-loop length is greatest. Mixed-cell and small-payload regression guards stay inside ±1 %. The release suite is not the right denominator (no lane is integer-heavy enough), so `select_bytes_int_heavy.dart` is the durable gate for future selectBytes integer-encode work. Exp 194 then specializes `write_json_to_buf`'s `SQLITE_FLOAT` arm for exact integral REAL values: finite values in the exact double integer range (`abs(v) <= 2^53`) reuse `fast_i64_to_str`, while fractional values, huge values, non-finite values, and negative zero stay on `snprintf(\"%.17g\")`. Focused `select_bytes_real_int_fastpath.dart` measures roughly -79% to -82% on 10k-row integral-REAL lanes across an order-flipped pair, -72% on the 1k x 2 small lane, a flat +0.5% fractional-REAL fallback guard, and a mixed lane that moves in proportion to its integral-REAL cells. The release suite is not the denominator for this per-cell formatter path; the focused REAL harness is the durable gate. Exp 195 promotes exp 190's per-query `tokens_buf` scratch into the `resqlite_cached_stmt` entry, amortizing the per-query `buf_init(64)` + `free` pair and the first-row pre-encode walk across every re-execution of the same prepared SQL. Per-row inner loop and JSON output are byte-identical to exp 190. The exp 190 1-row regression guard sits at the millisecond-reporting harness floor and looked neutral; a new microsecond-precision focused harness `select_bytes_repeated_calls.dart` (1000 calls per sample) measures the predicted shape directly — 1-row × 20-col improves −9.2 % / −7.2 % and 10-row × 20-col improves −5.1 % / −2.7 % across two order-flipped passes, while 100/1000-row guards show sign reversal (drift-suspected per exp 177's classifier; per-query setup is < 0.2 % of wall there). Exp 190's `wide_cols.dart` 10k-row shapes also trend candidate-faster on every lane across both passes. Memory cost per cached statement is `O(col_count * 8 + name_byte_count)` capped by `STMT_CACHE_MAX = 32` per connection. Exp 216 consumes exp 201's BLOB follow-up by unrolling `json_write_base64` from one 3-byte group per loop trip to four groups per trip. `select_bytes_blob_base64.dart` keeps the 4KB BLOB lane candidate-faster across three passes (-13.6%, -12.9%, -11.9%), keeps 128B BLOBs same-direction faster (-1.1%, -9.3%, -4.7%), and leaves 3B tiny-cell lanes neutral/noisy as expected because one-triplet cells mostly bypass the unrolled loop. Output bytes, padding, JSON framing, and public API are unchanged. Exp 218 then tested whether exp 216 left scalar loop-width headroom by widening the same base64 loop to eight triplets per iteration. The 128B lane stayed candidate-faster, but the load-bearing 4KB lane mixed across four paired passes (-10.0%, +2.0%, -13.6%, +0.8%), so the wider native body is rejected and exp 216's four-triplet unroll remains the portable baseline. Exp 219 then consumes exp 202's TEXT future note for unnamed control-character escapes: `json_write_string` now emits `\\u00XX` directly from a small hex table instead of formatting through `snprintf(\"\\\\u%04x\")`. The focused TEXT harness adds a control-character lane and reproduces the target win across the order flip (35927 -> 6371 us/query, -82.3%; 35824 -> 6370 us/query, -82.2%). Safe ASCII and named-escape rows remain guardrails for unrelated fallout, and JSON output/public API are unchanged. Exp 224 then consumes exp 074's pure-numeric bulk-step follow-up without copying TEXT/BLOB payloads: a dynamic batch returns after any pointer-backed row but batches up to 64 contiguous numeric/NULL rows. `select_rows_step_row_ffi.dart` finds no numeric win across the order flip (10k x 8 +1.9%/+0.5%; 10k x 20 +4.6%/-0.5%) and reproduces a 5-7% short-TEXT regression, so multi-row `select()` stepping is closed under the current Dart leaf-FFI/runtime baseline. Exp 225 then consumes exp 218's future-work note (loop-unroll of `json_write_base64` is at ceiling; further BLOB work needs a different mechanism): the inner base64 lookup widens from a 6-bit table (four per triplet) to a 12-bit pair table (two per triplet), keeping exp 216's 4x-unrolled outer loop. Three order-flipped passes of `select_bytes_blob_base64.dart` reproduce -26 to -31% on 4 KB blobs and -17 to -31% on 128 B blobs, with 3 B tiny-cell guards flat at ~-0.5% to -0.9%. Output is bit-identical; cost is 8 KiB `.bss`. The scalar body is now two 16-bit loads + two 16-bit stores per four output bytes. Exp 229 then consumes exp 225's future-work note (SIMD `_mm_shuffle_epi8` / `vqtbl4q_u8` base64 kernels named as the next mechanism) as the direction's first *moonshot* accept: an AArch64/NEON `vld3q_u8` + `vqtbl4q_u8` + `vst4q_u8` kernel gated on `defined(__aarch64__) && defined(__ARM_NEON)` processes 48 input bytes into 64 output bytes per iteration, with the exp 225 scalar 12-bit-LUT encoder retained as fallback for non-ARM64 targets and for the < 48-byte tail on all targets. Four order-flipped paired passes of `select_bytes_blob_base64.dart` reproduce ~-45% median on the 4 KB payload-throughput lane (roughly 2x faster BLOB base64 encoding) and ~-18% on the 128 B lane; tiny-cell (3 B) and mixed guards stay inside the harness noise floor because the SIMD kernel is out-of-lined (`__attribute__((noinline))`) so the small-input hot path retains exp 225's byte-identical layout. This is the first SIMD kernel in `native/resqlite.c` — the categorical implication is that ISA-specific kernels are viable when out-of-lined; SSSE3 x86_64, SIMD JSON escape scan, and SIMD FNV hash follow-ups are now unlocked. Exp 230 then tested the TEXT-side SIMD mechanism exp 229 unlocked: an AArch64/NEON encoder fused 16-byte escape classification with safe-prefix stores behind a 256 B cutoff, falling back to the canonical scalar loop at the first escapable vector. The far-end mechanism is real — safe 1 KiB ASCII reproduced -33.6% / -34.1% and long CJK -25.2% / -25.5% — but the load-bearing 256 B lane narrowed from -17.7% to -12.6% and missed the preset 15% adoption bar in the second ordering. Early escape stayed neutral and late-escape magnitude did not reproduce. The ISA-specific runtime prototype is rejected and archived; the long-safe, early-/late-escape harness lanes and exact boundary correctness test remain. Exp 231 then probed the other value type exp 229 unlocked — an out-of-lined AArch64/NEON i64→decimal kernel for the SQLITE_INTEGER arm — and rejected it, bounding exp 229's categorical claim. The kernel is byte-identical (magnitude split into low8/mid8/high groups via two /1e8 divides, each 8-digit group formatted with vector reciprocal splits, gated ≥ 1e8) but across 11 focused A/B passes in three methodologies the ~18-digit BIGINT selectBytes lane never leaned candidate-faster (+1.2/+6.0/+9.8% baseline-first, +11.3% min-of-N, ~flat only in the one quiet pair) while identical-code controls drifted ±10-120%. The finding: exp 229's out-of-line ISA-kernel win amortises SIMD setup over an entire BLOB per call, whereas the integer formatter converts ONE scalar value per call with nothing to amortise — SIMD is viable for bulk per-cell payloads (base64), not scalar per-cell values (integers). Runtime archived at `archive/exp-231`; the new i64 differential test (first direct coverage of the integer formatter) is kept. Exp 232 then tested extending exp 194 to exact `.25`/`.5`/`.75` REAL values and proved a real mechanism: 100%-eligible lanes improved 78-87% and a synthetic 50%-eligible row improved 50-53%. It is rejected because no production or representative distribution established the eligible share, all general-fractional summaries leaned 0.65-1.95% slower, and the specialization required 114 native/build-hook lines plus permanent value-lattice semantics and test surface. Runtime and quarter-only scaffolding are removed; `archive/exp-232` preserves the prototype, and exact integral remains the only shipped REAL specialization. Exp 240 then consumed exp 231's explicit reopen — batch many integer cells into one encode call — and closed it from both sides. Its three test-only array encoders (`resqlite_test_i64_array_*`) show, in isolation, that a 2-way software-pipelined SCALAR formatter overlaps two values' divide chains for -6 to -13% on mid/big magnitudes, while a NEON vector kernel handed an array still loses +45 to +60% on realistic short (<9 digit) values (fixed vector setup unamortisable below ~9 digits) — reproducing exp 231's per-value SIMD loss with an array in hand. But wiring the winning pipelined-scalar into `write_json_to_buf`'s SQLITE_INTEGER arm inverted the result: integer-heavy selectBytes was uniformly +1 to +12% slower across four passes, worst on the big-ints lane the microbench won most. Integer conversion is not the encoder's bottleneck, the pair-lookahead machinery outweighs the overlapped chains, and a pair's two i64s are fetched serially via sqlite3_value_int64 so the cross-value-pipelining premise never holds on the hot path (same shape as exp 226's isolated packer win that missed the end-to-end gate). Batched/columnar integer FORMATTING is now closed as a formatter tweak; reopen only via a columnar transfer shape that hands the encoder a contiguous array of already-fetched i64s with framing decoupled from the per-value fetch. `i64_batch_encode.dart` + the array encoders are the durable conversion gate. Exp 248 closes the last unmeasured component of the per-connection C statement cache. `resqlite_cached_stmt` is 1,632 bytes because it embeds exp 106's `read_tables[64]` (512 B) and `dep_columns[64]` (1 KB) arrays, so the move-to-back MRU promotion in `stmt_cache_lookup_entry` copies 4,896 bytes (three full-struct copies) every time it fires, and eviction memmoves ~50 KB. Exp 071 and exp 207 both examined this function but only on a SINGLE repeated SQL — a shape where the hot entry already sits at the tail and the promotion branch is structurally unreachable — so the swap had never been measured. Replacing it with a per-entry `lru_seq` recency stamp plus in-place min-stamp eviction (identical LRU policy, stable slots) removes ~60-65 ns per lookup when the swap fires, confirmed by an isolated C measurement over the real struct layout (85.47 -> 20.97 ns at cache=8/2 distinct SQL; 144.28 -> 88.98 at cache=32/2), with flat single-SQL controls. It is rejected anyway: ~65 ns against a ~7-10 us `selectBytes()` round trip is ~0.7% of per-call wall, and across two order-flipped passes no lane reproduced a same-sign win while the mechanically-inert control lanes (identical code both sides) swung +24%/+27%/+103%/+42%. Scan order (071), scan short-circuit (207), and promotion cost (248) are now all bounded and all immaterial — treat `stmt_cache_lookup_entry` as closed. `archive/exp-248` preserves the prototype, which also happens to be the ready-made fix if `reader->last_entry` / `writer_active_entry` ever stop being protected by serialization (they alias a slot, not an entry). Exp 236 extends exp 234's hop mechanism to the read side: blob cells >= 256 KB decode straight into TransferableTypedData (native->malloc'd-external, one copy, GC-invisible), and every main-isolate receive boundary materializes them back to Uint8List views. Blob-dominated reads stop sacrificing the reader per query (3-6x faster; 512 KB select -80% to -83% across three order-flipped passes) and tx.select — which can never sacrifice — improves ~6-7x. Exp 243 fixes an aliasing regression exp 234 introduced on the write side: wrapping ran per parameter OCCURRENCE, so one Uint8List passed to N positions became N external copies. Wrapping is now identity-keyed (one wrapper per unique buffer, referenced at every position, materialized once via a cache spanning a coalesced envelope), which is flat and fastest at every reference count where the old path blew up linearly (~27x at N=32). Exps 241/244/245/246 then settled send-vs-sacrifice, which exp 241 could not: alternating the two inside one live pool is a state-changing treatment (each sacrifice kills a worker, respawns, clears caches), so the estimands had to be separated. Exp 245 measured the INTRINSIC transfer with a prepared-result barrier (real ResultSet built before a Go signal, one fresh process per observation, ABBA, empty-envelope control): Isolate.exit carries a ~47 us FIXED premium, send's copy cost tracks the MUTABLE flat-list SLOT COUNT and not payload bytes (a 400 KB string adds ~0 us because immutable leaves are SHARED), and exit's sendability walk also scales with slots but more gently — so it is copy-per-slot vs verify-per-slot, crossing over at ~48k slots. Exp 244 measured the POOL estimand (8 requests / 4 workers behind a barrier, pool reset between bursts, decode-free dispatch queue-wait): there is no replacement-capacity hole — the no-sacrifice lane was the WORST (~+19% parked wait) because send's copy blocks the worker longer than exit plus an overlapped respawn — and an eager-respawn candidate was measured and rejected as inert at pool-4. Exp 246 shipped the consequence: the sacrifice trigger now routes on mutable slot count (32k slots = the all-integer equivalent of the old 256 KB byte threshold), because routing on BYTES misrouted results large in bytes but small in slots — a few rows holding a big TEXT/BLOB — into a sacrifice that respawned a reader on every such read to avoid a copy that never happens (131 -> 91 us, 1.00 -> 0.00 sacrifices per select, numeric/structural routing unchanged). The byte machinery was deleted with it, INCLUDING exp 236's transferableBytes subtraction, which slot routing makes moot. Net rule for this direction: BYTES for mutable payloads (a Uint8List really is copied), SLOTS for result structure. Exp 251 then consumed the exp 245/246 decomposition request with a native-asset-aware AOT harness over five fresh processes. The direct worker-path full proxy was typically 84-94% of separately measured main-observed select() latency, but the split was shape-dependent: mixed result construction 63%, small-integer stepping/native fill 60%, and the other two shapes roughly even. Estimated result construction was 34-58% of observed latency. No general decode bottleneck emerged, strengthening exp 258's broad columnar rejection while leaving its narrow Int64List consume signal workload-gated. Exp 259 then found a different kind of headroom inside the same shape: not where the result is stored, but which side of the FFI boundary answers a per-cell question. `decodeQuery` could not build a String from a TEXT cell without first classifying it as pure ASCII (`String.fromCharCodes` widen) or not (`utf8.decode`), and it was doing that in Dart — a second ExternalTypedData word view plus a bounds-checked scan per cell, degrading to a byte-by-byte loop under 16 bytes, which covers most database text. `resqlite_step_row` holds the same pointer and length one frame earlier, where the answer is a branch-free SWAR pass over bytes already in cache; it now reports RESQLITE_TEXT_ASCII (type code 6) and the decoder's TEXT arm splits into a widen path and a straight-to-utf8.decode path. Reproduced across two order-flipped AOT passes: 10k x 8 short ASCII TEXT -12.3% / -10.4%, 40 B -14.3% / -10.7%, 2k x 4 400 B -22.2% / -19.4%, the repo's canonical 6-column mixed product row -24.4% / -19.2%, with the INTEGER-only control and both non-ASCII guards neutral. Unlike exps 081/251/258, this removes work rather than relocating it, which is why it wins where the storage-shape rewrites did not. Exp 260 revises how exp 251's decomposition should be read: most of what it labelled `Dart result construction` was the result buffer growing, not cells decoding. `decodeQuery` starts at 256 rows' worth of `List` slots and doubles, and a `List` grow copies every live element with a store barrier into a freshly faulted array — 2318 us vs 865 us pre-sized on 10k x 20 INTEGER, against under 500 us for the loads, switch and stores combined. Sizing the buffer from a main-isolate memory of what the same SQL returned before is worth a reproduced 25-34% on repeated multi-thousand-row public `select()` reads. Two placement facts generalise beyond this change: a hint cannot live in the worker, because `Isolate.exit` destroys the reader on exactly the results large enough to need one (worker-local: 30-35% on sub-threshold reads, ~1% at 200k slots; request-carried: -25% at 200k slots); and it must be applied at the growth step rather than the initial allocation, which makes it structurally unreachable for small results instead of merely conservative — the initial-allocation version made a saturated `LIMIT ?` statement's 50-row leg 2.8x slower. Exp 263 closes exp 261's ~60x payload-to-peak ratio (claim 261.4) rather than confirming it. Holding seeding constant and varying only the read, select() costs 396 B/row against 137.8 B of payload — 2.9x, within ~1.4x of the theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The 60x was a fixed floor: 14.0 MB bare AOT Dart process, 20.5 MB after resqlite opens and spawns its pool, 32.8 MB after seeding 20k rows, and only 3.6 MB more for a live 10k-row result. Do not reopen a result-shape rewrite on ratio grounds — the one piece of contrary evidence has been removed. Two riders: selectBytes costs MORE per row than select (676 vs 396 B/row) because JSON repeats column names and quotes values, so it is for allocation churn and not footprint; and a memory sweep crossing sacrificeSlotThreshold reads sub-linear because Isolate.exit returns the worker heap, which looks like a representation win and is not one.", "keyPriors": [ - "245", "246", "251", "258", "259", - "260" + "260", + "263" ], "archive": [ "008", @@ -340,14 +340,14 @@ "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–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. Exp 261 adds the memory half of the focused-harness toolkit (benchmark/shared/memory_probe.dart). The instrument is fixed by a constraint rather than chosen: an AOT binary exposes no VM service, so heap and allocation profiles are unavailable in the only mode exp 193 permits for decode-path results, and ProcessInfo is all there is. Gate on maxRss with one process per lane — a sampled currentRss peak reported +75% about the same change maxRss reported as -1.7%, because retention on this codebase is dominated by sacrificed reader isolates returning their pages rather than by the change under test. Its sensitivity is now known and is low: peak read-path memory held within ~1 MB from v0.3.0 to exp 259 across ~40 merged experiments while wall time fell 25-40%, so this guard catches a doubling, not a 5% drift. Exp 262 closes the RSS-acceptance-criteria candidate that had been open since 2026-05-02, and fixes why the trend charts are sparse. The thresholds were never missing — generateMemoryComparison has rendered per-benchmark bootstrap-MDE thresholds and a regression marker all along — so compareMemory now returns the counts and regressed names beside the table, and --fail-on-memory-regression exits non-zero (opt-in; whether CI passes it is a maintainer decision). The larger fix is granularity: #282 persisted per completed repeat, but the sqlite_async peer segfault fires at the Memory scenario inside repeat 1, so no repeat ever completed and exps 260 and 261 each ran fourteen scenarios and produced nothing. The suite now persists per scenario, with repeatCount counting only finished repeats and scenariosCompleted/scenarioTotal/partial marking the shortfall so a partial run self-excludes from trends. A SIGKILL at scenario 10 of 16 preserves 9 scenarios and 153 metrics where nothing survived before.", + "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. Exp 261 adds the memory half of the focused-harness toolkit (benchmark/shared/memory_probe.dart). The instrument is fixed by a constraint rather than chosen: an AOT binary exposes no VM service, so heap and allocation profiles are unavailable in the only mode exp 193 permits for decode-path results, and ProcessInfo is all there is. Gate on maxRss with one process per lane — a sampled currentRss peak reported +75% about the same change maxRss reported as -1.7%, because retention on this codebase is dominated by sacrificed reader isolates returning their pages rather than by the change under test. Its sensitivity is now known and is low: peak read-path memory held within ~1 MB from v0.3.0 to exp 259 across ~40 merged experiments while wall time fell 25-40%, so this guard catches a doubling, not a 5% drift. Exp 262 closes the RSS-acceptance-criteria candidate that had been open since 2026-05-02, and fixes why the trend charts are sparse. The thresholds were never missing — generateMemoryComparison has rendered per-benchmark bootstrap-MDE thresholds and a regression marker all along — so compareMemory now returns the counts and regressed names beside the table, and --fail-on-memory-regression exits non-zero (opt-in; whether CI passes it is a maintainer decision). The larger fix is granularity: #282 persisted per completed repeat, but the sqlite_async peer segfault fires at the Memory scenario inside repeat 1, so no repeat ever completed and exps 260 and 261 each ran fourteen scenarios and produced nothing. The suite now persists per scenario, with repeatCount counting only finished repeats and scenariosCompleted/scenarioTotal/partial marking the shortfall so a partial run self-excludes from trends. A SIGKILL at scenario 10 of 16 preserves 9 scenarios and 153 metrics where nothing survived before. Exp 263 adds the rule that makes exp 261's instrument interpretable: a memory lane must state whether it measures one live result or what a repeatedly-reading process retains. RSS never falls, so 26 reads accumulate 26 results of garbage — the same 10k-row lane reads 99 MB one way and 36.1 MB the other, a 2.7x difference with no code change between them. benchmark/experiments/select_memory_decomposition.dart runs both configurations and reports which it used.", "keyPriors": [ - "136", "169", "177", "178", "261", - "262" + "262", + "263" ], "archive": [ "055", diff --git a/experiments/signals/entries/263.json b/experiments/signals/entries/263.json new file mode 100644 index 00000000..f9a8d9f1 --- /dev/null +++ b/experiments/signals/entries/263.json @@ -0,0 +1,38 @@ +{ + "directions": ["result-transfer-shape", "measurement-system"], + "outcomeClass": "accepted_measurement", + "changedBeliefs": [ + "The ~60x payload-to-peak ratio recorded as claim 261.4 does not describe the result representation. Holding seeding constant and varying only the read shows `select()` costs 396 B/row against 137.8 B of payload — 2.9x, and within ~1.4x of the ~280 B/row theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The ratio divided a peak that is ~90% fixed and setup cost by the payload.", + "A repeated-read RSS measurement is not a measurement of a result. RSS never falls, so exp 261's 5 warmup + 21 timed reads accumulated up to 26 results of retained garbage and reported 99 MB for a 10k-row lane where one read held live reports 36.1 MB — 2.7x. Both answer real questions; only the second answers `what does a result cost`. Any future memory lane must say which it is measuring.", + "The floor dominates every small and medium read, and it is mostly not resqlite's row handling. 14.0 MB is a bare AOT Dart process before resqlite exists; open plus a spawned pool is 20.5 MB; seeding 20,000 rows adds ~12 MB more. A live 10,000-row result adds 3.6 MB on top — the smallest term in the stack.", + "`selectBytes` uses MORE memory per row than `select`: 676 B/row against 396. It removes Dart objects, which is what it has always claimed, but JSON repeats every column name on every row and quotes and escapes every value, so the encoded bytes exceed the object graph they replace. It is the right tool for allocation churn and the wrong one for footprint.", + "The sacrifice path distorts a memory sweep the way exp 258 showed it distorts a transfer benchmark. `select`'s marginal reads 396 B/row over 1k-10k and 337 over 1k-20k, because results above sacrificeSlotThreshold return via Isolate.exit and the worker's heap goes with it. A memory comparison spanning that threshold measures transport as much as representation." + ], + "claims": [ + { + "id": "263.1", + "text": "A `select()` result costs 396 B/row (1k->10k span) against 137.8 B of payload on the canonical 6-column product row — a 2.9x representation overhead, within ~1.4x of the theoretical minimum for the row shape. Measured as a single live result with one process per lane, seeding held constant at 20,000 rows so the slope is purely the read.", + "conditions": "M1 Pro · AOT CLI bundle · maxRss, one process per lane, single read held live · 2026-08", + "edges": [ + { "type": "supersedes", "target": "261.4" }, + { "type": "dependsOn", "target": "261.1" } + ] + }, + { + "id": "263.2", + "text": "resqlite's resident floor before any row is read is 20.5 MB, of which 14.0 MB is a bare AOT Dart process; seeding 20,000 rows adds ~12 MB more, and a live 10,000-row result adds 3.6 MB. The result is the smallest term, so a payload-to-peak ratio taken on a read-and-discard workload is a statement about the floor.", + "conditions": "M1 Pro · AOT · maxRss, one process per lane · 2026-08" + }, + { + "id": "263.3", + "text": "`selectBytes` costs 676 B/row against `select`'s 396 B/row on the same rows. It avoids Dart object allocation, not bytes: JSON repeats every column name per row and quotes and escapes every value. Reach for it to reduce allocation churn, not footprint.", + "conditions": "M1 Pro · AOT · maxRss, one process per lane, single read held live · 2026-08" + } + ], + "nextSignals": [ + "Claim 261.4 is closed by 263.1. Do not reopen a result-shape rewrite on payload-to-peak-ratio grounds — exps 081, 251 and 258 closed storage rewrites on their own evidence, and the one piece of contrary evidence has now been removed rather than strengthened.", + "State which memory question a lane answers. `benchmark/experiments/select_memory_decomposition.dart` runs both: `--warmup=0 --reads=1` for the cost of one live result, the default for what a repeatedly-reading process retains. They differ by 2.7x on the same lane, so an unlabelled figure is not interpretable.", + "If footprint ever becomes a target, the isolate pool is the term to attack, not the row representation — 20.5 MB is resident before a row is read. Exp 105 established pool size is throughput-critical, so it is a trade; nothing here says the trade is worth making.", + "Keep a memory sweep on one side of `sacrificeSlotThreshold`, or tag lanes by which side they fall on. Crossing it makes the marginal sub-linear because `Isolate.exit` returns the worker's heap, which reads as a representation win and is not one." + ] +} From 2143979276f7ee01f372dc88098f86d6c94d82b6 Mon Sep 17 00:00:00 2001 From: Dan Reynolds Date: Wed, 5 Aug 2026 10:15:01 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Exp=20263:=20address=20review=20=E2=80=94?= =?UTF-8?q?=20say=20what=20the=20payload=20denominator=20actually=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _payloadBytes was documented as "bytes of actual cell data" while using String.length (UTF-16 code units) and a flat 8 bytes for numerics, and the writeup then used 137.8 B as though it were an exact count. Two things were conflated: it is not SQLite's on-disk record size (varint integers, per-row header), and code units only equal UTF-8 bytes for ASCII. Rather than hedge the number, made it exact: _assertAsciiFixture fails loudly if the fixture ever stops being ASCII, which is the only way the count could silently become an undercount. The docs, the writeup, the index row and claim 263.1 now all say what the denominator is and what it deliberately is not. Co-Authored-By: Claude Opus 5 --- .../select_memory_decomposition.dart | 38 +++++++++++++++++-- ...-00Z-exp263-select-memory-decomposition.md | 6 ++- .../263-select-memory-decomposition.md | 12 +++++- experiments/index/263.json | 2 +- experiments/signals/entries/263.json | 4 +- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/benchmark/experiments/select_memory_decomposition.dart b/benchmark/experiments/select_memory_decomposition.dart index 8b7c22a2..fc670007 100644 --- a/benchmark/experiments/select_memory_decomposition.dart +++ b/benchmark/experiments/select_memory_decomposition.dart @@ -98,8 +98,19 @@ List _standardRow(int i) => [ '2026-04-0${(i % 9) + 1}T12:00:00Z', ]; -/// Bytes of actual cell data in one seeded row, so the report can state the -/// payload the marginal cost is measured against rather than estimating it. +/// Logical in-memory payload of one seeded row, in bytes: the UTF-8 length of +/// each TEXT cell plus 8 bytes for each numeric one. +/// +/// This is the denominator the per-row marginal is reported against, so be +/// precise about what it is and is not. It is **not** SQLite's on-disk record +/// size — integers are stored as varints and the row carries a header — and it +/// is not what a Dart `String` occupies, which is the point of the comparison. +/// It is the number of bytes of actual cell content the row represents. +/// +/// `String.length` counts UTF-16 code units, which equals the UTF-8 byte count +/// only for ASCII. Every cell this fixture generates is ASCII, and +/// [_assertAsciiFixture] enforces that, so the two coincide here and the figure +/// is exact for this fixture rather than an approximation of it. int _payloadBytes(int i) { final row = _standardRow(i); var bytes = 8; // id, INTEGER @@ -109,6 +120,23 @@ int _payloadBytes(int i) { return bytes; } +/// Fails loudly if the fixture ever stops being ASCII, which would silently +/// turn [_payloadBytes] from an exact count into an undercount. +void _assertAsciiFixture() { + for (var i = 0; i < 100; i++) { + for (final cell in _standardRow(i)) { + if (cell is! String) continue; + for (final unit in cell.codeUnits) { + if (unit > 0x7F) { + throw StateError( + 'fixture is no longer ASCII, so _payloadBytes undercounts: $cell', + ); + } + } + } + } +} + Future main(List args) async { var reads = _defaultReads; var warmup = _defaultWarmup; @@ -133,9 +161,13 @@ Future main(List args) async { 'the marginal is one result rather than accumulated retention', ); } + _assertAsciiFixture(); final avgPayload = List.generate(100, _payloadBytes).reduce((a, b) => a + b) / 100; - print('avg_payload_bytes_per_row=${avgPayload.toStringAsFixed(1)}'); + print( + 'avg_payload_bytes_per_row=${avgPayload.toStringAsFixed(1)} ' + '(UTF-8 cell content; not SQLite on-disk size)', + ); for (final mode in _Mode.values) { for (final rows in mode == _Mode.open ? const [0] : _rowCounts) { diff --git a/benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md b/benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md index 08d3bd71..c74728a3 100644 --- a/benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md +++ b/benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md @@ -45,7 +45,11 @@ Marginal cost per row, over two spans: | `bytes` | 676 B/row | 668 B/row | | `id` | 140 B/row | 155 B/row | -Average payload in one seeded row: **137.8 bytes**. +Average cell content in one seeded row: **137.8 bytes** — the UTF-8 length of +each TEXT cell plus 8 bytes per numeric. Exact for this fixture (every generated +cell is ASCII, which the harness asserts, so UTF-16 code units and UTF-8 bytes +coincide), and deliberately not SQLite's on-disk record size, which varint-encodes +integers and carries a per-row header. `select`'s 1k→20k span reads lower than 1k→10k because results above `sacrificeSlotThreshold` (5,461 rows at 6 columns) return via `Isolate.exit`, diff --git a/experiments/263-select-memory-decomposition.md b/experiments/263-select-memory-decomposition.md index 1fcf84fa..890ed084 100644 --- a/experiments/263-select-memory-decomposition.md +++ b/experiments/263-select-memory-decomposition.md @@ -61,12 +61,20 @@ question the ratio was posed against. Single live result, `maxRss`, one process per lane: -| mode | marginal (1k→10k) | vs 137.8 B payload | +| mode | marginal (1k→10k) | vs payload | |---|---:|---:| | `select` | 396 B/row | **2.9×** | | `bytes` | 676 B/row | 4.9× | | `id` | 140 B/row | — | +The denominator is 137.8 B/row of cell content — the UTF-8 length of each TEXT +cell plus 8 bytes per numeric. It is exact for this fixture (every generated cell +is ASCII, and the harness asserts it, so code units and UTF-8 bytes coincide), +but it is deliberately *not* SQLite's on-disk record size, which stores integers +as varints and carries a per-row header. It is the bytes of actual content the +row represents, which is the thing a representation overhead should be measured +against. + And the floor, which is where the 60× actually lived: | stage | maxRss | @@ -82,7 +90,7 @@ a floor of ~32.8 MB, of which 14 MB is the Dart VM before resqlite exists at all seeding. Dividing a peak that is ~90% fixed-and-setup cost by the payload is what produced 60×. -At 396 B/row against 137.8 B of payload, `select()` carries a **2.9×** +At 396 B/row against 137.8 B of cell content, `select()` carries a **2.9×** representation overhead — and against the ~280 B/row theoretical minimum for this row shape it is within about 1.4×. That gap is page granularity and heap slack, not a structure worth rewriting. diff --git a/experiments/index/263.json b/experiments/index/263.json index 43ab5ee5..79bddbff 100644 --- a/experiments/index/263.json +++ b/experiments/index/263.json @@ -1,7 +1,7 @@ { "file": "263-select-memory-decomposition.md", "title": "The 60x memory ratio was mostly floor", - "impact": "Exp 261 recorded a ~60x ratio between a 10k-row `select()`'s peak RSS (~95 MB) and its payload (~1.5 MB) as claim 261.4, `never decomposed`, with `List>` overhead named as a partial cause. Decomposed here by holding seeding constant at 20,000 rows and varying only the timed statement's LIMIT, so the slope is the per-row marginal and the intercept is everything that does not scale. The measurement configuration was itself the first finding: exp 261's 5 warmup + 21 timed reads accumulate up to 26 results of retained garbage because RSS never falls, reporting 99 MB where one read held live reports 36.1 MB. On the single-result measurement `select()` costs 396 B/row against 137.8 B of payload — a 2.9x representation overhead, within ~1.4x of the ~280 B/row theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The floor is where the 60x lived: 14.0 MB is the bare Dart AOT process, 20.5 MB after resqlite opens and spawns its pool, 32.8 MB after seeding, and 36.1 MB with a live 10k-row result — so the result is the smallest term and the ratio divided a ~90%-fixed peak by the payload. Premise refuted; claim 261.4 closed. Two secondary findings: `selectBytes` costs MORE memory per row than `select` (676 vs 396 B/row) because JSON re-encodes every column name per row and quotes every value — it avoids Dart objects, not bytes, so it is the wrong tool for reducing memory as opposed to allocation churn; and the sacrifice path shows up as sub-linearity (396 B/row over 1k-10k vs 337 over 1k-20k) because results above sacrificeSlotThreshold end the worker and return its heap, so any memory comparison spanning that threshold measures transport as much as representation. If footprint is ever a target the numbers say it is the isolate pool, not the row shape — but exp 105 already made pool size throughput-critical, so that is a trade rather than a free win.", + "impact": "Exp 261 recorded a ~60x ratio between a 10k-row `select()`'s peak RSS (~95 MB) and its payload (~1.5 MB) as claim 261.4, `never decomposed`, with `List>` overhead named as a partial cause. Decomposed here by holding seeding constant at 20,000 rows and varying only the timed statement's LIMIT, so the slope is the per-row marginal and the intercept is everything that does not scale. The measurement configuration was itself the first finding: exp 261's 5 warmup + 21 timed reads accumulate up to 26 results of retained garbage because RSS never falls, reporting 99 MB where one read held live reports 36.1 MB. On the single-result measurement `select()` costs 396 B/row against 137.8 B of cell content (UTF-8 TEXT length plus 8 B per numeric; exact for this all-ASCII fixture, and deliberately not SQLite's on-disk record size) — a 2.9x representation overhead, within ~1.4x of the ~280 B/row theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The floor is where the 60x lived: 14.0 MB is the bare Dart AOT process, 20.5 MB after resqlite opens and spawns its pool, 32.8 MB after seeding, and 36.1 MB with a live 10k-row result — so the result is the smallest term and the ratio divided a ~90%-fixed peak by the payload. Premise refuted; claim 261.4 closed. Two secondary findings: `selectBytes` costs MORE memory per row than `select` (676 vs 396 B/row) because JSON re-encodes every column name per row and quotes every value — it avoids Dart objects, not bytes, so it is the wrong tool for reducing memory as opposed to allocation churn; and the sacrifice path shows up as sub-linearity (396 B/row over 1k-10k vs 337 over 1k-20k) because results above sacrificeSlotThreshold end the worker and return its heap, so any memory comparison spanning that threshold measures transport as much as representation. If footprint is ever a target the numbers say it is the isolate pool, not the row shape — but exp 105 already made pool size throughput-critical, so that is a trade rather than a free win.", "status": "accepted", "link": "" } diff --git a/experiments/signals/entries/263.json b/experiments/signals/entries/263.json index f9a8d9f1..c3aa377d 100644 --- a/experiments/signals/entries/263.json +++ b/experiments/signals/entries/263.json @@ -2,7 +2,7 @@ "directions": ["result-transfer-shape", "measurement-system"], "outcomeClass": "accepted_measurement", "changedBeliefs": [ - "The ~60x payload-to-peak ratio recorded as claim 261.4 does not describe the result representation. Holding seeding constant and varying only the read shows `select()` costs 396 B/row against 137.8 B of payload — 2.9x, and within ~1.4x of the ~280 B/row theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The ratio divided a peak that is ~90% fixed and setup cost by the payload.", + "The ~60x payload-to-peak ratio recorded as claim 261.4 does not describe the result representation. Holding seeding constant and varying only the read shows `select()` costs 396 B/row against 137.8 B of cell content (UTF-8 TEXT length plus 8 B per numeric, exact for this all-ASCII fixture and not SQLite's on-disk record size) — 2.9x, and within ~1.4x of the ~280 B/row theoretical minimum for four OneByteStrings, a boxed double, six pointer slots and a Row facade. The ratio divided a peak that is ~90% fixed and setup cost by the payload.", "A repeated-read RSS measurement is not a measurement of a result. RSS never falls, so exp 261's 5 warmup + 21 timed reads accumulated up to 26 results of retained garbage and reported 99 MB for a 10k-row lane where one read held live reports 36.1 MB — 2.7x. Both answer real questions; only the second answers `what does a result cost`. Any future memory lane must say which it is measuring.", "The floor dominates every small and medium read, and it is mostly not resqlite's row handling. 14.0 MB is a bare AOT Dart process before resqlite exists; open plus a spawned pool is 20.5 MB; seeding 20,000 rows adds ~12 MB more. A live 10,000-row result adds 3.6 MB on top — the smallest term in the stack.", "`selectBytes` uses MORE memory per row than `select`: 676 B/row against 396. It removes Dart objects, which is what it has always claimed, but JSON repeats every column name on every row and quotes and escapes every value, so the encoded bytes exceed the object graph they replace. It is the right tool for allocation churn and the wrong one for footprint.", @@ -11,7 +11,7 @@ "claims": [ { "id": "263.1", - "text": "A `select()` result costs 396 B/row (1k->10k span) against 137.8 B of payload on the canonical 6-column product row — a 2.9x representation overhead, within ~1.4x of the theoretical minimum for the row shape. Measured as a single live result with one process per lane, seeding held constant at 20,000 rows so the slope is purely the read.", + "text": "A `select()` result costs 396 B/row (1k->10k span) against 137.8 B of cell content on the canonical 6-column product row (UTF-8 TEXT length plus 8 B per numeric; exact for this all-ASCII fixture, not SQLite's on-disk record size) — a 2.9x representation overhead, within ~1.4x of the theoretical minimum for the row shape. Measured as a single live result with one process per lane, seeding held constant at 20,000 rows so the slope is purely the read.", "conditions": "M1 Pro · AOT CLI bundle · maxRss, one process per lane, single read held live · 2026-08", "edges": [ { "type": "supersedes", "target": "261.4" },