Skip to content

Exp 263: attribute the per-row memory cost to the right layer - #288

Merged
danReynolds merged 2 commits into
mainfrom
exp-263-representation-attribution
Aug 5, 2026
Merged

Exp 263: attribute the per-row memory cost to the right layer#288
danReynolds merged 2 commits into
mainfrom
exp-263-representation-attribution

Conversation

@danReynolds

Copy link
Copy Markdown
Owner

Correction to #287. No new measurement — same data, three errors in how it was
attributed and divided, all of which happened to flatter the row representation's
overhead in the same direction.

1. The 2.9× charged storage-engine cost to the representation

The headline divided the raw 396 B/row select marginal by the payload. But a
one-column and a six-column read of the same table make the same btree leaves
resident — the leaf holds the whole row either way — so the id lane's 140 B/row
is a per-row cost that has nothing to do with how the result is represented.

Differencing the projections isolates the Dart side:

B/row
select marginal (6 columns) 396
id marginal (shared, storage-side) 140
= Dart representation, five non-key columns 256
their cell content 134.9
representation overhead 1.9× (was reported as 2.9×)

2. The "theoretical minimum" was too low, in the direction that matters

The writeup cited ~280 B/row as the floor. A proper accounting of those five
columns is ~296 B — four OneByteStrings at a 24 B header (tags + length +
hash) plus rounded data (~240 B), one boxed _Double (16 B), five slots in the
flat values list (40 B). The measured 256 B/row comes in below it.

The Row facade should not have been in that accounting at all: it is three
fields and a header, but the iterator creates those objects transiently and only
the ResultSet is retained, so a retained result carries no per-row facade cost.

This is the one that actually mattered. A future runner reading "280 B/row
minimum, 396 measured" would infer ~40% of headroom and go looking for it. There
is none — the measurement is already under a first-principles floor.

3. The payload denominator sampled the wrong rows

_payloadBytes averaged the first 100 rows while the lanes read up to 20,000, and
both Item $i and the description's $i grow with the row index: 137.8 B against
a true 142.9 B. The harness now averages over the whole seeded range, and the
writeup notes the change so the older figure isn't confusing.

What this changes

The conclusion is unchanged and slightly stronger: the row representation has no
headroom
, now confirmed two independent ways — the differenced cost is 0.86× a
first-principles accounting, and the raw marginal is ~0.9× the same accounting
once the storage-side term is included.

Adds claim 263.4 for that shared ~140 B/row term. It is bounded — scales with
rows read, indifferent to column count — but not discriminated: resqlite opens
connections with mmap_size = 256 MB and cache_size = -8192, so mmap'd file
pages becoming resident, WAL pages, and a cold reader page cache are all live
candidates. I've recorded it as a hypothesis rather than a finding, with the cheap
discriminating test (hold row count, widen the row) written down for whoever wants
it.

And a nextSignal: difference two projections before quoting a per-row memory
cost, or you charge the storage engine's work to the row representation — which is
exactly how the first revision got 2.9×.

Test plan

  • dart analyze --fatal-infos on the harness — clean
  • Harness rebuilt and run; now reports avg_payload_bytes_per_row=142.9
  • check_experiment_signals.dart valid; finalize_experiment.dart green;
    check_knowledge_links.dart clean (75 claims)
  • Docs and one benchmark harness only. No lib/, native/ or hook/ changes.

🤖 Generated with Claude Code

Three errors, all flattering the same conclusion in the wrong direction.

The headline 2.9x divided the raw 396 B/row marginal by the payload. But a
one-column and a six-column read of the same table make the same btree
leaves resident, so the id lane's 140 B/row is a per-row storage-side cost
independent of how the result is represented. Differencing the projections
isolates the Dart side at 256 B/row for the five non-key columns — 1.9x
their cell content, not 2.9x.

The "~280 B/row theoretical minimum" undercounted: a proper accounting of
those five columns is ~296 B (four OneByteStrings at a 24 B header plus
rounded data, one boxed double, five values-list slots), against which the
measured 256 B/row comes in BELOW. A future runner reading 280 would have
inferred ~40% of headroom where there is none. The Row facade should not
have been in that accounting at all — the iterator creates those objects
transiently and only the ResultSet is retained.

The payload denominator averaged the first 100 rows while the lanes read up
to 20,000, and both `Item $i` and the description's `$i` grow with the row
index: 137.8 B against a true 142.9 B.

Adds claim 263.4 for the shared storage-side term — bounded (scales with
rows, indifferent to column count) but not discriminated between mmap
residency, WAL pages and a cold reader cache — and a nextSignal that a
per-row memory figure must difference two projections or it charges the
storage engine's work to the row representation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 14:42
@danReynolds danReynolds added the type: measurement Measurement/profiling run: counters, benchmarks, or focused probes label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Belief impact

Learned

  • 263.4 · The 60x memory ratio was mostly floor
    A per-row cost of ~140 B/row is shared by every projection of the same table and is not part of the result representation: the 1-column id lane pay…

What this changed

The ~60x payload-to-peak ratio recorded as claim 261.4 does not describe the result representation. Two corrections were needed to say what does. First, the raw 396 B/row marginal cannot be divided by the payload: a one-column and a six-column read of the same table make the same btree leaves resident, so the id lane's 140 B/row is a per-row storage-side cost independent of representation, and differencing the projections isolates the Dart side at 256 B/row for the five non-key columns. Second, that is 1.9x their 134.9 B of cell content, and BELOW a first-principles accounting of what they must occupy (~296 B: four OneByteStrings at a 24 B header plus rounded data, one boxed double, five values-list slots). The row representation has no headroom.

The Row facade costs nothing in a retained result. It is three fields and a header, but the iterator creates those objects transiently and only the ResultSet is held, so a per-row facade charge does not belong in an accounting of what a result occupies.

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

This PR corrects experiment 263’s narrative and claims by properly attributing the per-row RSS marginal between storage-engine residency and Dart-side row representation, and updates the harness/result writeup to use a correctly averaged payload denominator.

Changes:

  • Re-attributes the 396 B/row select marginal by differencing against the 140 B/row id lane, isolating a 256 B/row Dart-side representation cost and adding a new shared-term claim (263.4).
  • Updates the experiment writeup/index impact text to reflect the corrected attribution and a revised first-principles floor (~296 B for the five non-key columns).
  • Updates the harness and benchmark result markdown to average payload bytes over the full 20,000-row seeded range (142.9 B/row), documenting the prior 100-row-prefix undercount.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
experiments/signals/entries/263.json Updates changed-beliefs narrative; revises claim 263.1; adds claim 263.4 and a new “difference projections” next-signal.
experiments/index/263.json Updates the experiment index “impact” summary to match the corrected attribution and denominator.
experiments/263-select-memory-decomposition.md Updates the experiment writeup to difference projections and document the shared storage-side per-row term and revised denominator.
benchmark/results/2026-08-05T15-30-00Z-exp263-select-memory-decomposition.md Updates reported avg payload bytes and adds an “Attributing the marginal” section consistent with the new claims.
benchmark/experiments/select_memory_decomposition.dart Changes avg payload computation to average over the full seeded range (but currently does so via per-row string allocation).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread benchmark/experiments/select_memory_decomposition.dart Outdated
Averaging over the full seeded range built every row to measure it —
~100,000 transient strings allocated before any lane runs, ahead of an RSS
measurement that cannot see them released. The floor reads 20.5-20.6 MB
either way, so nothing moved, but a needless allocation burst in front of a
memory probe is a hazard whether or not it fired.

Now sampled at a stride of 100 across the range, which captures the
digit-width distribution that made a prefix wrong in the first place and
lands within 0.01 B of the exact mean while allocating ~140 KB.

The sweep results predate the denominator fix entirely: the payload average
is an arithmetic property of the fixture, not an input to any measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@danReynolds
danReynolds merged commit 4f7ac9c 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

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