Experiment 280: who should pack the parameters

Date: 2026-09-02

Status: Rejected

Category: Moonshot

Direction:parameter-encoding-and-binding

Archive:archive/exp-280

Benchmark Run: none — the runtime prototype is reverted and no code ships

in lib/, native/ or hook/. The decision evidence is the mechanism price

plus two order-flipped, separate-binary A/B collections in

benchmark/results/2026-09-02T11-20-00Z-exp280-caller-side-param-arena.md.

Problem

db.executeBatch(sql, paramSets) hands the writer isolate a

List<List<Object?>>. For the release suite's Wide Batch Insert shape that is

10,000 inner lists holding 200,000 slots, and the VM copies that whole object

graph across the SendPort before the writer has done anything at all. The

writer then walks the rebuilt graph a second time, in allocateBatchParams, to

produce the flat native arena SQLite actually binds from.

Exp 234 named that main→writer hop "copy (1)" and

removed it for a single large blob (claim 234.1). Nothing has ever measured

what it costs for a whole parameter matrix, and the obvious way to remove it

is not a smaller copy but no copy: pack the arena on the isolate that issued

the write and send the writer an address.

Assumption challenged. Parameters must cross the writer boundary as a Dart

object graph, and packing them is the writer's job because the writer is where

the native handle lives. Neither half is load-bearing — a calloc'd arena is

process memory, and any isolate can fill it.

Hypothesis

If the issuing isolate packs the matrix and sends paramsAddress, then the

graph copy disappears from the write entirely: the caller pays a packing walk

instead of a serialize walk, and the writer pays nothing before it binds. The

work is conserved, one full pass over the parameter data is not.

The bet has a stated risk. The walk the candidate moves lands on the isolate

that called executeBatch, which in a Flutter application is the UI isolate,

and resqlite's stated contract is that its work runs "with zero main-isolate

jank". So a wall-time win is necessary and not sufficient: the candidate had to

be judged on main-isolate blocking as well, and a wall win bought with a large

jank regression is a rejection, not a trade to be argued about later.

Predeclared:

and main-isolate blocking does not get worse on them;

incidence before it can ship.

Approach

Two harnesses, both retained.

batch_param_transport_price.dart

prices the boundary before anything is built, in the shape of exp 278's lesson.

A persistent echo isolate receives the matrix and replies with an int, so

graph-rt − graph-floor is the graph copy and nothing else; a second lane

times SendPort.send alone, which is the serialize half and the half the

sender pays; a third times allocateBatchParams on the sending isolate; a

fourth is the public executeBatch denominator.

The prototype (archive/exp-280, 263ad64) changes four files. BatchRequest

carries (sql, paramsAddress, paramCount, rowCount) instead of paramSets;

Writer.executeBatchLocked calls allocateBatchParams(paramSets, owned: true)

before sending; _handleBatch reconstructs the pointer and calls new

executeBatchWriteFromArena / executeNestedBatchWriteFromArena bindings that

free the arena on every path out.

One correctness detail is load-bearing and worth recording even though the

prototype is reverted. allocateReusableParamStructBuf hands packs of ≤64 KB

an isolate-local scratch buffer, and freeReusableParamStructBuf decides

whether to free by comparing against that isolate's own scratch pointer. A

buffer packed on one isolate and freed on another therefore must be owned

memory, or the freeing isolate misses the comparison and frees a buffer the

packing isolate still holds. Hence the owned: true flag — and hence the

small-100-x8 lane, which prices what small batches lose by giving up the

scratch buffer.

batch_param_arena_ab.dart

is the gate. Each lane reports the awaited executeBatch wall and, for the

same call, the longest gap seen by an event-loop probe that reschedules itself

on the event queue — how long the main isolate was unavailable to anything

else. Both arms are separate AOT bundles from separate worktrees, because

exp 249's in-process toggle reported a false −27%.

Results

Full tables in the

receipt.

The boundary is real, and the sender pays for all of it

Shapecopy µssend µspack µse2e µscopy/e2epack/send
10k × 20 mixed1287.01171.05760.022822.05.6%4.92×
10k × 4 ascii818.0708.02802.08390.09.7%3.96×
10k × 8 int773.0764.0618.04544.017.0%0.81×
100 × 8 mixed6.07.020.0106.05.7%2.86×

The parameter graph copy is 5.6–17.0% of a batch write, so it is worth

attacking. But send is 91–99% of copy: the receiving isolate rebuilds the

graph almost for free, and essentially the entire hop is already paid by the

isolate that issued the write. That single number decides the experiment before

the A/B runs, because it means the candidate is not removing a cost from the

main isolate — it is replacing main-isolate serialize work with main-isolate

packing work, and pack/send is the exchange rate. For a fixed-width numeric

matrix it is 0.81, for anything holding text it is 4–5×.

The A/B agrees, in both orders

Candidate against base within the same pass; negative is faster / less blocked.

LaneΔ wall p1Δ wall p2Δ block p1Δ block p2
int-10k-x8−23.6%−21.9%−34.4%−33.3%
ascii-10k-x4−13.6%−13.1%+229.3%+253.7%
mixed-10k-x20−7.8%−7.6%+371.4%+369.2%
small-100-x8−4.9%−4.1%+160.0%+188.9%
tx-10k-x8−19.4%−18.8%−18.6%−20.4%

Every lane reproduces to within 1.8 percentage points across the order flip,

and the candidate's blocking medians land on the price harness's pack column

while the baseline's land on its send column — two independent harnesses

agreeing on the same mechanism.

So the mechanism works exactly as hypothesized. Wall time improves on every

batch shape measured, by 4–24%, and the win is largest where the packing is

cheapest. It is also not a transport curiosity: the tx-10k-x8 lane shows the

nested-batch path inside db.transaction behaves the same way.

And on every text-bearing shape the main isolate is blocked 2.3–4.7× longer for

it. A 10k × 20 batch takes the UI isolate from 1.2 ms to 5.7 ms of continuous

unavailability — at 60 fps, from most of a frame to most of three.

The baseline's jank is smaller but spikier

On mixed-10k-x20 the baseline's median block is 1213/1214 µs against a

maximum of 12212/4174 µs, while the candidate's median is 5718/5696 µs against

a maximum of 5989/5904 µs. The baseline's graph copy leaves 200,000 freshly

copied objects on the shared GC heap — the mechanism claim 234.1 attributes for

blobs — and its tail is those collections; the candidate allocates one native

arena and produces no heap garbage, so its blocking is larger but flat. This is

a genuine point in the candidate's favour and it does not rescue it: the

median, which is what a UI isolate experiences on the ordinary batch, is

several times worse.

The one shape that wins both has no incidence

int-10k-x8 and tx-10k-x8 — fixed-width numeric matrices — improve wall

and blocking, because packing an unboxed integer is cheaper than serializing

the list slot that holds it. That is a real result and it cannot ship. The

repo's own representative batch workloads are the release suite's Batch Insert

lane, which binds ['item_$i', i * 1.5], and its Wide Batch Insert lane, which

binds a 20-column mixed row. Both carry text in every row; neither is eligible.

A numeric-only admission would also have to prove the matrix is numeric, and

the only sound proof is a full scan — while a row-0 speculation is exactly the

shape exp 226 measured regressing a

final-row TEXT fallback.

Decision

Rejected. The assumption is false — parameters do not have to cross as a

Dart object graph, and not crossing as one is 4–24% faster end to end, in both

orders, on every batch shape measured. The direction is closed anyway, because

of where the saving comes from. The parameter hop is already paid almost

entirely by the sending isolate, so relocating the packing walk does not remove

main-isolate work; it exchanges serialize for pack at 4–5× on any matrix

holding text. resqlite exists to keep that isolate free, and buying 8% of batch

wall with 3.7× the UI-isolate blocking is the wrong side of its own trade.

This is not a "below the noise floor" rejection, so the prototype is worth

keeping: archive/exp-280 holds a working implementation, including the

cross-isolate ownership fix that any future version needs.

Reopen conditions

all-numeric executeBatch matrices are a material share of batch writes.

The numeric case wins both metrics today; only its eligibility is missing.

filled by a third isolate, or incrementally by the writer as rows arrive, the

wall win survives without the jank; nothing in the current design needs the

packing and the binding to happen on the same isolate.

rather than by copy. That removes send without adding pack, which is the

only version of this idea that is free.

Do not reopen it as a shape-keyed default. The eligibility test is a scan,

the misprediction lands on the UI isolate, and the cost of being wrong is

measured above.

Future Notes

proposal that changes what crosses the writer boundary is trading

main-isolate time, and can be costed in ten minutes before it is built.

metric this library's contract is actually about, and no release-suite lane

reports it. Any future candidate that moves work between isolates should be

judged on it, not on wall time alone.

Test plan

writes, nested/transaction batches, and batch error paths

AOT bundles, 15 samples after 3 warmup per lane

lib/ to origin/main