Skip to main content

krishiv_plan/optimizer/
coalesce.rs

1//! AQE coalesce-small-partitions rule.
2
3use std::collections::HashSet;
4
5use crate::{NodeOp, PhysicalPlan, PlanNode};
6
7use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
8
9const DEFAULT_TARGET_PARTITION_BYTES: u64 = krishiv_common::partition::TARGET_BYTES_PER_PARTITION;
10
11/// Advice returned by the coalesce rule: which partition indices should be merged.
12#[non_exhaustive]
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CoalesceAdvice {
15    /// Groups of partition indices to merge. Each inner `Vec` is one merged partition.
16    pub groups: Vec<Vec<usize>>,
17}
18
19/// Merges partitions whose `memory_bytes` falls below `min_partition_bytes`.
20///
21/// When coalescing is beneficial (i.e. the advised group count is smaller than
22/// the current partition count), `apply` rewrites the physical plan by appending
23/// a [`NodeOp::CoalescePartitions`] node that signals downstream operators to
24/// merge the output into `target_partitions` partitions.
25pub struct CoalesceRule {
26    /// Partitions smaller than this threshold (bytes) are candidates for merging.
27    min_partition_bytes: u64,
28    /// Target size for each merged partition (bytes).
29    ///
30    /// Used to determine `target_partitions = ceil(total_bytes / target_partition_bytes)`
31    /// when inserting a `CoalescePartitions` node.  Default: 128 MiB.
32    target_partition_bytes: u64,
33    /// Floor on the coalesced partition count — see [`Self::with_min_partitions`].
34    min_partitions: usize,
35}
36
37impl CoalesceRule {
38    /// Create a new `CoalesceRule` with the given minimum partition byte threshold.
39    ///
40    /// Uses the default `target_partition_bytes` of 128 MiB and no parallelism
41    /// floor; see [`Self::with_min_partitions`].
42    pub fn new(min_partition_bytes: u64) -> Self {
43        Self {
44            min_partition_bytes,
45            target_partition_bytes: DEFAULT_TARGET_PARTITION_BYTES,
46            min_partitions: 1,
47        }
48    }
49
50    /// Set a custom `target_partition_bytes` (bytes per merged output partition).
51    #[must_use]
52    pub fn with_target_partition_bytes(mut self, target_partition_bytes: u64) -> Self {
53        self.target_partition_bytes = target_partition_bytes;
54        self
55    }
56
57    /// Return the configured `target_partition_bytes`.
58    pub fn target_partition_bytes(&self) -> u64 {
59        self.target_partition_bytes
60    }
61
62    /// Never coalesce below `min_partitions` partitions.
63    ///
64    /// Sizing partitions purely by bytes answers "how big should a partition
65    /// be" and never asks "how many workers are there". A stage whose whole
66    /// output is under `target_partition_bytes` collapses to a single group,
67    /// so it runs as one task on one core — measured live on TPC-H q2 at
68    /// SF100, where four stages coalesced to 1 partition and the cluster sat
69    /// at one busy core per executor with eight of nine slots idle. Bytes were
70    /// small; the *work* over them was not, and coalescing cannot see that.
71    ///
72    /// Callers pass the live slot count so the floor tracks the actual
73    /// cluster. This mirrors Spark's `coalescePartitions.parallelismFirst`,
74    /// which shrinks the advisory partition size for the same reason.
75    ///
76    /// The floor is advisory in one direction only: it never *raises* the
77    /// partition count above what the stage already has, because coalescing
78    /// may only merge.
79    #[must_use]
80    pub fn with_min_partitions(mut self, min_partitions: usize) -> Self {
81        self.min_partitions = min_partitions.max(1);
82        self
83    }
84
85    /// Return the configured parallelism floor.
86    pub fn min_partitions(&self) -> usize {
87        self.min_partitions
88    }
89
90    /// Bytes per merged group, shrunk so grouping cannot fall below the floor.
91    ///
92    /// `total_bytes / min_partitions` is the largest group size that still
93    /// leaves `min_partitions` groups. Taking the min with the configured
94    /// target means the floor only ever makes partitions *smaller* — it can
95    /// never inflate them past the size the operator asked for.
96    fn effective_target_bytes(&self, total_bytes: u128) -> u128 {
97        let configured = u128::from(self.target_partition_bytes.max(1));
98        if self.min_partitions <= 1 || total_bytes == 0 {
99            return configured;
100        }
101        let by_parallelism = total_bytes.div_ceil(self.min_partitions as u128).max(1);
102        configured.min(by_parallelism)
103    }
104
105    /// Compute coalesce advice from per-partition stats, without modifying the plan.
106    ///
107    /// Partitions are sorted by `memory_bytes` (ascending) before grouping so
108    /// that all small partitions cluster together regardless of their original
109    /// execution order. Without sorting, a large partition sitting between two
110    /// small ones would prevent them from coalescing (Spark's AQE sorts before
111    /// coalescing for the same reason). Each group of small partitions is
112    /// capped at `target_partition_bytes`. Large partitions are always singleton
113    /// groups.
114    ///
115    /// Each group contains the original partition indices (not sorted indices),
116    /// so callers can map groups back to the original execution order.
117    ///
118    /// Example: `[small(0), big(1), small(2)]` → `[[0,2], [1]]` (2 groups)
119    /// vs. the old consecutive-only approach: `[[0], [1], [2]]` (3 groups, no gain)
120    pub fn advise(&self, stats: &[RuntimeStats]) -> CoalesceAdvice {
121        if stats.is_empty() {
122            return CoalesceAdvice { groups: Vec::new() };
123        }
124
125        // Sort by effective_bytes ascending so small partitions cluster together.
126        // Prefer serialized_bytes over memory_bytes (same logic as in the loop
127        // below). Stable sort preserves original order among equal-size partitions.
128        let mut order: Vec<usize> = (0..stats.len()).collect();
129        order.sort_by_key(|&i| {
130            stats.get(i).map_or(0u128, |s| {
131                u128::from(if s.serialized_bytes > 0 {
132                    s.serialized_bytes
133                } else {
134                    s.memory_bytes
135                })
136            })
137        });
138
139        let mut groups: Vec<Vec<usize>> = Vec::new();
140        let mut current_small: Vec<usize> = Vec::new();
141        let mut current_small_bytes = 0u128;
142        let total_bytes: u128 = stats
143            .iter()
144            .map(|s| {
145                u128::from(if s.serialized_bytes > 0 {
146                    s.serialized_bytes
147                } else {
148                    s.memory_bytes
149                })
150            })
151            .sum();
152        let target_bytes = self.effective_target_bytes(total_bytes);
153
154        for i in order {
155            let Some(s) = stats.get(i) else {
156                continue;
157            };
158            // Prefer serialized_bytes over memory_bytes for the same reason as
159            // AutoPartitionRule: shuffle output is compressed and a better
160            // proxy for actual partition cost than peak in-memory footprint.
161            let effective_bytes = if s.serialized_bytes > 0 {
162                s.serialized_bytes
163            } else {
164                s.memory_bytes
165            };
166            if effective_bytes < self.min_partition_bytes {
167                let partition_bytes = u128::from(effective_bytes);
168                if !current_small.is_empty() && current_small_bytes + partition_bytes > target_bytes
169                {
170                    groups.push(std::mem::take(&mut current_small));
171                    current_small_bytes = 0;
172                }
173                current_small.push(i);
174                current_small_bytes += partition_bytes;
175            } else {
176                if !current_small.is_empty() {
177                    groups.push(std::mem::take(&mut current_small));
178                    current_small_bytes = 0;
179                }
180                groups.push(vec![i]);
181            }
182        }
183        if !current_small.is_empty() {
184            groups.push(current_small);
185        }
186
187        CoalesceAdvice { groups }
188    }
189}
190
191impl AqeRule for CoalesceRule {
192    fn name(&self) -> &str {
193        "coalesce-small-partitions"
194    }
195
196    /// Compute coalesce advice and, when beneficial, rewrite the plan.
197    ///
198    /// When `advise()` produces fewer groups than the current partition count,
199    /// stamps `coalesced_partition_count` on the plan and appends a
200    /// [`NodeOp::CoalescePartitions`] node carrying the computed target count.
201    fn apply(&self, plan: &PhysicalPlan, stats: &[RuntimeStats]) -> Option<PhysicalPlan> {
202        if stats.is_empty() || StreamingAqeGuard::plan_is_streaming(plan) {
203            return None;
204        }
205        let advice = self.advise(stats);
206        let original_count = stats.len();
207
208        if advice.groups.len() >= original_count || original_count == 0 {
209            return None;
210        }
211
212        let target_partitions = advice.groups.len().max(1);
213        if target_partitions >= original_count {
214            return None;
215        }
216
217        tracing::debug!(
218            rule = self.name(),
219            original_partitions = original_count,
220            coalesced_partitions = advice.groups.len(),
221            coalesce_groups = ?advice.groups,
222            target_partitions,
223            "CoalesceRule: {} partition(s) → {} group(s)",
224            original_count,
225            advice.groups.len(),
226        );
227
228        let referenced_ids = plan
229            .nodes()
230            .iter()
231            .flat_map(|node| node.inputs().iter().map(String::as_str))
232            .collect::<HashSet<_>>();
233        let terminal_indexes = plan
234            .nodes()
235            .iter()
236            .enumerate()
237            .filter_map(|(index, node)| (!referenced_ids.contains(node.id())).then_some(index))
238            .collect::<Vec<_>>();
239        if terminal_indexes.len() > 1 {
240            return None;
241        }
242
243        let label = format!("CoalescePartitions({original_count} → {target_partitions})");
244        let existing_coalesce_index = terminal_indexes.first().and_then(|&terminal_index| {
245            let terminal = plan.nodes().get(terminal_index)?;
246            if matches!(terminal.op(), Some(NodeOp::CoalescePartitions { .. })) {
247                return Some(terminal_index);
248            }
249            if matches!(terminal.op(), Some(NodeOp::Sink { .. })) && terminal.inputs().len() == 1 {
250                let input_id = terminal.inputs().first()?;
251                return plan.nodes().iter().position(|node| {
252                    node.id() == input_id
253                        && matches!(node.op(), Some(NodeOp::CoalescePartitions { .. }))
254                });
255            }
256            None
257        });
258        if let Some(existing_coalesce_index) = existing_coalesce_index {
259            let mut updated = PhysicalPlan::new(plan.name(), plan.kind());
260            for (index, node) in plan.nodes().iter().enumerate() {
261                let node = if index == existing_coalesce_index {
262                    node.clone()
263                        .with_label(label.clone())
264                        .with_op(NodeOp::CoalescePartitions { target_partitions })
265                } else {
266                    node.clone()
267                };
268                updated.add_node(node);
269            }
270            return Some(updated.with_coalesced_partition_count(target_partitions));
271        }
272
273        let existing_ids = plan
274            .nodes()
275            .iter()
276            .map(PlanNode::id)
277            .collect::<HashSet<_>>();
278        let mut suffix = 1usize;
279        let coalesce_id = loop {
280            let candidate = if suffix == 1 {
281                "aqe:coalesce".to_string()
282            } else {
283                format!("aqe:coalesce:{suffix}")
284            };
285            if !existing_ids.contains(candidate.as_str()) {
286                break candidate;
287            }
288            suffix = suffix.saturating_add(1);
289        };
290
291        let mut rewritten = PhysicalPlan::new(plan.name(), plan.kind());
292        let mut coalesce_inputs = Vec::new();
293        for (index, node) in plan.nodes().iter().enumerate() {
294            if terminal_indexes.first() == Some(&index)
295                && matches!(node.op(), Some(NodeOp::Sink { .. }))
296                && node.inputs().len() == 1
297            {
298                coalesce_inputs.extend(node.inputs().iter().cloned());
299                rewritten.add_node(node.clone().with_inputs([coalesce_id.clone()]));
300            } else {
301                rewritten.add_node(node.clone());
302            }
303        }
304        if coalesce_inputs.is_empty()
305            && let Some(&terminal_index) = terminal_indexes.first()
306            && let Some(node) = plan.nodes().get(terminal_index)
307        {
308            coalesce_inputs.push(node.id().to_string());
309        }
310        rewritten.add_node(
311            PlanNode::new(coalesce_id, label, plan.kind())
312                .with_inputs(coalesce_inputs)
313                .with_op(NodeOp::CoalescePartitions { target_partitions }),
314        );
315        Some(rewritten.with_coalesced_partition_count(target_partitions))
316    }
317}
318
319#[cfg(test)]
320mod parallelism_floor_tests {
321    use super::CoalesceRule;
322    use crate::optimizer::RuntimeStats;
323
324    /// `n` partitions of `bytes` each — the shape a shuffle stage reports.
325    fn stats(n: usize, bytes: u64) -> Vec<RuntimeStats> {
326        (0..n)
327            .map(|_| RuntimeStats {
328                serialized_bytes: bytes,
329                ..RuntimeStats::default()
330            })
331            .collect()
332    }
333
334    #[test]
335    fn without_a_floor_a_small_stage_collapses_to_one_partition() {
336        // The behaviour being fixed, pinned so the fix is visibly a change:
337        // 18 partitions of 1 MiB is 18 MiB total, under the 128 MiB target,
338        // so byte-only sizing merges the whole stage into a single task.
339        let rule = CoalesceRule::new(64 * 1024 * 1024);
340        let advice = rule.advise(&stats(18, 1024 * 1024));
341        assert_eq!(advice.groups.len(), 1);
342    }
343
344    #[test]
345    fn a_floor_keeps_a_small_stage_spread_across_the_cluster() {
346        let rule = CoalesceRule::new(64 * 1024 * 1024).with_min_partitions(9);
347        let advice = rule.advise(&stats(18, 1024 * 1024));
348        assert_eq!(
349            advice.groups.len(),
350            9,
351            "coalescing must not drop below the cluster's schedulable width",
352        );
353        // Still a real reduction — 18 partitions became 9, not 18.
354        assert!(advice.groups.len() < 18);
355    }
356
357    #[test]
358    fn the_floor_never_invents_partitions_the_stage_does_not_have() {
359        // Four partitions on a nine-slot cluster stay four: coalescing merges,
360        // it cannot split. Asking for nine groups from four inputs would be a
361        // different rule (skew splitting), and silently producing empty groups
362        // would hand the scheduler tasks with no work.
363        let rule = CoalesceRule::new(64 * 1024 * 1024).with_min_partitions(9);
364        let advice = rule.advise(&stats(4, 1024 * 1024));
365        assert!(advice.groups.len() <= 4);
366        assert!(advice.groups.iter().all(|g| !g.is_empty()));
367    }
368
369    #[test]
370    fn the_floor_only_shrinks_partitions_never_grows_them() {
371        // A stage already larger than the target must not have its partitions
372        // inflated past `target_partition_bytes` just because the floor is
373        // low: total/min_partitions could otherwise exceed the target.
374        let rule = CoalesceRule::new(64 * 1024 * 1024)
375            .with_target_partition_bytes(8 * 1024 * 1024)
376            .with_min_partitions(2);
377        // 16 x 4 MiB = 64 MiB total; total/2 = 32 MiB > the 8 MiB target.
378        let advice = rule.advise(&stats(16, 4 * 1024 * 1024));
379        // Groups are capped by the 8 MiB target (2 partitions each), not by
380        // the 32 MiB the floor alone would allow.
381        assert_eq!(advice.groups.len(), 8);
382    }
383
384    #[test]
385    fn a_floor_of_one_is_exactly_the_old_behaviour() {
386        let stats = stats(18, 1024 * 1024);
387        let plain = CoalesceRule::new(64 * 1024 * 1024);
388        let floored = CoalesceRule::new(64 * 1024 * 1024).with_min_partitions(1);
389        assert_eq!(plain.advise(&stats), floored.advise(&stats));
390    }
391
392    #[test]
393    fn every_input_partition_survives_grouping() {
394        // Whatever the floor, coalescing is a partition of the index set:
395        // losing an index loses that partition's rows.
396        for floor in [1usize, 3, 9, 64] {
397            let rule = CoalesceRule::new(64 * 1024 * 1024).with_min_partitions(floor);
398            let advice = rule.advise(&stats(18, 1024 * 1024));
399            let mut seen: Vec<usize> = advice.groups.iter().flatten().copied().collect();
400            seen.sort_unstable();
401            assert_eq!(seen, (0..18).collect::<Vec<_>>(), "floor={floor}");
402        }
403    }
404}