Skip to main content

krishiv_plan/optimizer/
broadcast_runtime.rs

1//! AQE runtime broadcast-join promotion/demotion rule.
2
3use crate::{Partitioning, PhysicalPlan};
4
5use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
6
7/// Default maximum observed output size for runtime broadcast promotion:
8/// 64 MiB.
9///
10/// Matches the spirit of Spark's `spark.sql.autoBroadcastJoinThreshold` family
11/// of defaults (10 MiB static, larger when runtime sizes are known): once a
12/// stage has actually executed we trust the observed size, so the threshold
13/// can be more generous than the logical-time row estimate used by
14/// `BroadcastAutoRule`.
15pub const DEFAULT_MAX_BROADCAST_BYTES: u64 = 64 * 1024 * 1024;
16
17/// Target bytes per partition used when sizing the demotion fallback.
18/// Shared with `AutoPartitionRule` (128 MiB).
19const DEMOTION_TARGET_PARTITION_BYTES: u64 = krishiv_common::partition::TARGET_BYTES_PER_PARTITION;
20
21/// Bucket-count clamp for demoted nodes: at least 2 (a demoted broadcast is by
22/// definition too large for one replica, so it must actually be split) and at
23/// most 64 (matching the default `AutoPartitionRule` parallelism cap).
24const DEMOTION_MIN_BUCKETS: u64 = 2;
25const DEMOTION_MAX_BUCKETS: u64 = 64;
26
27/// AQE rule that promotes or demotes broadcast joins based on the observed
28/// output size from the previous execution.
29///
30/// `BroadcastAutoRule` makes a logical-time guess from `estimated_rows`; this
31/// rule corrects that guess at runtime:
32///
33/// - **Promotion**: when the observed stage output is at or below
34///   `max_broadcast_bytes` and a node is `broadcast_eligible()` with `Hash` or
35///   `RoundRobin` partitioning, the node's partitioning is rewritten to
36///   [`Partitioning::Broadcast`], replacing the shuffle with a replicate.
37/// - **Demotion**: when a node is already [`Partitioning::Broadcast`] but the
38///   observed output exceeds the threshold, the broadcast is undone.
39///   `Partitioning::Broadcast` does not record the original hash keys, so they
40///   cannot be recovered; the node is demoted to
41///   `Partitioning::RoundRobin { buckets }` with
42///   `buckets = clamp(ceil(observed / 128 MiB), 2, 64)`.  Round-robin is the
43///   semantically safe choice — it makes no key-colocation promise, whereas
44///   guessing hash keys could silently mis-distribute keyed data.
45///
46/// Like the other AQE sizing rules, the observed size is the sum over the
47/// per-stage [`RuntimeStats`] slice, preferring `serialized_bytes` (shuffle
48/// wire size) and falling back to `memory_bytes` when it is zero — the same
49/// convention as `AutoPartitionRule`.
50///
51/// The rule is intrinsically disabled for streaming plans (changing
52/// partitioning mid-job would orphan keyed state) and returns `None` when
53/// stats are empty or nothing changes.
54pub struct BroadcastRuntimeRule {
55    /// Max observed output bytes for a node to be (or stay) broadcast.
56    max_broadcast_bytes: u64,
57}
58
59impl BroadcastRuntimeRule {
60    /// Create a new rule with the given broadcast size threshold in bytes.
61    ///
62    /// Use [`DEFAULT_MAX_BROADCAST_BYTES`] (64 MiB) for the standard default.
63    pub fn new(max_broadcast_bytes: u64) -> Self {
64        Self {
65            max_broadcast_bytes,
66        }
67    }
68
69    /// Compute the round-robin bucket count for a demoted broadcast node:
70    /// `clamp(ceil(observed_bytes / 128 MiB), 2, 64)`.
71    fn demotion_buckets(observed_bytes: u64) -> u32 {
72        observed_bytes
73            .div_ceil(DEMOTION_TARGET_PARTITION_BYTES)
74            .clamp(DEMOTION_MIN_BUCKETS, DEMOTION_MAX_BUCKETS) as u32
75    }
76}
77
78impl AqeRule for BroadcastRuntimeRule {
79    fn name(&self) -> &str {
80        "broadcast-runtime"
81    }
82
83    fn apply(&self, plan: &PhysicalPlan, stats: &[RuntimeStats]) -> Option<PhysicalPlan> {
84        if stats.is_empty() || StreamingAqeGuard::plan_is_streaming(plan) {
85            return None;
86        }
87
88        // Sum the best available size metric across all partitions, preferring
89        // serialized_bytes over memory_bytes (same convention as
90        // AutoPartitionRule — see RuntimeStats::serialized_bytes docs).
91        let observed_bytes: u64 = stats
92            .iter()
93            .map(|s| {
94                if s.serialized_bytes > 0 {
95                    s.serialized_bytes
96                } else {
97                    s.memory_bytes
98                }
99            })
100            .sum();
101        if observed_bytes == 0 {
102            return None;
103        }
104
105        let fits_broadcast = observed_bytes <= self.max_broadcast_bytes;
106
107        // First pass: detect whether any node needs rewriting so non-firing
108        // applications pay no clone cost.
109        let mut changed = false;
110        for node in plan.nodes() {
111            match node.partitioning() {
112                Partitioning::Hash { .. } | Partitioning::RoundRobin { .. }
113                    if fits_broadcast && node.broadcast_eligible() =>
114                {
115                    changed = true;
116                }
117                Partitioning::Broadcast if !fits_broadcast => {
118                    changed = true;
119                }
120                _ => {}
121            }
122        }
123
124        if !changed {
125            return None;
126        }
127
128        // Only clone when we know a rewrite is needed.
129        let mut plan = plan.clone();
130        for node in plan.nodes_mut() {
131            let eligible = node.broadcast_eligible();
132            let old = node.partitioning().clone();
133            match old {
134                Partitioning::Hash { .. } | Partitioning::RoundRobin { .. }
135                    if fits_broadcast && eligible =>
136                {
137                    node.set_partitioning(Partitioning::Broadcast);
138                }
139                Partitioning::Broadcast if !fits_broadcast => {
140                    node.set_partitioning(Partitioning::RoundRobin {
141                        buckets: Self::demotion_buckets(observed_bytes),
142                    });
143                }
144                _ => {}
145            }
146        }
147
148        tracing::debug!(
149            rule = "broadcast-runtime",
150            observed_bytes,
151            promoted = fits_broadcast,
152            "BroadcastRuntimeRule applied"
153        );
154
155        Some(plan)
156    }
157}
158
159// ── Tests ─────────────────────────────────────────────────────────────────────
160
161#[cfg(test)]
162mod tests {
163    use crate::optimizer::AqeOptimizer;
164    use crate::{ExecutionKind, Partitioning, PhysicalPlan, PlanNode};
165
166    use super::{AqeRule, BroadcastRuntimeRule, DEFAULT_MAX_BROADCAST_BYTES, RuntimeStats};
167
168    const ONE_MIB: u64 = 1024 * 1024;
169
170    fn hash_node(id: &str, eligible: bool) -> PlanNode {
171        PlanNode::new(id, "exchange", ExecutionKind::Batch)
172            .with_partitioning(Partitioning::Hash {
173                keys: vec!["k".into()],
174                buckets: 8,
175            })
176            .with_broadcast_eligible(eligible)
177    }
178
179    fn broadcast_node(id: &str) -> PlanNode {
180        PlanNode::new(id, "broadcast exchange", ExecutionKind::Batch)
181            .with_partitioning(Partitioning::Broadcast)
182            .with_broadcast_eligible(true)
183    }
184
185    fn plan_with(nodes: Vec<PlanNode>) -> PhysicalPlan {
186        let mut plan = PhysicalPlan::new("test", ExecutionKind::Batch);
187        for node in nodes {
188            plan = plan.with_node(node);
189        }
190        plan
191    }
192
193    fn stats_with_serialized(bytes: &[u64]) -> Vec<RuntimeStats> {
194        bytes
195            .iter()
196            .map(|&b| RuntimeStats {
197                serialized_bytes: b,
198                ..Default::default()
199            })
200            .collect()
201    }
202
203    // ── promotion ─────────────────────────────────────────────────────────
204
205    #[test]
206    fn promotion_fires_for_small_eligible_hash_node() {
207        let plan = plan_with(vec![hash_node("xchg", true)]);
208        let stats = stats_with_serialized(&[10 * ONE_MIB]);
209        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
210
211        let result = rule.apply(&plan, &stats).expect("promotion must fire");
212        let node = result.nodes().iter().find(|n| n.id() == "xchg").unwrap();
213        assert_eq!(node.partitioning(), &Partitioning::Broadcast);
214    }
215
216    #[test]
217    fn promotion_fires_for_small_eligible_round_robin_node() {
218        let plan = plan_with(vec![
219            PlanNode::new("rr", "exchange", ExecutionKind::Batch)
220                .with_partitioning(Partitioning::RoundRobin { buckets: 4 })
221                .with_broadcast_eligible(true),
222        ]);
223        let stats = stats_with_serialized(&[ONE_MIB]);
224        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
225
226        let result = rule.apply(&plan, &stats).expect("promotion must fire");
227        assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
228    }
229
230    #[test]
231    fn promotion_fires_at_exact_threshold() {
232        // "at or below" — observed == threshold must still promote.
233        let plan = plan_with(vec![hash_node("xchg", true)]);
234        let stats = stats_with_serialized(&[DEFAULT_MAX_BROADCAST_BYTES]);
235        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
236
237        let result = rule.apply(&plan, &stats).expect("boundary must promote");
238        assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
239    }
240
241    #[test]
242    fn promotion_aggregates_stats_across_partitions() {
243        // Two partitions of 40 MiB each → 80 MiB total, above the 64 MiB
244        // threshold even though each individual partition is below it.
245        let plan = plan_with(vec![hash_node("xchg", true)]);
246        let stats = stats_with_serialized(&[40 * ONE_MIB, 40 * ONE_MIB]);
247        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
248
249        assert!(
250            rule.apply(&plan, &stats).is_none(),
251            "summed size exceeds threshold → no promotion"
252        );
253    }
254
255    #[test]
256    fn promotion_prefers_serialized_bytes_over_memory_bytes() {
257        // 200 MiB in memory but only 10 MiB serialized: the rule must use
258        // serialized_bytes and promote.
259        let plan = plan_with(vec![hash_node("xchg", true)]);
260        let stats = vec![RuntimeStats {
261            memory_bytes: 200 * ONE_MIB,
262            serialized_bytes: 10 * ONE_MIB,
263            ..Default::default()
264        }];
265        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
266
267        let result = rule.apply(&plan, &stats).expect("promotion must fire");
268        assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
269    }
270
271    #[test]
272    fn promotion_falls_back_to_memory_bytes_when_serialized_is_zero() {
273        let plan = plan_with(vec![hash_node("xchg", true)]);
274        let stats = vec![RuntimeStats {
275            memory_bytes: ONE_MIB,
276            serialized_bytes: 0,
277            ..Default::default()
278        }];
279        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
280
281        let result = rule.apply(&plan, &stats).expect("promotion must fire");
282        assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
283    }
284
285    #[test]
286    fn no_promotion_when_not_broadcast_eligible() {
287        let plan = plan_with(vec![hash_node("xchg", false)]);
288        let stats = stats_with_serialized(&[ONE_MIB]);
289        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
290
291        assert!(
292            rule.apply(&plan, &stats).is_none(),
293            "ineligible node must not be promoted"
294        );
295    }
296
297    #[test]
298    fn no_promotion_above_threshold() {
299        let plan = plan_with(vec![hash_node("xchg", true)]);
300        let stats = stats_with_serialized(&[DEFAULT_MAX_BROADCAST_BYTES + 1]);
301        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
302
303        assert!(
304            rule.apply(&plan, &stats).is_none(),
305            "observed size above threshold must not promote"
306        );
307    }
308
309    #[test]
310    fn no_promotion_for_unpartitioned_node() {
311        let plan = plan_with(vec![
312            PlanNode::new("scan", "scan", ExecutionKind::Batch).with_broadcast_eligible(true),
313        ]);
314        let stats = stats_with_serialized(&[ONE_MIB]);
315        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
316
317        assert!(
318            rule.apply(&plan, &stats).is_none(),
319            "only Hash/RoundRobin nodes are promotion candidates"
320        );
321    }
322
323    // ── demotion ──────────────────────────────────────────────────────────
324
325    #[test]
326    fn demotion_fires_when_broadcast_node_observed_too_large() {
327        let plan = plan_with(vec![broadcast_node("bcast")]);
328        // 300 MiB observed → demote; ceil(300 / 128) = 3 buckets.
329        let stats = stats_with_serialized(&[300 * ONE_MIB]);
330        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
331
332        let result = rule.apply(&plan, &stats).expect("demotion must fire");
333        assert_eq!(
334            result.nodes()[0].partitioning(),
335            &Partitioning::RoundRobin { buckets: 3 }
336        );
337    }
338
339    #[test]
340    fn demotion_bucket_count_clamped_to_minimum_two() {
341        // Just above the broadcast threshold: ceil(65 MiB / 128 MiB) = 1, but a
342        // demoted node must be split into at least 2 buckets.
343        let plan = plan_with(vec![broadcast_node("bcast")]);
344        let stats = stats_with_serialized(&[65 * ONE_MIB]);
345        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
346
347        let result = rule.apply(&plan, &stats).expect("demotion must fire");
348        assert_eq!(
349            result.nodes()[0].partitioning(),
350            &Partitioning::RoundRobin { buckets: 2 }
351        );
352    }
353
354    #[test]
355    fn demotion_bucket_count_clamped_to_maximum_sixty_four() {
356        // 64 GiB observed → ceil(64 GiB / 128 MiB) = 512, clamped to 64.
357        let plan = plan_with(vec![broadcast_node("bcast")]);
358        let stats = stats_with_serialized(&[64 * 1024 * ONE_MIB]);
359        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
360
361        let result = rule.apply(&plan, &stats).expect("demotion must fire");
362        assert_eq!(
363            result.nodes()[0].partitioning(),
364            &Partitioning::RoundRobin { buckets: 64 }
365        );
366    }
367
368    #[test]
369    fn no_demotion_when_broadcast_node_within_threshold() {
370        let plan = plan_with(vec![broadcast_node("bcast")]);
371        let stats = stats_with_serialized(&[ONE_MIB]);
372        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
373
374        assert!(
375            rule.apply(&plan, &stats).is_none(),
376            "small broadcast node stays broadcast → no change → None"
377        );
378    }
379
380    #[test]
381    fn promotion_and_demotion_apply_together() {
382        // One small-side eligible hash node and one oversized broadcast node
383        // in the same plan: with the observed size above the threshold, the
384        // hash node stays put and the broadcast node is demoted.
385        let plan = plan_with(vec![hash_node("xchg", true), broadcast_node("bcast")]);
386        let stats = stats_with_serialized(&[200 * ONE_MIB]);
387        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
388
389        let result = rule.apply(&plan, &stats).expect("demotion must fire");
390        let xchg = result.nodes().iter().find(|n| n.id() == "xchg").unwrap();
391        let bcast = result.nodes().iter().find(|n| n.id() == "bcast").unwrap();
392        assert!(
393            matches!(xchg.partitioning(), Partitioning::Hash { .. }),
394            "hash node above threshold must not be promoted"
395        );
396        assert_eq!(
397            bcast.partitioning(),
398            &Partitioning::RoundRobin { buckets: 2 },
399            "broadcast node above threshold must be demoted"
400        );
401    }
402
403    // ── no-change / guard / empty-stats contracts ─────────────────────────
404
405    #[test]
406    fn returns_none_when_no_change() {
407        // Unpartitioned, ineligible node: neither promotion nor demotion applies.
408        let plan = plan_with(vec![PlanNode::new("scan", "scan", ExecutionKind::Batch)]);
409        let stats = stats_with_serialized(&[ONE_MIB]);
410        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
411
412        assert!(rule.apply(&plan, &stats).is_none());
413    }
414
415    #[test]
416    fn empty_stats_returns_none() {
417        let plan = plan_with(vec![hash_node("xchg", true)]);
418        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
419
420        assert!(rule.apply(&plan, &[]).is_none());
421    }
422
423    #[test]
424    fn zero_observed_bytes_returns_none() {
425        let plan = plan_with(vec![hash_node("xchg", true)]);
426        let stats = vec![RuntimeStats::default()];
427        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
428
429        assert!(rule.apply(&plan, &stats).is_none());
430    }
431
432    #[test]
433    fn rule_is_intrinsically_disabled_for_streaming() {
434        let mut plan = PhysicalPlan::new("stream", ExecutionKind::Streaming);
435        plan = plan.with_node(
436            PlanNode::new("xchg", "exchange", ExecutionKind::Streaming)
437                .with_partitioning(Partitioning::Hash {
438                    keys: vec!["k".into()],
439                    buckets: 8,
440                })
441                .with_broadcast_eligible(true),
442        );
443        let stats = stats_with_serialized(&[ONE_MIB]);
444        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
445
446        assert!(rule.apply(&plan, &stats).is_none());
447    }
448
449    #[test]
450    fn streaming_guard_respected_via_aqe_optimizer() {
451        let mut aqe = AqeOptimizer::new();
452        aqe.add_guarded_rule(Box::new(BroadcastRuntimeRule::new(
453            DEFAULT_MAX_BROADCAST_BYTES,
454        )));
455
456        let plan = PhysicalPlan::new("stream", ExecutionKind::Streaming).with_node(
457            PlanNode::new("xchg", "exchange", ExecutionKind::Streaming)
458                .with_partitioning(Partitioning::Hash {
459                    keys: vec!["k".into()],
460                    buckets: 8,
461                })
462                .with_broadcast_eligible(true),
463        );
464        let stats = stats_with_serialized(&[ONE_MIB]);
465
466        let (result, applied) = aqe.apply(plan.clone(), &stats).expect("aqe");
467        assert_eq!(result, plan, "streaming plan must be untouched");
468        assert!(applied.is_empty(), "guarded rule must not fire");
469    }
470
471    #[test]
472    fn batch_plan_promoted_via_aqe_optimizer() {
473        let mut aqe = AqeOptimizer::new();
474        aqe.add_guarded_rule(Box::new(BroadcastRuntimeRule::new(
475            DEFAULT_MAX_BROADCAST_BYTES,
476        )));
477
478        let plan = plan_with(vec![hash_node("xchg", true)]);
479        let stats = stats_with_serialized(&[ONE_MIB]);
480
481        let (result, applied) = aqe.apply(plan, &stats).expect("aqe");
482        assert_eq!(applied, vec!["broadcast-runtime"]);
483        assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
484    }
485
486    #[test]
487    fn rule_name_is_broadcast_runtime() {
488        let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
489        assert_eq!(rule.name(), "broadcast-runtime");
490    }
491}