Skip to content

Commit bd97ff3

Browse files
authored
timely-util: whole-chunk extract pass-through via resident time bounds (#38254)
### 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_into` uses 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 * The residual-frontier fold is the subtle part for partial orders: a kept chunk contributes its lower bound, not its individual times, which is sound because extraction only needs an upper bound on what remains. * Both fast paths have dedicated tests, including a frontier-disjoint pass-through case. ### 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)). - [ ] 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.
1 parent 0481090 commit bd97ff3

3 files changed

Lines changed: 145 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/timely-util/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ lz4_flex.workspace = true
4040
mz-ore = { path = "../ore", default-features = false, features = ["async", "process", "tracing", "test", "num-traits", "region", "differential-dataflow", "overflowing", "pager", "pool"] }
4141
num-traits.workspace = true
4242
serde.workspace = true
43+
smallvec.workspace = true
4344
timely.workspace = true
4445
tokio.workspace = true
4546
tracing.workspace = true

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

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
//! resident fence metadata so a probe set faults only the chunk bodies it
4545
//! actually touches.
4646
47+
use std::borrow::Cow;
4748
#[cfg(test)]
4849
use std::cell::Cell;
4950
use std::cell::RefCell;
@@ -58,11 +59,13 @@ use differential_dataflow::lattice::Lattice;
5859
use differential_dataflow::trace::chunk::Chunk;
5960
use mz_ore::cast::CastFrom;
6061
use mz_ore::pool::{ChunkHandle, ChunkHints, ExtentCodec, IDENTITY_CODEC, Pool};
62+
use smallvec::SmallVec;
6163
use timely::Accountable;
64+
use timely::PartialOrder;
6265
use timely::container::{ContainerBuilder, PushInto};
6366
use timely::dataflow::channels::ContainerBytes;
6467
use timely::progress::Timestamp;
65-
use timely::progress::frontier::AntichainRef;
68+
use timely::progress::frontier::{Antichain, AntichainRef};
6669

6770
use crate::columnar::batcher::{ColumnChunker, gallop};
6871
use crate::columnar::unload::UnloadChunk;
@@ -258,15 +261,25 @@ fn rr<'b, 'a: 'b, C: Columnar>(item: columnar::Ref<'a, C>) -> columnar::Ref<'b,
258261

