krishiv_plan/optimizer/
coalesce.rs1use 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#[non_exhaustive]
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CoalesceAdvice {
15 pub groups: Vec<Vec<usize>>,
17}
18
19pub struct CoalesceRule {
26 min_partition_bytes: u64,
28 target_partition_bytes: u64,
33 min_partitions: usize,
35}
36
37impl CoalesceRule {
38 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 #[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 pub fn target_partition_bytes(&self) -> u64 {
59 self.target_partition_bytes
60 }
61
62 #[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 pub fn min_partitions(&self) -> usize {
87 self.min_partitions
88 }
89
90 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 pub fn advise(&self, stats: &[RuntimeStats]) -> CoalesceAdvice {
121 if stats.is_empty() {
122 return CoalesceAdvice { groups: Vec::new() };
123 }
124
125 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 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 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 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 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 assert!(advice.groups.len() < 18);
355 }
356
357 #[test]
358 fn the_floor_never_invents_partitions_the_stage_does_not_have() {
359 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 let rule = CoalesceRule::new(64 * 1024 * 1024)
375 .with_target_partition_bytes(8 * 1024 * 1024)
376 .with_min_partitions(2);
377 let advice = rule.advise(&stats(16, 4 * 1024 * 1024));
379 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 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}