differential_dataflow/operators/reduce.rs
1//! Applies a reduction function on records grouped by key.
2//!
3//! The `reduce` operator acts on `(key, val)` data.
4//! Records with the same key are grouped together, and a user-supplied reduction function is applied
5//! to the key and the list of values.
6//! The function is expected to populate a list of output values.
7//!
8//! The output can change at times that are joins of input times, not only at input times themselves,
9//! and the operator must determine at which times to re-evaluate the reduction. A machine-checked
10//! account of which times suffice lives in `formal/Differential/Coverage.lean`.
11
12use timely::progress::frontier::Antichain;
13use timely::progress::Timestamp;
14use timely::dataflow::operators::Operator;
15use timely::dataflow::operators::CapabilitySet;
16use timely::dataflow::channels::pact::Pipeline;
17
18use crate::operators::arrange::{Arranged, TraceAgent};
19use crate::trace::{Span, ExertionLogic, Trace, TraceReader};
20
21/// Sort and deduplicate a list. Shared by the cursor and proxy tactics, which
22/// each previously carried an identical copy.
23#[inline(never)]
24pub(crate) fn sort_dedup<T: Ord>(list: &mut Vec<T>) {
25 list.dedup();
26 list.sort();
27 list.dedup();
28}
29
30/// A type that resolves a key-wise reduction over batches arriving on the input.
31///
32/// Unlike join, reduce does not suspend: its output is at most linear in its input, so a single
33/// `retire` runs the whole `[lower, upper)` interval to completion rather than yielding under a fuel
34/// budget.
35pub trait ReduceTactic<T, B1, B2> {
36 /// Retire the interval `[lower, upper)`, producing the output batch it informs.
37 ///
38 /// It is presented with the pre-existing input batches and output batches (those before `lower`),
39 /// the new input batches, and `held`: the times the operator currently holds capabilities for. It
40 /// reasons only about times, returning the output batch to ship — `None` when the interval holds
41 /// no work at all — and the new frontier of interesting times for the operator to hold.
42 ///
43 /// # Contract
44 ///
45 /// The driver ([`reduce_with_tactic`]) relies on the following; the first is cheap to check
46 /// and is `debug_assert!`ed there.
47 ///
48 /// * **Spanning output.** The returned batch's description is exactly `[lower, upper)`. The
49 /// driver ships it stamped with the held times not in advance of `upper`, which justify its
50 /// contents: every update time lies at or beyond one of them (a time at or beyond only the
51 /// remaining held times would be in advance of `upper`, and so outside the interval).
52 /// * **Frontier bounds withheld work, and collapses to empty when there is none.** The returned
53 /// frontier must be at-or-below every time the tactic defers, so the driver knows what is safe to
54 /// release. In particular, with no work to defer it must be the *empty* antichain. Derive it from
55 /// the actual withheld set rather than constructing it and this holds for free; returning a
56 /// non-empty frontier with nothing pending holds capabilities forever and **deadlocks recursive
57 /// scopes**. (Not driver-checkable — the withheld set is tactic-internal — so tactics self-enforce.)
58 fn retire(
59 &mut self,
60 source_batches: Vec<B1>,
61 output_batches: Vec<B2>,
62 input_batches: Vec<B1>,
63 lower: &Antichain<T>,
64 upper: &Antichain<T>,
65 held: &Antichain<T>,
66 ) -> (Option<Span<T, B2>>, Antichain<T>);
67}
68
69pub use crate::operators::cursor::reduce::reduce_trace;
70
71/// Drives a key-wise reduction using a supplied [`ReduceTactic`].
72///
73/// This is the general reduce operator: it does the dataflow plumbing (frontiers, capabilities, output
74/// trace maintenance) and routes the per-interval work through the tactic. It requires only
75/// `TraceReader` of its input and `Trace` of its output, never `Navigable`: it extracts batches via
76/// `spans_through`, and building cursors over them (if that is how the reduce proceeds) is the
77/// tactic's concern.
78pub fn reduce_with_tactic<'scope, Tr1, Tr2, T>(trace: Arranged<'scope, Tr1>, name: &str, mut tactic: T) -> Arranged<'scope, TraceAgent<Tr2>>
79where
80 Tr1: TraceReader + 'static,
81 Tr2: Trace<Time = Tr1::Time> + 'static,
82 T: ReduceTactic<Tr1::Time, Tr1::Batch, Tr2::Batch> + 'static,
83{
84 let mut result_trace = None;
85
86 // fabricate a data-parallel operator that holds capabilities and consults its input frontier.
87 let stream = {
88
89 let mut source_trace = trace.trace;
90 let result_trace = &mut result_trace;
91 let scope = trace.stream.scope();
92 trace.stream.unary_frontier(Pipeline, name, move |_capability, operator_info| {
93
94 // Acquire a logger for arrange events.
95 let logger = scope.worker().logger_for::<crate::logging::DifferentialEventBuilder>("differential/arrange").map(Into::into);
96
97 let activator = Some(scope.activator_for(std::rc::Rc::clone(&operator_info.address)));
98 let mut empty = Tr2::new(operator_info.clone(), logger.clone(), activator);
99 // If there is default exert logic set, install it.
100 if let Some(exert_logic) = scope.worker().config().get::<ExertionLogic>("differential/default_exert_logic").cloned() {
101 empty.set_exert_logic(exert_logic);
102 }
103
104 let (mut output_reader, mut output_writer) = TraceAgent::new(empty, operator_info, logger);
105
106 *result_trace = Some(output_reader.clone());
107
108 // Capabilities for the lower envelope of the interesting times the operator holds.
109 let mut capabilities = CapabilitySet::<Tr1::Time>::default();
110
111 // Upper and lower frontiers for the pending input and output batches to process.
112 let mut upper_limit = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
113 let mut lower_limit = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
114 // The same, but as bounds of source trace batches. The limits above join in the input frontier,
115 // which can put them strictly inside a source batch, where the trace cannot be cut. No update lies
116 // between the two, as the input frontier rules out the times the join adds.
117 let mut source_upper = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
118 let mut source_lower = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
119
120 move |(input, frontier), output| {
121
122 // The operator receives input batches, which it treats as contiguous and will collect and
123 // then process as one batch. It captures the input frontier from the batches, from the upstream
124 // trace, and from the input frontier, and retires the work through that interval.
125 //
126 // Reduce may retain capabilities and need to perform work and produce output at times that
127 // may not be seen in its input. The standard example is that updates at `(0, 1)` and `(1, 0)`
128 // may result in outputs at `(1, 1)` as well, even with no input at that time.
129
130 let mut batch_storage = Vec::new();
131
132 // Downgrade previous upper limit to be current lower limit.
133 lower_limit.clear();
134 lower_limit.extend(upper_limit.borrow().iter().cloned());
135 source_lower.clone_from(&source_upper);
136
137 // Drain input batches in order, capturing capabilities and the last upper.
138 input.for_each(|capability, batches| {
139 for capability in capability.retain_stamp(0).iter() {
140 capabilities.insert(capability.clone());
141 }
142 for batch in batches.drain(..) {
143 source_upper.clone_from(batch.upper());
144 batch_storage.push(batch);
145 }
146 });
147
148 // Pull in any subsequent empty batches we believe to exist.
149 source_trace.advance_upper(&mut source_upper);
150 // Incorporate the input frontier guarantees as well.
151 let mut joined = Antichain::new();
152 crate::lattice::antichain_join_into(&source_upper.borrow()[..], &frontier.frontier()[..], &mut joined);
153 upper_limit = joined;
154
155 // We plan to retire the interval [lower_limit, upper_limit), which should be non-empty to proceed.
156 if upper_limit != lower_limit {
157
158 // Acquire the pre-existing input and output batches preceding the interval. Batch handles
159 // are cheap to clone, so we fetch them whether or not the tactic finds work to do.
160 let source_batches = source_trace.batches_through(source_lower.borrow()).expect("failed to acquire source batches");
161 let output_batches = output_reader.batches_through(lower_limit.borrow()).expect("failed to acquire output batches");
162
163 // The times the operator currently holds capabilities for, as an antichain.
164 let held: Antichain<Tr1::Time> = capabilities.iter().map(|c| c.time().clone()).collect();
165
166 // Retire the interval. The tactic reasons only about times: it returns the output
167 // batch to ship, if any, and the new frontier of interesting times.
168 let (produced, new_frontier) = tactic.retire(source_batches, output_batches, batch_storage.into_iter().filter_map(|b| b.inner).collect(), &lower_limit, &upper_limit, &held);
169
170 // Contract check (see `ReduceTactic::retire`). Cheap, debug-only.
171 debug_assert!(
172 produced.as_ref().is_none_or(|batch| batch.lower() == &lower_limit && batch.upper() == &upper_limit),
173 "ReduceTactic::retire output must span [lower, upper)",
174 );
175
176 // Ship the batch stamped with the capabilities it retires — those not in advance of
177 // the upper limit — and commit it to the output trace. The times are elements of
178 // `held`, so they stay valid until we downgrade.
179 if let Some(batch) = produced {
180 let retiring = capabilities
181 .iter()
182 .filter(|c| !upper_limit.less_equal(c.time()))
183 .cloned()
184 .collect::<CapabilitySet<_>>();
185 output.session(&retiring).give(batch.clone());
186 let stamp = retiring.iter().map(|c| c.time().clone()).collect::<timely::progress::Stamp<_>>();
187 output_writer.insert(batch, stamp);
188 }
189
190 // Downgrade to the frontier the tactic handed back (a no-op when it found no work).
191 capabilities.downgrade(new_frontier);
192
193 // ensure that observed progress is reflected in the output.
194 output_writer.seal(upper_limit.clone());
195
196 // We only anticipate future times in advance of `upper_limit`.
197 source_trace.set_logical_compaction(upper_limit.borrow());
198 output_reader.set_logical_compaction(upper_limit.borrow());
199
200 // We will only slice the data between future batches.
201 source_trace.set_physical_compaction(source_upper.borrow());
202 output_reader.set_physical_compaction(upper_limit.borrow());
203 }
204
205 // Exert trace maintenance if we have been so requested.
206 output_writer.exert();
207 }
208 }
209 )
210 };
211
212 Arranged { stream, trace: result_trace.unwrap() }
213}