Skip to content

timely-util: bound serialized column chunks and merge allocations - #39042

Merged
DAlperin merged 1 commit into
mainfrom
dov/column-chunk-bounds
Oct 1, 2026
Merged

DAlperin merged 1 commit into
mainfrom
dov/column-chunk-bounds

Conversation

@DAlperin

@DAlperin DAlperin commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Motivation

Merged column chunks were emitted once they crossed 2 MiB rather than bounded to it, and the check ran every 1024 records, so wide rows routinely produced chunks well above the 2 MiB size class. Those chunks take 4 MiB huge-page slots in the buffer pool, halving the effective pool budget and doubling the bytes every eviction and read-back touches. This showed up on a production upsert hydration with about 2 KB rows and 30x more state than pool budget.

Description

  • Settle and the chunker bound serialized column chunks to 2 MiB, splitting oversized output while preserving order and generational depth. Pieces are cut at the column's average record width, so uniform rows fill their size class and leave one short remainder to coalesce. A piece that still exceeds the bound, from uneven widths, is halved until it fits, which bounds the recursion depth by log2 of the record count. A single indivisible update may still exceed the bound.
  • Settle fills an open carry toward the bound with a prefix of the next chunk and returns the rest to the input, so a stream of chunks just over half the bound does not commit each into a half-empty slot. An oversized shared body is cut by borrow, copied once.
  • Merges check their output size at a cadence set by observed row width instead of every 1024 records, which keeps uniform rows within the ship size, and reserve output capacity only when both inputs fit within two ship-sized chunks.

The pool's bound on insertion debt, previously part of this PR, is split out into #39374, below this one in the stack.

Verification

New unit tests cover the chunker, merge output before settle, settle byte bounds, depth preservation when a shared body is split and its last piece coalesces with a shallower neighbor, carry filling, indivisible oversized updates, and uneven record widths.

Release note: None.

@DAlperin
DAlperin added this pull request to stack #39045 September 22, 2026 17:03
@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from 1350c45 to aaefc77 Compare September 22, 2026 17:35
@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from aaefc77 to a74ddfa Compare September 23, 2026 15:05
Base automatically changed from dov/storage-exert-policy-16 to main September 23, 2026 15:35
@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch 2 times, most recently from f14c743 to 7cf7ece Compare September 28, 2026 18:21
@DAlperin
DAlperin marked this pull request as ready for review September 29, 2026 19:15
@DAlperin
DAlperin requested review from a team as code owners September 29, 2026 19:16
@antiguru
antiguru self-requested a review September 30, 2026 09:00
@DAlperin

Copy link
Copy Markdown
Member Author

Benchmark results

Workload: a skewed hydration of 105 upsert sources, 192.36M rows of 1,900-byte compressible values (about 407 GB logical). Replica: 400cc (8 workers, 65 GiB, swap-backed) with a 20% pool budget and four spill workers. Exertion policy 1337, the storage default at the time. The two changes in this PR ran together as one build (d169fb3c) and were not measured separately.

Equal progress, 30M to 55M indexed rows (the same 25M rows in each run):

Build Elapsed CPU seconds Extents created Pool inserts Direct-extent inserts
main (f0e632d1), trial 1 362 s 2,928 200.5 GB 846,175 n/a
main (f0e632d1), trial 2 363 s 2,953 221.9 GB 892,984 n/a
this PR (d169fb3c) 224 s 1,760 112.5 GB 728,818 0

Over this window that is 38% less time, 40% less CPU and 47% fewer extent bytes. The build ran one trial. A later image carrying this PR and #39043 reproduced it at 221 s, 1,712 CPU seconds and 107.8 GB. The direct-extent fallback never fired on this synchronous path.

Full hydration, slowest source. The same build ran five times across two batteries. In two of them (50m41s and 52m56s) the async-drain flag was on but executed no offloaded reads, so all five ran the same path.

Build Trials
main 53m22s, 55m56s
this PR 45m04s, 49m39s, 50m41s, 52m56s, 66m27s (median 50m41s)
v1 (RocksDB) 45m09s, 46m28s

The median is about 7% under main, but the spread is too wide to claim a wall-clock win from this PR alone. At policy 1337, cadence-bounded exertion drove most of the variance. #39044 addresses that.

Memory. On this build the pool ledger peaked at 14.65 GB against a 13.02 GB budget, 1.13x. On the landing stack, pool resident stayed at 13.1 to 14.7 GB on every 300 GB-seed hydration. For comparison, the production hydration on the unfixed code reached 366.9 GB against a 50.5 GB budget (7.3x). Physical memory there ran 1.3 to 1.8x the ledger, because merged chunks landed in the 4 MiB huge-page class. That is a different workload, so read the comparison as directional.


Written by Claude (Claude Code) from the benchmark notes.

@antiguru antiguru 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.

I think this makes sense, but it would benefit from splitting in two PRs, one for the pool, and another for the chunker.

Comment thread src/ore/src/pool.rs
ChunkHandle { meta }
}

fn insert_extent(

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.

Please add a short function doc comment, it's hard to tell from the implementation what exactly this is doing.

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.

Added a doc comment. This change now lives in #39374, split out below this PR.

Comment thread src/timely-util/src/columnar/chunk.rs Outdated
let end = len.min(start + records);
let mut part = <(D, T, R) as Columnar>::Container::default();
part.extend_from_self(view, start..end);
Self::push_bounded(Column::Typed(part), depth, out);

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.

Is this right to call itself recursively? Or am I missing something?

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, intentional. The first cut is at the average record width, which is exact for uniform rows. With uneven widths a piece can still overflow, and the recursive call cuts it again. Each piece has strictly fewer records than its parent, and the recursion stops at a single record, which may exceed the bound. A piece that still overflows is now halved, so the depth is at most log2 of the record count. The comment now says this, and push_bounded_splits_uneven_widths covers the halving path.

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.

Thanks for the explanation, makes sense!

@antiguru antiguru 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.

Posted by Claude Code on behalf of @antiguru.

No blockers from me. The main open point is the settle carry flush, which can commit half-full 2 MiB chunks (inline). The spill-thread fallback cost and the compressed-tier shrink need a number or a sentence in the description.

On the recursion question in push_bounded: it terminates. bytes > COMMIT_BYTES implies records < len, so every piece is strictly shorter than its parent, and len <= 1 is the base case. Recursion only goes deeper for pieces that still overflow because of uneven widths.

Comment thread src/timely-util/src/columnar/chunk.rs Outdated
// A sub-threshold chunk coalesces into the open carry by borrow,
// never unwrapping a shared body.
if !full && let Some((mut acc, acc_depth)) = carry.take() {
let fits = carry.as_ref().is_some_and(|(acc, _)| {

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.

When the incoming chunk doesn't fit, the carry flushes at whatever size it has. A 1.1 MiB carry followed by a 1.0 MiB chunk commits 1.1 MiB into a 2 MiB slot, and a stream of ~1 MiB chunks commits every chunk at about 50% slot utilization. That trades the old 4 MiB slots for half-empty 2 MiB ones.

Filling the carry up to COMMIT_BYTES with a prefix of rc (extend_from_self(view, 0..k), with k chosen the way push_bounded picks its cut) and pushing the remainder back to input would keep commits near full.

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.

Fixed: when the next chunk is below commit size, the carry now fills toward the bound from a prefix of it, cut at its average width, and the rest returns to the input. A chunk at commit size still commits whole rather than being rewritten to top up the carry. settle_fills_carry_from_next_chunk fails on the old code, where every chunk committed at 1.12 MB.

Comment thread src/timely-util/src/columnar/chunk.rs Outdated
ColumnChunk::Resident(rc, depth) => (rc, depth),
};
if rc.length_in_bytes() > COMMIT_BYTES && rc.borrow().len() > 1 {
let col = Rc::try_unwrap(rc).unwrap_or_else(|rc| copy_column(&rc));

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.

A shared body gets copied twice here: copy_column makes a full copy, and push_bounded then only borrows it and copies again, piece by piece, via extend_from_self. If push_bounded took a borrowed view, shared bodies could be split directly from &rc, and the unique case would lose nothing.

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.

Fixed: an oversized chunk is now cut directly from the borrow, so a shared body is copied once, into its pieces.

Comment thread src/ore/src/pool.rs
if !inner.reserve_insert(len_bytes) {
inner.enforce_budget();
if !inner.reserve_insert(len_bytes) {
return self.insert_extent(len, class, hints, codec, fill);

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.

With spill threads on, evictions complete asynchronously, so resident_bytes only drops after a spill finishes. Under sustained load, resident bytes stay at budget + slack and this fallback runs SwapExtent::write on the dataflow worker for every insert. That bounds memory but undoes what the spill threads offload, exactly when they are behind. The description flags this. Could we get a number for direct_extent_inserts_total / inserts_total on a hydration with spill threads on before merging? Waiting briefly for spill progress before falling back might be the better trade.

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.

On the synchronous path it didn't fire: 0 of 728,818 inserts over a 25M-row window of a 192M-row hydration with four spill workers. The async-merge path, which is not in this stack, hit 15%. I'm getting the ratio for the landing-stack hydrations as well. If it's material, I'll add a bounded wait for spill progress before the fallback. The numbers are now in #39374's description.

Comment thread src/ore/src/pool.rs Outdated
.load(Ordering::Relaxed)
let budget = self.budget_bytes.load(Ordering::Relaxed);
let floor = budget
.saturating_add(insert_slack(budget))

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.

This shrinks the compressed-resident tier by budget / 8 for every pool with an RSS target, even when no insertion is using the slack. It's a behavior change for existing deployments that the description doesn't mention. Either call it out, or subtract only the slack currently in use (resident_bytes - budget, clamped at zero).

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.

Fixed in #39374: the cap now subtracts only the slack in use, clamped to the eighth, so the compressed tier is unchanged when nothing is using it. unused_insert_slack_leaves_compressed_tier_intact fails on the old cap.

Comment thread src/timely-util/src/columnar/batcher.rs Outdated
// to the ship size but is only a cadence hint for heterogeneous
// ones. `ColumnChunk::settle` splits oversized chunks at record
// boundaries; the merge-batcher callers ship results as cut.
let average_bytes = input_bytes / (upper_l + upper_r).max(1);

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.

The average is taken over both whole inputs. If one side is narrow and the other wide, the stretch between checks can consume only wide rows and overshoot. Even for uniform rows the margin is thin: the check fires at 1.8 MiB and a stretch adds up to 0.2 MiB, so the worst case is just under 2.0 MiB before framing. The comment says merge-batcher output ships as cut without settle. Can those chunks reach the pool? If so, a test with mixed row widths would help.

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.

No. This merger feeds ColumnMergeBatcher, whose output goes to the column pager, which has no pool size classes. I've corrected the comment: the cadence bounds uniform rows to just under the ship size, it is only a hint for mixed widths, and only ColumnChunk output needs the hard bound, which settle enforces.

Comment thread src/timely-util/src/columnar/batcher.rs Outdated
const RESERVE_RECORD_THRESHOLD: usize = 1_000_000;
if upper_l + upper_r <= RESERVE_RECORD_THRESHOLD {
use columnar::Container as _;
// The reservation covers both whole inputs, however little of

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.

nit: the rewrite drops two pieces of rationale that still apply. One is why reserve_for walks each input's as_bytes instead of reserving a record count (variable-length leaves). The other is why the capacity check is amortized (it walks every leaf slice). Worth keeping both.

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.

Restored both.

Comment thread src/ore/src/pool.rs
) -> ChunkHandle {
// Compress synchronously to keep denied insertions from queuing
// uncompressed payloads behind an occupied enforcer.
let mut words = vec![0; len];

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.

nit: this allocates up to one full size class on the heap, outside any accounting, and in debug builds writes it twice (zero, then poison). Harmless, but worth noting in the comment. Chunks inserted this way also pay full compression even if they die young, so under pressure writes_elided under-reports.

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.

Noted in the doc comment, in #39374.

@@ -2140,7 +2323,7 @@ mod tests {
#[cfg_attr(miri, ignore)] // too slow
fn settle_commits_at_accumulated_depth() {

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.

nit: shrinking this to 60k rows means no test now settles a single Resident chunk above COMMIT_BYTES at a non-zero depth together with a small neighbor. settle_split_preserves_depth covers the split, but not the coalescing that follows 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.

Added settle_split_then_coalesce_keeps_depth: an oversized depth-2 chunk with a small depth-0 neighbor, checking that every committed piece keeps depth 2 and stays within the bound.

@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from 7cf7ece to 0d5d05d Compare September 30, 2026 14:56
@DAlperin
DAlperin removed this pull request from stack #39045 September 30, 2026 14:57
@DAlperin DAlperin changed the title timely-util: bound column chunk bytes and pool insertion debt timely-util: bound serialized column chunks and merge allocations Sep 30, 2026
@DAlperin
DAlperin changed the base branch from main to dov/pool-insertion-debt September 30, 2026 14:57
@DAlperin
DAlperin added this pull request to stack #39375 September 30, 2026 14:57
@DAlperin

Copy link
Copy Markdown
Member Author

Split: the pool change is now #39374, below this one in the stack, and this PR keeps the chunker. The benchmark numbers above measured both commits together.

@def-

def- commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- push_cuts holds a full copy of an oversized update at every halving level

src/timely-util/src/columnar/chunk.rs:377

push_cuts recurses on &part while part stays alive, so every level on the path to an update larger than COMMIT_BYTES keeps its own copy of that update. Transient heap during one settle or chunker extract is about (ceil(log2 n) + 1) × W, outside the pool budget. For a 64 MiB update that is roughly 640 MiB, and it repeats on every merge whose output holds the update.

Details

Measured with a counting allocator on push_bounded, using one wide update in the middle of 20,000 records of about 100 bytes. That is the shape merge_from emits, since it cuts right after the wide record. The table shows peak transient bytes as a multiple of the wide update size W:

W this revision previous revision (average-width recursion) ranges over the original view
16 MiB 12.1× 5.1× 1.2×
64 MiB 10.0× 3.0× 1.0×
256 MiB 8.0× 2.0× 1.0×
2 MiB − 4 KiB (the new test's shape) 8.6× 15.8× 1.3×

Halving fixes the depth for updates just under the bound. For updates well over the bound it makes things worse than the previous revision: the top-level cut already leaves only a few hundred records per piece, so halving costs about 10 levels where average-width cutting took 2 or 3. The inputs are user data, for example a multi-MiB JSON value or a jsonb_agg over a skewed key, arranged next to narrow rows on the chunked batcher or the upsert v2 path.

Fix: recurse over index ranges of the original view instead of the materialized piece. Drop the oversized part before descending, e.g. push_cuts(view, start..end, (end - start).div_ceil(2), depth, out) with view: BorrowedOf<'_, (D, T, R)>. This keeps the depth bound, and peak memory drops to the original plus one piece. The last column of the table measures that variant.

@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from 0d5d05d to 6358932 Compare September 30, 2026 16:07
Base automatically changed from dov/pool-insertion-debt to main September 30, 2026 19:33
DAlperin added a commit that referenced this pull request Sep 30, 2026
### Motivation

When pool enforcement could not make room, concurrent insertions queued
unbounded resident bytes behind a single-flight enforcer. On a
production upsert hydration with about 2 KB rows and 30x more state than
pool budget, the pool ledger reached 7.3x its budget and drove the
container to its working-set limit.

### Description

Reserve resident bytes before filling pool slots. If enforcement cannot
make room within the bounded allowance, an eighth of the budget, encode
the insertion directly to an extent on the inserting thread and expose
that fallback as `mz_column_pool_direct_extent_inserts_total`.

- **RSS target.** The allowance counts against the RSS target only while
insertions are using it, so the compressed-resident tier keeps its full
capacity otherwise. Deployments with an RSS target see no change unless
insertions outrun enforcement.
- **Fallback cost.** The fallback compresses on the dataflow worker,
which is the work the spill threads exist to offload. It fires only when
spill threads are behind. On a synchronous-merge upsert hydration (192M
rows, 400cc, four spill workers) it fired 0 times in 728,818 inserts
over a 25M-row window. On an async-merge build, which is not in this
stack, it fired on 15% of inserts. If it turns out to be common, a
bounded wait for spill progress before falling back is the next step.

### Verification

New unit tests cover a stalled enforcer with concurrent fills, payload
round trips through the fallback, accounting after drops, and a
compressed tier left intact by unused slack. Benchmark results for this
change and the chunk bounds in #39042, which were measured together, are
in [this
comment](#39042 (comment)).

Release note: None.

Generated with [Claude Code](https://claude.com/claude-code)
@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from 6358932 to 5767082 Compare September 30, 2026 19:33

@antiguru antiguru 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.

Posted by Claude Code on behalf of @antiguru.

I re-checked the fixes on the current head. The carry fill, the single copy when cutting by borrow, the restored comments, and the new tests all look right. The carry fill always makes progress and preserves order and depth.

One open item, inline: push_cuts holds a copy of an oversized update at every halving level. The QA bot measured 8–12x the update size, and the code confirms the mechanism. It's worth fixing before merge, since wide values like large JSON or jsonb_agg output are realistic.

Comment thread src/timely-util/src/columnar/chunk.rs Outdated
part.extend_from_self(view, start..end);
let part = Column::Typed(part);
if end - start > 1 && part.length_in_bytes() > COMMIT_BYTES {
Self::push_cuts(&part, (end - start).div_ceil(2), depth, out);

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.

This confirms the QA bot's finding. part stays alive while the recursive call materializes its own pieces, so each halving level on the path to an oversized update holds another copy of that update. For one update of W bytes among narrow rows, the top-level cut leaves a few hundred records per piece. Halving that down to a single record takes about log2(records) levels, each holding about W, all outside the pool budget, and this repeats on every settle or extract that sees the update.

Recursing over index ranges of the original view keeps the depth bound and limits peak memory to one piece: compute the piece's byte size from view over start..end, or materialize it only when it fits, and otherwise descend with push_cuts(view, start..end, (end - start).div_ceil(2), …).

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.

Fixed: push_cuts now recurses over index ranges of the original view and drops an oversized piece before descending. Peak transient memory is the input plus one piece, and the depth stays at most log2 of the record count. Thanks to the QA review for the measurements.

@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from 5767082 to 848b93b Compare October 1, 2026 14:26
@DAlperin
DAlperin requested a review from antiguru October 1, 2026 16:49

@antiguru antiguru 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.

Thank you!

@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from 848b93b to 87d70d2 Compare October 1, 2026 16:56
Merged column chunks were cut once they crossed 2 MiB rather than bounded
to it, and the size check ran every 1024 records, so wide rows produced
chunks well past the 2 MiB size class, each taking a 4 MiB pool slot.

Settle and the chunker split an oversized column into pieces of at most
2 MiB, preserving order and generational depth. Pieces are cut at the
column's average record width, so uniform rows fill their size class and
leave one short remainder that settle coalesces with its neighbor. A single
update larger than the bound stays whole. Settle coalesces only while the
combined chunk stays within the bound.

Column::merge_from sets its size-check cadence from the inputs' average
width and caps each gallop run at the next check, so uniform rows stay
within the ship size. It reserves output capacity only when both inputs fit
within two ship-sized chunks.

Tests cover the chunker, merge output before settle, and settle byte
bounds, depth preservation when a shared body is split, and an indivisible
oversized update.
@DAlperin
DAlperin force-pushed the dov/column-chunk-bounds branch from 87d70d2 to 6ace7c8 Compare October 1, 2026 19:07
@DAlperin
DAlperin merged commit 32d39e6 into main Oct 1, 2026
82 checks passed
@DAlperin
DAlperin deleted the dov/column-chunk-bounds branch October 1, 2026 19:41
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.

3 participants