Exp 285: the worker that never sleeps - #321
Conversation
Archived prototype only. A self-sent token circulates through the writer isolate's own receive port for a bounded window after each request, so the VM never parks the worker's thread between the writes of a burst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moonshot, rejected. Exp 284 measured that 5.30 us of the 6.28 us write hop is the isolate boundary rather than resqlite, and left one number unexplained inside it: the identical executeWrite costs 7.612 us on a running isolate and 9.380 us on a worker parked until the message arrived. This run asks whether a worker has to idle. It does not, and it does not help. A worker circulating a token through its own receive port runs the identical C call 6.6% faster than a parked one, and with the calling isolate kept runnable too the isolate boundary drops 17%. But warming the worker alone -- the only half a library controls -- is 5.7% slower on the floor lane and 6.2-9.6% slower in the shipping path with CPU up 13-17%, at every window from 5 to 200 us. The lane that wins needs the application's own UI isolate spinning. Runtime reverted; prototype at archive/exp-285; four floor lanes retained as benchmark/experiments/worker_keep_warm.dart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Belief impactLearned
What this changed
|
Hypothesis
Yesterday's exp 284 stopped nine experiments in their tracks by doing something
none of them had done: measuring the write hop before proposing a mechanism for
it. Of the 6.28 µs between
await db.execute()and the identical insert runinline on the calling isolate, 5.30 µs turned out to be the isolate boundary
and only 0.98 µs was everything resqlite actually does (claim 284.1). Its
rejection came with a condition for reopening the family — a candidate that
attacks the boundary rather than the bookkeeping.
Inside that boundary it left one number sitting there unexplained. The very
same
executeWritecall — same C entry point, same statement, same parameters,same handle — costs 7.612 µs on a running isolate and 9.380 µs on a worker that
was parked until the message arrived (claim 284.3). About 1.8 µs of every write
is just the woken side running cold.
So this run challenges an assumption nobody in the program has questioned:
that an idle worker isolate should idle. Every resqlite worker handles its
message, returns to its event loop, and lets the VM park its thread. If that
1.8 µs is the VM releasing and re-acquiring the thread, then a worker that never
runs out of work never pays it — and the boundary is partly a scheduling
artifact rather than a transport cost. Unlike exps 271 and 279, which attacked
the same boundary from the completion side and the transport side, this changes
neither what rides on the message nor who runs SQLite. It changes only what
state the receiver is in when the message lands.
Stated up front as the risk budget: a worker that stays runnable burns a core,
which is a real cost in a library that ships to phones. So a win had to be
priced in CPU as well as wall time.
Approach
Dart gives a library exactly one lever here — leave something in the isolate's
event loop — and microtasks are disqualified before you write any code, because
a microtask loop runs to exhaustion before the event loop delivers a port
message, so the isolate would never receive the request it is staying awake for.
That leaves two primitives, and the harness measures both.
benchmark/experiments/worker_keep_warm.dartextends exp 284's floor writer —a hand-rolled isolate writer with none of resqlite's machinery, so the isolate
architecture is priced on its own — with four lanes:
cold(the worker asexp 284 left it),
timer(a self-rescheduling zero-durationTimer),self(the worker sends a token to its own receive port, so tokens andrequests share one FIFO queue), and
both(selfon the worker and thesame loop on the calling isolate, which parks awaiting the reply exactly as the
worker parks awaiting the request). Each worker times its own
executeWritecalls, so the cold tax is read inside the C call rather than inferred from a
round trip, and process CPU is read through
getrusagearound every block.The shippable half was then built into resqlite's real writer isolate and A/B'd
through
await db.execute(...), twoDatabasehandles alternating in oneprocess. Full detail in
experiments/285-worker-keep-warm.md.
Results
The cold tax is real and recoverable. The identical C call, medians of three
passes over 6,400 writes per worker:
executeWriteµscoldselfself(insideboth)Recovering it costs more than it is worth.
coldcoldtimerselfbothIn resqlite's real writer the loss is wider still: +9.6% at a 15 µs window,
+9.0% at 40 µs, +6.2% in a third collection, CPU up 13–17%.
So the mechanism does what it was supposed to do and still loses. Keeping the
worker runnable really does buy back 0.6–0.9 µs inside SQLite, but a request
that arrives mid-spin waits behind a token turn — and a self-sent port message
turn costs 1.85 µs, which is more than the residency it buys. The
timerlaneloses far worse because its turns cost 6.50 µs each. A control at a 5 µs window,
where the round trip outlasts the window so the loop exits on its first turn and
never engages (
spins=0), reads flat at 14.567 vs 14.523 µs — confirming boththe tax and the cost come from the loop actually running.
The lane that wins is the one resqlite cannot have.
bothis the only laneof four that beats
cold, and against the inline reference it cuts the isolateboundary from 5.117 µs to 4.225 µs — 17% of what claim 284.1 prices at
5.30 µs. The boundary really is substantially scheduling rather than
transport. But the half that wins belongs to the caller, and the caller in a
Flutter app is the UI isolate; a token loop in its event loop is exactly the
main-isolate blocking exp 280 rejected a candidate for (claim 280.2). There is
no version of this that ships.
And that yields the finding most worth carrying forward.
cold's callerparks between writes only because the benchmark gives it nothing else to do. The
release suite's
Single Inserts (100 sequential)row — the one public lane whereresqlite loses to a peer, 15.8 µs against raw
sqlite3's 9.3 µs — awaits eachwrite and idles in between, so about a sixth of the boundary it charges resqlite
is the caller's own wake, which an application issuing the same hundred writes
from an isolate that has frames and futures pending never pays. The row is a
fair worst case. It is not the typical one, and it should be cited that way.
Outcome
Rejected, as a moonshot that confirmed its mechanism and closed its
direction anyway. Worker-side keep-warm — the only half a database library
controls — is 6–10% slower and 13–17% more expensive in CPU in the shipping
path, reproduced across three windows and nine passes.
Would reopen if the VM offers a way to keep an isolate's thread hot without
keeping the isolate runnable: a spin-before-park window in the isolate
scheduler, or thread affinity for a long-lived isolate. That is a Dart SDK-shaped
change, not a library-shaped one, and this run is the evidence for what it would
be worth — roughly 0.9 µs per request on the worker side, plus the caller's wake
for an application that is genuinely idle.
Do not reopen by making the window adaptive, the token cheaper, or the loop
smarter. The sweep already covers 5 µs to 200 µs and the shape is flat:
selfis above
coldat every window. The self-sent port message is already thecheapest keep-alive Dart exposes, and it costs twice the residency it buys back.
Runtime reverted —
lib/on this branch is byte-identical tomain. Prototypeand the shipping-path lane at
archive/exp-285; the four floor lanes retainedas
benchmark/experiments/worker_keep_warm.dart, whose--part=bothlane isthe durable instrument: it separates the two thread wakes in a round trip and
shows how much of a benchmark's hop is the benchmark's own idle caller.
Test plan
dart analyze --fatal-infos lib benchmark/experiments/worker_keep_warm.dart— cleandart test test/database_test.dart test/reader_pool_test.dart test/transaction_test.dart test/experiment_outcomes_test.dart test/knowledge_impact_test.dart test/knowledge_pins_test.dart— 198 passingdart test test/benchmark_generated_outputs_test.dart test/tracelite_workflow_docs_test.dart— passingdart run benchmark/finalize_experiment.dart --experiment=experiments/285-worker-keep-warm.md— greendart run tool/knowledge/impact.dart origin/main— five new claims, nothing retiredgit diff origin/main -- lib/empty; retained harness rebuilt and re-run on the reverted tree, reproducing the pattern a fourth timelib/,native/,hook/unchanged)🤖 Generated with Claude Code