Skip to main content

mz_timely_util/columnar/
consolidate.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! A `ContainerBuilder` that consolidates `(D, T, R)` updates and emits columnar containers.
17//!
18//! Two-level buffering:
19//!
20//! 1. AoS staging `Vec<(D, T, R)>` with a small cap so `consolidate_updates`' `n log n` cost
21//!    stays bounded. Cancellations and same-key updates collapse here before reaching the
22//!    column-shaped storage. A drain-multiple-of-half-cap trick keeps the leftover in staging,
23//!    so cross-batch keys with the same `(D, T)` continue consolidating on the next sort.
24//! 2. SoA accumulator: one sub-container per column. Drains push in fixed-size chunks
25//!    (`DRAIN_CHUNK_ROWS`) with three sequential per-column passes per chunk, checking the
26//!    serialized size per chunk; at the flush threshold the accumulator is serialized into an
27//!    aligned `Vec<u64>` and shipped as `Column::Align`, and the trailing partial on `finish`
28//!    ships as `Column::Typed`.
29//!
30//! Generic over `(D, T, R): Columnar` via the columnar tuple decomposition
31//! `<(D, T, R) as Columnar>::Container = (D::Container, T::Container, R::Container)`.
32
33use std::collections::VecDeque;
34
35use columnar::bytes::indexed;
36use columnar::{Borrow, Columnar, Push};
37use differential_dataflow::Data;
38use differential_dataflow::consolidation::consolidate_updates;
39use differential_dataflow::difference::Semigroup;
40use timely::container::{ContainerBuilder, PushInto};
41
42use crate::columnar::Column;
43
44/// Per-buffer byte budget for the staging cap. Matches the 8 KiB basis DD's
45/// `ConsolidatingContainerBuilder` uses via `timely::container::buffer::default_capacity`.
46const STAGING_BUFFER_BYTES: usize = 8 * 1024;
47
48/// Default items per staging buffer: `2 * STAGING_BUFFER_BYTES / size_of::<(D, T, R)>()`,
49/// matching DD's `ConsolidatingContainerBuilder`. Small enough that O(n log n) sort stays
50/// cheap, large enough to amortize the sort + drain overhead across many pushes.
51fn default_staging_cap<D, T, R>() -> usize {
52    let elem = std::mem::size_of::<(D, T, R)>().max(1);
53    // Floor at 2 so the half-cap drain grain is at least 1.
54    (2 * STAGING_BUFFER_BYTES / elem).max(2)
55}
56/// Target serialized chunk size in u64 words (2 MiB). Bounded slop matters once we put these
57/// behind huge pages.
58const OUTPUT_TARGET_WORDS: usize = 1 << 18;
59/// Flush when within 10% of `OUTPUT_TARGET_WORDS` — matches `ColumnBuilder`'s slop heuristic.
60/// Computed at compile time so the hot loop is a single `cmp`/`jae` instead of a per-row
61/// round-up + divide-by-10.
62const FLUSH_THRESHOLD_WORDS: usize = OUTPUT_TARGET_WORDS - OUTPUT_TARGET_WORDS / 10;
63/// Drain rows from staging in chunks of this size. Inside each chunk we do three sequential
64/// per-column passes — long enough for autovectorization (4–8 vector iterations on NEON / SVE2
65/// 128-bit / AVX2 / SVE 256-bit) — while the outer per-chunk size check bounds overshoot to
66/// `K * row_words`. With a 1 KiB row (128 words) and K=16, worst-case overshoot is 2 KiB,
67/// well under the 10% slop budget on a 2 MiB target.
68const DRAIN_CHUNK_ROWS: usize = 16;
69
70/// A container builder that consolidates `(D, T, R)` updates and emits `Column<(D, T, R)>`.
71/// See the module docs for the two-level buffering pipeline.
72///
73/// Does **not** maintain FIFO ordering (consolidation reorders updates).
74pub struct ConsolidatingColumnBuilder<D, T, R>
75where
76    D: Columnar,
77    T: Columnar,
78    R: Columnar,
79{
80    /// AoS staging buffer for in-place consolidation. Cap = [`Self::staging_cap`].
81    staging: Vec<(D, T, R)>,
82    /// Capacity of `staging`. Drain triggers when `staging.len()` hits this.
83    staging_cap: usize,
84    /// SoA accumulator, one sub-container per column.
85    cur_d: D::Container,
86    cur_t: T::Container,
87    cur_r: R::Container,
88    /// Number of `(D, T, R)` tuples currently in `cur_*`.
89    cur_len: usize,
90    /// Finished columns ready to ship.
91    pending: VecDeque<Column<(D, T, R)>>,
92    /// The currently extracted/finished column.
93    finished: Option<Column<(D, T, R)>>,
94}
95
96impl<D, T, R> Default for ConsolidatingColumnBuilder<D, T, R>
97where
98    D: Columnar,
99    T: Columnar,
100    R: Columnar,
101{
102    fn default() -> Self {
103        let cap = default_staging_cap::<D, T, R>();
104        Self {
105            // Pre-allocate so `push` is unconditional (no per-push capacity check or lazy
106            // reserve branch).
107            staging: Vec::with_capacity(cap),
108            staging_cap: cap,
109            cur_d: D::Container::default(),
110            cur_t: T::Container::default(),
111            cur_r: R::Container::default(),
112            cur_len: 0,
113            pending: VecDeque::new(),
114            finished: None,
115        }
116    }
117}
118
119impl<D, T, R> ConsolidatingColumnBuilder<D, T, R>
120where
121    D: Data + Columnar,
122    T: Data + Columnar,
123    R: Semigroup + Columnar + 'static,
124    (D, T, R): Columnar<Container = (D::Container, T::Container, R::Container)>,
125{
126    /// Sort and consolidate `staging`, then drain a multiple-of-`grain` prefix into the SoA
127    /// accumulator. Pass `1` to drain everything (used by `finish`). Pushes in chunks of
128    /// `DRAIN_CHUNK_ROWS` and flushes mid-drain whenever the accumulator hits
129    /// `FLUSH_THRESHOLD_WORDS`, so a single drain can mint several aligned containers when the
130    /// prefix is large.
131    #[cold]
132    fn consolidate_and_drain(&mut self, grain: usize) {
133        consolidate_updates(&mut self.staging);
134        let drain_n = (self.staging.len() / grain) * grain;
135        if drain_n == 0 {
136            return;
137        }
138
139        // The per-chunk size check reads serialized words, not item count, so
140        // output columns stay bounded for variable-width `D` like `Row`.
141        let mut consumed = 0;
142        while consumed < drain_n {
143            let take = (drain_n - consumed).min(DRAIN_CHUNK_ROWS);
144            let head = &self.staging[consumed..consumed + take];
145            for (d, _, _) in head {
146                self.cur_d.push(d);
147            }
148            for (_, t, _) in head {
149                self.cur_t.push(t);
150            }
151            for (_, _, r) in head {
152                self.cur_r.push(r);
153            }
154            self.cur_len += take;
155            consumed += take;
156
157            let words = {
158                let view = (
159                    self.cur_d.borrow(),
160                    self.cur_t.borrow(),
161                    self.cur_r.borrow(),
162                );
163                indexed::length_in_words(&view)
164            };
165            if words >= FLUSH_THRESHOLD_WORDS {
166                self.flush_aligned();
167            }
168        }
169        self.staging.drain(..consumed);
170    }
171
172    /// Serialize the SoA accumulator into a `Column::Align` via `indexed::encode`, which
173    /// builds the buffer with `Vec::push`/`extend_from_slice` so no memory is initialized
174    /// twice.
175    #[cold]
176    fn flush_aligned(&mut self) {
177        if self.cur_len == 0 {
178            return;
179        }
180        let cur: <(D, T, R) as Columnar>::Container = (
181            std::mem::take(&mut self.cur_d),
182            std::mem::take(&mut self.cur_t),
183            std::mem::take(&mut self.cur_r),
184        );
185        self.cur_len = 0;
186
187        let mut buf: Vec<u64> = Vec::with_capacity(indexed::length_in_words(&cur.borrow()));
188        indexed::encode(&mut buf, &cur.borrow());
189        self.pending.push_back(Column::Align(buf));
190    }
191}
192
193impl<D, T, R> PushInto<(D, T, R)> for ConsolidatingColumnBuilder<D, T, R>
194where
195    D: Data + Columnar,
196    T: Data + Columnar,
197    R: Semigroup + Columnar + 'static,
198    (D, T, R): Columnar<Container = (D::Container, T::Container, R::Container)>,
199{
200    /// Push an element into the staging buffer; consolidate + drain when full.
201    #[inline]
202    fn push_into(&mut self, item: (D, T, R)) {
203        self.staging.push(item);
204        if self.staging.len() == self.staging_cap {
205            self.consolidate_and_drain(self.staging_cap / 2);
206        }
207    }
208}
209
210impl<D, T, R> ContainerBuilder for ConsolidatingColumnBuilder<D, T, R>
211where
212    D: Data + Columnar,
213    T: Data + Columnar,
214    R: Semigroup + Columnar + 'static,
215    (D, T, R): Columnar<Container = (D::Container, T::Container, R::Container)>,
216    <(D, T, R) as Columnar>::Container: Clone,
217{
218    type Container = Column<(D, T, R)>;
219
220    #[inline]
221    fn extract(&mut self) -> Option<&mut Self::Container> {
222        if let Some(c) = self.pending.pop_front() {
223            self.finished = Some(c);
224            self.finished.as_mut()
225        } else {
226            None
227        }
228    }
229
230    #[inline]
231    fn finish(&mut self) -> Option<&mut Self::Container> {
232        if !self.staging.is_empty() {
233            // `multiple = 1` so any remainder also leaves staging.
234            self.consolidate_and_drain(1);
235        }
236        // Trailing partial: ship as `Column::Typed` (no extra serialize copy).
237        if self.cur_len > 0 {
238            let cur: <(D, T, R) as Columnar>::Container = (
239                std::mem::take(&mut self.cur_d),
240                std::mem::take(&mut self.cur_t),
241                std::mem::take(&mut self.cur_r),
242            );
243            self.cur_len = 0;
244            self.pending.push_back(Column::Typed(cur));
245        }
246        self.extract()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use columnar::Index;
253    use columnar::Len;
254    use timely::container::{ContainerBuilder, PushInto};
255
256    use super::*;
257
258    /// Collect every `(D, T, R)` row from a `Column<(u64, u64, i64)>`.
259    fn rows(column: &Column<(u64, u64, i64)>) -> Vec<(u64, u64, i64)> {
260        let borrow = column.borrow();
261        (0..borrow.len())
262            .map(|i| {
263                let r = borrow.get(i);
264                (*r.0, *r.1, *r.2)
265            })
266            .collect()
267    }
268
269    /// Drain a builder by repeatedly calling `extract` then `finish`.
270    fn drain(mut builder: ConsolidatingColumnBuilder<u64, u64, i64>) -> Vec<(u64, u64, i64)> {
271        let mut out: Vec<(u64, u64, i64)> = Vec::new();
272        while let Some(c) = builder.extract() {
273            for r in rows(c) {
274                out.push(r);
275            }
276        }
277        if let Some(c) = builder.finish() {
278            for r in rows(c) {
279                out.push(r);
280            }
281        }
282        out
283    }
284
285    #[mz_ore::test]
286    fn empty_finish_yields_none() {
287        let mut builder: ConsolidatingColumnBuilder<u64, u64, i64> = Default::default();
288        assert!(builder.extract().is_none());
289        assert!(builder.finish().is_none());
290    }
291
292    #[mz_ore::test]
293    fn single_push_finish_yields_one() {
294        let mut builder: ConsolidatingColumnBuilder<u64, u64, i64> = Default::default();
295        builder.push_into((1u64, 0u64, 1i64));
296        let column = builder.finish().expect("one container");
297        assert_eq!(rows(column), vec![(1, 0, 1)]);
298        assert!(builder.finish().is_none());
299    }
300
301    #[mz_ore::test]
302    fn consolidates_on_threshold() {
303        let mut builder: ConsolidatingColumnBuilder<u64, u64, i64> = Default::default();
304        // Push enough +1/-1 pairs to exceed the staging cap several times over and trigger
305        // multiple consolidation cycles. Everything cancels in the staging buffer before
306        // ever reaching the SoA accumulator.
307        let cap = default_staging_cap::<u64, u64, i64>();
308        for _ in 0..(cap * 4) {
309            builder.push_into((7u64, 0u64, 1i64));
310            builder.push_into((7u64, 0u64, -1i64));
311        }
312        assert!(drain(builder).is_empty());
313    }
314
315    #[mz_ore::test]
316    fn cross_batch_consolidation() {
317        // A single key pushed many times must collapse to one row even across many staging
318        // refills. The drain-multiple-of-grain trick keeps the in-progress consolidated row
319        // in staging so subsequent pushes merge into it.
320        let mut builder: ConsolidatingColumnBuilder<u64, u64, i64> = Default::default();
321        let n: i64 = 100_000;
322        for _ in 0..n {
323            builder.push_into((42u64, 0u64, 1i64));
324        }
325        let out = drain(builder);
326        assert_eq!(out, vec![(42, 0, n)]);
327    }
328
329    #[mz_ore::test]
330    fn multiple_distinct_keys() {
331        let mut builder: ConsolidatingColumnBuilder<u64, u64, i64> = Default::default();
332        builder.push_into((1u64, 0u64, 1i64));
333        builder.push_into((2u64, 0u64, 1i64));
334        builder.push_into((1u64, 0u64, 1i64));
335        let mut out = drain(builder);
336        out.sort();
337        assert_eq!(out, vec![(1, 0, 2), (2, 0, 1)]);
338    }
339
340    #[mz_ore::test]
341    #[cfg_attr(miri, ignore)] // too slow
342    fn emits_multiple_containers() {
343        let mut builder: ConsolidatingColumnBuilder<u64, u64, i64> = Default::default();
344        // Enough distinct rows to fill the SoA accumulator past the output target multiple
345        // times. Each row is 24 bytes; ~87k rows ≈ 2 MiB, so 300k pushes should mint at least
346        // 2-3 aligned containers plus a typed partial on `finish`.
347        let n: u64 = 300_000;
348        for d in 0..n {
349            builder.push_into((d, 0u64, 1i64));
350        }
351
352        let mut containers = 0;
353        let mut out: Vec<(u64, u64, i64)> = Vec::new();
354        while let Some(c) = builder.extract() {
355            containers += 1;
356            for r in rows(c) {
357                out.push(r);
358            }
359        }
360        if let Some(c) = builder.finish() {
361            containers += 1;
362            for r in rows(c) {
363                out.push(r);
364            }
365        }
366        assert!(
367            containers > 1,
368            "expected multiple containers, got {containers}"
369        );
370        out.sort();
371        let expected: Vec<(u64, u64, i64)> = (0..n).map(|d| (d, 0u64, 1i64)).collect();
372        assert_eq!(out, expected);
373    }
374}