Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions benchmark/experiments/select_memory_decomposition.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,24 @@ Future<void> main(List<String> args) async {
);
}
_assertAsciiFixture();
final avgPayload =
List.generate(100, _payloadBytes).reduce((a, b) => a + b) / 100;
// Sampled at a fixed stride *across* the seeded range rather than over a
// prefix of it. Both `Item $i` and the description's `$i` grow with the row
// index, so a prefix undercounts what a 20,000-row lane reads (137.8 B
// against 142.9); a stride captures the digit-width distribution instead.
//
// Strided rather than exhaustive because this runs before any lane does, and
// `_payloadBytes` builds the row to measure it. Materializing all 20,000 rows
// would allocate megabytes of transient strings ahead of an RSS measurement
// that cannot see them released. 200 samples land within 0.01 B of the exact
// mean and allocate ~140 KB.
const payloadStride = 100;
var payloadTotal = 0;
var payloadSamples = 0;
for (var i = 0; i < _seedRows; i += payloadStride) {
payloadTotal += _payloadBytes(i);
payloadSamples++;
}
final avgPayload = payloadTotal / payloadSamples;
print(
'avg_payload_bytes_per_row=${avgPayload.toStringAsFixed(1)} '
'(UTF-8 cell content; not SQLite on-disk size)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,41 @@ Marginal cost per row, over two spans:
| `bytes` | 676 B/row | 668 B/row |
| `id` | 140 B/row | 155 B/row |

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.
Average cell content in one seeded row: **142.9 bytes** — the UTF-8 length of
each TEXT cell plus 8 bytes per numeric, averaged over the whole 20,000-row
seeded range, sampled at a fixed stride. (An earlier revision reported 137.8 B,
averaged over the first 100 rows; `Item $i` and the description's `$i` both grow with the row index, so a
prefix undercounts what the lanes actually read.) 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. Strided rather than
exhaustive on purpose: the average is computed before any lane runs, and building
all 20,000 rows to measure them would allocate megabytes of transient strings
ahead of an RSS measurement that cannot see them released. 200 samples land
within 0.01 B of the exact mean. The `open` lane reads 20.5-20.6 MB either way,
so this is a hazard removed rather than a number changed — and the sweep above
predates the denominator fix entirely, since that figure is an arithmetic
property of the fixture rather than an input to any measurement.

## Attributing the marginal

A one-column and a six-column read of the same table make the same btree leaves
resident, so `id`'s marginal is a per-row cost independent of the result's
representation. Differencing 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 (142.9 − 8 for the id) | 134.9 |
| representation overhead | **1.9x** |

First-principles accounting for those five columns: four `OneByteString`s at a
24 B header plus rounded data (~240 B), one boxed `_Double` (16 B), five slots in
the flat values list (40 B) = ~296 B. The measured 256 B/row is **below** it. The
`Row` facade contributes nothing to a retained result: the iterator creates those
objects transiently and only the `ResultSet` is held.

`select`'s 1k→20k span reads lower than 1k→10k because results above
`sacrificeSlotThreshold` (5,461 rows at 6 columns) return via `Isolate.exit`,
Expand Down
72 changes: 48 additions & 24 deletions experiments/263-select-memory-decomposition.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,8 @@ 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 near the theoretical minimum for the row shape, 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.

Expand All @@ -61,19 +60,36 @@ question the ratio was posed against.

Single live result, `maxRss`, one process per lane:

| mode | marginal (1k→10k) | vs payload |
|---|---:|---:|
| `select` | 396 B/row | **2.9×** |
| `bytes` | 676 B/row | 4.9× |
| `id` | 140 B/row | — |
| mode | marginal (1k→10k) |
|---|---:|
| `select` (6 columns) | 396 B/row |
| `bytes` | 676 B/row |
| `id` (1 column) | 140 B/row |

**Difference the projections rather than dividing the raw marginal.** A
one-column and a six-column read of the same table both make the same btree
leaves resident — the leaf holds the whole row either way — so `id`'s 140 B/row
is a per-row cost that has nothing to do with how the result is represented.
Subtracting it isolates what the five extra columns actually cost in Dart:

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.
| | B/row |
|---|---:|
| `select` marginal | 396 |
| − `id` marginal (shared, storage-side) | 140 |
| **= Dart representation, five non-key columns** | **256** |
| their cell content | 134.9 |
| **representation overhead** | **1.9×** |

And against a first-principles accounting of what those five columns *must*
occupy — four `OneByteString`s at a 24 B header plus rounded data (~240 B), one
boxed `_Double` (16 B), five slots in the flat values list (40 B), so ~296 B —
the measured 256 B/row comes in **below** it. The `Row` facade costs nothing
here: it is three fields and a header, but the iterator creates those objects
transiently and only the `ResultSet` is retained.

There is no headroom in the row representation. Two independent routes say so:
the differenced cost is 0.86× a straightforward accounting, and the raw marginal
is ~0.9× the same accounting once the storage-side term is included.

And the floor, which is where the 60× actually lived:

Expand All @@ -90,10 +106,19 @@ 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 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.
The denominator above is cell content: the UTF-8 length of each TEXT cell plus
8 bytes per numeric, averaged over the whole seeded range. It is exact for this
fixture — every generated cell is ASCII and the harness asserts it, so UTF-16
code units and UTF-8 bytes coincide — and it is deliberately *not* SQLite's
on-disk record size, which varint-encodes integers and carries a per-row header.

What the ~140 B/row shared term *is* has not been established, only bounded: it
scales with rows read, it is indifferent to how many columns are projected, and
resqlite opens connections with `mmap_size = 256 MB` and `cache_size = -8192`
(8 MB), so mmap'd file pages becoming resident, WAL pages, and a cold reader page
cache are all live candidates. Whichever it is, it is the storage engine making
scanned data resident, which is what a database does, and it is governed by the
tuning exps 016/021 chose rather than by anything the row path controls.

Two secondary findings worth keeping:

Expand All @@ -111,11 +136,10 @@ Two secondary findings worth keeping:

## 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<Map<String, Object?>>` 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.
**Accepted as measurement; premise refuted.** Claim 261.4's ~60× is closed. The
Dart representation of a row costs 1.9× its cell content and comes in below a
first-principles accounting of what it must occupy; the ratio 261.4 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
Expand Down
2 changes: 1 addition & 1 deletion experiments/index/263.json
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object?>>` 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.",
"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<Map<String, Object?>>` 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, but that raw marginal must not 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 1-column `id` lane's 140 B/row is a per-row storage-side cost independent of how the result is represented. Differencing isolates the Dart side at 256 B/row for the five non-key columns against 134.9 B of their cell content — a 1.9x representation overhead — and against a first-principles accounting of what those columns must occupy (four OneByteStrings at a 24 B header plus rounded data ~240 B, one boxed double 16 B, five values-list slots 40 B, so ~296 B) the measurement comes in BELOW it. The Row facade contributes nothing to a retained result: the iterator creates those objects transiently and only the ResultSet is held. 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": ""
}
Loading
Loading