Skip to content

perf(hotblocks): prepare unspilled chunks in memory#98

Merged
mo4islona merged 1 commit into
masterfrom
perf/hotblocks-in-memory-prepare
Jul 17, 2026
Merged

perf(hotblocks): prepare unspilled chunks in memory#98
mo4islona merged 1 commit into
masterfrom
perf/hotblocks-in-memory-prepare

Conversation

@mo4islona

@mo4islona mo4islona commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

At chain head every flush (= every block on 1s chains) builds a full out-of-core ChunkProcessor: 488 temp files for one EVM block (solana: 229), then tears them down. Measured on the prod node (a production fleet node): ~52 ms per head flush, ~90% kernel time, create+teardown alone = 86%. Fleet-wide that is ~10 CPU cores of syscall/inode churn, growing linearly with dataset count. Full investigation record: docs/adr/0001-in-memory-chunk-prepare.md + docs/measurements/ (in this PR).

Fix

DataBuilder::finish takes an in-memory prepared path when processor.is_none() — which by construction (maybe_flush trims the builder after every block) means the whole chunk still sits in the builder, under the 30 MiB spill bound. prepare_in_memory() sorts and downcasts straight from the builder slices into a new TableReader::Mem variant. No temp files, no syscalls.

Chunks that DID spill (backfill, fat bursts) keep the disk path bit-for-bit unchanged — that is the out-of-core design's home turf.

Numbers — measured on the prod node (production fleet node, overlay /tmp, image e96c877)

crates/data/benches/flush_spill interleaves the three modes and asserts identical record batches on every measured iteration:

rows fresh p50 (old path) mem p50 (this PR) saves
1 (head block) 52.4 ms 0.224 ms 100%
100 53.4 ms 0.453 ms 99%
1,000 56.1 ms 2.1 ms 96%
10,000 116.7 ms 22.1 ms 81%

Cross-mode output equality held for the whole sweep, both on overlay /tmp and tmpfs.

Safety

  • Differential tests (crates/data-core/tests/in_memory_prepare.rs): byte-equality with the spill path — sorted + plain tables, strings, lists, nulls, empty tables, partial read windows, chunk-wide downcast consistency (incl. >u32::MAX block numbers).
  • Decision point is deterministic and local; no new threshold or config — finish() re-checks the existing 30 MiB spill bound, so an oversized unspilled chunk (a row-bound flush whose final block is fat) falls back to the disk path. Pinned by unit tests on both sides of the bound.
  • Ownership model unchanged: a prepared chunk is still built once, consumed once.
  • Memory: bounded by the existing 30 MiB spill bound × ≤3 in-flight chunks per dataset (ingest→controller mpsc cap 1); ~1 GiB steady estimate at 100 datasets. Estimate ≠ allocation bound (builders retain capacity) — peak-RSS to be validated on the canary.
  • into_processor() on a mem-prepared table errors explicitly — processor reuse is a separate, riskier fix (see the ADR, Alternatives).

Rollout

testnet-dev → mainnet-internal (canary) → mainnet/morpho, judged by same-pod before/after per-thread profile (same-pod before/after per-thread profile). Rollback = image tag revert.

Before undrafting

  • Re-run flush_spill on a production fleet node with the mem mode (image e96c877) — 52.4 → 0.224 ms, −99.6%
  • Peak-RSS under harness load — plateaus ~110 MB above the spill path at 25k blocks / ~177 blocks/s (150× prod per-dataset head rate); heap-instead-of-page-cache, no leak shape; crates/hotblocks/tests/peak_rss.rs
  • Tie-order caveat reviewed — ties are REAL (evm statediffs, solana instructions), but harmless: query layer re-sorts all output by a row-unique primary key (compute_order), and byte-equality on ties currently holds anyway (pdqsort sorted-run identity, pinned by test; multiset test is the semantic floor)

🤖 Generated with Claude Code

@mo4islona
mo4islona force-pushed the perf/hotblocks-in-memory-prepare branch 3 times, most recently from 3c09eb7 to d9dc7c3 Compare July 16, 2026 16:16
Every head flush built a full out-of-core ChunkProcessor — hundreds of
temp files created and torn down per block, ~52 ms of mostly kernel time
on production hardware, times one flush per block per dataset. When no
spill happened and the builder is within the 30 MiB spill bound,
finish() now sorts and downcasts straight from the builder into a
TableReader::Mem-backed PreparedTable: 0.22 ms, no temp files. Spilled
or oversized chunks keep the disk path unchanged. Differential tests pin
observational equality with the spill path; ADR 0001 records the
investigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mo4islona
mo4islona force-pushed the perf/hotblocks-in-memory-prepare branch from d9dc7c3 to 250a9db Compare July 16, 2026 16:24
@mo4islona
mo4islona marked this pull request as ready for review July 16, 2026 16:25
@mo4islona
mo4islona merged commit a53b41b into master Jul 17, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant