Experiment 246: Slot-count sacrifice trigger — fix the string misroute

Date: 2026-07-23

Status: Accepted

Direction:result-transfer-shape

Benchmark Run: none — focused end-to-end A/B; raw table in

benchmark/results/2026-07-23-exp246-slot-sacrifice.md.

The A/B was driven by a temporary byte-vs-slot compile toggle and a sacrifice

counter; both were removed with the byte machinery once the result settled, so

the shipped tree carries only the slot-count path (see Outcome).

Problem

The reader sacrifices a row result (Isolate.exit, zero-copy, at the cost of a

reader respawn) when its estimated bytes exceed 256 KB.

Exp 245 measured the intrinsic transfer and

showed the byte trigger asks the wrong question: SendPort.send's cost tracks

the mutable flat-list slot count (rows × columns), not payload bytes —

strings and other immutable leaves are shared on send for free. So a result

that is large in bytes but small in slots — a handful of rows with a big

TEXT/BLOB column (base64 blobs, large JSON/text documents) — is cheap to send

yet gets sacrificed, paying a reader respawn for a copy that never happens.

Hypothesis

Route the sacrifice decision on mutable slot count (raw.values.length)

instead of estimated bytes. A slot threshold of 32 * 1024 is the exact

all-integer equivalent of the old 256 KB byte threshold (8 bytes/cell), so

numeric/structural results keep their prior routing, while string-heavy results

stop being misrouted. Slot count is also free — it is the flat list's length,

no per-cell accounting.

Approach

A single _shouldSacrifice(raw) helper backs all three row-result decision

sites, routing on raw.values.length > sacrificeSlotThreshold

(32 * 1024, a compile-time define so a benchmark can force either lane).

selectBytes still never sacrifices (unchanged).

During the A/B the byte path was kept behind a RESQLITE_SLOT_TRIGGER toggle and

a ReaderPool.debugSacrificeCount counter proved the routing flipped. Both were

removed once the result settled, along with the byte machinery they existed

to serve: sacrificeByteThreshold, RawQueryResult.estimatedBytes, and the

decoder's per-cell byteEstimate accumulation — which had no remaining readers

once routing moved to slot count. The net change is a simplification: the

decision is one comparison against a value the flat list already knows

(values.length), and the decode loop no longer maintains a byte tally.

The threshold is end-to-end ~32k, deliberately below exp 245's ~48k

intrinsic crossover: exp 244 showed the send-side copy sits on the worker's

critical path, so at the production pool the crossover where sacrifice becomes

favorable arrives earlier. 32k reconciles both — it matches the byte trigger for

numeric results (validated below) and fixes strings.

Results

Median end-to-end select latency and sacrifices per select (M1 Pro):

shape (slots / bytes)bytes triggerslot trigger @32krouting
bigstr — 4 slots / ~400 KB131.0 µs · 1.00 sac91.3 µs · 0.00 sacfixed → send
band — 40k slots / ~320 KB1462 µs · 1.001467 µs · 1.00parity (sacrifice)
large — 200k slots / ~1.6 MB6328 µs · 1.006089 µs · 1.00parity (sacrifice)
medium — 20k slots / ~160 KB800 µs · 0.00780 µs · 0.00parity (send)
small — 400 slots17.6 µs · 0.0016.3 µs · 0.00parity (send)

The bigstr misroute is fixed: a 4-row × 100 KB-string result stopped

sacrificing (1.00 → 0.00 per select) and got 31% faster (131 → 91 µs) — every

such read previously killed and respawned a reader for nothing. Every other shape

is byte-for-byte the same routing at parity latency.

Threshold choice is measured, not guessed. A first pass at the ~48k intrinsic

crossover regressed the band (40k slots sent → +12%, 1462 → 1640 µs), because

end-to-end the send copy is on the worker's critical path (exp 244) so sacrifice

wins below 48k. Dropping to 32k = the byte trigger's all-int equivalent restores

band parity while keeping the string fix — the best of both.

Outcome

Accepted. This is the one shippable win of the send-vs-sacrifice arc

(exp 241 confound → exp 244 pool → exp 245 intrinsic → this). Routing on slot

count fixes the string/shared-leaf misroute — large-TEXT/BLOB reads no longer

sacrifice and respawn a reader on every call, eliminating that reader churn (and

its connection re-open / WAL-pin churn) for a 31% latency win on those reads —

while leaving numeric/structural routing exactly where the (well-tuned) byte

threshold had it. The byte machinery is deleted, not deprecated — routing on

slot count leaves it with no readers, so the change removes more code than it

adds.

Interaction: exp 236 (#253) subtracts

transferableBytes from the byte estimate so TTD-wrapped blob cells don't force a

sacrifice. Under slot-count routing that subtraction is moot — blob size no

longer influences the decision at all. Exp 236's blob-cell TTD wrapping stands on

its own merits; its sacrifice-decision arithmetic is superseded here. Whichever

lands second should drop the byte-based decision rather than maintain two notions

of result size.