Skip to content

Commit 98658f5

Browse files
frankmcsherryclaude
andcommitted
row-spine: radix sort the snapshot index and settle equal prefixes per run
The snapshot batcher's seal sorts a (prefix, chunk, position) index. Sort it by prefix with an LSD radix sort that skips the byte positions on which all prefixes agree, and let the gather order runs of equal prefix by full comparison, so the random row reads a comparison costs happen only where the prefix leaves the order undecided. Touch the row sixteen entries ahead in the gather so its cache miss overlaps the current row's work. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 692ba79 commit 98658f5

1 file changed

Lines changed: 229 additions & 28 deletions

File tree

‎src/row-spine/src/snapshot_batcher.rs‎

Lines changed: 229 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ use differential_dataflow::lattice::Lattice;
3535
use differential_dataflow::logging::Logger;
3636
use differential_dataflow::trace::implementations::merge_batcher::MergeBatcher;
3737
use differential_dataflow::trace::{Batcher, Description};
38+
use mz_ore::cast::CastFrom;
3839
use mz_repr::Row;
3940
use mz_timely_util::columnar::Column;
4041
use mz_timely_util::columnation::{ColInternalMerger, ColumnationStack};
@@ -153,40 +154,61 @@ where
153154
let get = |e: &(u64, u32, u32)| {
154155
&pending[usize::try_from(e.1).unwrap()][usize::try_from(e.2).unwrap()]
155156
};
156-
// Full comparisons only on equal prefixes, which `sort_prefix` guarantees agree with the
157-
// row order otherwise.
158-
index.sort_unstable_by(|a, b| {
159-
a.0.cmp(&b.0).then_with(|| {
160-
let (da, ta, _) = get(a);
161-
let (db, tb, _) = get(b);
162-
(da, ta).cmp(&(db, tb))
163-
})
164-
});
157+
// Sort by prefix alone; equal prefixes are settled by full comparison as they are gathered,
158+
// which `sort_prefix` guarantees agrees with the row order otherwise.
159+
radix_sort_by_prefix(&mut index);
165160

