Skip to content

compute: spill correction chunk bodies through the buffer pool - #38897

Merged
antiguru merged 1 commit into
moritz/correction-borrow-hoistfrom
moritz/correction-pool-substrate
Sep 30, 2026
Merged

antiguru merged 1 commit into
moritz/correction-borrow-hoistfrom
moritz/correction-pool-substrate

Conversation

@antiguru

@antiguru antiguru commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

CorrectionV2 paged its chunk bodies through the column pager, a separate spill substrate with its own policy, backends, and accounting. Chunk bodies now go to mz_ore::pool, which is where the rest of the codebase spills and which accounts for its own residency. Under a 128 MiB cap with swap the pool is 1.13x faster than the best tuned pager configuration and needs no compression to get there, and at a 64 MiB budget it holds live RSS at a third of the unspilled figure.

The spill decision moves into one function in mz_timely_util::columnar::chunk, shared with ColumnChunk, so the gates, the installed pool, and the size floor live in one place. A new try_spill_ref spills from a borrowed column and encodes straight into the pool slot, so a spilled body costs no intermediate allocation and the caller keeps its column allocation to refill.

The correction buffer spills under a flag of its own, enable_compute_correction_v2_spill. It defaults off in production and on in CI, and turning it on installs the pool like the arrange and upsert spill flags do. Those two flags gate chunk spilling only, so enabling them does not change how MV sinks use memory.

ChunkBuilder no longer wraps ColumnBuilder: it owns one column, mints as soon as the column reports itself at capacity, and returns the minted chunk from push. Merges and splits take a chunk's body out of the pool, freeing the slot, so a chunk holds exactly one copy of its body at any time. Other reads copy the body out for the duration of the read and leave it in the pool. The main one is the persist write reading emitted, so a written batch, as large as the snapshot right after hydration, stays spillable until the next consolidation merges it. A body the pool declines is serialized into an exactly sized allocation rather than kept as a typed container, whose growth slack costs 54% of peak RSS.

The correction bench gains a spill sweep. The column_borrow bench's encode group compares minting into a fresh allocation against a reused one, which is the cost try_spill_ref avoids. The correction_mem example stays an example: it runs one fill per process and reads VmHWM and VmRSS, which criterion cannot do because setup, measured closure, and the previous iteration's drop overlap.

Supersedes the fork PR #38645, which holds the earlier review discussion.

🤖 Generated with Claude Code

@antiguru
antiguru added this pull request to stack #38898 September 16, 2026 17:59
@antiguru
antiguru marked this pull request as ready for review September 16, 2026 18:00
@antiguru
antiguru requested review from a team as code owners September 16, 2026 18:00
@antiguru antiguru changed the title moritz/correction pool substrate compute: spill correction chunk bodies through the buffer pool Sep 16, 2026
@antiguru
antiguru requested a review from DAlperin September 16, 2026 21:08
@antiguru
antiguru requested a review from a team as a code owner September 17, 2026 13:54
@antiguru
antiguru force-pushed the moritz/correction-pool-substrate branch from 47662fb to d789f1f Compare September 17, 2026 13:54
@antiguru
antiguru force-pushed the moritz/correction-pool-substrate branch from d789f1f to 5a9f690 Compare September 23, 2026 13:23

@antiguru antiguru left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review.

  • Gate: correction spilling turns on when either enable_column_paged_batcher_spill or enable_upsert_paged_spill is on. Enabling the upsert flag therefore starts spilling every MV sink's correction buffer in the process. This coupling across teams should not live only in a flag description. The correction buffer needs its own flag, default off in production and on in CI.
  • correction_mem: the --pager presets still call column_pager::apply_tiered_config, which neither buffer reads after this PR. Also, --chunk-size defaults to 8192, while the flag now defaults to 2 MiB.
  • Dead API: Spilled::compressed has no reader outside chunk.rs, so try_spill_ref can return the handle directly.
  • Nits: neither #[inline(always)] on Column::borrow nor the pub on at_serialized_capacity has a user or a measurement behind it.

🤖 Posted by Claude Code

@antiguru
antiguru force-pushed the moritz/correction-pool-substrate branch from 5a9f690 to b97665a Compare September 30, 2026 13:12
@antiguru

Copy link
Copy Markdown
Member Author

Addressed:

  • Correction spilling has its own flag, enable_compute_correction_v2_spill. It defaults off in production and on in CI, and it is varied in parallel-workload and allowlisted in the LaunchDarkly consistency check. try_spill_ref reads only this gate, and ColumnChunk reads only the compute and storage legs, which spill_gates_compose checks in both directions. The coupling sentences in both existing flag descriptions are reverted.
  • correction_mem drops the pager presets and defaults --chunk-size to 2 MiB.
  • try_spill_ref returns the ChunkHandle directly.
  • at_serialized_capacity is pub(crate) again. I was wrong about #[inline(always)] on Column::borrow: it measured 7.1 ns to 5.8 ns per call, so it stays, and a note at the attribute now records the number.

🤖 Posted by Claude Code

/// cleared once populated, so its contents have a stable address.
/// cleared once populated, so its contents have a stable address. Taking the body frees the
/// pool's copy, so the chunk holds exactly one.
fn column(&self) -> &Column<(D, Timestamp, Diff)> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

column frees the pool slot on any read, so a chunk that's read and kept stays on the heap for good. That's fine for merge inputs, but not for emitted. The persist write reads all of it, and it then sits in the buffer until the feedback comes back and the next read merges it away. So a whole batch, the full snapshot right after hydration, can't be spilled at the point where we most need it to be.

The pool already has a read that doesn't free the slot (read_into), and ColumnChunk uses it for with_column, keeping into_column as the consuming read. Can Chunk do the same? Merges would take the body, and everything else would read a copy. Not a regression, since the pager also took the body on first read, but this PR seems like the place to fix it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done in this PR. Chunk::with_view reads a materialized chunk in place. Otherwise it copies the body out of the pool with read_into for the length of the call, so the slot stays and the pool can still spill it. Chain::iter uses it, and so does the soft-assertion tie check in can_accept_chunk, which used to materialize both chunks. iter turns one chunk's updates into owned values at a time, so the iterator holds at most one chunk's worth. The soft assertion on emitted in updates_before now reads the cached last time and touches no body.

Only merges and splits take the body now. They go through Chunk::column, and the pool round-trip test checks that every body is still pooled and none is materialized after iter. In correction_mem at a 64 MiB pool budget, drain takes 4.7 s and live RSS ends near 30 MiB.

🤖 Posted by Claude Code

@antiguru
antiguru force-pushed the moritz/correction-pool-substrate branch from b97665a to ba73103 Compare September 30, 2026 15:44
}

/// Reads a `/proc/self/status` size field, in MiB.
fn status_mib(field: &str) -> f64 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linux only?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It reads /proc/self/status. Off Linux it now prints NaN for the memory figures instead of panicking, so the timings still work, and the module doc says so.

🤖 Posted by Claude Code

@DAlperin

Copy link
Copy Markdown
Member

We should benchmark with this enabled at some point

@DAlperin DAlperin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM mod a nit and q, thanks!

`CorrectionV2` paged its chunk bodies through the column pager, a separate
spill substrate with its own policy, backends, and accounting. Chunk bodies now
go to `mz_ore::pool`, which is where the rest of the codebase spills and which
accounts for its own residency. Under a 128 MiB cap with swap the pool is 1.13x
faster than the best tuned pager configuration and needs no compression to get
there, and at a 64 MiB budget it holds live RSS at a third of the unspilled
figure.

The spill decision moves into one function in `mz_timely_util::columnar::chunk`,
shared with `ColumnChunk`, so the gates, the installed pool, and the size floor
live in one place. A new `try_spill_ref` spills from a borrowed column and
encodes straight into the pool slot, so a spilled body costs no intermediate
allocation and the caller keeps its column allocation to refill. It reads a gate
of its own, set by the new `enable_compute_correction_v2_spill` flag, so turning
on the arrange or upsert spill flags does not change how MV sinks use memory.
The flag defaults off in production and on in CI, and turning it on installs
the pool like the other two spill flags.

`ChunkBuilder` no longer wraps `ColumnBuilder`: it owns one column, mints as soon
as the column reports itself at capacity, and returns the minted chunk from
`push`, which retires the builder's pending queue. Merges and splits take a
chunk's body out of the pool, freeing the slot, so a chunk holds exactly one copy
of its body at any time. Other reads copy the body out for the duration of the
read and leave it in the pool. The main one is the persist write reading
`emitted`, so a written batch, as large as the snapshot right after hydration,
stays spillable until the next consolidation merges it. A body the pool declines is serialized into an exactly sized
allocation rather than kept as a typed container, whose growth slack costs 54% of
peak RSS.

The `correction` bench gains a spill sweep. The `column_borrow` bench's encode
group compares minting into a fresh allocation against a reused one, which is
the cost `try_spill_ref` avoids. The `correction_mem` example runs one fill per
process and reads `VmHWM` and `VmRSS`, which criterion cannot do because setup,
measured closure, and the previous iteration's drop overlap.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@antiguru
antiguru force-pushed the moritz/correction-pool-substrate branch from ba73103 to ba795f4 Compare September 30, 2026 18:07
@antiguru

Copy link
Copy Markdown
Member Author

Benchmarked on staging, with the stack deployed. The workload creates a materialized view SELECT * FROM lineitem over TPC-H at scale factor 10, which is 60M rows with the input closed. Each run uses a fresh 200cc replica, three runs per configuration. Spilling was enabled through a replica-scoped LaunchDarkly rule. At the staging budget fraction of 5%, the pool gets about 1.5 GiB.

main stack, spill off stack, spill on
time until the view is complete 97.3 s 88.3 s 88.6 s
replica CPU seconds 298 264 about 286
peak replica memory, sampled about every 5 s 16.5 to 19.7 GiB 16.8 to 19.7 GiB 12.0 to 16.0 GiB

With spilling on, each run inserted about 40,000 bodies into the pool, wrote 32 to 36 GB of extents, and peaked at about 6 GB of swap. Spilling costs no wall-clock time and about 8% more CPU. Peak memory went down, though a 5 s sample is too coarse to put an exact number on it. A sweep over 100cc and 50cc, and the M.1 sizes with the same CPU and memory but more disk, is running now, and I'll post it here.

🤖 Posted by Claude Code

@antiguru
antiguru merged commit 07d9506 into main Sep 30, 2026
85 checks passed
@antiguru
antiguru deleted the moritz/correction-pool-substrate branch September 30, 2026 19:45
antiguru added a commit that referenced this pull request Sep 30, 2026
`compute_correction_v2_chunk_size` sizes `CorrectionV2`'s staging area,
not its chunks: chunk bodies are fixed at the columnar ship size. The
staging size sets how many updates accumulate before a chain is minted,
and every chain minted is a chain some later read has to merge. Raising
the default from 8 KiB to 2 MiB, one chunk body's worth, takes insert on
a 4.19M-update append workload from 3503 ms to 1897 ms, with drain and
peak RSS unchanged. These are medians of three `correction_mem` runs
with the rest of the stack applied and no pool installed. The curve is
monotone across the range (the new `bench_chunk_size` sweep in the
`correction` bench shows it), so the default is bounded only by the
per-sink, per-worker cost of the staging vector.

The stage counted its fill in tuples of `size_of::<(D, Timestamp,
Diff)>()`, which is 40 bytes for a `Row` however wide the row is, so a
byte-sized flag bounded neither the staging memory nor the reported size
once rows spill out of line. A new `DataBytes` trait on the buffer's
data types reports each datum's bytes with the heap included (`Row` via
`byte_len`, `DataflowErrorSer` via its serialized length), the stage
ships when the staged bytes reach the flag, and its size metrics carry
the same figure.

`stage_ships_by_bytes` checks that rows of two widths ship after the
number of updates their bytes predict.

That vector was allocated eagerly at the configured capacity and
re-allocated at that capacity on every flush. At 2 MiB per sink and
worker that is no longer free, so it now grows on demand and gives its
allocation back on flush.

The mzcompose and parallel-workload flag tables follow the new default,
since CI runs the value listed there rather than the compiled default.
The flag's description now says what it sizes.

