Skip to content

Exp 263: the 60x memory ratio was mostly floor - #287

Merged
danReynolds merged 2 commits into
mainfrom
exp-263-select-memory-decomposition
Aug 5, 2026
Merged

Exp 263: the 60x memory ratio was mostly floor#287
danReynolds merged 2 commits into
mainfrom
exp-263-select-memory-decomposition

Conversation

@danReynolds

Copy link
Copy Markdown
Owner

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 partial
cause 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, the
intercept 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 read
from every row so the lazy Row facade actually materializes), bytes
(selectBytes, no Dart objects), id (the INTEGER key alone: structure without
payload) — plus an open lane that opens the database, spawns the pool, and
reads 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

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 10k-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.
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× representation
overhead — within ~1.4× of the theoretical minimum for four OneByteStrings, a
boxed double, six pointer slots and a Row facade. 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 — 676 B/row against
    396. 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.
  • The sacrifice path shows up as sub-linearity, not 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 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-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
    recorded rather than one silently chosen
  • dart test on the database, result-sizing, release-artifact and
    parse-results suites — pass. Diff touches no lib/, native/ or hook/.
  • finalize_experiment.dart green; check_knowledge_links.dart clean
    (74 claims; warnings are 263 citing the claim it supersedes)

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 14:06
@danReynolds danReynolds added rejected Experiment failed: below the decision bar, regressed, or abandoned type: measurement Measurement/profiling run: counters, benchmarks, or focused probes approved Experiment succeeded: a kept win or a passing guard and removed rejected Experiment failed: below the decision bar, regressed, or abandoned labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Belief impact

Learned

  • 263.1 · The 60x memory ratio was mostly floor
    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 …
  • 263.2 · The 60x memory ratio was mostly floor
    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…
  • 263.3 · The 60x memory ratio was mostly floor
    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 n…

Retired

  • 261.4 · A memory guard for focused experiments, and four months of trend
    select() holds far more than its payload: mixed6-10k reads a table containing roughly 1.5 MB of data and peaks at 95 MB, and 89.5 MB of the pre-e…
    now superseded by 263.1

What this changed

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.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.json direction 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.

Comment thread benchmark/experiments/select_memory_decomposition.dart Outdated
Comment thread experiments/263-select-memory-decomposition.md Outdated
_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>
@danReynolds
danReynolds merged commit 7f86eaf into main Aug 5, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Experiment succeeded: a kept win or a passing guard type: measurement Measurement/profiling run: counters, benchmarks, or focused probes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants