Skip to content

Exp 284: the isolate, not the library - #320

Merged
danReynolds merged 1 commit into
mainfrom
exp-284-write-hop-decomposition
Sep 7, 2026
Merged

Exp 284: the isolate, not the library#320
danReynolds merged 1 commit into
mainfrom
exp-284-write-hop-decomposition

Conversation

@danReynolds

Copy link
Copy Markdown
Owner

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 against
0.934 ms for raw sqlite3 — 15.8 µs per write against 9.3 µs — on the same
schema, the same statement, the same journal mode and the same synchronous
setting. 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 Stopwatch marks in the real path, five on the
main isolate around Writer.execute, the writer mutex, the request build, the
SendPort.send and the reply, and five inside the writer isolate around the
handler entry, unwrapParams, executeWrite, getDirtyTableDependencies and
the reply send. This is exp 282's parting instruction applied to the write
path. Nothing forks logic; the marks only read a clock.

An inline reference — the identical insert run on the calling isolate
through the same native entry point the writer uses, so hop = writer − inline.

A floor lane — the piece the previous nine did not have. The same insert
through 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 executeWrite on the other side, send an int
back. Subtracting inline from resqlite charges resqlite for the isolate
architecture it exists to provide. Subtracting inline from the floor prices
that 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

lane µs per write
writerawait db.execute(...) 13.895
floor-hop — hand-rolled isolate writer 12.915
inline — identical executeWrite, calling isolate 7.612
hop (writer − inline) 6.283
the isolate boundary (floor − inline) 5.303
everything resqlite does (writer − floor) 0.980

84% of the gap against raw sqlite3 is the cost of not running SQLite on the
calling 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:

the same write-shaped message, sent to… µs per send
a port in the sending isolate (copy only) 0.272
another isolate, already draining a backlog 0.445
another isolate, parked waiting 0.737

And the wake keeps costing after send returns: the identical executeWrite
call — 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 a
single 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.

build in-situ send slice, µs
baseline (port on every request) 1.469 / 1.485 / 1.521
candidate (port handed over once) 1.471 / 1.483 / 1.484

The 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 pump
amortises 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.dart is retained. Its
--part=floor lane is the durable gate: size a write-path candidate against the
floor before building it, because the floor is what it is actually competing
with.

Test plan

  • dart analyze clean across the repo
  • dart 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 to origin/main on this branch)
  • dart run benchmark/finalize_experiment.dart --experiment=experiments/284-write-hop-decomposition.md — green
  • dart run benchmark/check_generated_data.dart — generated-docs sources build cleanly
  • dart run tool/knowledge/impact.dart origin/main — five claims learned, nothing retired or orphaned
  • Five AOT passes × 15 samples × 400 writes for the headline lanes; three probed passes for the in-situ table
  • Benchmark Run: none (rejected; no runtime code ships — lib/, native/ and hook/ are unchanged from origin/main)

Host caveat. mediaanalysisd held roughly one core for the session and load
average 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.

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>
@danReynolds danReynolds added rejected Experiment failed: below the decision bar, regressed, or abandoned type: measurement Measurement/profiling run: counters, benchmarks, or focused probes labels Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Belief impact

Learned

  • 284.1 · The isolate, not the library
    84% of the gap between a standalone db.execute() and the same insert run inline is the isolate boundary, not resqlite. await db.execute() costs 1…
  • 284.2 · The isolate, not the library
    The in-situ decomposition of the shipping path agrees with the floor lane from the other direction and shows there is no large item to take. Eleven S…
  • 284.3 · The isolate, not the library
    What a small SendPort.send costs is mostly waking the receiver, and the wake keeps costing after send returns. The same write-shaped message cost…
  • 284.4 · The isolate, not the library
    Removing the per-request reply SendPort from writer messages is worth nothing. The main isolate has had one persistent reply port since exp 159, so…
  • 284.5 · The isolate, not the library
    A floor lane -- the same operation through a hand-rolled version of the library's own architecture, stripped of everything the library adds -- is the…

What this changed

We believed a standalone db.execute() carried a resqlite-side residual worth chasing, because the release suite's Single Inserts (100 sequential) row has resqlite at 15.8 us against raw sqlite3's 9.3 us and nine experiments (151, 170, 171, 182, 197, 214, 215, 257, 271) each proposed a mechanism for that 6.4 us. Claim 284.1 measures it: 5.30 us of the 6.28 us hop is the isolate boundary and 0.98 us is everything resqlite does, corroborated independently at 1.21 us by the in-situ decomposition in claim 284.2. Read the nine rejections as one result rather than nine: each was chasing a share of about a microsecond on a fourteen-microsecond operation, below the resolution of the harness it used. Do not open a tenth candidate against writer-side bookkeeping. The writer-residual passages in the stream-rerun-dispatch synthesis now have a ceiling attached to them.

We believed the cost of crossing an isolate boundary was the object-graph copy, which is what claims 279.4 and 280.1 measured for bulk payloads and what exp 281 is currently collecting on the read reply. For a small message it is not. Claim 284.3 splits the same write-shaped message three ways -- 0.272 us to a port in the sending isolate, 0.445 us to an isolate already draining a backlog, 0.737 us to a parked one -- and then shows the wake keeps costing after send returns: the identical executeWrite call is 7.612 us inline and 9.380 us on a just-woken worker. A proposal that shrinks a small message is shrinking the cheapest third of the send.

We believed claim 279.1's 1.46 us awaited isolate round trip was the price of the boundary. Claim 284.3 refines it: that figure comes from an echo isolate that does nothing between messages and never goes cold, and in a sequential awaited write -- where both sides park for ten microseconds and the woken side then touches a page cache and a statement cache -- the same boundary costs 5.30 us. Read 1.46 us as the floor for a tight ping-pong. Anything that sizes a candidate by multiplying round trips by 1.46 us, including the arithmetic behind claim 279.3's residual, is under-counting by roughly 3.5x for a workload whose isolates actually sleep.

New, and the reason this run could answer a question nine others could not: claim 284.5 records the floor lane as an instrument. Subtracting an inline reference from a library measures the library's architecture, not its overhead; subtracting it from a hand-rolled version of that same architecture measures the overhead. Any future write-path candidate should be sized against write_hop_decomposition.dart --part=floor before it is built.

@danReynolds
danReynolds merged commit 1a90907 into main Sep 7, 2026
7 checks passed
@danReynolds
danReynolds deleted the exp-284-write-hop-decomposition branch September 7, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex codex-automation rejected Experiment failed: below the decision bar, regressed, or abandoned type: measurement Measurement/profiling run: counters, benchmarks, or focused probes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant