Skip to content

Commit 5ae338b

Browse files
committed
timely-util: whole-chunk extract pass-through via resident time bounds
extract loaded every chunk to partition records by the seal frontier, so each seal round-tripped the batcher's entire backlog through the pool, decode and re-encode, even when the frontier split none of it. Spilled bodies now carry their time bounds as resident metadata (the minimal-time antichain and the maximal times), and extract consults them first: a chunk the frontier is entirely past ships whole, one entirely at or past it keeps whole, and both pass through untouched, spilled bodies included. Only chunks the frontier actually splits load. Resident chunks compute the same bounds with a time-column scan, cheaper than the copy and re-commit the pass-through avoids. A kept chunk folds its minimal-time antichain into the residual frontier, which lower-bounds every kept time by construction, preserving the batcher frontier contract for partially ordered times. In a steady-state upsert source, successive seals leave most of the stash untouched (the un-persisted window straddles at most one chunk per seal), so per-seal pool traffic drops from the whole backlog to the straddling chunk.
1 parent b293114 commit 5ae338b

1 file changed

Lines changed: 126 additions & 8 deletions

File tree

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

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ use differential_dataflow::trace::chunk::Chunk;
5757
use mz_ore::cast::CastFrom;
5858
use mz_ore::pool::{ChunkHandle, ChunkHints, ExtentCodec, IDENTITY_CODEC, Pool};
5959
use timely::Accountable;
60+
use timely::PartialOrder;
6061
use timely::container::{ContainerBuilder, PushInto};
6162
use timely::dataflow::channels::ContainerBytes;
6263
use timely::progress::Timestamp;
63-
use timely::progress::frontier::AntichainRef;
64+
use timely::progress::frontier::{Antichain, AntichainRef};
6465

6566
use crate::columnar::batcher::{ColumnChunker, gallop};
6667
use crate::columnar::unload::UnloadChunk;
@@ -243,15 +244,24 @@ fn rr<'b, 'a: 'b, C: Columnar>(item: columnar::Ref<'a, C>) -> columnar::Ref<'b,
243244

244245
/// A spilled chunk body: the serialized column in the pool, plus the resident
245246
/// metadata every [`Chunk`] must answer without fetching. That metadata is
246-
/// the record count and the first and last data items (the fence entries
247-
/// [`UnloadChunk::locate`] consults).
248-
pub struct SpilledBody<D: Columnar> {
247+
/// the record count, the first and last data items (the fence entries
248+
/// [`UnloadChunk::locate`] consults), and the time bounds `extract` consults
249+
/// to pass frontier-disjoint chunks through without loading them.
250+
pub struct SpilledBody<D: Columnar, T> {
249251
/// Number of updates in the body.
250252
records: usize,
251253
/// The first and last data items, as a two-element container. One
252254
/// container rather than two singletons, so the leaf allocations are not
253255
/// duplicated per fence.
254256
fences: D::Container,
257+
/// The minimal times in the body: a lower bound antichain every
258+
/// contained time is greater-or-equal to. Folded into `extract`'s
259+
/// residual frontier when the chunk is kept whole.
260+
time_lower: Antichain<T>,
261+
/// The maximal times in the body. Some contained time is
262+
/// greater-or-equal to a frontier exactly when some maximal time is,
263+
/// which is `extract`'s ship-whole test.
264+
time_upper: Vec<T>,
255265
/// The chunk's generational depth, mirrored into the pool's
256266
/// [`ChunkHints`] at spill time.
257267
depth: u8,
@@ -271,7 +281,7 @@ pub enum ColumnChunk<D: Columnar, T: Columnar, R: Columnar> {
271281
/// Body on the heap, shared via `Rc`, with its generational depth.
272282
Resident(Rc<Column<(D, T, R)>>, u8),
273283
/// Body in the pool. See [`SpilledBody`].
274-
Spilled(Rc<SpilledBody<D>>),
284+
Spilled(Rc<SpilledBody<D, T>>),
275285
}
276286

277287
impl<D: Columnar, T: Columnar, R: Columnar> Clone for ColumnChunk<D, T, R> {
@@ -356,7 +366,10 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
356366
/// Commit a non-empty column at the given generational depth: spill it to
357367
/// the pool when spilling is on and the body is worth a slot, else keep it
358368
/// resident.
359-
fn commit(column: Column<(D, T, R)>, depth: u8) -> Self {
369+
fn commit(column: Column<(D, T, R)>, depth: u8) -> Self
370+
where
371+
T: Timestamp,
372+
{
360373
debug_assert!(column.borrow().len() > 0, "chunks must be non-empty");
361374
if let Some(pool) = spill_pool() {
362375
if column.length_in_bytes() >= SPILL_MIN_BYTES {
@@ -373,9 +386,13 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
373386
/// identity codec: rewritten too soon for compression to amortize, they
374387
/// stay budgeted and swap-backed while encode and decode reduce to
375388
/// copies.
376-
fn spill_body(column: Column<(D, T, R)>, pool: &Pool, depth: u8) -> Self {
389+
fn spill_body(column: Column<(D, T, R)>, pool: &Pool, depth: u8) -> Self
390+
where
391+
T: Timestamp,
392+
{
377393
let codec = codec_for_depth(depth);
378394
let len_bytes = column.length_in_bytes();
395+
let (time_lower, time_upper) = Self::time_bounds(&column);
379396
let view = column.borrow();
380397
let records = view.len();
381398
let mut fences = D::Container::default();
@@ -385,10 +402,48 @@ impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
385402
ColumnChunk::Spilled(Rc::new(SpilledBody {
386403
records,
387404
fences,
405+
time_lower,
406+
time_upper,
388407
depth,
389408
handle,
390409
}))
391410
}
411+
412+
/// The chunk's time bounds: stored metadata for spilled bodies, a scan
413+
/// of the time column for resident ones. The scan costs less than the
414+
/// copy it lets `extract` avoid when the chunk passes through whole.
415+
fn chunk_time_bounds(&self) -> (Antichain<T>, Vec<T>)
416+
where
417+
T: Timestamp,
418+
{
419+
match self {
420+
ColumnChunk::Resident(col, _) => Self::time_bounds(col),
421+
ColumnChunk::Spilled(body) => (body.time_lower.clone(), body.time_upper.clone()),
422+
}
423+
}
424+
425+
/// The time bounds of a non-empty column: the antichain of minimal times
426+
/// (every contained time is greater-or-equal to some element) and the
427+
/// set of maximal times (some contained time is greater-or-equal to a
428+
/// frontier exactly when some maximal one is).
429+
fn time_bounds(column: &Column<(D, T, R)>) -> (Antichain<T>, Vec<T>)
430+
where
431+
T: Timestamp,
432+
{
433+
let view = column.borrow();
434+
let times = view.1;
435+
let mut lower = Antichain::new();
436+
let mut upper: Vec<T> = Vec::new();
437+
for i in 0..times.len() {
438+
let t = T::into_owned(rr::<T>(times.get(i)));
439+
if !upper.iter().any(|u| PartialOrder::less_equal(&t, u)) {
440+
upper.retain(|u| !PartialOrder::less_equal(u, &t));
441+
upper.push(t.clone());
442+
}
443+
lower.insert(t);
444+
}
445+
(lower, upper)
446+
}
392447
}
393448

394449
/// Copy a column into a fresh `Typed` column via bulk per-leaf extension.
@@ -592,6 +647,25 @@ where
592647
let Some(chunk) = input.pop_front() else {
593648
return;
594649
};
650+
// Whole-chunk pass-through from the resident time bounds: a chunk
651+
// the frontier is entirely past ships unchanged, one entirely at or
652+
// past the frontier keeps unchanged. Spilled bodies pass through
653+
// without a load, a re-commit, or any codec work; only chunks the
654+
// frontier actually splits are loaded below.
655+
let (time_lower, time_upper) = chunk.chunk_time_bounds();
656+
if time_upper.iter().all(|t| !frontier.less_equal(t)) {
657+
ship.push_back(chunk);
658+
return;
659+
}
660+
if time_lower.elements().iter().all(|m| frontier.less_equal(m)) {
661+
// The residual must lower-bound every kept time, which is the
662+
// chunk's lower bound antichain by construction.
663+
for m in time_lower.elements() {
664+
residual.insert(m.clone());
665+
}
666+
keep.push_back(chunk);
667+
return;
668+
}
595669
// Partitioning rewrites within a generation, so both sides keep the
596670
// input chunk's depth.
597671
let depth = chunk.depth();
@@ -1469,10 +1543,54 @@ mod tests {
14691543
assert_eq!(collect_chunks(out), consolidate(advanced));
14701544
}
14711545

1546+
/// Chunks the extract frontier does not split pass through whole from
1547+
/// their resident time bounds: spilled bodies land on their side still
1548+
/// spilled, with no load or re-commit, and a kept chunk's minimal times
1549+
/// feed the residual frontier.
1550+
#[mz_ore::test]
1551+
#[cfg_attr(miri, ignore)]
1552+
fn extract_passes_frontier_disjoint_chunks_through() {
1553+
set_spill_override(Some(test_pool()));
1554+
let low: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), i % 4, 1)).collect();
1555+
let high: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 6 + i % 4, 1)).collect();
1556+
let spilled_chunk = |data: &[Tuple]| {
1557+
let chunk = TestChunk::commit(build_column(&consolidate(data.to_vec())), 1);
1558+
assert!(chunk.is_spilled());
1559+
chunk
1560+
};
1561+
1562+
// A frontier between the two chunks' time ranges: the low chunk
1563+
// ships whole and the high chunk keeps whole, both still spilled
1564+
// (no load, no re-commit), and the residual is the kept chunk's
1565+
// minimal time.
1566+
let mut input = VecDeque::from([spilled_chunk(&low), spilled_chunk(&high)]);
1567+
let frontier = Antichain::from_elem(5u64);
1568+
let mut residual = Antichain::new();
1569+
let (mut keep, mut ship) = (VecDeque::new(), VecDeque::new());
1570+
while !input.is_empty() {
1571+
TestChunk::extract(
1572+
&mut input,
1573+
frontier.borrow(),
1574+
&mut residual,
1575+
&mut keep,
1576+
&mut ship,
1577+
);
1578+
}
1579+
assert_eq!(ship.len(), 1);
1580+
assert!(ship[0].is_spilled(), "shipped whole: body untouched");
1581+
assert_eq!(keep.len(), 1);
1582+
assert!(keep[0].is_spilled(), "kept whole: body untouched");
1583+
assert_eq!(residual, Antichain::from_elem(6));
1584+
let shipped = ship.pop_front().unwrap().into_column();
1585+
assert_eq!(collect_column(&shipped), consolidate(low));
1586+
let kept = keep.pop_front().unwrap().into_column();
1587+
assert_eq!(collect_column(&kept), consolidate(high));
1588+
set_spill_override(None);
1589+
}
1590+
14721591
/// Extracting a large chunk at an intermediate frontier cuts both sides
14731592
/// into several chunks and partitions exactly by time.
14741593
#[mz_ore::test]
1475-
#[cfg_attr(miri, ignore)]
14761594
fn extract_cuts_large_output() {
14771595
let records: Vec<Tuple> = (0..300_000u64).map(|k| ((k, 0), k % 2, 1)).collect();
14781596
let mut input = VecDeque::from([ColumnChunk::from_column(build_column(&records))]);

0 commit comments

Comments
 (0)