Exp 234: zero-copy blob parameter transfer via TransferableTypedData#252
Merged
Conversation
Large Uint8List blob write params are deep-copied by the VM serializer on the main->writer SendPort hop. Wrap blobs >= 256 KB in TransferableTypedData (a byte move) before send; materialize on the writer before binding. Smaller blobs and non-blob params take the unchanged direct path (fast path returns the input list with no allocation). Reproduced ~15-20% end-to-end win on 256 KB-512 KB single-row blob INSERTs across order-flipped passes; sub-threshold controls stay in noise; 1 MB+ is WAL-write dominated and neutral. Threshold floor is measured (64 KB regresses in the transport A/B). Accepted (in review), parameter-encoding-and-binding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A source-level deep-dive (runtime/vm/object_graph_copy.cc, runtime/lib/ isolate.cc) disproved the original 'avoids the VM serializer deep copy' framing: since Dart 2.15 same-group sends do one object-graph copy on the sender, so both routes copy the payload exactly once, on main. The win is the copy's destination — direct sends land every in-flight blob on the shared GC heap (scavenge evacuation + group-wide safepoints hitting the writer mid-step); the wrapped buffer is malloc'd external memory the GC never traces. - blob_param_mechanism_proof.dart: per-claim costs (send linearity proves sender-side copy; flat send(ttd)/materialize prove move + view; setRange lanes prove no cost shifted to the writer) - blob_param_gc_split.dart: per-lane GC attribution on the real INSERT path (29 GCs / 8.6 ms pause vs 20 / 1.2 ms per 300 x 256 KB inserts) - corrected writeup (new Mechanism section), signal fragment, index row, base.json synthesis, JOURNAL lesson, results addendum - blob_param_transfer.dart comments now document the two routes, the mechanism with SDK source refs, and the threshold's floor-no-ceiling shape Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-path wrap Review findings fixed: - Critical: a failing tx.select with a >= 256 KB blob param embedded the already-materialized (spent) TransferableTypedData in its exception via msg.params; the reply send threw from inside the catch clause, killed the writer isolate, and hung the caller forever. Exceptions now embed the unwrapped params, and writerEntrypoint's reply-send falls back to a stripped exception copy if a payload is ever unsendable. Regression test asserts the error propagates and the writer survives. - Coalesced standalone writes (MultiExecuteRequest, exp 180 pump) bypassed blob wrapping, silently reverting the win under the exact concurrent-burst shape the pump exists for. Wrap at the drain-site record build (no extra allocation in the no-blob case), unwrap in _handleMultiExecute; new concurrent large-blob round-trip test. - Writer._request now builds the request before enqueueing the completer: constructors can throw (native allocation in fromList), and a throw after addLast would desync FIFO reply matching. - Writeup heading Outcome -> Decision so generate_history.dart's why-accepted extraction recognizes it (parser accepts Decision / Why Accepted; Outcome rendered as null on the experiments page). - Boundary test now covers 256 KB - 1 (direct) and exactly 256 KB (wrapped) instead of claiming boundary coverage it didn't have. - Docs/signals updated for the coalesced-path coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…comments The two original A/B harness headers and two short lib comments were written before the mechanism deep-dive and still described the direct path as a 'VM C++ serializer deep copy'. Corrected to the proven story: one sender-side object-graph copy landing on the shared GC heap (chunked with safepoint polls on the slow path for large payloads), vs the wrap's memcpy into malloc'd external memory + ownership move. Pointers added to the mechanism-proof harnesses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hypothesis
The
parameter-encoding-and-bindingdirection carried an open question: are there blob-heavy parameter shapes where the parameter hop, not SQLite stepping, dominates? The text and numeric bind paths were tightened by earlier experiments, but the blob arm was never measured.Tracing a
Uint8Listblob write param, it is copied three times before it reaches a database page: (1) main → writer isolate, whenSendPort.send(ExecuteRequest)copies the request graph — including the blob bytes — via the VM's object-graph copy (Dart SDKruntime/vm/object_graph_copy.cc), landing the payload on the shared GC heap; (2) writer → native param arena, inallocateParams; (3) arena → SQLite page, insidesqlite3_step. Copies (2) and (3) are unavoidable. Copy (1) is the target:TransferableTypedDatacarries bytes across aSendPortby ownership transfer of a malloc'd buffer instead of a graph copy onto the heap.Exp 005 rejected a Dart binary codec for structured map results — but that lost on encoding structure in Dart. A raw blob parameter has no structure to encode, so it is a distinct question exp 005 never answered.
Approach
New internal helper
lib/src/writer/blob_param_transfer.dart:wrapBlobParams(params)— on the main isolate, replaces anyUint8Listwithlength >= 256 KBbyTransferableTypedData.fromList([blob]). Returns the input list unchanged, with no allocation, when nothing qualifies — so non-blob and small-blob writes are structurally untouched.unwrapBlobParams(params)— on the writer isolate, materializes anyTransferableTypedDataback to aUint8Listview before binding. Also a no-op when nothing was wrapped.Wrapping is wired into the
ExecuteRequestandQueryRequestconstructors and the coalescing pump'sMultiExecuteRequestbuild; unwrapping into_handleExecute,_handleTxQuery, and_handleMultiExecuteon the writer.fromListcopies rather than takes — it does not neuter the caller's list — so the public contract (the caller keeps ownership of the blob it passed) holds, verified by boundary round-trip tests (256 KB − 1 direct, 256 KB wrapped, 512 KB). The threshold is internal (not exported) and matches the read-sidesacrificeByteThreshold(256 KB) by design. Deliberately conservative: the batch (executeBatch) path stays on the direct route for now. Full detail in experiments/234-blob-param-transfer.md.Review-pass fixes
A recall-biased multi-angle review of the branch surfaced and fixed three defects before merge:
tx.selectwith a wrapped blob embedded the already-materialized (spent)TransferableTypedDatain its exception; sending that exception back threw from inside the catch clause, killed the writer isolate, and hung the caller forever. Exceptions now embed the unwrapped params,writerEntrypoint's reply-send falls back to a stripped copy if a payload is ever unsendable, and a regression test asserts the error propagates asResqliteQueryExceptionwith the writer surviving.MultiExecuteRequest), which didn't wrap — so the win silently vanished under the exact burst shape the pump exists for. Wrapping now happens at the drain-site record build (no extra allocation in the no-blob case) with matching unwrap in the handler, covered by a concurrent large-blob round-trip test.Writer._requestenqueued its reply completer before constructing the request; a constructor throw (now possible —fromListallocates native memory) would strand the completer and shift every later reply off by one. The request is now built first.The review also fixed the writeup's decision heading (
Outcome→Decision, which the docs generator's why-accepted parser actually recognizes) and tightened the boundary test to cover 256 KB − 1 and exactly 256 KB.Results
Single-row BLOB INSERT, median µs/INSERT, two order-flipped passes (lanes toggled in one process, sampled interleaved so drift indicts both equally):
For 256 KB–512 KB single-row blob INSERTs — thumbnails, documents, serialized payloads, small media — the wrapped route is a ~15–20% end-to-end speedup, reproduced same-direction in every order-flipped leg across ~7 passes (12+/12 negative at 256 KB, 8/8 at 512 KB), while the sub-threshold controls flip sign across the order flip (the exp 177 drift signature), confirming the wins clear the noise floor.
Mechanism (why it wins — attributed per claim)
A follow-up deep-dive (
blob_param_mechanism_proof.dart,blob_param_gc_split.dart, plus VM source) corrected the intuitive "avoids the serializer deep copy" story. Both routes copy the payload exactly once, on the main isolate —send(blob)'s synchronous wall scales linearly with size (proving the sender-side copy; there is no receive-side rebuild since Dart 2.15 isolate groups),fromListis one linear memcpy,send(ttd)is constant-time at every size, andmaterialize()is ~1 µs flat (a view). The win is where the copy lands:--verbose_gc, 300 × 256 KB inserts) that costs 29 GCs / 8.6 ms of pause vs 20 GCs / 1.2 ms for the wrapped lane — the scavenger must evacuate live payloads, and each collection safepoints the whole isolate group, including the writer mid-step. Blobs too large for the copier's fast-path new-space allocation additionally abort onto a slow path that redoes the copy in 100 KB safepoint-polled chunks (~2× a plain memcpy at ≥ 1 MB).setRangefrom the materialized view is equal-or-faster — no cost shifted to the writer.This also explains the size boundaries: below 256 KB the graph copy's fast path is near-free and wrap bookkeeping loses (+23% at 64 KB transport — hence the floor); at ≥ 1 MB the WAL write dominates and huge payloads skip new space (neutral, never negative — hence no ceiling).
Outcome
Accepted (in review). A contained, threshold-gated ~15–20% win on moderate-large single-row blob INSERTs, with the mechanism attributed per claim and the boundaries measured. Would extend to the batch/coalesced blob paths if a blob-heavy
executeBatchshows the same hop fraction; would revisit the 256 KB threshold on a differing production blob-size profile.Test plan
dart analyze --fatal-infosonlib/, harnesses, and tests (clean)dart test test/database_test.dart -j 1(55/55, incl. a new test that a >256 KB blob round-trips byte-identically through the wrapped path on bothexecuteandtx.select)blob_param_write_ab.dart), interleaved + order-flipped, ~7 passesblob_param_transport_ab.dart)blob_param_mechanism_proof.dartper-call table ×2;blob_param_gc_split.dartper-lane GC attribution on the real path)dart run benchmark/finalize_experiment.dart --experiment=experiments/234-blob-param-transfer.md(green)🤖 Generated with Claude Code