Repository navigation
timely-util: bound serialized column chunks and merge allocations - #39042
Conversation
1350c45 to
aaefc77
Compare
aaefc77 to
a74ddfa
Compare
f14c743 to
7cf7ece
Compare
Benchmark resultsWorkload: 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 ( Equal progress, 30M to 55M indexed rows (the same 25M rows in each run):
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.
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
left a comment
There was a problem hiding this comment.
I think this makes sense, but it would benefit from splitting in two PRs, one for the pool, and another for the chunker.
| ChunkHandle { meta } | ||
| } | ||
|
|
||
| fn insert_extent( |
There was a problem hiding this comment.
Please add a short function doc comment, it's hard to tell from the implementation what exactly this is doing.
There was a problem hiding this comment.
Added a doc comment. This change now lives in #39374, split out below this PR.
| 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); |
There was a problem hiding this comment.
Is this right to call itself recursively? Or am I missing something?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thanks for the explanation, makes sense!
antiguru
left a comment
There was a problem hiding this comment.
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.
| // 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, _)| { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed: an oversized chunk is now cut directly from the borrow, so a shared body is copied once, into its pieces.
| if !inner.reserve_insert(len_bytes) { | ||
| inner.enforce_budget(); | ||
| if !inner.reserve_insert(len_bytes) { | ||
| return self.insert_extent(len, class, hints, codec, fill); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| .load(Ordering::Relaxed) | ||
| let budget = self.budget_bytes.load(Ordering::Relaxed); | ||
| let floor = budget | ||
| .saturating_add(insert_slack(budget)) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| // 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| ) -> ChunkHandle { | ||
| // Compress synchronously to keep denied insertions from queuing | ||
| // uncompressed payloads behind an occupied enforcer. | ||
| let mut words = vec![0; len]; |
There was a problem hiding this comment.
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.
| @@ -2140,7 +2323,7 @@ mod tests { | |||
| #[cfg_attr(miri, ignore)] // too slow | |||
| fn settle_commits_at_accumulated_depth() { | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
7cf7ece to
0d5d05d
Compare
|
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. |
QA LLM Review1. MEDIUM --
|
| 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.
0d5d05d to
6358932
Compare
### 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)
6358932 to
5767082
Compare
antiguru
left a comment
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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), …).
There was a problem hiding this comment.
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.
5767082 to
848b93b
Compare
848b93b to
87d70d2
Compare
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.
87d70d2 to
6ace7c8
Compare
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
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.