Skip to content

Exp 234: zero-copy blob parameter transfer via TransferableTypedData#252

Merged
danReynolds merged 4 commits into
mainfrom
exp-234-blob-param-transfer
Jul 21, 2026
Merged

Exp 234: zero-copy blob parameter transfer via TransferableTypedData#252
danReynolds merged 4 commits into
mainfrom
exp-234-blob-param-transfer

Conversation

@danReynolds

@danReynolds danReynolds commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Hypothesis

The parameter-encoding-and-binding direction 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 Uint8List blob write param, it is copied three times before it reaches a database page: (1) main → writer isolate, when SendPort.send(ExecuteRequest) copies the request graph — including the blob bytes — via the VM's object-graph copy (Dart SDK runtime/vm/object_graph_copy.cc), landing the payload on the shared GC heap; (2) writer → native param arena, in allocateParams; (3) arena → SQLite page, inside sqlite3_step. Copies (2) and (3) are unavoidable. Copy (1) is the target: TransferableTypedData carries bytes across a SendPort by 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 any Uint8List with length >= 256 KB by TransferableTypedData.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 any TransferableTypedData back to a Uint8List view before binding. Also a no-op when nothing was wrapped.

Wrapping is wired into the ExecuteRequest and QueryRequest constructors and the coalescing pump's MultiExecuteRequest build; unwrapping into _handleExecute, _handleTxQuery, and _handleMultiExecute on the writer. fromList copies 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-side sacrificeByteThreshold (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:

  1. Writer-isolate death on failing large-blob queries (critical). A failing tx.select with a wrapped blob embedded the already-materialized (spent) TransferableTypedData in 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 as ResqliteQueryException with the writer surviving.
  2. Coalesced writes bypassed the optimization. Concurrent standalone blob writes ride the exp 180 coalescing pump (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.
  3. FIFO desync window. Writer._request enqueued its reply completer before constructing the request; a constructor throw (now possible — fromList allocates 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 (OutcomeDecision, 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):

Size Δ P1 (base→cand) Δ P2 (cand→base) Read
64 KB (control) +4.2% +3.3% direct path — noise
128 KB (control) −2.9% +4.1% direct path — noise
256 KB −16.4% −22.7% wrapped — reproduced win
512 KB −10.4% −11.8% wrapped — reproduced win
1 MB +0.8% −4.0% WAL-write dominated — neutral

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 isolatesend(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), fromList is one linear memcpy, send(ttd) is constant-time at every size, and materialize() is ~1 µs flat (a view). The win is where the copy lands:

  • Direct: every in-flight blob becomes live data on the shared GC heap. On the real INSERT path (isolated per-lane processes under --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).
  • Wrapped: the buffer is malloc'd external memory the GC never traces, and the writer's arena setRange from 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 executeBatch shows the same hop fraction; would revisit the 256 KB threshold on a differing production blob-size profile.

Test plan

  • dart analyze --fatal-infos on lib/, 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 both execute and tx.select)
  • Focused end-to-end A/B (blob_param_write_ab.dart), interleaved + order-flipped, ~7 passes
  • Transport microbenchmark (blob_param_transport_ab.dart)
  • Mechanism attribution (blob_param_mechanism_proof.dart per-call table ×2; blob_param_gc_split.dart per-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

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>
@danReynolds danReynolds added approved Experiment succeeded: a kept win or a passing guard type: performance Implementation experiment changing a runtime hot path labels Jul 18, 2026
danReynolds and others added 3 commits July 20, 2026 22:32
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>
@danReynolds
danReynolds merged commit 82513d2 into main Jul 21, 2026
7 checks passed
@danReynolds
danReynolds deleted the exp-234-blob-param-transfer branch July 21, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Experiment succeeded: a kept win or a passing guard type: performance Implementation experiment changing a runtime hot path

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant