Experiment 236: Reader blob-cell TransferableTypedData transfer

Date: 2026-07-21

Status: Accepted

Direction:result-transfer-shape

Benchmark Run: none — focused

benchmark/experiments/blob_read_transfer_ab.dart;

three order-flipped per-process passes in

benchmark/results/2026-07-21T14-05-00Z-exp236-blob-cell-transfer.md.

No release-suite run because no release lane isolates large-blob row reads;

the focused harness is the durable gate.

Problem

Exp 234 proved the isolate-hop mechanism for write params: a payload's one

mandatory copy is cheap, but landing it on the shared GC heap is not, and

TransferableTypedData (TTD) moves malloc'd bytes by ownership transfer. A

survey of the remaining hops found the read side pays worse costs for blob

cells:

(Isolate.exit): zero-copy, but the worker dies — ~2–5 ms respawn plus

statement-cache and schema-cache loss, on every large read;

rows pay the full graph copy back to main with no mitigation at all.

(The survey also established that selectBytes is already optimal: it sends

a native-backed view, and the VM copies external typed data into malloc'd

memory — the good destination — which retroactively explains part of

exp 174's win.)

Hypothesis

Decoding blob cells ≥ 256 KB directly into TransferableTypedData — one

native → malloc'd-external copy, skipping the heap copy entirely — lets

blob-dominated results cross the hop by ownership move: no sacrifice, no

respawn, no cache loss, and nothing for the GC to trace. The sacrifice

decision then weighs only the residual (non-transferable) bytes, so

text-heavy results keep sacrificing unchanged.

Approach

blobCellTransferThreshold (256 KB, matching exp 234's param threshold and

the sacrifice threshold) decode into TTD in both decode loops;

RawQueryResult carries transferableBytes. The threshold is a

compile-time define (-DRESQLITE_BLOB_CELL_TRANSFER_THRESHOLD) because the

decode loop runs on worker isolates, where a main-isolate runtime toggle

cannot reach — a const define reaches every isolate identically.

on estimatedBytes - transferableBytes > sacrificeByteThreshold.

materializeTransferableBlobCells rewrites TTD cells to Uint8List views

in place at every main-isolate receive boundary —

reader_pool.dartselect /

selectWithDeps / selectIfChanged (streams ride these) and

writer.dartselectLocked (tx.select) —

so the public surface only ever exposes Uint8List. The sacrifice path's

Isolate.exit message may also carry TTDs; the same boundary materializes

them.

> Superseded on landing (exp 246). The transferableBytes subtraction above

> was this experiment's way of stopping TTD-wrapped cells from forcing a

> sacrifice. Exp 246 — landing in the same change

> — moved the sacrifice decision onto mutable slot count, under which blob

> size cannot influence the decision at all, so the subtraction (and the byte

> accounting behind it: estimatedBytes, transferableBytes, the decoder's

> per-cell byteEstimate) became dead and was removed. This experiment's

> substance is unaffected — large blob cells still decode into

> TransferableTypedData and still materialize at the receive boundary; only the

> sacrifice arithmetic it added is gone. The results below were measured against

> the byte-threshold routing described here.

> Refined on landing: the receive boundary is flag-gated. As first written,

> that boundary scanned the whole flat values list on the main isolate for

> every result, wrapped cells or not — measured at ~0.86 ns/slot, so ~171 µs at

> 200k slots and ~1 ms at 1M, all of it wasted on the blob-free majority.

> RawQueryResult/ResultSet now carry a hasWrappedCells flag set during

> decode, and the scan early-returns unless it is set. The materialization

> itself needed no change: materialize() is flat in payload size (0.63 µs at

> 256 KB, 0.97 µs at 16 MB) and main's total unwrap cost scales with the number

> of wrapped cells (~0.5 µs each), which the 256 KB size gate already bounds —

> 200 cells is a 50 MB result and costs 93 µs.

Results

Median µs/select across three order-flipped per-process passes:

ShapeΔ rangeRead
1×512 KB blob select−80% to −83%sacrifice avoided — ~5× faster
1×1 MB blob select−70% to −81%reproduced
4×300 KB blobs select−67% to −82%multi-cell shape reproduced
tx.select 1×512 KB−84% to −87%writer hop, ~6–7× faster
200 KB control (direct both lanes)+2% clean-ordernoise
400 KB text control (sacrifices both)0% to −11%unchanged path
20×512 B controlsub-resolution24–47 µs lane

Mechanism attribution (temporary reader-spawn counter, removed before merge,

270 blob reads/lane): baseline 270 spawns, candidate 0 — the baseline

sacrifices and respawns a reader isolate on every large-blob read, 1:1; the

candidate never does. Interspersing a trivial query between blob reads still

produced 270 baseline spawns, so the respawns do not hide: they outrun the

~4-worker pool's respawn capacity (~2-5 ms each). Each avoided sacrifice is

worth ~410 us even with pool overlap. The magnitude scales with how

blob-read-heavy the workload is relative to pool size.

The 200 KB control initially read +14–20% candidate-slower until pass 3

reordered controls before any wrapped shape: the baseline's sacrifices hand

its later lanes freshly-respawned readers, a cross-lane contamination worth

knowing about when a lane changes worker lifecycle.

Blob-dominated reads ≥ 256 KB are 3–6× faster because the per-query

isolate death is gone; tx.select improves ~6–7× because it previously

paid the full unmitigated graph copy. Sub-threshold, text-heavy, and small

reads are structurally unchanged.

Decision

Accepted (in review). A contained ~3–7× win on large-blob row reads

across select(), streams, and tx.select, delivered under the existing

public surface (rows still expose plain Uint8List). Correctness covered at

every receive boundary: threshold-edge round-trips, repeated reads on a

long-lived worker, tx.select, mixed text+blob results that still sacrifice

(TTD riding Isolate.exit), and stream initial/change emissions.

Would extend to executeBatch blob params (exp 234's recorded follow-up) and

revisit the shared 256 KB floor if a production blob-size profile differs;

the multi-medium-blob shape (many cells each < 256 KB summing large) still

sacrifices and is recorded as the next candidate in this lane.

Test plan