Skip to content

Exp 258: columnar typed-array result store (moonshot, rejected) - #276

Merged
danReynolds merged 1 commit into
mainfrom
exp-258-columnar-result-storage
Jul 29, 2026
Merged

Exp 258: columnar typed-array result store (moonshot, rejected)#276
danReynolds merged 1 commit into
mainfrom
exp-258-columnar-result-storage

Conversation

@danReynolds

Copy link
Copy Markdown
Owner

Moonshot · publication-only (harness + writeup + fragments; no lib//native/ change) · experiments/258-columnar-result-store.md

Hypothesis

Every select() result is backed by one flat, row-major List<Object?>: decodeQuery boxes every numeric cell into it, the list crosses the reader→main isolate boundary, and Row['col'] reads the boxed value back out on the main isolate. Exp 224 recently closed the FFI-crossing axis of this path and, in doing so, named the real remaining cost out loud — "Dart object construction," i.e. the boxing. The obvious structural answer is per-column typed arrays (Int64List/Float64List for numeric columns), which exp 055 proposed back in April but assessed and never implemented: it only estimated the transfer win as "too small," and said the memory win needed a profiling methodology that didn't exist yet.

Two things changed since then, which is why this was worth a bounded pass now: that memory methodology now exists (ProcessInfo.currentRss + the RSS diagnostics from exp 174/183, and the process-isolated transfer harnesses from exp 244/245), and exp 224 gave direct evidence that object construction — not the FFI crossing — is the top rows-path cost. The bet: storing numeric columns as typed arrays lets the worker skip boxing during decode, the container cross the isolate boundary as a memcpy instead of a boxed structured-clone, and the main isolate box lazily only on the cells a caller reads.

Approach

A focused, AOT-compiled A/B harness (benchmark/experiments/columnar_result_transfer.dart) feeds a flat boxed List<Object?> and a columnar typed-array container identical raw numeric source data, and measures three costs separately across a real worker→main SendPort hop, over two order-flipped passes: build (worker wall — flat boxes, columnar doesn't), hop (main-observed round trip = build + serialize + structured-clone receive), and consume (main-isolate full scan, reading every cell as Object? the way Row['col'] does). It deliberately doesn't stand up SQLite — the goal is to isolate the container mechanism 055 estimated and never ran, not to re-measure the decode loop exp 224 already covered.

The load-bearing production detail the harness encodes: a reader sacrifices (hands its heap to main via Isolate.exit, zero-copy) once a result exceeds 32768 structural slots. So lanes are tagged (send) (under the threshold → real SendPort copy) vs (exit) (over it → production transfers for free), because a columnar transfer win only reaches production on the (send) lanes.

Results

Δ = columnar vs flat (negative = columnar faster), both order passes; NET = hop+cons is the main-isolate figure.

Lane build Δ hop Δ cons Δ NET Δ
1k × 8 REAL (send) −61/−56% −55/−37% −13/−11% −30/−21%
10k × 8 INTEGER (exit) −67/−66% −73/−73% −52/−48% −64/−61%
10k × 20 REAL (exit) −90/−91% −91/−85% −5/−8% −67/−56%

Signs are stable across the flip, so these are real — but the mechanism splits three ways once mapped to production. (1) The build win is large (60–90%, ~10× on wide REAL — doubles always box) but it's worker wall, off the main isolate. (2) The headline transfer win doesn't reach production where it's largest: the (exit) lanes already zero-copy via Isolate.exit, so columnar's memcpy competes with free; on the (send) lanes where it does apply, the absolute saving is tens of microseconds (31 µs → 14 µs), below the select() round-trip floor exp 105 established. (3) One surprise — main-isolate integer consume is ~2× faster (Int64List locality vs pointer-chasing a List<Object?> of Smis), while REAL consume is only neutral (box-on-access cancels the gain) but never regresses. Memory is a wash-to-regression: Smi ints live inline in a List<Object?>, so an Int64List column costs more, not less.

Outcome

Rejected — don't rewrite the ResultSet backing store to columnar. The one axis that would justify touching resqlite's hottest, most-tested path (main-isolate time) isn't moved in the general case: the transfer win is neutralized by the existing sacrifice path for large results and sits below the round-trip floor for small ones, REAL consume is neutral, and memory doesn't improve. This completes exp 055's open assessment with the first real measurement of the typed-array mechanism and confirms exp 081's main-isolate-access caution generalizes.

Reopen only if a workload profile shows integer-heavy, main-isolate-bound reads dominating — the one place columnar showed a real primary-metric signal (~2× Int64List consume). That's a narrow numeric-column path, not the whole-store rewrite; it needs its own design pass (how Row dispatches columnar-vs-flat per column without slowing text), so it's recorded as a scoped, out-of-budget candidate in the signal map, backed by this measurement.

Test plan

  • dart analyze --fatal-infos benchmark/experiments/columnar_result_transfer.dart
  • AOT-compiled focused A/B, two order-flipped passes (signs stable across the flip) — receipt: benchmark/results/2026-07-29T11-23-43Z-exp258-columnar-result-transfer.md
  • dart run benchmark/finalize_experiment.dart --experiment=experiments/258-columnar-result-store.md (green)
  • dart run benchmark/check_experiment_signals.dart (valid)
  • No lib//native//hook/ changes — publication-only

Reopens exp 055's assessed-but-never-implemented columnar result store
and gives the first real AOT measurement of the typed-array mechanism.
Publication-only: focused harness + receipt + writeup + signal/index
fragments; no lib/native change.

Columnar builds 60-90% faster worker-side (no double-boxing) and its
transfer is 73-91% cheaper on a SendPort A/B, but the large results
where transfer wins most already zero-copy via the Isolate.exit
sacrifice path (>32768 slots), and sub-threshold SendPort lanes save
only tens of microseconds (below the round-trip floor). REAL consume is
neutral; memory is a wash-to-regression (inline Smis beat Int64List).
The one real primary-metric signal was ~2x faster main-isolate integer
consume from Int64List locality, recorded as a scoped candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@danReynolds danReynolds added type: moonshot Frontier experiment challenging an architecture assumption rejected Experiment failed: below the decision bar, regressed, or abandoned labels Jul 29, 2026
@danReynolds
danReynolds merged commit 94d580f into main Jul 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rejected Experiment failed: below the decision bar, regressed, or abandoned type: moonshot Frontier experiment challenging an architecture assumption

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant