1use std::collections::HashMap;
7use std::fmt;
8
9use crate::KernelPattern;
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct OpCost {
14 pub flops: u64,
16 pub bytes_read: u64,
18 pub bytes_written: u64,
20}
21
22impl OpCost {
23 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Bottleneck {
46 ComputeBound,
48 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#[derive(Debug, Clone)]
63pub struct HardwareProfile {
64 pub peak_gflops: f64,
66 pub memory_bandwidth_gbs: f64,
68 pub name: String,
70}
71
72impl HardwareProfile {
73 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 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 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
102pub fn estimate_kernel_cost(pattern: &KernelPattern) -> OpCost {
107 const ELEM_SIZE: u64 = 4; match pattern {
110 KernelPattern::MatMul { shape, .. } => {
111 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 let flops = 2 * batch * cout * oh * ow * (cin / groups) * kh * kw;
128 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 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 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 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; 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
212pub 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#[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
248pub 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
258pub 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
268pub 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
298fn 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 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
328fn parse_dim(s: &str) -> u64 {
330 s.parse::<u64>().unwrap_or(256)
331}
332
333fn 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 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 let cost = estimate_matmul_cost(1024, 1024, 1024);
367 let profile = HardwareProfile {
368 peak_gflops: 100.0,
369 memory_bandwidth_gbs: 200.0, name: "test".into(),
371 };
372 assert_eq!(profile.bottleneck(&cost), Bottleneck::ComputeBound);
373 }
374
375 #[test]
376 fn roofline_memory_bound() {
377 let cost = estimate_elementwise_cost(64);
379 let profile = HardwareProfile {
380 peak_gflops: 1000.0, 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, 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 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(); 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 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 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}