First of four, split out of #38645: this, the O(1) metrics (#38895), the
per-chunk borrow hoist (#38896), and the pool substrate (#38897) land in
that order. Supersedes the fork PR #38647.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <[email protected]>
@antiguru

Copy link
Copy Markdown
Member Author

Staging results across replica sizes, as promised above.

The workload is the same as before: a materialized view SELECT * FROM lineitem over TPC-H at scale factor 10, which is 60M rows with the input closed. The build is this stack plus the column body split (#39159). Each run creates the view on a fresh replica, and the time is until its write frontier closes. Each cell is three runs. The V1 columns switched enable_compute_correction_v2 off for that one staging environment through LaunchDarkly.

V1 against V2, three sizes running in parallel in one environment, so both versions saw the same shared persist and S3 load:

Size V1 V2, spill off
200cc (4 workers, 30 GiB) 95–99 s 84–88 s
100cc (2 workers, 15 GiB) 206–244 s 230–233 s
50cc (1 worker, 7.6 GiB) 812–836 s 671–712 s

Spill off against spill on, runs one at a time. The pool budget is the staging budget fraction of 5%.

Size V2, spill off V2, spill on
200cc 87–91 s 87–90 s
100cc 232–238 s 187–192 s
M.1-xsmall (100cc CPU and memory, 4x disk) 220–236 s 185–191 s
50cc 703–714 s 467–484 s
M.1-micro (50cc CPU and memory, 4x disk) 673–702 s (two runs) 465–482 s
  • V2 against V1. V2 without spilling is about 11% faster than V1 at 200cc and about 17% faster at 50cc, and on par at 100cc. V1's 100cc runs varied more than any other cell.
  • Memory at 200cc. V2 without spilling peaks at 18 to 20 GiB against V1's 16 GiB, sampled about every 5 s. At a single timestamp V2 holds more than V1 until it can spill.
  • Spilling. It makes no difference at 200cc, where everything fits. At 100cc it is about 20% faster, and at 50cc about a third faster. At 50cc, spill-on ends near 2.8 GiB of replica memory, while spill-off stays at the 7.6 GiB limit.
  • Disk size. The M.1 sizes with 4x the disk behave like their cc equivalents, so disk size is not what limits this workload.

A CPU profile of the 200cc runs attributes about 15% of replica CPU to the correction buffer. Persist encoding and decoding take most of the rest.

🤖 Posted by Claude Code

@linear-code

linear-code Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

CPU-279

CPU-280

antiguru added a commit that referenced this pull request Oct 1, 2026
`Column` served two roles. On a dataflow edge it is built by pushing,
crosses an exchange as channel bytes, and is read on arrival, which is
what its `Typed`, `Bytes`, and `Align` variants are for. Behind an edge,
in a merge batcher's chains, a spill-backed `ColumnChunk`, and a batch
builder's seal, the same type held sorted, consolidated bodies that were
only ever typed or, when read back from a store, serialized words. That
code never constructed `Bytes`, and it asserted the variant it expected
at seven sites with `unreachable!`.

`ColumnBody<C>` is the type at rest: `Typed(C::Container)` while a body
is written, `Words(Vec<u64>)` once a store handed it back, and nothing
else. Writing to a serialized body materializes it by a bulk per-leaf
copy, so the merge, extract, advance, and settle bodies address the
typed containers without matching on a variant. `ColumnChunk`,
`ColumnChunker`, `ColumnMerger`, `UnchunkBuilder`,
`RowRowColPagedBuilder`, and the correction buffer's chunks and builder
now hold bodies. The edge container keeps its name and every operator
that names it is untouched.

The boundaries are moves. A chunker takes whatever the edge delivered
and emits bodies, and a body goes back onto an edge through `From`,
typed staying typed and words becoming `Align`. The column pager's
batcher and `ValRowColPagedBuilder` keep `Column` chains until the pager
is retired, so the merge and extract bodies are generic over a
`MergeChunk` trait both types implement, and `PagedChunker` wraps the
chunker for that batcher.

Fifth in the stack, on top of #38897. The chunked consolidation from
#38900 hands its bodies back onto the edge through `From`. #38334
touches the same files and will need rebasing onto this, since edge
paging lands on `Column` alone, which is the point of the split.

Part of [CPU-279](https://linear.app/materializeinc/issue/CPU-279).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
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.

2 participants