Skip to main content

nxpu_analysis/
cost.rs

1//! Cost model and latency prediction for kernel operations.
2//!
3//! Provides per-op FLOP/memory estimation, roofline analysis,
4//! and hardware profile–based latency prediction.
5
6use std::collections::HashMap;
7use std::fmt;
8
9use crate::KernelPattern;
10
11/// Per-operation cost in terms of compute and memory traffic.
12#[derive(Debug, Clone, PartialEq)]
13pub struct OpCost {
14    /// Total floating-point operations.
15    pub flops: u64,
16    /// Bytes read from memory.
17    pub bytes_read: u64,
18    /// Bytes written to memory.
19    pub bytes_written: u64,
20}
21
22impl OpCost {
23    /// Arithmetic intensity: FLOPs per byte of memory traffic.
24    pub fn arithmetic_intensity(&self) -> f64 {
25        let total_bytes = self.bytes_read + self.bytes_written;
26        if total_bytes == 0 {
27            return 0.0;
28        }
29        self.flops as f64 / total_bytes as f64
30    }
31}
32
33impl fmt::Display for OpCost {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        write!(
36            f,
37            "OpCost {{ flops: {}, bytes_read: {}, bytes_written: {} }}",
38            self.flops, self.bytes_read, self.bytes_written
39        )
40    }
41}
42
43/// Whether an operation is bottlenecked by compute or memory bandwidth.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Bottleneck {
46    /// FLOPs exceed what can be hidden by memory latency.
47    ComputeBound,
48    /// Memory traffic exceeds what can be hidden by compute.
49    MemoryBound,
50}
51
52impl fmt::Display for Bottleneck {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(match self {
55            Self::ComputeBound => "compute-bound",
56            Self::MemoryBound => "memory-bound",
57        })
58    }
59}
60
61/// Hardware profile for roofline analysis.
62#[derive(Debug, Clone)]
63pub struct HardwareProfile {
64    /// Peak throughput in GFLOPS.
65    pub peak_gflops: f64,
66    /// Memory bandwidth in GB/s.
67    pub memory_bandwidth_gbs: f64,
68    /// Human-readable profile name.
69    pub name: String,
70}
71
72impl HardwareProfile {
73    /// Ridge point: arithmetic intensity where compute and memory are balanced.
74    pub fn ridge_point(&self) -> f64 {
75        if self.memory_bandwidth_gbs == 0.0 {
76            return 0.0;
77        }
78        self.peak_gflops / self.memory_bandwidth_gbs
79    }
80
81    /// Predict execution latency in seconds using the roofline model.
82    ///
83    /// `latency = max(flops / peak_flops_per_sec, bytes / bandwidth_per_sec)`
84    pub fn predict_latency_secs(&self, cost: &OpCost) -> f64 {
85        let compute_secs = cost.flops as f64 / (self.peak_gflops * 1e9);
86        let total_bytes = (cost.bytes_read + cost.bytes_written) as f64;
87        let memory_secs = total_bytes / (self.memory_bandwidth_gbs * 1e9);
88        compute_secs.max(memory_secs)
89    }
90
91    /// Determine the bottleneck for a given operation cost.
92    pub fn bottleneck(&self, cost: &OpCost) -> Bottleneck {
93        let ai = cost.arithmetic_intensity();
94        if ai >= self.ridge_point() {
95            Bottleneck::ComputeBound
96        } else {
97            Bottleneck::MemoryBound
98        }
99    }
100}
101
102/// Estimate the compute and memory cost of a classified kernel pattern.
103///
104/// Element size defaults to 4 bytes (f32) unless the pattern provides
105/// enough information to determine otherwise.
106pub fn estimate_kernel_cost(pattern: &KernelPattern) -> OpCost {
107    const ELEM_SIZE: u64 = 4; // f32
108
109    match pattern {
110        KernelPattern::MatMul { shape, .. } => {
111            // Symbolic dims — use dummy size for cost estimation.
112            // For actual numeric shapes, callers can provide concrete dims.
113            // FLOPs = 2 * M * N * K (multiply-accumulate)
114            let (m, n, k) = parse_matmul_dims(shape);
115            let flops = 2 * m * n * k;
116            let bytes_read = (m * k + k * n) * ELEM_SIZE;
117            let bytes_written = m * n * ELEM_SIZE;
118            OpCost {
119                flops,
120                bytes_read,
121                bytes_written,
122            }
123        }
124        KernelPattern::Conv2D { shape, .. } => {
125            let (batch, cin, cout, oh, ow, kh, kw, groups) = parse_conv2d_dims(shape);
126            // FLOPs = 2 * batch * cout * oh * ow * (cin/groups) * kh * kw
127            let flops = 2 * batch * cout * oh * ow * (cin / groups) * kh * kw;
128            // Input read: batch * cin * (oh + kh - 1) * (ow + kw - 1) * elem
129            let ih = oh + kh - 1;
130            let iw = ow + kw - 1;
131            let bytes_read = (batch * cin * ih * iw + cout * (cin / groups) * kh * kw) * ELEM_SIZE;
132            let bytes_written = batch * cout * oh * ow * ELEM_SIZE;
133            OpCost {
134                flops,
135                bytes_read,
136                bytes_written,
137            }
138        }
139        KernelPattern::ElementWise { op, .. } => {
140            let n = parse_dim_name_default();
141            let flops = n;
142            // Read 2 inputs, write 1 output
143            let bytes_read = 2 * n * ELEM_SIZE;
144            let bytes_written = n * ELEM_SIZE;
145            let _ = op;
146            OpCost {
147                flops,
148                bytes_read,
149                bytes_written,
150            }
151        }
152        KernelPattern::Activation { .. } => {
153            let n = parse_dim_name_default();
154            let flops = n;
155            // Read 1 input, write 1 output
156            let bytes_read = n * ELEM_SIZE;
157            let bytes_written = n * ELEM_SIZE;
158            OpCost {
159                flops,
160                bytes_read,
161                bytes_written,
162            }
163        }
164        // One operation per element per step. A scalar operand is read once
165        // for the whole dispatch, not once per element, so only the tensor
166        // operands count toward the traffic — which is the whole reason `axpy`
167        // keeps its coefficient in a uniform.
168        KernelPattern::ElementWiseChain { steps, .. } => {
169            let n = parse_dim_name_default();
170            let tensor_operands = steps
171                .iter()
172                .filter(|s| matches!(s.operand, crate::analyze::ChainOperand::Tensor(_)))
173                .count() as u64;
174            OpCost {
175                flops: n * steps.len() as u64,
176                bytes_read: (1 + tensor_operands) * n * ELEM_SIZE,
177                bytes_written: n * ELEM_SIZE,
178            }
179        }
180        KernelPattern::Pool { shape, .. } => {
181            let (batch, cout, oh, ow) = (1u64, 1, 64, 64);
182            let kh = shape.kernel_h as u64;
183            let kw = shape.kernel_w as u64;
184            let flops = batch * cout * oh * ow * kh * kw;
185            let bytes_read = batch * cout * (oh + kh - 1) * (ow + kw - 1) * ELEM_SIZE;
186            let bytes_written = batch * cout * oh * ow * ELEM_SIZE;
187            OpCost {
188                flops,
189                bytes_read,
190                bytes_written,
191            }
192        }
193        KernelPattern::Reduce { .. } => {
194            let n = parse_dim_name_default();
195            let flops = n;
196            let bytes_read = n * ELEM_SIZE;
197            let bytes_written = ELEM_SIZE; // scalar output
198            OpCost {
199                flops,
200                bytes_read,
201                bytes_written,
202            }
203        }
204        _ => OpCost {
205            flops: 0,
206            bytes_read: 0,
207            bytes_written: 0,
208        },
209    }
210}
211
212/// Estimate cost with explicit numeric dimensions for a MatMul pattern.
213pub fn estimate_matmul_cost(m: u64, n: u64, k: u64) -> OpCost {
214    const ELEM_SIZE: u64 = 4;
215    OpCost {
216        flops: 2 * m * n * k,
217        bytes_read: (m * k + k * n) * ELEM_SIZE,
218        bytes_written: m * n * ELEM_SIZE,
219    }
220}
221
222/// Estimate cost with explicit numeric dimensions for a Conv2D pattern.
223#[allow(clippy::too_many_arguments)]
224pub fn estimate_conv2d_cost(
225    batch: u64,
226    cin: u64,
227    cout: u64,
228    oh: u64,
229    ow: u64,
230    kh: u64,
231    kw: u64,
232    groups: u64,
233) -> OpCost {
234    const ELEM_SIZE: u64 = 4;
235    let g = if groups == 0 { 1 } else { groups };
236    let flops = 2 * batch * cout * oh * ow * (cin / g) * kh * kw;
237    let ih = oh + kh - 1;
238    let iw = ow + kw - 1;
239    let bytes_read = (batch * cin * ih * iw + cout * (cin / g) * kh * kw) * ELEM_SIZE;
240    let bytes_written = batch * cout * oh * ow * ELEM_SIZE;
241    OpCost {
242        flops,
243        bytes_read,
244        bytes_written,
245    }
246}
247
248/// Estimate cost for an element-wise operation with known element count.
249pub fn estimate_elementwise_cost(n: u64) -> OpCost {
250    const ELEM_SIZE: u64 = 4;
251    OpCost {
252        flops: n,
253        bytes_read: 2 * n * ELEM_SIZE,
254        bytes_written: n * ELEM_SIZE,
255    }
256}
257
258/// Estimate cost for an activation operation with known element count.
259pub fn estimate_activation_cost(n: u64) -> OpCost {
260    const ELEM_SIZE: u64 = 4;
261    OpCost {
262        flops: n,
263        bytes_read: n * ELEM_SIZE,
264        bytes_written: n * ELEM_SIZE,
265    }
266}
267
268/// Return default hardware profiles for known backends.
269pub fn default_profiles() -> HashMap<&'static str, HardwareProfile> {
270    let mut map = HashMap::new();
271    map.insert(
272        "onnx",
273        HardwareProfile {
274            peak_gflops: 100.0,
275            memory_bandwidth_gbs: 50.0,
276            name: "ONNX Runtime (Generic CPU)".into(),
277        },
278    );
279    map.insert(
280        "tflite",
281        HardwareProfile {
282            peak_gflops: 50.0,
283            memory_bandwidth_gbs: 25.0,
284            name: "TFLite (Mobile CPU)".into(),
285        },
286    );
287    map.insert(
288        "arm-ethos",
289        HardwareProfile {
290            peak_gflops: 4.0,
291            memory_bandwidth_gbs: 16.0,
292            name: "Arm Ethos-U65".into(),
293        },
294    );
295    map
296}
297
298// ---------------------------------------------------------------------------
299// Internal helpers
300// ---------------------------------------------------------------------------
301
302fn parse_matmul_dims(shape: &crate::MatMulShape) -> (u64, u64, u64) {
303    let m = parse_dim(&shape.m);
304    let n = parse_dim(&shape.n);
305    let k = parse_dim(&shape.k);
306    (m, n, k)
307}
308
309fn parse_conv2d_dims(shape: &crate::Conv2DShape) -> (u64, u64, u64, u64, u64, u64, u64, u64) {
310    let batch = parse_dim(&shape.batch);
311    let cin = parse_dim(&shape.channels_in);
312    let cout = parse_dim(&shape.channels_out);
313    let height = parse_dim(&shape.height);
314    let width = parse_dim(&shape.width);
315    let kh = shape.kernel_h_val.max(1) as u64;
316    let kw = shape.kernel_w_val.max(1) as u64;
317    let groups = shape.groups.max(1) as u64;
318    // Approximate output spatial dims accounting for stride and padding.
319    let sh = shape.stride_h.max(1) as u64;
320    let sw = shape.stride_w.max(1) as u64;
321    let ph = shape.pad_h.max(0) as u64;
322    let pw = shape.pad_w.max(0) as u64;
323    let oh = (height + 2 * ph - kh) / sh + 1;
324    let ow = (width + 2 * pw - kw) / sw + 1;
325    (batch, cin, cout, oh, ow, kh, kw, groups)
326}
327
328/// Try to parse a numeric dimension string; fall back to a default.
329fn parse_dim(s: &str) -> u64 {
330    s.parse::<u64>().unwrap_or(256)
331}
332
333/// Default element count for symbolic dimension names.
334fn parse_dim_name_default() -> u64 {
335    256
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn matmul_flop_count() {
344        let cost = estimate_matmul_cost(128, 128, 64);
345        assert_eq!(cost.flops, 2 * 128 * 128 * 64);
346    }
347
348    #[test]
349    fn conv2d_flop_count() {
350        // batch=1, cin=3, cout=16, oh=32, ow=32, kh=3, kw=3, groups=1
351        let cost = estimate_conv2d_cost(1, 3, 16, 32, 32, 3, 3, 1);
352        assert_eq!(cost.flops, 2 * 16 * 32 * 32 * 3 * 3 * 3);
353    }
354
355    #[test]
356    fn elementwise_cost() {
357        let cost = estimate_elementwise_cost(256);
358        assert_eq!(cost.flops, 256);
359        assert_eq!(cost.bytes_read, 2 * 256 * 4);
360        assert_eq!(cost.bytes_written, 256 * 4);
361    }
362
363    #[test]
364    fn roofline_compute_bound() {
365        // Large MatMul → high arithmetic intensity → compute-bound.
366        let cost = estimate_matmul_cost(1024, 1024, 1024);
367        let profile = HardwareProfile {
368            peak_gflops: 100.0,
369            memory_bandwidth_gbs: 200.0, // very high BW
370            name: "test".into(),
371        };
372        assert_eq!(profile.bottleneck(&cost), Bottleneck::ComputeBound);
373    }
374
375    #[test]
376    fn roofline_memory_bound() {
377        // Small element-wise → low arithmetic intensity → memory-bound.
378        let cost = estimate_elementwise_cost(64);
379        let profile = HardwareProfile {
380            peak_gflops: 1000.0, // very high compute
381            memory_bandwidth_gbs: 10.0,
382            name: "test".into(),
383        };
384        assert_eq!(profile.bottleneck(&cost), Bottleneck::MemoryBound);
385    }
386
387    #[test]
388    fn predict_latency_value() {
389        let cost = OpCost {
390            flops: 1_000_000_000, // 1 GFLOP
391            bytes_read: 0,
392            bytes_written: 0,
393        };
394        let profile = HardwareProfile {
395            peak_gflops: 100.0,
396            memory_bandwidth_gbs: 50.0,
397            name: "test".into(),
398        };
399        let latency = profile.predict_latency_secs(&cost);
400        // 1e9 / (100 * 1e9) = 0.01 seconds
401        assert!((latency - 0.01).abs() < 1e-9);
402    }
403
404    #[test]
405    fn default_profiles_has_3_entries() {
406        let profiles = default_profiles();
407        assert!(profiles.len() >= 3);
408        assert!(profiles.contains_key("onnx"));
409        assert!(profiles.contains_key("tflite"));
410        assert!(profiles.contains_key("arm-ethos"));
411    }
412
413    #[test]
414    fn bottleneck_at_ridge_point() {
415        let profile = HardwareProfile {
416            peak_gflops: 100.0,
417            memory_bandwidth_gbs: 50.0,
418            name: "test".into(),
419        };
420        let ridge = profile.ridge_point(); // 2.0
421        // At exactly the ridge point, AI >= ridge → ComputeBound.
422        let total_bytes = 1000u64;
423        let flops = (ridge * total_bytes as f64) as u64;
424        let cost = OpCost {
425            flops,
426            bytes_read: total_bytes,
427            bytes_written: 0,
428        };
429        assert_eq!(profile.bottleneck(&cost), Bottleneck::ComputeBound);
430    }
431
432    #[test]
433    fn arithmetic_intensity() {
434        let cost = OpCost {
435            flops: 1000,
436            bytes_read: 400,
437            bytes_written: 100,
438        };
439        let ai = cost.arithmetic_intensity();
440        assert!((ai - 2.0).abs() < 1e-9);
441    }
442
443    #[test]
444    fn cost_model_used_by_schedule() {
445        // Verify that OpCost can produce cost values consumed by critical_path_with_costs.
446        use crate::DataflowGraph;
447        use nxpu_ir::{Expression, Function, Literal, Range, Statement};
448
449        let mut func = Function::new("test");
450        let lit = func
451            .expressions
452            .append(Expression::Literal(Literal::F32(1.0)));
453        func.body
454            .push(Statement::Emit(Range::from_index_range(0..1)));
455        func.body.push(Statement::Return { value: Some(lit) });
456
457        let dfg = DataflowGraph::build(&func);
458        // Compute cost-model-derived costs (one per DFG node).
459        let cost = estimate_elementwise_cost(256);
460        let costs: Vec<usize> = (0..dfg.nodes().len())
461            .map(|_| cost.flops as usize)
462            .collect();
463        let cp = dfg.critical_path_with_costs(&costs);
464        assert!(cp.critical_path_length > 0);
465    }
466}