166161
let cap = Self::chunk_capacity();
167162
let mut output = Vec::with_capacity(self.pending_len / cap + 1);
168163
let mut result: Chunk<D, T, R> = ColumnationStack::with_capacity(cap);
169-
let mut iter = index.iter().peekable();
170-
while let Some(e) = iter.next() {
171-
let (d, t, r) = get(e);
172-
let mut diff = r.clone();
173-
while let Some(n) = iter.peek() {
174-
let (d2, t2, r2) = get(n);
175-
if (d2, t2).cmp(&(d, t)) == Ordering::Equal {
176-
diff.plus_equals(r2);
177-
iter.next();
178-
} else {
179-
break;
180-
}
164+
// The sorted order visits the held rows at random, so each row is a cache miss. Touching
165+
// the row `LOOKAHEAD` entries ahead lets that miss overlap the work on this row. The
166+
// value read is unused.
167+
const LOOKAHEAD: usize = 16;
168+
let n = index.len();
169+
let mut i = 0;
170+
while i < n {
171+
if i + LOOKAHEAD < n {
172+
let (d, _, _) = get(&index[i + LOOKAHEAD]);
173+
// SAFETY: `d` is a live element of a held chunk.
174+
unsafe { std::ptr::read_volatile(std::ptr::from_ref(d).cast::<u8>()) };
181175
}
182-
if !diff.is_zero() {
183-
if result.len() == cap {
184-
output.push(std::mem::replace(
185-
&mut result,
186-
ColumnationStack::with_capacity(cap),
187-
));
176+
// A run of equal prefixes is the only place rows can compare equal, and the only
177+
// place the prefix order leaves undecided. Order the run by full comparison first.
178+
let prefix = index[i].0;
179+
let mut end = i + 1;
180+
while end < n && index[end].0 == prefix {
181+
end += 1;
182+
}
183+
if end - i > 1 {
184+
index[i..end].sort_unstable_by(|a, b| {
185+
let (da, ta, _) = get(a);
186+
let (db, tb, _) = get(b);
187+
(da, ta).cmp(&(db, tb))
188+
});
189+
}
190+
while i < end {
191+
let (d, t, r) = get(&index[i]);
192+
let mut diff = r.clone();
193+
i += 1;
194+
while i < end {
195+
let (d2, t2, r2) = get(&index[i]);
196+
if (d2, t2).cmp(&(d, t)) == Ordering::Equal {
197+
diff.plus_equals(r2);
198+
i += 1;
199+
} else {
200+
break;
201+
}
202+
}
203+
if !diff.is_zero() {
204+
if result.len() == cap {
205+
output.push(std::mem::replace(
206+
&mut result,
207+
ColumnationStack::with_capacity(cap),
208+
));
209+
}
210+
result.copy_destructured(d, t, &diff);
188211
}
189-
result.copy_destructured(d, t, &diff);
190212
}
191213
}
192214
if !result.is_empty() {
@@ -198,6 +220,55 @@ where
198220
}
199221
}
200222

223+
/// Sorts `index` by its `u64` prefix with a least-significant-digit radix sort, skipping the
224+
/// byte positions on which every prefix agrees.
225+
///
226+
/// Row prefixes share their length and leading tag bytes, so typically three or four of the
227+
/// eight passes run. Each pass streams the index once, against the log-factor of a comparison
228+
/// sort over a 16-byte-entry index that no longer fits in cache. Small inputs use the
229+
/// comparison sort, whose constant is lower.
230+
fn radix_sort_by_prefix(index: &mut Vec<(u64, u32, u32)>) {
231+
const RADIX_MIN: usize = 1 << 16;
232+
let n = index.len();
233+
if n < RADIX_MIN {
234+
index.sort_unstable_by_key(|e| e.0);
235+
return;
236+
}
237+
let mut histograms = [[0u32; 256]; 8];
238+
for (prefix, _, _) in index.iter() {
239+
for (digit, histogram) in histograms.iter_mut().enumerate() {
240+
histogram[usize::cast_from((prefix >> (8 * digit)) & 0xFF)] += 1;
241+
}
242+
}
243+
let mut scratch: Vec<(u64, u32, u32)> = vec![(0, 0, 0); n];
244+
let mut in_index = true;
245+
for (digit, histogram) in histograms.iter().enumerate() {
246+
if histogram.iter().any(|&count| usize::cast_from(count) == n) {
247+
continue;
248+
}
249+
let mut offsets = [0usize; 256];
250+
let mut sum = 0;
251+
for (bucket, &count) in histogram.iter().enumerate() {
252+
offsets[bucket] = sum;
253+
sum += usize::cast_from(count);
254+
}
255+
let (src, dst) = if in_index {
256+
(&*index, &mut scratch)
257+
} else {
258+
(&scratch, &mut *index)
259+
};
260+
for entry in src.iter() {
261+
let bucket = usize::cast_from((entry.0 >> (8 * digit)) & 0xFF);
262+
dst[offsets[bucket]] = *entry;
263+
offsets[bucket] += 1;
264+
}
265+
in_index = !in_index;
266+
}
267+
if !in_index {
268+
std::mem::swap(index, &mut scratch);
269+
}
270+
}
271+
201272
impl<D, T, R> Batcher for SnapshotBatcher<D, T, R>
202273
where
203274
D: SortPrefix + Ord + Columnation + Clone + 'static,
@@ -542,6 +613,136 @@ mod tests {
542613
assert_eq!(keys, vec![4, 3, 2, 1, 0]);
543614
}
544615

616+
/// Timing of the snapshot path on exchange-shaped input. Run with
617+
/// `cargo test --profile optimized -p mz-row-spine -- --ignored --nocapture bench_snapshot`;
618+
/// `BENCH_ROWS` and `BENCH_KEY_MOD` (0 for full-range keys) shape the input.
619+
#[mz_ore::test]
620+
#[ignore]
621+
fn bench_snapshot_path() {
622+
use differential_dataflow::trace::Builder;
623+
use mz_repr::Timestamp;
624+
use std::time::Instant;
625+
626+
let rows_n: usize = std::env::var("BENCH_ROWS")
627+
.ok()
628+
.and_then(|s| s.parse().ok())
629+
.unwrap_or(10_000_000);
630+
let key_mod: u64 = std::env::var("BENCH_KEY_MOD")
631+
.ok()
632+
.and_then(|s| s.parse().ok())
633+
.unwrap_or(0);
634+
let per_container = 200;
635+
let mut containers: Vec<Vec<((Row, Row), Timestamp, i64)>> = Vec::new();
636+
let mut cur = Vec::with_capacity(per_container);
637+
for i in 0..rows_n {
638+
let mut k = u64::cast_from(i).wrapping_mul(0x9E3779B97F4A7C15) >> 1;
639+
if key_mod > 0 {
640+
k %= key_mod;
641+
}
642+
let k = i64::try_from(k).expect("fits after the shift");
643+
let key = Row::pack_slice(&[Datum::Int64(k)]);
644+
let val = Row::pack_slice(&[Datum::Int64(i64::try_from(i).expect("row count fits"))]);
645+
cur.push(((key, val), Timestamp::from(1u64), 1i64));
646+
if cur.len() == per_container {
647+
containers.push(std::mem::take(&mut cur));
648+
}
649+
}
650+
if !cur.is_empty() {
651+
containers.push(cur);
652+
}
653+
println!(
654+
"{} rows in {} containers, key_mod {}",
655+
rows_n,
656+
containers.len(),
657+
key_mod
658+
);
659+
let upper = Antichain::from_elem(Timestamp::from(2u64));
660+
661+
for round in 0..2 {
662+
let t = Instant::now();
663+
let mut ch: UnsortedChunker<(Row, Row), Timestamp, i64> = Default::default();
664+
let mut b: SnapshotBatcher<(Row, Row), Timestamp, i64> = Batcher::new(None, 0);
665+
for c in containers.iter() {
666+
let mut c = c.clone();
667+
ch.push_into(&mut c);
668+
while let Some(chunk) = ch.extract() {
669+
b.push_into(std::mem::take(chunk));
670+
}
671+
}
672+
while let Some(chunk) = ch.finish() {
673+
b.push_into(std::mem::take(chunk));
674+
}
675+
let t_push = t.elapsed();
676+
let (mut chain, desc) = b.seal(upper.clone());
677+
let t_seal = t.elapsed() - t_push;
678+
let batch = crate::RowRowBuilder::<Timestamp, i64>::seal(&mut chain, desc);
679+
let t_build = t.elapsed() - t_push - t_seal;
680+
println!(
681+
"round {round}: chunk {:?} seal {:?} build {:?} total {:?} ({} updates)",
682+
t_push,
683+
t_seal,
684+
t_build,
685+
t.elapsed(),
686+
differential_dataflow::trace::BatchReader::len(&batch)
687+
);
688+
}
689+
}
690+
691+
#[mz_ore::test]
692+
fn radix_sort_matches_comparison_sort() {
693+
// Above the radix threshold, with two constant byte positions to skip and ties.
694+
let mut index: Vec<(u64, u32, u32)> = (0..100_000u32)
695+
.map(|i| {
696+
let scrambled = u64::from(i).wrapping_mul(0x9E3779B97F4A7C15);
697+
let prefix =
698+
(0x0009 << 48) | (scrambled & 0x0000_00FF_FFFF_0000) | (u64::from(i % 7) << 8);
699+
(prefix, i / 1000, i % 1000)
700+
})
701+
.collect();
702+
let mut expected = index.clone();
703+
expected.sort_unstable();
704+
radix_sort_by_prefix(&mut index);
705+
assert!(
706+
index.windows(2).all(|w| w[0].0 <= w[1].0),
707+
"sorted by prefix"
708+
);
709+
index.sort_unstable();
710+
assert_eq!(index, expected, "a permutation of the input");
711+
}
712+
713+
#[mz_ore::test]
714+
fn equal_prefixes_are_ordered_by_full_comparison() {
715+
// Two-column rows sharing the first column share the six-byte prefix; the second column
716+
// must still order them, and equal rows must still consolidate.
717+
let mut updates: Vec<((Row, ()), u64, i64)> = Vec::new();
718+
for y in [5i64, -3, 9, 0, 5, 9] {
719+
let row = Row::pack_slice(&[Datum::Int64(1 << 40), Datum::Int64(y)]);
720+
updates.push(((row, ()), 1, 1));
721+
}
722+
let mut b = B::new(None, 0);
723+
let mut chunk = ColumnationStack::with_capacity(updates.len());
724+
for u in &updates {
725+
chunk.copy(u);
726+
}
727+
b.push_into(chunk);
728+
let (chain, _) = b.seal(upper(2));
729+
let sealed: Vec<(Row, i64)> = chain
730+
.iter()
731+
.flat_map(|c| c.iter())
732+
.map(|((k, ()), _, r)| (k.clone(), *r))
733+
.collect();
734+
let mut expected: Vec<(Row, i64)> = Vec::new();
735+
let mut rows: Vec<Row> = updates.iter().map(|((k, ()), _, _)| k.clone()).collect();
736+
rows.sort();
737+
for row in rows {
738+
match expected.last_mut() {
739+
Some((prev, r)) if *prev == row => *r += 1,
740+
_ => expected.push((row, 1)),
741+
}
742+
}
743+
assert_eq!(sealed, expected);
744+
}
745+
545746
#[mz_ore::test]
546747
fn sort_prefix_agrees_with_row_order() {
547748
let rows: Vec<Row> = [

0 commit comments

Comments
 (0)