Experiment 265: run a small read on the isolate that asked for it

Date: 2026-08-08

Status: Rejected

Category: Moonshot

Direction:result-transfer-shape

Benchmark Run: none — focused AOT A/B, two collections of four

alternating-order lane-isolated passes of

benchmark/experiments/select_inline_dispatch.dart;

receipt in

benchmark/results/2026-08-08T18-30-00Z-exp265-inline-main-isolate-select.md.

No release-suite lane resolves a microsecond of per-read scheduling, so the

focused harness was the gate.

> Rejected after the measurements came in. The wall-clock numbers below are

> real and reproduced, and they are the experiment's lasting contribution: they

> price the select() round trip, which four prior experiments closed

> candidates against without ever measuring. The idea is rejected because the

> safety argument does not hold — eligibility was decided by a prediction, and

> the row-count signal it predicts from cannot bound main-isolate work along

> three independent axes. The runtime prototype is preserved at

> archive/exp-265; the reopen conditions are at the bottom.

Problem

Every read resqlite serves crosses to a reader isolate and back. The request is

copied to a worker, the worker steps SQLite and decodes the rows, and the result

is copied — or, past 32,768 slots, moved by ending the worker — back to the

caller. For a large result that hop is the cheapest part of the operation and

the pool is plainly earning its keep: the work is real, it is off the main

isolate, and the transfer has been optimised down the length of this direction.

For a point read there is almost nothing to transfer, and the hop is most of

what the caller waits for. The surrounding experiments have been saying so for

months without ever pricing it. Exp 264 put

a point read at "about 5-8 us, most of which is the isolate round trip". [Exp

258](258-columnar-result-store.md) rejected a columnar result store partly

because its sub-threshold transfer saving was "below the round-trip floor". Exp

209 and exp 239 both went after that floor by

amortising the hop across several queries, and both were rejected because a

shared reply cannot complete independently. And exp 264's own handoff note names

what is left: "the next thing to attack on the small-read path is the isolate

round trip itself."

Nobody had tried removing it.

Hypothesis

Assumption challenged: that every read has to cross an isolate boundary.

The pool exists so that SQLite work does not block the isolate that paints

frames. That is a proposition about work, and it is quantitative rather than

categorical — a read that costs two microseconds is not work worth protecting a

frame from, and today it is charged an isolate round trip for the protection.

So: when the caller already knows a statement returns a handful of rows, run it

on the calling isolate and skip the hop entirely. Not batched, not amortised,

not deferred — not run anywhere else at all.

The reason this looked attemptable now is that exps

260 and 264 built what seemed to be the missing

piece for a different purpose. ReaderPool keeps a per-SQL RowSizeMemory on

the main isolate, and it already holds a high-water mark of every row count a

statement has ever returned. That reads like the private cost signal exp 239's

rejection said would be needed to reopen read routing — arriving as a

buffer-sizing hint rather than a routing one.

It is not that signal. Recognising why is most of what this experiment produced,

and it is in Why this is rejected.

Primary gate: at least 25% faster median wall on point reads, reproduced across

the order flip, with the concurrency guard not regressing. Kill conditions: the

guard lane regresses, the mispredict cost is unbounded rather than one-off, or

any lane that cannot reach the changed path moves.

Approach

ReaderPool.select consults the memory it already keeps, and when the statement

qualifies it runs the query itself:

 if (memory.initialRows == 0 || memory.highWater > inlineRowMax) return null; 

Two conditions, each inherited rather than invented. initialRows == 0 until the

pool has watched a statement twice, which is exp 264's rule and is there because

one observation cannot tell a point read from the small leg of a LIMIT ?. And

the size test is against the high-water mark, not a recent count, for the

reason exp 264 had to discover: a window of length k is defeated by any burst

longer than k.

Everything downstream is the code the worker runs. executeQueryInline acquires

the statement through the same resqlite_stmt_acquire_on and decodes it through

the same decodeQuery; the result is the same ResultSet over the same flat

value list. What differs is three things:

connections and the pool reserves the last for the calling isolate. It cannot

borrow an idle worker's: the connections are SQLITE_OPEN_NOMUTEX, and the

reply that marks a worker slot free is sent before the worker's finally

releases the connection, so "idle" on the main isolate is not yet "idle" in C.

InlineRowCapExceeded, which resets the statement and returns null so the

caller dispatches to a worker as usual. The reset is load-bearing: a statement

abandoned mid-iteration holds its connection's read transaction open, and every

later read on that connection would be served from a stale snapshot.

past 256 KB in TransferableTypedData to keep the payload off the sender's

heap for the hop. There is no hop, so the wrap is suppressed and the cell

decodes straight to a Uint8List. This turns out to matter — see below.

Scope was select() only. selectBytes builds no Dart result and serialises in

C; tx.select runs on the writer connection.

Results

Two collections, each four alternating-order lane-isolated passes, both arms

built as native-asset-aware AOT CLI bundles from an identical harness source.

The table is the second collection (51 samples per lane per pass); the first

(41 samples) agrees on every lane and is in the receipt. Verdicts are

benchmark/ab_drift_check.dart's.

lanerolep1p2p3p4meanverdict
point1primary−74.7%−70.6%−73.0%−81.1%−74.8%reproduced
point1-wide20primary−74.4%−72.1%−76.6%−60.8%−71.0%reproduced
page20primary−48.2%−37.1%−28.3%−29.3%−35.7%reproduced
page64primary−27.1%+44.8%−29.9%−20.7%−8.2%1 of 2 pairs reproduced
point-under-loadprimary−92.1%−94.5%−96.2%−96.7%−94.9%reproduced
concurrent8guard−67.2%−78.6%−77.9%−57.2%−70.3%reproduced
mixed6-1kcontrol+0.9%+4.2%−0.8%−3.8%+0.1%neutral
int20-10kcontrol−2.4%−6.7%+0.7%+21.3%+3.2%neutral
cap-abortguard+26.4%+15.3%+48.1%+9.0%+24.7%reproduced

A point read is roughly four times faster. In absolute terms 8.4 us per read

becomes 2.1 us on the six-column canonical row, and the twenty-one-column row

behaves the same way — unlike exp 264's win, which scaled with projection width,

this one does not, because what it removes is per-request and not per-slot.

The effect decays with result size exactly as the mechanism predicts: −75% at one

row, −36% at twenty, and by sixty-four rows the decode is large enough that the

hop is no longer the dominant term and the lane stops resolving cleanly.

A point read issued while the pool is busy is about nineteen times faster.

Four outstanding 1,000-row reads occupy every worker, and a point read behind

them waits for one to finish: 533-1169 us across the passes, against 37-52 us

for a read that never enters the queue. The hop's cost to a caller is its own

latency plus the queue in front of it, and every prior transfer measurement in

this direction held the request population constant and so saw only the first

term.

Losing the pool's parallelism costs nothing at these sizes. Eight distinct

point reads issued together run four-wide across the pool and strictly one after

another on the calling isolate — and the serial version is 70% faster,

reproduced in both pairs. Four workers cannot make up a per-request overhead

larger than the request.

Both controls are flat, at +0.1% and +3.2%: they return far more rows than

the cap admits, so both arms execute the same machine code.

These numbers were collected against 4b963ad, before

exp 266 landed. Exp 266 attacks a different

component of the same overhead — a fixed per-(statement, worker) warmup, worth

−32.2% on a statement's first four executions and decaying to −1.6% by the

eight-thousandth (claim 266.1) — and every lane here runs 12 warmups plus tens to

hundreds of executions per sample, well past where that term matters. So the

pricing below should survive on current main, but it was not re-measured there.

What the numbers are worth

The durable result is the denominator, not the candidate: on the canonical

6-column point read the isolate round trip is 6.3 us of an 8.4 us read. Four

experiments have closed candidates against a "round-trip floor" that nobody had

measured. It is now measured, and it was most of a small read — so anything

previously rejected as below the round-trip floor was compared against a

denominator four times larger than the work it was competing with. That stands

regardless of this experiment's disposition.

Why this is rejected

The kill conditions the experiment set were all met — and they were the wrong

conditions. Every one of them tests whether the hint is wrong about row count,

which is the single failure mode the row cap already handles. None of them tests

whether row count is the right thing to predict from.

It is not. Rows-returned fails to bound main-isolate work along three

independent axes, and the design leans on it for safety in all three.

One row can be arbitrarily large.SELECT * FROM photos WHERE id = ?

returns exactly one row. Its high-water mark is 1, so it is admitted after two

executions and admitted permanently — and because there is no hop, exp 236's

blob wrapping is suppressed, so a 5 MB image is copied straight onto the calling

isolate. In the prototype the guarding branch reads

inlineRowCap == 0 && blobLen >= BlobTransfer.cellThreshold, so on the inline

path it is dead code and the blob is copied at any size, unchecked

(archive/exp-265, lib/src/query_decoder.dart). The most common point-read

shape in a real application is the

design's worst case, and the row cap does not see it. Worse, this is not even a

mispredict that a high-water mark corrects cheaply: cost varies per execution

of the same statement — id=1 is a 2 KB thumbnail, id=2 is a 20 MB raw — so

no history of the SQL string predicts the next parameter.

Rows returned is not query cost.SELECT count(*) FROM huge_table returns

one row forever; its high-water mark is 1; it is admitted forever; and every

execution scans the whole table on the calling isolate. The same holds for an

unindexed ORDER BY ... LIMIT 10: ten rows out, a full sort in. A result-shape

signal is structurally blind to this, because the work happens before the first

row is produced.

Inline reads do not yield._selectInline returns a List, not a Future,

so an inline select() completes without a single event-loop turn. Today,

awaiting a worker reply parks on a message from another isolate and the event

loop runs in the gap — frame callbacks can fire between reads. Inline, a chain

of awaits drains entirely in microtasks, and microtasks drain to completion

before the event loop regains control. So N inline reads are not N interleavable

2 us slices; they are one uninterruptible N×2 us block. The point1 lane is

unintentional evidence: 200 awaited inline reads at 427 us is a 0.43 ms stretch

with no opportunity to paint. This one has no cheap fix — it is inherent to

running synchronously, and the mitigation would be a per-drain time budget that

yields back to the event loop, which is a design question rather than a guard.

The writeup as first drafted claimed a bound of "~16 us per admitted read". That

was the bound for the shapes the harness measured — small integers, short TEXT,

cheap indexed lookups, a hot page cache — and it was stated as though it were

general. It is not, and none of the nine lanes could have caught any of the

three cases above.

The structural fix, and why it is not a follow-up

The row cap is the part of this design that holds up, because it does not

predict anything: it aborts mid-decode and falls back, and it is correct

whether or not the hint was right. The fix is to make every safety property work

that way — enforce rather than predict — which needs two more enforcement points:

copied into Dart, and blobLen is already loaded at the very line where the

wrap branch is skipped. A running byte total against a cap is one comparison

on a value already in hand.

bounds the count(*) and unindexed-sort cases that no result-shape signal can

see.

With both, the high-water hint stops being load-bearing for safety and becomes

what it is actually good at: avoiding wasted aborts. That is a better design

than the one measured here.

It is not merged as a follow-up because it changes the thing being tested. The

guard set would have to be rebuilt around the failure modes rather than the

happy path — at minimum a one-row-with-5 MB-blob lane and a count(*)-over-a-

large-table lane, both of which fail against this prototype — and the third

problem (microtask coalescing) is untouched by either enforcement point and has

no obvious cheap answer. That is a new experiment with a different hypothesis,

not a patch to this one.

Decision

Rejected. The performance result is real and reproduced; the safety

argument is not sound. Shipping a read path whose worst case is an unbounded

main-isolate blob copy, on the most common point-read shape there is, is not

worth 6 us on a 8 us read — and the library's whole positioning is that SQLite

work does not land on the calling isolate.

The measurement survives as the contribution: the round-trip floor is priced,

and benchmark/experiments/select_inline_dispatch.dart is kept as the gate for

any future read-routing work.

Reopen conditions

Any of these changes the answer:

  1. An enforcement-based design — byte cap and VM-step cap alongside the row cap

— with a guard set built from the three failure modes above rather than from

the happy path.

  1. An answer to microtask coalescing: a per-drain budget that yields to the

event loop, measured against a frame-shaped workload rather than a throughput

one.

  1. Evidence that the round trip's 6.3 us is worth more than it looks — a

production profile or user report where small-read latency, not throughput,

is the complaint. That would justify the extra machinery the first two

conditions require.

The prototype is at archive/exp-265 for cherry-pick.

Why the earlier rejections still do not cover this

Recorded because the reasoning about prior art was sound even though the

candidate failed, and a future runner will hit the same question:

attacked the point read from inside the worker and found the transparent share

was 2-5%, below the noise floor. Neither touched where the query runs.

trip by making several queries share one. Exp 239's rejection is precise:

queue depth cannot encode query cost, and members of a batch share an

indivisible reply. Removing a hop for one query has neither problem.

Note the irony: exp 239 was rejected partly because *queue depth cannot encode

query cost, and this experiment was rejected because row count cannot encode

query cost* either. The lesson generalises further than either rejection put

it.

213 are the moonshots that reproduced

their numbers and were rejected anyway. This now joins them, for a reason none

of them had: not shipped complexity, and not a semantic change, but a guard

set that tested the wrong proposition.

Future work

they are not a shortcut around this rejection. selectIfChanged on an

unchanged small result is a C hash pass with no Dart decode — the cheapest

thing the pool carries and so the largest relative hop — but an unchanged

result is still produced by a query whose cost row count cannot predict.

this hardware, for this shape. Any future candidate in this direction should

be compared against 2.1 us of actual work, not against 8.4 us of read.