259262
/// A spilled chunk body: the serialized column in the pool, plus the resident
260263
/// metadata every [`Chunk`] must answer without fetching. That metadata is
261-
/// the record count and the first and last data items (the fence entries
262-
/// [`UnloadChunk::locate`] consults).
263-
pub struct SpilledBody<D: Columnar> {
264+
/// the record count, the first and last data items (the fence entries
265+
/// [`UnloadChunk::locate`] consults), and the time bounds `extract` consults
266+
/// to pass frontier-disjoint chunks through without loading them.
267+
pub struct SpilledBody<D: Columnar, T> {
264268
/// Number of updates in the body.
265269
records: usize,
266270
/// The first and last data items, as a two-element container. One
267271
/// container rather than two singletons, so the leaf allocations are not
268272
/// duplicated per fence.
269273
fences: D::Container,
274+
/// The minimal times in the body: a lower bound antichain every
275+
/// contained time is greater-or-equal to. Folded into `extract`'s
276+
/// residual frontier when the chunk is kept whole.
277+
time_lower: Antichain<T>,
278+
/// The maximal times in the body. Some contained time is
279+
/// greater-or-equal to a frontier exactly when some maximal time is,
280+
/// which is `extract`'s ship-whole test. A single element for totally
281+
/// ordered times, hence the inline capacity.
282+
time_upper: SmallVec<[T; 1]>,
270283
/// Whether the body was inserted under the compressing codec. The pool
271284
/// stores the codec itself and reads decode through it, so this is the
272285
/// only handle chunk code has on what a body is stored as, and it is
@@ -298,7 +311,7 @@ pub enum ColumnChunk<D: Columnar, T: Columnar, R: Columnar> {
298311
/// Body on the heap, shared via `Rc`, with its generational depth.
299312
Resident(Rc<Column<(D, T, R)>>, u8),
300313
/// Body in the pool, with its generational depth. See [`SpilledBody`].
301-
Spilled(Rc<SpilledBody<D>>, u8),
314+
Spilled(Rc<SpilledBody<D, T>>, u8),
302315
}
303316

304317
impl<D: Columnar, T: Columnar, R: Columnar> Clone for ColumnChunk<D, T, R> {
@@ -382,7 +395,10 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
382395
/// Commit a non-empty column at the given generational depth: spill it to
383396
/// the pool when spilling is on and the body is worth a slot, else keep it
384397
/// resident.
385-
fn commit(column: Column<(D, T, R)>, depth: u8) -> Self {
398+
fn commit(column: Column<(D, T, R)>, depth: u8) -> Self
399+
where
400+
T: Timestamp,
401+
{
386402
mz_ore::soft_assert_no_log!(!column.is_empty(), "chunks must be non-empty");
387403
if let Some(pool) = spill_pool() {
388404
if column.length_in_bytes() >= SPILL_MIN_BYTES {
@@ -399,9 +415,13 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
399415
/// identity codec: rewritten too soon for compression to amortize, they
400416
/// stay budgeted and swap-backed while encode and decode reduce to
401417
/// copies.
402-
fn spill_body(column: Column<(D, T, R)>, pool: &Pool, depth: u8) -> Self {
418+
fn spill_body(column: Column<(D, T, R)>, pool: &Pool, depth: u8) -> Self
419+
where
420+
T: Timestamp,
421+
{
403422
let (codec, compressed) = codec_for_depth(depth);
404423
let len_bytes = column.length_in_bytes();
424+
let (time_lower, time_upper) = Self::time_bounds(&column);
405425
let view = column.borrow();
406426
let records = view.len();
407427
let mut fences = D::Container::default();
@@ -412,6 +432,8 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
412432
Rc::new(SpilledBody {
413433
records,
414434
fences,
435+
time_lower,
436+
time_upper: time_upper.into(),
415437
compressed,
416438
handle,
417439
}),
@@ -440,7 +462,10 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
440462
/// A shared body is skipped because re-spilling this reference cannot
441463
/// change what the other holder stores, and the compaction merger that
442464
/// shares bodies rewrites its clones immediately.
443-
fn survive_merge(self) -> Self {
465+
fn survive_merge(self) -> Self
466+
where
467+
T: Timestamp,
468+
{
444469
let depth = self.depth().saturating_add(1);
445470
match self {
446471
ColumnChunk::Resident(col, _) => ColumnChunk::Resident(col, depth),
@@ -459,6 +484,52 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
459484
}
460485
}
461486
}
487+
488+
/// The chunk's time bounds: borrowed from the stored metadata for
489+
/// spilled bodies, computed by a time-column scan for resident ones. The
490+
/// scan costs less than the copy it lets `extract` avoid when the chunk
491+
/// passes through whole.
492+
fn chunk_time_bounds(&self) -> (Cow<'_, Antichain<T>>, Cow<'_, [T]>)
493+
where
494+
T: Timestamp,
495+
{
496+
match self {
497+
ColumnChunk::Resident(col, _) => {
498+
let (lower, upper) = Self::time_bounds(col);
499+
(Cow::Owned(lower), Cow::Owned(upper))
500+
}
501+
ColumnChunk::Spilled(body, _) => (
502+
Cow::Borrowed(&body.time_lower),
503+
Cow::Borrowed(&body.time_upper[..]),
504+
),
505+
}
506+
}
507+
508+
/// The time bounds of a non-empty column: the antichain of minimal times
509+
/// (every contained time is greater-or-equal to some element) and the
510+
/// set of maximal times (some contained time is greater-or-equal to a
511+
/// frontier exactly when some maximal one is).
512+
fn time_bounds(column: &Column<(D, T, R)>) -> (Antichain<T>, Vec<T>)
513+
where
514+
T: Timestamp,
515+
{
516+
let (_, times, _) = column.borrow();
517+
let mut lower = Antichain::new();
518+
let mut upper: Vec<T> = Vec::new();
519+
// One owned time reused across the scan, so times with owned
520+
// allocations do not allocate per element; the bound sets clone only
521+
// the elements they retain.
522+
let mut time = T::minimum();
523+
for i in 0..times.len() {
524+
time.copy_from(rr::<T>(times.get(i)));
525+
if !upper.iter().any(|u| PartialOrder::less_equal(&time, u)) {
526+
upper.retain(|u| !PartialOrder::less_equal(u, &time));
527+
upper.push(time.clone());
528+
}
529+
lower.insert_ref(&time);
530+
}
531+
(lower, upper)
532+
}
462533
}
463534

464535
/// Copy a column into a fresh `Typed` column via bulk per-leaf extension.
@@ -666,6 +737,25 @@ where
666737
let Some(chunk) = input.pop_front() else {
667738
return;
668739
};
740+
// Whole-chunk pass-through from the resident time bounds: a chunk
741+
// the frontier is entirely past ships unchanged, one entirely at or
742+
// past the frontier keeps unchanged. Spilled bodies pass through
743+
// without a load, a re-commit, or any codec work; only chunks the
744+
// frontier actually splits are loaded below.
745+
let (time_lower, time_upper) = chunk.chunk_time_bounds();
746+
if time_upper.iter().all(|t| !frontier.less_equal(t)) {
747+
ship.push_back(chunk);
748+
return;
749+
}
750+
if time_lower.elements().iter().all(|m| frontier.less_equal(m)) {
751+
// The residual must lower-bound every kept time, which is the
752+
// chunk's lower bound antichain by construction.
753+
for m in time_lower.elements() {
754+
residual.insert_ref(m);
755+
}
756+
keep.push_back(chunk);
757+
return;
758+
}
669759
// Partitioning rewrites within a generation, so both sides keep the
670760
// input chunk's depth.
671761
let depth = chunk.depth();
@@ -1553,6 +1643,51 @@ mod tests {
15531643
assert_eq!(collect_chunks(out), consolidate(advanced));
15541644
}
15551645

1646+
/// Chunks the extract frontier does not split pass through whole from
1647+
/// their resident time bounds: spilled bodies land on their side still
1648+
/// spilled, with no load or re-commit, and a kept chunk's minimal times
1649+
/// feed the residual frontier.
1650+
#[mz_ore::test]
1651+
#[cfg_attr(miri, ignore)]
1652+
fn extract_passes_frontier_disjoint_chunks_through() {
1653+
set_spill_override(Some(test_pool()));
1654+
let low: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), i % 4, 1)).collect();
1655+
let high: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 6 + i % 4, 1)).collect();
1656+
let spilled_chunk = |data: &[Tuple]| {
1657+
let chunk = TestChunk::commit(build_column(&consolidate(data.to_vec())), 1);
1658+
assert!(chunk.is_spilled());
1659+
chunk
1660+
};
1661+
1662+
// A frontier between the two chunks' time ranges: the low chunk
1663+
// ships whole and the high chunk keeps whole, both still spilled
1664+
// (no load, no re-commit), and the residual is the kept chunk's
1665+
// minimal time.
1666+
let mut input = VecDeque::from([spilled_chunk(&low), spilled_chunk(&high)]);
1667+
let frontier = Antichain::from_elem(5u64);
1668+
let mut residual = Antichain::new();
1669+
let (mut keep, mut ship) = (VecDeque::new(), VecDeque::new());
1670+
while !input.is_empty() {
1671+
TestChunk::extract(
1672+
&mut input,
1673+
frontier.borrow(),
1674+
&mut residual,
1675+
&mut keep,
1676+
&mut ship,
1677+
);
1678+
}
1679+
assert_eq!(ship.len(), 1);
1680+
assert!(ship[0].is_spilled(), "shipped whole: body untouched");
1681+
assert_eq!(keep.len(), 1);
1682+
assert!(keep[0].is_spilled(), "kept whole: body untouched");
1683+
assert_eq!(residual, Antichain::from_elem(6));
1684+
let shipped = ship.pop_front().unwrap().into_column();
1685+
assert_eq!(collect_column(&shipped), consolidate(low));
1686+
let kept = keep.pop_front().unwrap().into_column();
1687+
assert_eq!(collect_column(&kept), consolidate(high));
1688+
set_spill_override(None);
1689+
}
1690+
15561691
/// Extracting a large chunk at an intermediate frontier cuts both sides
15571692
/// into several chunks and partitions exactly by time.
15581693
#[mz_ore::test]

0 commit comments

Comments
 (0)