Skip to main content

optirs_gpu/
kernel_fusion.rs

1//! # Elementwise Kernel-Fusion Planner
2//!
3//! A CPU-side, compiler-style planner that operates over an operation DAG and
4//! decides which (primarily elementwise) operations can be fused into a single
5//! GPU kernel. No GPU execution happens here: the planner reasons purely about
6//! the graph topology, fusion legality, and the memory-bandwidth implications
7//! of fusion via a bytes-moved cost model.
8//!
9//! ## Operation graph
10//!
11//! The graph is a DAG of [`FusionOp`]s. Each op has a stable `id` (equal to its
12//! insertion index), an [`OpKind`], a list of `inputs` (the op ids of its
13//! producers), and an output tensor described by `output_shape` and
14//! `dtype_bytes`. The byte size of an op's output tensor is
15//! `product(output_shape) * dtype_bytes` (an empty shape denotes a scalar, i.e.
16//! `product == 1`).
17//!
18//! An op with **no inputs** is a *source* (leaf): it represents data that is
19//! already resident in memory (a parameter, gradient, or the result of an
20//! upstream subgraph). [`FusionGraph::validate`] guarantees the graph is
21//! acyclic and that every input references a real op.
22//!
23//! ## Fusion legality
24//!
25//! Two or more ops may be fused into one kernel iff:
26//! 1. every op in the group is *fusible* (an elementwise [`OpKind`], see
27//!    [`OpKind::is_fusible`]); barrier ops (`MatMul`, `Reduce`, `Transpose`)
28//!    can never join an elementwise group and always break chains;
29//! 2. the members form a connected producer -> consumer chain in the DAG (a
30//!    fusible edge connects a fusible producer to a fusible consumer);
31//! 3. the producer's output shape is broadcast-compatible with the consumer's
32//!    output shape (NumPy trailing-dimension rule, optional via
33//!    [`FusionPlanner::with_broadcast`]); and
34//! 4. fusing must not create a cycle in the *group-contracted* dependency
35//!    graph. Greedily merging fusible edges can otherwise sandwich a barrier
36//!    group between two halves of an elementwise group, which would require the
37//!    fused kernel to run both before and after the barrier. Such merges are
38//!    rejected so the inter-group schedule stays a DAG.
39//!
40//! When an intermediate that is internal to a group is *also* consumed by an op
41//! **outside** the group, the intermediate cannot be elided: it is marked as a
42//! group output and materialized to memory (never illegally dropped).
43//!
44//! ## Group formation
45//!
46//! Groups are formed by traversing ops in topological order and greedily
47//! merging fusible producer -> consumer edges, subject to the legality checks
48//! above (barriers stay as singletons; cycle-creating merges are skipped). The
49//! result is a partition where every op belongs to exactly one group
50//! (singletons allowed) and the contracted group graph is acyclic.
51//!
52//! ## Memory-bandwidth cost model
53//!
54//! - **Unfused** bytes moved: for every op, read each of its *distinct* input
55//!   tensors once and write its output tensor once; summed over all ops.
56//! - **Fused** bytes moved: for every group, read the group's *external* input
57//!   tensors once (distinct producers outside the group) and write the group's
58//!   *external* output tensors (members consumed outside the group or that are
59//!   graph terminals). Intermediates that stay inside the group live in
60//!   registers and are not counted.
61//!
62//! Because a singleton group reproduces exactly the unfused contribution of its
63//! single op, fusion can only ever remove traffic: `bytes_fused <=
64//! bytes_unfused` and `speedup_estimate = bytes_unfused / bytes_fused >= 1.0`
65//! (a bandwidth-bound proxy).
66
67use std::collections::{HashMap, HashSet, VecDeque};
68
69use crate::GpuOptimError;
70
71/// The kind of an operation in the fusion graph.
72///
73/// The first nine variants are *elementwise* and therefore fusible; the final
74/// three are *barrier* ops that change the iteration space or reduce/permute
75/// data and so cannot participate in an elementwise fusion group.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum OpKind {
78    /// Elementwise addition.
79    Add,
80    /// Elementwise multiplication.
81    Mul,
82    /// Elementwise subtraction.
83    Sub,
84    /// Elementwise division.
85    Div,
86    /// Rectified linear unit activation.
87    Relu,
88    /// Logistic sigmoid activation.
89    Sigmoid,
90    /// Hyperbolic tangent activation.
91    Tanh,
92    /// Scalar scaling (`alpha * x`).
93    Scale,
94    /// Fused multiply-add (`alpha * x + y`).
95    Axpy,
96    /// Matrix multiplication (barrier: not fusible into an elementwise group).
97    MatMul,
98    /// Reduction such as sum/mean/max (barrier).
99    Reduce,
100    /// Transpose / permutation (barrier).
101    Transpose,
102}
103
104impl OpKind {
105    /// Returns `true` if the op is an elementwise op that may be fused into a
106    /// single kernel together with other fusible ops.
107    pub fn is_fusible(&self) -> bool {
108        matches!(
109            self,
110            OpKind::Add
111                | OpKind::Mul
112                | OpKind::Sub
113                | OpKind::Div
114                | OpKind::Relu
115                | OpKind::Sigmoid
116                | OpKind::Tanh
117                | OpKind::Scale
118                | OpKind::Axpy
119        )
120    }
121
122    /// Returns `true` for barrier ops (`MatMul`, `Reduce`, `Transpose`) that
123    /// break fusion chains and always form singleton groups.
124    pub fn is_barrier(&self) -> bool {
125        !self.is_fusible()
126    }
127}
128
129/// A single operation in the fusion graph.
130///
131/// `id` equals the op's insertion index in its [`FusionGraph`]. `inputs` lists
132/// the op ids of the producers whose output tensors this op consumes; an empty
133/// `inputs` list marks a source (leaf) op.
134#[derive(Debug, Clone)]
135pub struct FusionOp {
136    /// Stable identifier (equal to the insertion index in the graph).
137    pub id: usize,
138    /// The kind of operation.
139    pub kind: OpKind,
140    /// Op ids of the producers this op reads from.
141    pub inputs: Vec<usize>,
142    /// Shape of this op's output tensor (empty == scalar).
143    pub output_shape: Vec<usize>,
144    /// Size in bytes of a single output element.
145    pub dtype_bytes: usize,
146}
147
148impl FusionOp {
149    /// Number of elements in the output tensor (product of the shape dims).
150    ///
151    /// Returns an error if the product overflows a `u64`.
152    pub fn output_elements(&self) -> Result<u64, GpuOptimError> {
153        let mut elements: u64 = 1;
154        for &dim in &self.output_shape {
155            elements = elements.checked_mul(dim as u64).ok_or_else(|| {
156                GpuOptimError::UnsupportedOperation(format!(
157                    "op {} output shape {:?} overflows the element counter",
158                    self.id, self.output_shape
159                ))
160            })?;
161        }
162        Ok(elements)
163    }
164
165    /// Size in bytes of the output tensor (`product(shape) * dtype_bytes`).
166    ///
167    /// Returns an error if the computation overflows a `u64`.
168    pub fn output_bytes(&self) -> Result<u64, GpuOptimError> {
169        let elements = self.output_elements()?;
170        elements
171            .checked_mul(self.dtype_bytes as u64)
172            .ok_or_else(|| {
173                GpuOptimError::UnsupportedOperation(format!(
174                    "op {} output ({} elements x {} bytes) overflows the byte counter",
175                    self.id, elements, self.dtype_bytes
176                ))
177            })
178    }
179}
180
181/// An append-only builder for an operation DAG.
182#[derive(Debug, Default, Clone)]
183pub struct FusionGraph {
184    ops: Vec<FusionOp>,
185}
186
187impl FusionGraph {
188    /// Creates an empty graph.
189    pub fn new() -> Self {
190        Self { ops: Vec::new() }
191    }
192
193    /// Appends an op and returns its freshly assigned id.
194    ///
195    /// `inputs` must reference the ids of previously added ops (an empty list
196    /// denotes a source/leaf). Validity and acyclicity are checked by
197    /// [`FusionGraph::validate`], not here, so a forward reference can be built
198    /// and later rejected.
199    pub fn add_op(
200        &mut self,
201        kind: OpKind,
202        inputs: Vec<usize>,
203        output_shape: Vec<usize>,
204        dtype_bytes: usize,
205    ) -> usize {
206        let id = self.ops.len();
207        self.ops.push(FusionOp {
208            id,
209            kind,
210            inputs,
211            output_shape,
212            dtype_bytes,
213        });
214        id
215    }
216
217    /// Returns the ops in insertion order.
218    pub fn ops(&self) -> &[FusionOp] {
219        &self.ops
220    }
221
222    /// Number of ops in the graph.
223    pub fn num_ops(&self) -> usize {
224        self.ops.len()
225    }
226
227    /// Returns `true` if the graph has no ops.
228    pub fn is_empty(&self) -> bool {
229        self.ops.is_empty()
230    }
231
232    /// Validates the graph: every input must reference an existing op, no op may
233    /// reference itself, `dtype_bytes` must be non-zero, byte counts must not
234    /// overflow, and the graph must be acyclic.
235    ///
236    /// Returns [`GpuOptimError::InvalidState`] for a dangling input, a
237    /// self-reference, or a cycle.
238    pub fn validate(&self) -> Result<(), GpuOptimError> {
239        let n = self.ops.len();
240        for op in &self.ops {
241            if op.dtype_bytes == 0 {
242                return Err(GpuOptimError::InvalidState(format!(
243                    "op {} has dtype_bytes == 0",
244                    op.id
245                )));
246            }
247            for &producer in &op.inputs {
248                if producer >= n {
249                    return Err(GpuOptimError::InvalidState(format!(
250                        "op {} references non-existent input op {}",
251                        op.id, producer
252                    )));
253                }
254                if producer == op.id {
255                    return Err(GpuOptimError::InvalidState(format!(
256                        "op {} references itself as an input",
257                        op.id
258                    )));
259                }
260            }
261            // Surface overflow eagerly so the planner never has to.
262            op.output_bytes()?;
263        }
264        self.topological_order()?;
265        Ok(())
266    }
267
268    /// Computes a topological order via Kahn's algorithm.
269    ///
270    /// Returns [`GpuOptimError::InvalidState`] if a dangling input is found or
271    /// the graph contains a cycle.
272    fn topological_order(&self) -> Result<Vec<usize>, GpuOptimError> {
273        let n = self.ops.len();
274        let mut indegree = vec![0usize; n];
275        let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); n];
276        for consumer in &self.ops {
277            let mut seen: HashSet<usize> = HashSet::new();
278            for &producer in &consumer.inputs {
279                if producer >= n {
280                    return Err(GpuOptimError::InvalidState(format!(
281                        "op {} references non-existent input op {}",
282                        consumer.id, producer
283                    )));
284                }
285                // Collapse duplicate edges so indegree counts distinct producers.
286                if !seen.insert(producer) {
287                    continue;
288                }
289                adjacency[producer].push(consumer.id);
290                indegree[consumer.id] += 1;
291            }
292        }
293
294        let mut queue: VecDeque<usize> = (0..n).filter(|&i| indegree[i] == 0).collect();
295        let mut order = Vec::with_capacity(n);
296        while let Some(node) = queue.pop_front() {
297            order.push(node);
298            for &consumer in &adjacency[node] {
299                indegree[consumer] -= 1;
300                if indegree[consumer] == 0 {
301                    queue.push_back(consumer);
302                }
303            }
304        }
305
306        if order.len() != n {
307            return Err(GpuOptimError::InvalidState(
308                "operation graph contains a cycle".to_string(),
309            ));
310        }
311        Ok(order)
312    }
313}
314
315/// A group of ops fused into a single kernel.
316///
317/// `members` is the set of op ids in the group (sorted ascending).
318/// `external_inputs` are the producer op ids outside the group that the group
319/// reads. `external_outputs` are member ids whose results must be materialized
320/// (consumed outside the group, or graph terminals). `internal_intermediates`
321/// are members whose results stay in registers and are elided.
322#[derive(Debug, Clone)]
323pub struct FusionGroup {
324    /// Op ids fused into this kernel (sorted ascending).
325    pub members: Vec<usize>,
326    /// Producer op ids outside the group that the group reads (sorted).
327    pub external_inputs: Vec<usize>,
328    /// Member op ids whose outputs are written to memory (sorted).
329    pub external_outputs: Vec<usize>,
330    /// Member op ids whose outputs are fully elided into registers (sorted).
331    pub internal_intermediates: Vec<usize>,
332    /// Bytes read by this group (external inputs, each counted once).
333    pub bytes_read: u64,
334    /// Bytes written by this group (external outputs).
335    pub bytes_written: u64,
336}
337
338impl FusionGroup {
339    /// Total fused bytes moved by this group (`bytes_read + bytes_written`).
340    pub fn bytes_fused(&self) -> u64 {
341        self.bytes_read + self.bytes_written
342    }
343
344    /// Returns `true` if the group fuses more than one op.
345    pub fn is_fused(&self) -> bool {
346        self.members.len() > 1
347    }
348}
349
350/// The output of [`FusionPlanner::plan`]: the fusion groups plus a
351/// memory-bandwidth cost summary.
352#[derive(Debug, Clone)]
353pub struct FusionPlan {
354    /// Fusion groups, ordered by their smallest member id.
355    pub groups: Vec<FusionGroup>,
356    /// Total bytes moved without fusion.
357    pub bytes_unfused: u64,
358    /// Total bytes moved with fusion.
359    pub bytes_fused: u64,
360    /// `bytes_unfused - bytes_fused`.
361    pub bytes_saved: u64,
362    /// `bytes_unfused / bytes_fused` (1.0 when no traffic).
363    pub speedup_estimate: f64,
364}
365
366impl FusionPlan {
367    /// Number of groups in the plan.
368    pub fn num_groups(&self) -> usize {
369        self.groups.len()
370    }
371}
372
373/// Plans elementwise kernel fusion over an operation DAG.
374#[derive(Debug, Clone)]
375pub struct FusionPlanner {
376    allow_broadcast: bool,
377}
378
379impl Default for FusionPlanner {
380    fn default() -> Self {
381        Self::new()
382    }
383}
384
385impl FusionPlanner {
386    /// Creates a planner that allows NumPy-style broadcast on fusible edges.
387    pub fn new() -> Self {
388        Self {
389            allow_broadcast: true,
390        }
391    }
392
393    /// Enables or disables broadcast compatibility on fusible edges. When
394    /// disabled, a producer -> consumer edge only fuses if the shapes are equal.
395    pub fn with_broadcast(mut self, allow_broadcast: bool) -> Self {
396        self.allow_broadcast = allow_broadcast;
397        self
398    }
399
400    /// Returns `true` if `producer` can broadcast into `consumer` for the
401    /// purpose of fusing into one elementwise kernel.
402    ///
403    /// Equal shapes are always compatible. With broadcast enabled, the NumPy
404    /// trailing-dimension rule applies: the producer's shape is right-aligned
405    /// with the consumer's, must not be longer, and each aligned producer
406    /// dimension must equal the consumer's or be `1` (a scalar producer
407    /// broadcasts to anything).
408    fn shapes_fuse_compatible(&self, producer: &[usize], consumer: &[usize]) -> bool {
409        if producer == consumer {
410            return true;
411        }
412        if !self.allow_broadcast {
413            return false;
414        }
415        if producer.len() > consumer.len() {
416            return false;
417        }
418        let offset = consumer.len() - producer.len();
419        for (i, &producer_dim) in producer.iter().enumerate() {
420            let consumer_dim = consumer[offset + i];
421            if producer_dim != consumer_dim && producer_dim != 1 {
422                return false;
423            }
424        }
425        true
426    }
427
428    /// Plans fusion for `graph` and returns the groups plus the cost summary.
429    ///
430    /// Returns an error if the graph fails [`FusionGraph::validate`] or if any
431    /// tensor byte count overflows.
432    pub fn plan(&self, graph: &FusionGraph) -> Result<FusionPlan, GpuOptimError> {
433        graph.validate()?;
434        let ops = graph.ops();
435        let n = ops.len();
436
437        // Pre-compute the byte size of every op's output tensor.
438        let mut bytes: Vec<u64> = Vec::with_capacity(n);
439        for op in ops {
440            bytes.push(op.output_bytes()?);
441        }
442
443        let topo = graph.topological_order()?;
444
445        // consumers[p] = distinct ops that read op p's output.
446        let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
447        for consumer in ops {
448            let mut seen: HashSet<usize> = HashSet::new();
449            for &producer in &consumer.inputs {
450                if seen.insert(producer) {
451                    consumers[producer].push(consumer.id);
452                }
453            }
454        }
455
456        // Greedy union over legal fusible edges. `group_of[i]` is i's group label.
457        let mut group_of: Vec<usize> = (0..n).collect();
458        for &consumer_id in &topo {
459            let consumer = &ops[consumer_id];
460            if !consumer.kind.is_fusible() {
461                continue;
462            }
463            let mut seen: HashSet<usize> = HashSet::new();
464            for &producer_id in &consumer.inputs {
465                if !seen.insert(producer_id) {
466                    continue;
467                }
468                let producer = &ops[producer_id];
469                if !producer.kind.is_fusible() {
470                    continue;
471                }
472                if !self.shapes_fuse_compatible(&producer.output_shape, &consumer.output_shape) {
473                    continue;
474                }
475                let group_producer = group_of[producer_id];
476                let group_consumer = group_of[consumer_id];
477                if group_producer == group_consumer {
478                    continue;
479                }
480                // Only merge if the contracted group graph stays acyclic.
481                if merge_keeps_acyclic(ops, &group_of, group_producer, group_consumer) {
482                    for label in group_of.iter_mut() {
483                        if *label == group_consumer {
484                            *label = group_producer;
485                        }
486                    }
487                }
488            }
489        }
490
491        // Bucket op ids by group label (members collected in topological order).
492        let mut label_to_members: HashMap<usize, Vec<usize>> = HashMap::new();
493        for &id in &topo {
494            label_to_members.entry(group_of[id]).or_default().push(id);
495        }
496        let mut raw_groups: Vec<Vec<usize>> = label_to_members.into_values().collect();
497        for members in raw_groups.iter_mut() {
498            members.sort_unstable();
499        }
500        raw_groups.sort_by_key(|members| members[0]);
501
502        // Materialize each group's external interface and per-group bytes.
503        let mut groups: Vec<FusionGroup> = Vec::with_capacity(raw_groups.len());
504        let mut bytes_fused: u64 = 0;
505        for members in raw_groups {
506            let member_set: HashSet<usize> = members.iter().copied().collect();
507
508            let mut external_inputs: Vec<usize> = Vec::new();
509            let mut external_input_seen: HashSet<usize> = HashSet::new();
510            for &member in &members {
511                let mut seen: HashSet<usize> = HashSet::new();
512                for &producer in &ops[member].inputs {
513                    if !seen.insert(producer) {
514                        continue;
515                    }
516                    if !member_set.contains(&producer) && external_input_seen.insert(producer) {
517                        external_inputs.push(producer);
518                    }
519                }
520            }
521            external_inputs.sort_unstable();
522
523            let mut external_outputs: Vec<usize> = Vec::new();
524            let mut internal_intermediates: Vec<usize> = Vec::new();
525            for &member in &members {
526                let consumed_externally = consumers[member].iter().any(|c| !member_set.contains(c));
527                let is_terminal = consumers[member].is_empty();
528                if consumed_externally || is_terminal {
529                    external_outputs.push(member);
530                } else {
531                    internal_intermediates.push(member);
532                }
533            }
534
535            let bytes_read: u64 = external_inputs.iter().map(|&p| bytes[p]).sum();
536            let bytes_written: u64 = external_outputs.iter().map(|&m| bytes[m]).sum();
537            bytes_fused += bytes_read + bytes_written;
538
539            groups.push(FusionGroup {
540                members,
541                external_inputs,
542                external_outputs,
543                internal_intermediates,
544                bytes_read,
545                bytes_written,
546            });
547        }
548
549        // Unfused traffic: every op reads each distinct input and writes once.
550        let mut bytes_unfused: u64 = 0;
551        for op in ops {
552            let mut seen: HashSet<usize> = HashSet::new();
553            let mut read: u64 = 0;
554            for &producer in &op.inputs {
555                if seen.insert(producer) {
556                    read += bytes[producer];
557                }
558            }
559            bytes_unfused += read + bytes[op.id];
560        }
561
562        let bytes_saved = bytes_unfused.saturating_sub(bytes_fused);
563        let speedup_estimate = if bytes_fused == 0 {
564            1.0
565        } else {
566            bytes_unfused as f64 / bytes_fused as f64
567        };
568
569        Ok(FusionPlan {
570            groups,
571            bytes_unfused,
572            bytes_fused,
573            bytes_saved,
574            speedup_estimate,
575        })
576    }
577}
578
579/// Returns `true` if merging groups `group_a` and `group_b` keeps the
580/// group-contracted dependency graph acyclic.
581///
582/// The two groups are treated as a single contracted node; an edge is added
583/// between the contracted labels of every producer -> consumer pair whose
584/// endpoints land in different groups. A Kahn pass then checks for a cycle.
585fn merge_keeps_acyclic(
586    ops: &[FusionOp],
587    group_of: &[usize],
588    group_a: usize,
589    group_b: usize,
590) -> bool {
591    let label = |op_id: usize| -> usize {
592        let group = group_of[op_id];
593        if group == group_b {
594            group_a
595        } else {
596            group
597        }
598    };
599
600    let mut adjacency: HashMap<usize, HashSet<usize>> = HashMap::new();
601    let mut nodes: HashSet<usize> = HashSet::new();
602    for consumer in ops {
603        let consumer_label = label(consumer.id);
604        nodes.insert(consumer_label);
605        for &producer in &consumer.inputs {
606            let producer_label = label(producer);
607            nodes.insert(producer_label);
608            if producer_label != consumer_label {
609                adjacency
610                    .entry(producer_label)
611                    .or_default()
612                    .insert(consumer_label);
613            }
614        }
615    }
616
617    let mut indegree: HashMap<usize, usize> = nodes.iter().map(|&node| (node, 0usize)).collect();
618    for targets in adjacency.values() {
619        for &target in targets {
620            if let Some(degree) = indegree.get_mut(&target) {
621                *degree += 1;
622            }
623        }
624    }
625
626    let mut queue: VecDeque<usize> = indegree
627        .iter()
628        .filter_map(|(&node, &degree)| if degree == 0 { Some(node) } else { None })
629        .collect();
630    let mut visited = 0usize;
631    while let Some(node) = queue.pop_front() {
632        visited += 1;
633        if let Some(targets) = adjacency.get(&node) {
634            for &target in targets {
635                if let Some(degree) = indegree.get_mut(&target) {
636                    *degree -= 1;
637                    if *degree == 0 {
638                        queue.push_back(target);
639                    }
640                }
641            }
642        }
643    }
644
645    visited == nodes.len()
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    fn find_group(plan: &FusionPlan, op_id: usize) -> &FusionGroup {
653        plan.groups
654            .iter()
655            .find(|g| g.members.contains(&op_id))
656            .expect("every op must belong to exactly one group")
657    }
658
659    #[test]
660    fn is_fusible_classification() {
661        assert!(OpKind::Add.is_fusible());
662        assert!(OpKind::Mul.is_fusible());
663        assert!(OpKind::Axpy.is_fusible());
664        assert!(OpKind::Scale.is_fusible());
665        assert!(!OpKind::MatMul.is_fusible());
666        assert!(!OpKind::Reduce.is_fusible());
667        assert!(!OpKind::Transpose.is_fusible());
668        assert!(OpKind::MatMul.is_barrier());
669        assert!(!OpKind::Relu.is_barrier());
670    }
671
672    #[test]
673    fn linear_chain_fuses_into_one_group() {
674        let mut graph = FusionGraph::new();
675        let a = graph.add_op(OpKind::Relu, vec![], vec![256], 4);
676        let b = graph.add_op(OpKind::Sigmoid, vec![a], vec![256], 4);
677        let c = graph.add_op(OpKind::Tanh, vec![b], vec![256], 4);
678
679        let plan = FusionPlanner::new()
680            .plan(&graph)
681            .expect("fusible chain must plan");
682
683        assert_eq!(plan.groups.len(), 1);
684        let group = &plan.groups[0];
685        assert_eq!(group.members, vec![a, b, c]);
686        assert!(group.external_inputs.is_empty());
687        assert_eq!(group.external_outputs, vec![c]);
688        assert_eq!(group.internal_intermediates, vec![a, b]);
689
690        let tensor = 256u64 * 4;
691        assert_eq!(plan.bytes_unfused, 5 * tensor);
692        assert_eq!(plan.bytes_fused, tensor);
693        assert!(plan.bytes_fused < plan.bytes_unfused);
694        assert_eq!(plan.bytes_saved, 4 * tensor);
695        assert!((plan.speedup_estimate - 5.0).abs() < 1e-9);
696    }
697
698    #[test]
699    fn barrier_splits_into_three_groups() {
700        let mut graph = FusionGraph::new();
701        let a = graph.add_op(OpKind::Relu, vec![], vec![128], 4);
702        let b = graph.add_op(OpKind::Sigmoid, vec![a], vec![128], 4);
703        let c = graph.add_op(OpKind::MatMul, vec![b], vec![128], 4); // barrier
704        let d = graph.add_op(OpKind::Relu, vec![c], vec![128], 4);
705        let e = graph.add_op(OpKind::Tanh, vec![d], vec![128], 4);
706
707        let plan = FusionPlanner::new()
708            .plan(&graph)
709            .expect("graph with a barrier must plan");
710
711        assert_eq!(plan.groups.len(), 3);
712        assert_eq!(find_group(&plan, a).members, vec![a, b]);
713        assert_eq!(find_group(&plan, c).members, vec![c]);
714        assert_eq!(find_group(&plan, d).members, vec![d, e]);
715    }
716
717    #[test]
718    fn external_consumer_materializes_intermediate() {
719        let mut graph = FusionGraph::new();
720        let a = graph.add_op(OpKind::Relu, vec![], vec![256], 4);
721        let b = graph.add_op(OpKind::Sigmoid, vec![a], vec![256], 4);
722        let c = graph.add_op(OpKind::Tanh, vec![b], vec![256], 4);
723        // External (barrier) consumer of `b` forces `b` to be materialized.
724        let d = graph.add_op(OpKind::MatMul, vec![b], vec![256], 4);
725
726        let plan = FusionPlanner::new()
727            .plan(&graph)
728            .expect("diamond graph must plan");
729
730        assert_eq!(plan.groups.len(), 2);
731        let group = find_group(&plan, b);
732        assert_eq!(group.members, vec![a, b, c]);
733        assert!(
734            group.external_outputs.contains(&b),
735            "b is consumed outside the group and must be materialized"
736        );
737        assert!(!group.internal_intermediates.contains(&b));
738        assert!(group.external_outputs.contains(&c));
739        assert_eq!(group.internal_intermediates, vec![a]);
740        assert_eq!(find_group(&plan, d).members, vec![d]);
741
742        // Hand-computed bytes: tensor = 256 * 4 = 1024.
743        let tensor = 256u64 * 4;
744        assert_eq!(plan.bytes_unfused, 7 * tensor);
745        assert_eq!(plan.bytes_fused, 4 * tensor);
746        assert_eq!(plan.bytes_saved, 3 * tensor);
747        assert!((plan.speedup_estimate - 1.75).abs() < 1e-9);
748    }
749
750    #[test]
751    fn shared_external_input_counted_once() {
752        let mut graph = FusionGraph::new();
753        // Barrier source acts as a shared external input tensor (16 * 4 = 64 bytes).
754        let x = graph.add_op(OpKind::MatMul, vec![], vec![16], 4);
755        let r = graph.add_op(OpKind::Relu, vec![x], vec![16], 4);
756        let s = graph.add_op(OpKind::Add, vec![r, x], vec![16], 4);
757
758        let plan = FusionPlanner::new().plan(&graph).expect("graph must plan");
759
760        assert_eq!(plan.groups.len(), 2);
761        let group = find_group(&plan, r);
762        assert_eq!(group.members, vec![r, s]);
763        // x is read by both r and s but appears exactly once as an external input.
764        assert_eq!(group.external_inputs, vec![x]);
765
766        let tensor = 16u64 * 4; // 64
767                                // Unfused: x:64, r:64+64, s:(64+64)+64 = 64 + 128 + 192 = 384 = 6 * 64.
768        assert_eq!(plan.bytes_unfused, 6 * tensor);
769        // Fused: {x} writes 64; {r,s} reads x once (64) + writes s (64) = 128.
770        assert_eq!(plan.bytes_fused, 3 * tensor);
771        assert_eq!(plan.bytes_saved, 3 * tensor);
772        assert!((plan.speedup_estimate - 2.0).abs() < 1e-9);
773    }
774
775    #[test]
776    fn hand_computed_bytes_exact() {
777        let mut graph = FusionGraph::new();
778        let a = graph.add_op(OpKind::Mul, vec![], vec![10], 4); // 40 bytes
779        let b = graph.add_op(OpKind::Add, vec![a], vec![10], 4);
780
781        let plan = FusionPlanner::new()
782            .plan(&graph)
783            .expect("two-op chain must plan");
784
785        assert_eq!(plan.groups.len(), 1);
786        let group = &plan.groups[0];
787        assert!(group.external_inputs.is_empty());
788        assert_eq!(group.external_outputs, vec![b]);
789        assert_eq!(group.internal_intermediates, vec![a]);
790        assert_eq!(group.bytes_read, 0);
791        assert_eq!(group.bytes_written, 40);
792
793        // Unfused: a writes 40; b reads 40 + writes 40 => 120.
794        assert_eq!(plan.bytes_unfused, 120);
795        // Fused: write b only (a stays in registers) => 40.
796        assert_eq!(plan.bytes_fused, 40);
797        assert_eq!(plan.bytes_saved, 80);
798        assert!((plan.speedup_estimate - 3.0).abs() < 1e-9);
799    }
800
801    #[test]
802    fn fusion_avoids_introducing_cycle() {
803        // a -> b -> c(barrier) -> d, plus a -> d. Fusing a with d would sandwich
804        // the barrier group c and create a cycle, so d must stay separate.
805        let mut graph = FusionGraph::new();
806        let a = graph.add_op(OpKind::Relu, vec![], vec![16], 4);
807        let b = graph.add_op(OpKind::Sigmoid, vec![a], vec![16], 4);
808        let c = graph.add_op(OpKind::MatMul, vec![b], vec![16], 4); // barrier
809        let d = graph.add_op(OpKind::Add, vec![a, c], vec![16], 4);
810
811        let plan = FusionPlanner::new().plan(&graph).expect("graph must plan");
812
813        assert_eq!(plan.groups.len(), 3);
814        assert_eq!(find_group(&plan, a).members, vec![a, b]);
815        assert_eq!(find_group(&plan, c).members, vec![c]);
816        assert_eq!(find_group(&plan, d).members, vec![d]);
817        // a feeds b (internal) and d (external) so it is materialized.
818        assert!(find_group(&plan, a).external_outputs.contains(&a));
819    }
820
821    #[test]
822    fn broadcast_compatible_edge_fuses() {
823        let mut graph = FusionGraph::new();
824        let a = graph.add_op(OpKind::Relu, vec![], vec![1], 4); // scalar-ish
825        let b = graph.add_op(OpKind::Add, vec![a], vec![32], 4); // broadcasts [1] -> [32]
826
827        let plan = FusionPlanner::new()
828            .plan(&graph)
829            .expect("broadcast chain must plan");
830        assert_eq!(plan.groups.len(), 1);
831        assert_eq!(plan.groups[0].members, vec![a, b]);
832
833        // With broadcast disabled the mismatched shapes do not fuse.
834        let strict = FusionPlanner::new()
835            .with_broadcast(false)
836            .plan(&graph)
837            .expect("strict planner must plan");
838        assert_eq!(strict.groups.len(), 2);
839    }
840
841    #[test]
842    fn bytes_saved_and_speedup_invariants() {
843        let mut graph = FusionGraph::new();
844        let a = graph.add_op(OpKind::Scale, vec![], vec![64, 64], 4);
845        let b = graph.add_op(OpKind::Relu, vec![a], vec![64, 64], 4);
846        let c = graph.add_op(OpKind::Sigmoid, vec![b], vec![64, 64], 4);
847        graph.add_op(OpKind::Tanh, vec![c], vec![64, 64], 4);
848
849        let plan = FusionPlanner::new()
850            .plan(&graph)
851            .expect("fusible chain must plan");
852
853        assert_eq!(plan.groups.len(), 1);
854        assert_eq!(plan.bytes_saved, plan.bytes_unfused - plan.bytes_fused);
855        assert!(plan.speedup_estimate >= 1.0);
856        assert!(plan.bytes_fused < plan.bytes_unfused);
857    }
858
859    #[test]
860    fn cyclic_graph_rejected() {
861        let mut graph = FusionGraph::new();
862        let _a = graph.add_op(OpKind::Add, vec![1], vec![8], 4); // forward ref to op 1
863        let _b = graph.add_op(OpKind::Add, vec![0], vec![8], 4); // back ref to op 0 => cycle
864
865        let error = graph
866            .validate()
867            .expect_err("a cyclic graph must be rejected");
868        assert!(matches!(error, GpuOptimError::InvalidState(_)));
869        assert!(FusionPlanner::new().plan(&graph).is_err());
870    }
871
872    #[test]
873    fn dangling_input_rejected() {
874        let mut graph = FusionGraph::new();
875        let _a = graph.add_op(OpKind::Relu, vec![99], vec![8], 4); // op 99 does not exist
876
877        let error = graph
878            .validate()
879            .expect_err("a dangling input must be rejected");
880        assert!(matches!(error, GpuOptimError::InvalidState(_)));
881    }
882
883    #[test]
884    fn zero_dtype_rejected() {
885        let mut graph = FusionGraph::new();
886        let _a = graph.add_op(OpKind::Relu, vec![], vec![8], 0);
887        assert!(graph.validate().is_err());
888    }
889
890    #[test]
891    fn empty_graph_is_valid() {
892        let graph = FusionGraph::new();
893        assert!(graph.is_empty());
894        let plan = FusionPlanner::new()
895            .plan(&graph)
896            .expect("empty graph must plan");
897        assert_eq!(plan.num_groups(), 0);
898        assert_eq!(plan.bytes_unfused, 0);
899        assert_eq!(plan.bytes_fused, 0);
900        assert_eq!(plan.bytes_saved, 0);
901        assert!((plan.speedup_estimate - 1.0).abs() < 1e-9);
902    }
903}