Exp 263: the 60x memory ratio was mostly floor - #287
Conversation
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 <noreply@anthropic.com>
Belief impactLearned
Retired
What this changed
|
There was a problem hiding this comment.
Pull request overview
Adds Experiment 263 to decompose the previously-reported ~60× “payload-to-peak RSS” ratio for select() into fixed-floor vs per-row marginal cost, and records the measurement outcome in the repo’s experiments + signals system.
Changes:
- Adds a focused memory decomposition harness (
select/bytes/id/open) and a corresponding benchmark result markdown. - Adds the experiment writeup + index fragment + signals entry (claims + changed beliefs) for exp 263.
- Updates
experiments/signals/base.jsondirection narrative to incorporate exp 263 and close claim 261.4.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| experiments/signals/entries/263.json | Adds the exp 263 signal entry, including claims that supersede claim 261.4. |
| experiments/signals/base.json | Updates direction synthesis text and key priors to incorporate exp 263’s findings. |
| experiments/index/263.json | Adds the exp 263 index fragment for generated experiment listings. |
| experiments/263-select-memory-decomposition.md | Adds the main experiment writeup describing method, results, and decision. |
| benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md | Adds the recorded benchmark output tables for the decomposition sweep. |
| benchmark/experiments/select_memory_decomposition.dart | Adds the focused harness used to produce the decomposition measurements. |
Suppressed comments (1)
experiments/263-select-memory-decomposition.md:85
- Same as above: this sentence reads as though 137.8 B is an exact payload size. If it’s a derived estimate from the harness, it’s safer to say so explicitly to avoid overclaiming precision.
At 396 B/row against 137.8 B of payload, `select()` carries a **2.9×**
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
_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 <noreply@anthropic.com>
Follows #285, which flagged this ratio as the next thing to look at.
Hypothesis
Exp 261 measured the canonical 6-column product row at 10k rows peaking at ~95 MB
while the table holds roughly 1.5 MB, and recorded the ~60× ratio as claim 261.4 —
"never decomposed", with
List<Map<String, Object?>>overhead named as a partialcause and exps 008/032's lazy and facade shapes flagged as judged on wall time
rather than on this.
A 60× representation overhead would be a serious finding. It would put the result
shape back on the table despite exps 081, 251 and 258 all having closed storage
rewrites on their own evidence. So it was worth decomposing before anyone acted
on it.
RSS can't resolve heap composition — #285 established that, and an AOT binary has
no VM service to ask. But it can separate fixed from marginal, 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 is the per-row marginal, theintercept is everything that doesn't scale.
Decision rule, declared before measuring: a marginal near the theoretical minimum
for the row shape (~280 B/row) refutes the premise and closes 261.4; several
times that makes it an implementation candidate.
Approach
Three modes over the same rows —
select(full object graph, with a cell readfrom every row so the lazy
Rowfacade actually materializes),bytes(
selectBytes, no Dart objects),id(the INTEGER key alone: structure withoutpayload) — plus an
openlane that opens the database, spawns the pool, andreads nothing.
The measurement configuration turned out to be the first finding. Run the way
#285 ran it — 5 warmup plus 21 timed reads — the 10k-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 repeatedly-reading process, not a result. With one
read held live across the sample: 36.1 MB. Both are honest; only the second
answers what the ratio was posed against.
Results
selectbytesidAnd the floor, which is where the 60× actually lived:
The result is the smallest term. A 10k-row
select()holds ~3.6 MB above afloor of ~32.8 MB, of which 14 MB is the Dart VM before resqlite exists at all.
Dividing a peak that's ~90% fixed-and-setup cost by the payload is what produced
60×. At 396 B/row against 137.8 B,
select()carries a 2.9× representationoverhead — within ~1.4× of the theoretical minimum for four
OneByteStrings, aboxed double, six pointer slots and a
Rowfacade. That gap is page granularityand heap slack, not a structure worth rewriting.
Two secondary findings worth keeping:
selectBytescosts more memory per row thanselect— 676 B/row against396. It avoids 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 bytes exceed the object graph they replace. It's the right tool for
allocation churn and the wrong one for footprint.
select'smarginal drops from 396 B/row (1k→10k) to 337 (1k→20k) because results above
sacrificeSlotThresholdreturn viaIsolate.exit, ending the worker andreturning its heap. A memory sweep spanning that threshold measures transport
as much as representation — the exp 258 trap in its memory form.
Outcome
Accepted as measurement; premise refuted. Claim 261.4 is closed. Nothing here
reopens a result-shape rewrite — it removes the one piece of evidence that might
have.
If footprint ever becomes a target the numbers say where to look, and it isn't
the row representation: 20.5 MB is resident before a single row is read, and the
isolate pool is most of resqlite's share. Exp 105 already made pool size
throughput-critical, so that's a trade rather than a free win, and nothing here
says it's worth making.
Test plan
dart analyze --fatal-infoson the harness — cleanopen20.5/20.5/20.5,select-100032.8/32.9/32.8,select-1000036.8/36.4/36.8recorded rather than one silently chosen
dart teston the database, result-sizing, release-artifact andparse-results suites — pass. Diff touches no
lib/,native/orhook/.finalize_experiment.dartgreen;check_knowledge_links.dartclean(74 claims; warnings are 263 citing the claim it supersedes)
🤖 Generated with Claude Code