Exp 284: the isolate, not the library - #320
Conversation
Decomposes the one release-suite row where resqlite loses to a peer -- 100 sequential single inserts, 15.8 us per write against raw sqlite3's 9.3 us -- and finds no collectible resqlite-side residual. A floor lane running the same insert through a hand-rolled isolate writer with none of resqlite's machinery costs 12.92 us against resqlite's 13.90 us and 7.61 us inline, so 5.30 us of the 6.28 us hop is the isolate boundary and 0.98 us is everything the library does. In-situ Stopwatch marks in the shipping path corroborate at 1.209 us across five items whose largest is 0.348 us. The candidate the decomposition named -- dropping the per-request reply SendPort, redundant since exp 159's persistent reply port -- was implemented and rejected: the send slice it targets does not move, because send's cost is a thread wake, not the graph copy. No runtime code ships. Prototype and probes at archive/exp-284; harness retained as benchmark/experiments/write_hop_decomposition.dart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Belief impactLearned
What this changed
|
Hypothesis
There is exactly one row in the public release suite where resqlite loses to a
peer.
Single Inserts (100 sequential)reads 1.577 ms for resqlite against0.934 ms for raw
sqlite3— 15.8 µs per write against 9.3 µs — on the sameschema, the same statement, the same journal mode and the same
synchronoussetting. Sequential single writes are about as common as a database call gets:
insert the message, update the row, mark the item read.
Ten experiments have proposed a mechanism for that 6.4 µs. Exp 159 pipelined
the writer request path and was accepted. The other nine — exps 151, 170, 171,
182, 197, 214, 215, 257 and 271 — attacked the writer mutex, a microtask hop,
dependency tracking, group commit, the write-result decode, the result buffer,
a native autocommit interpreter and a native completion mailbox. All nine were
rejected.
Nine rejections in one family is a pattern, and the pattern is that nobody
measured the 6.4 µs before proposing a mechanism for it. So: measure all of it
at once, in the shipping code path, and implement against whichever slice turns
out to be biggest. The rule was written down first — a slice worth ≥ 1.5 µs
that could be removed without a semantic or public-API change would be
implemented and gated at ≥ 5% reproduced in both orders; no such slice, and the
decomposition closes the family.
Approach
Three instruments, all AOT, all alternating lanes inside one process.
In-situ probes — eleven
Stopwatchmarks in the real path, five on themain isolate around
Writer.execute, the writer mutex, the request build, theSendPort.sendand the reply, and five inside the writer isolate around thehandler entry,
unwrapParams,executeWrite,getDirtyTableDependenciesandthe reply send. This is exp 282's parting instruction applied to the write
path. Nothing forks logic; the marks only read a clock.
An
inlinereference — the identical insert run on the calling isolatethrough the same native entry point the writer uses, so
hop = writer − inline.A
floorlane — the piece the previous nine did not have. The same insertthrough a hand-rolled isolate writer with none of resqlite's machinery: no
coalescing pump, no writer mutex, no blob wrapping, no dependency harvest, no
response object, no completer queue, not even the SQL on the wire. Send a
two-slot parameter list, call
executeWriteon the other side, send anintback. Subtracting
inlinefrom resqlite charges resqlite for the isolatearchitecture it exists to provide. Subtracting
inlinefrom the floor pricesthat architecture on its own, and what is left is the only part any experiment
could ever collect.
Full detail in
experiments/284-write-hop-decomposition.md;
all tables in
the receipt.
Results
writer—await db.execute(...)floor-hop— hand-rolled isolate writerinline— identicalexecuteWrite, calling isolatewriter − inline)floor − inline)writer − floor)84% of the gap against raw
sqlite3is the cost of not running SQLite on thecalling isolate. That is not resqlite's to collect; it is the thing resqlite
is for. Everything the library actually does on a write — coalescing pump,
mutex, request construction, blob wrapping, dependency harvest, response graph,
completer queue, stream-invalidation dispatch — comes to about one microsecond,
7% of a write.
The in-situ decomposition agrees from the other direction. Its five
resqlite-owned slices sum to 1.209 µs against the floor lane's independent
0.980 µs — two instruments built on different principles landing a fifth of
a microsecond apart — and the largest single item resqlite owns is 0.348 µs,
2.5% of a write. There is no 1.5 µs slice; there is no 0.5 µs slice.
The boundary also is not what everyone assumes. It is mostly a thread wake, not
an object-graph copy:
sendAnd the wake keeps costing after
sendreturns: the identicalexecuteWritecall — same C entry point, same statement, same parameters — costs 7.612 µs
inline on a running isolate and 9.380 µs on a worker that was parked until the
message arrived. That refines claim 279.1, whose 1.46 µs awaited round trip
was measured against an echo isolate that never goes cold; when both sides park
and the woken side then touches a page cache and a statement cache, the same
boundary costs 5.30 µs.
The candidate, run and rejected. The decomposition named one: every writer
request carries a
SendPort replyPort, even though the main isolate has had asingle persistent reply port since exp 159, so each message pays the VM to
carry a port handle the worker could simply have kept. The prototype removes
the field from all eight request types and hands the port over once in the
spawn arguments — internal only, no public API change.
sendslice, µsThe slice it targets does not move at all, and the wake measurement says why.
Rejected and reverted.
Outcome
Rejected, under the measurement rule's premise refuted escape. The
premise — that a standalone write carries a collectible resqlite-side residual
— is false: the residual is 1.0–1.2 µs of a 13.9 µs write, spread across five
items none of which exceeds 0.35 µs. The candidate the measurement unlocked was
implemented, measured against the mechanism it targets, and rejected.
The nine prior rejections now have one explanation instead of nine. Each was
chasing a share of about a microsecond on a fourteen-microsecond operation, with
a harness that could not tell that share from drift. Exp 182 is the clearest
case: its measured 3.8–5.3% for removing all dependency tracking is exactly
consistent with the 0.288 µs harvest slice plus the C-side preupdate hook — a
real mechanism whose entire ceiling sits below the noise of the workloads that
would have to justify it.
Would reopen only for a candidate that attacks the boundary rather than the
bookkeeping — something that changes how often a sequential write pays a pair of
isolate wakes. Exps 271 and 279 have already been rejected there, from the
completion side and the transport side, so a third attempt needs a mechanism
neither had.
The actionable finding is advice, not code. The release row directly beneath
the losing one is the same hundred inserts issued concurrently: resqlite
0.819 ms against
sqlite3's 0.858 ms, because exp 180's coalescing pumpamortises the boundary across the group. An application that awaits each write
in turn is paying two isolate wakes per row by choice, and nothing in the docs
says so.
benchmark/experiments/write_hop_decomposition.dartis retained. Its--part=floorlane is the durable gate: size a write-path candidate against thefloor before building it, because the floor is what it is actually competing
with.
Test plan
dart analyzeclean across the repodart test test/database_test.dart test/write_coalescing_test.dart test/transaction_test.dart test/stream_test.dart— 137 passed (sanity;lib/is byte-identical toorigin/mainon this branch)dart run benchmark/finalize_experiment.dart --experiment=experiments/284-write-hop-decomposition.md— greendart run benchmark/check_generated_data.dart— generated-docs sources build cleanlydart run tool/knowledge/impact.dart origin/main— five claims learned, nothing retired or orphanedlib/,native/andhook/are unchanged fromorigin/main)Host caveat.
mediaanalysisdheld roughly one core for the session and loadaverage sat at 5.6–6.4. Every comparison here is between lanes alternating
inside one process on one build, so drift lands on both sides; the one
two-build figure is reported as a mechanism slice rather than an end-to-end
delta for exactly that reason.