timely-util: whole-chunk extract pass-through via resident time bounds - #38254
Conversation
5ae338b to
93f419d
Compare
antiguru
left a comment
There was a problem hiding this comment.
Seems fine, left some comments inline, please address before merging. I tried something like this a few years ago before we had columns and the likes, and for the limited set of cases I considered it wasn't magically better, but with the I/O cost, the cost model changes, and this seems to make a lot more sense.
| // The residual must lower-bound every kept time, which is the | ||
| // chunk's lower bound antichain by construction. | ||
| for m in time_lower.elements() { | ||
| residual.insert(m.clone()); |
There was a problem hiding this comment.
| residual.insert(m.clone()); | |
| residual.insert_ref(m); |
| /// Extracting a large chunk at an intermediate frontier cuts both sides | ||
| /// into several chunks and partitions exactly by time. | ||
| #[mz_ore::test] | ||
| #[cfg_attr(miri, ignore)] |
| /// The maximal times in the body. Some contained time is | ||
| /// greater-or-equal to a frontier exactly when some maximal time is, | ||
| /// which is `extract`'s ship-whole test. | ||
| time_upper: Vec<T>, |
There was a problem hiding this comment.
This could be SmallVec because it's at least one element, and mostly one element for non-empty chunks.
| ColumnChunk::Resident(col, _) => Self::time_bounds(col), | ||
| ColumnChunk::Spilled(body) => (body.time_lower.clone(), body.time_upper.clone()), |
There was a problem hiding this comment.
It's a bit unfortunate that we need to clone here. We could avoid it by returning Cow I think? It seems the callers of chunk_time_bounds don't need owned data.
| let view = column.borrow(); | ||
| let times = view.1; |
There was a problem hiding this comment.
| let view = column.borrow(); | |
| let times = view.1; | |
| let (_, times, _) = column.borrow(); |
Maybe?
| let mut lower = Antichain::new(); | ||
| let mut upper: Vec<T> = Vec::new(); | ||
| for i in 0..times.len() { | ||
| let t = T::into_owned(rr::<T>(times.get(i))); |
There was a problem hiding this comment.
This can be expensive for times with owned allocations. Instead, do what Column::extract does: keep a mutable owned_t: T = T::default(), and use T::copy_from(&mut owned_t, times.get(i)) to get an owned variant, re-using the allocation.
| for m in time_lower.elements() { | ||
| residual.insert(m.clone()); | ||
| } |
There was a problem hiding this comment.
This could be residual.extend(&time_lower) or so.
| // past the frontier keeps unchanged. Spilled bodies pass through | ||
| // without a load, a re-commit, or any codec work; only chunks the | ||
| // frontier actually splits are loaded below. | ||
| let (time_lower, time_upper) = chunk.chunk_time_bounds(); |
There was a problem hiding this comment.
Note that this possibly duplicates some work that col.extract does subsequently. I don't see a way around this, tho.
fc348d4 to
4b2d65b
Compare
4b2d65b to
d087cb9
Compare
d087cb9 to
15c9ed3
Compare
### Motivation The upsert v2 (continual feedback) stash moves from its previous representation onto `ChunkBatcher`/`ChunkSpine`: stashed updates live in columnar chunks that participate in the buffer pool, and the feedback arrangement drains through `UnloadChunk` one chunk at a time instead of materializing whole batches. With `enable_upsert_paged_spill` on, stash and arrangement state past the residency budget spills as compressed, budget-accounted extents rather than raw kernel-swapped heap. Benchmarked extensively on AWS (self-managed EKS, MSK source, swap-enabled nodes), August 2026 campaign: * Hydration under pressure, 25cc replica: at 4.5x state-to-memory, v2-spill hydrates in 707 s vs 1187 s for v1 (RocksDB mem-env). At 9x: 1607 s vs 4607 s (v1 goes superlinear, v2 stays near-linear). At 13.5x, v1 and v2-resident crash-loop at the pod swap ceiling and never complete; v2-spill finishes in 2386 s with matching counts. * Swap traffic: v2-spill moves roughly half the swap bytes of v1 at every depth measured. * Steady-state ingest freshness under the same pressure: statistically identical across v1, v2-resident, and v2-spill. * Unpressured regimes: v2-resident tracks v1 within ~10% everywhere; spill costs appear only at a 50cc saturation edge and vanish one size up (or under the two chunk patches below this PR in the stack). The new representation is flagged: `enable_upsert_chunked_stash` (off in production, on and randomized in CI) selects between the `ChunkBatcher` representation above and the previous paged-columnar-merge-batcher + `ValRowSpine` representation, which stays the production default while the chunked flavor earns trust. The operator loop is shared; a small `UpsertStashArm` trait carries the flavor-specific pieces (stash batcher, feedback spine, flush, drain), and the arms are resolved from the config set once at operator construction, so a dataflow keeps its flavor for life. Spilling in either flavor remains gated by `enable_upsert_paged_spill`. ### Tips for reviewer * Stacked on #38253 and #38254 (the two chunk patches the campaign produced); the interesting upsert logic is `upsert_continual_feedback_v2.rs`. * `enable_upsert_paged_spill` is replica-scoped and composes with compute's spill gate as an OR on the process-wide pool. * The unit-test harness runs every scenario under both stash flavors and asserts they produce identical output, so the paged arm is exercised by the same tests as the chunked one. ### Checklist - [ ] This PR has adequate test coverage / QA involvement has been duly considered. ([trigger-ci for additional test/nightly runs](https://trigger-ci.dev.materialize.com/)) - [ ] This PR has an associated up-to-date [design doc](https://github.com/MaterializeInc/materialize/blob/main/doc/developer/design/README.md), is a design doc ([template](https://github.com/MaterializeInc/materialize/blob/main/doc/developer/design/00000000_template.md)), or is sufficiently small to not require a design. - [ ] If this PR evolves [an existing `$T ⇔ Proto$T` mapping](https://github.com/MaterializeInc/materialize/blob/main/doc/developer/command-and-response-binary-encoding.md) (possibly in a backwards-incompatible way), then it is tagged with a `T-proto` label. - [ ] If this PR will require changes to cloud orchestration or tests, there is a companion cloud PR to account for those changes that is tagged with the release-blocker label ([example](MaterializeInc/cloud#5021)). - [x] If this PR includes major [user-facing behavior changes](https://github.com/MaterializeInc/materialize/blob/main/doc/developer/guide-changes.md#what-changes-require-a-release-note), I have pinged the relevant PM to schedule a changelog post.
Motivation
A seal walks every stashed chunk to partition updates against the frontier, and for a spilled chunk that walk previously loaded and decoded the whole body even when no update in it could possibly be extracted (or when all of them must be). During sustained ingest the stash backlog is mostly frontier-disjoint chunks, so per seal this decoded the entire backlog to move one boundary chunk. The August benchmark campaign measured the two stash patches together as a 35% mean and ~2x p90 reduction in co-tenant probe latency at 50cc.
Spilled bodies now carry their time bounds as resident metadata (an antichain lower bound and the maximal time elements).
extract_intouses them for two whole-chunk fast paths: ship the chunk untouched when the frontier is past all of its times, keep it untouched (folding its lower bound into the residual frontier) when none of its times are ready. Only chunks the frontier genuinely straddles are loaded, decoded, and split.Tips for reviewer
Checklist
$T ⇔ Proto$Tmapping (possibly in a backwards-incompatible way), then it is tagged with aT-protolabel.