qualia_core_db/wgsl_forge/roofline.rs
1//! Simple roofline estimates per kernel (plan §6 / §10).
2//!
3//! For each kernel we estimate the FLOPs performed and the bytes moved over a
4//! representative problem size, giving an arithmetic intensity (FLOP/byte) and a
5//! memory-vs-compute classification.
6//!
7//! **Known limitations (honest, not stubs).** This is an *estimate only*; it never
8//! rejects a schedule. wgpu does not expose device peak FLOPS or memory bandwidth, so
9//! there is no device-relative roofline ceiling to reject against — a real
10//! device-relative bound would require a calibration micro-benchmark. Likewise,
11//! compute-unit-saturation pruning is not implemented because wgpu does not expose a
12//! compute-unit (SM/CU) count. The classification here is therefore used to *explain*
13//! why a schedule is (or isn't) worth pursuing and to drive the search-tree dump, not
14//! to gate the search.
15
16use serde::{Deserialize, Serialize};
17
18use super::BuiltinKernel;
19
20/// Whether a kernel is dominated by memory traffic or by arithmetic.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum RooflineBound {
24 Memory,
25 Compute,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
29pub struct RooflineEstimate {
30 pub flops: u64,
31 pub bytes: u64,
32 pub arithmetic_intensity: f64,
33 pub bound: RooflineBound,
34}
35
36/// Crossover FLOP/byte below which a kernel is treated as memory-bound. Modern
37/// discrete GPUs sit roughly in the 10–40 range; 10 is a conservative default
38/// used when no per-device calibration is available.
39pub const DEFAULT_BALANCE_FLOP_PER_BYTE: f64 = 10.0;
40
41impl RooflineEstimate {
42 pub fn new(flops: u64, bytes: u64) -> Self {
43 let arithmetic_intensity = if bytes == 0 {
44 0.0
45 } else {
46 flops as f64 / bytes as f64
47 };
48 let bound = if arithmetic_intensity < DEFAULT_BALANCE_FLOP_PER_BYTE {
49 RooflineBound::Memory
50 } else {
51 RooflineBound::Compute
52 };
53 Self {
54 flops,
55 bytes,
56 arithmetic_intensity,
57 bound,
58 }
59 }
60}
61
62/// Roofline estimate for `kernel` over a representative size `n` (output
63/// elements / records / rays, depending on the kernel).
64pub fn roofline_for(kernel: BuiltinKernel, n: u64) -> RooflineEstimate {
65 match kernel {
66 // out[i] = in[i]*scale + bias: one FMA (2 FLOP); read + write one f32.
67 BuiltinKernel::AffineF32 => RooflineEstimate::new(2 * n, 8 * n),
68 // per record: 16 MACs (32 FLOP); read 64 bytes, write 4.
69 BuiltinKernel::P64Project => RooflineEstimate::new(32 * n, 68 * n),
70 // top-k: ~k tree passes per block; load-dominated, little arithmetic.
71 BuiltinKernel::TopK => RooflineEstimate::new(4 * n, 4 * n),
72 // ray-probe: per-ray traversal is variable; modelled as load-dominated.
73 BuiltinKernel::RayProbe => RooflineEstimate::new(8 * n, 36 * n),
74 // FFN (input=64, hidden=128): weights amortised (read once), so for large
75 // n this is compute-bound — the desired roofline answer.
76 BuiltinKernel::FusedFfn => {
77 let input = 64u64;
78 let hidden = 128u64;
79 let flops = n * (hidden * (2 * input + 8) + 2 * hidden);
80 let bytes = (input + hidden * input + n * hidden + n) * 4;
81 RooflineEstimate::new(flops, bytes)
82 }
83 // ternary GEMV (K=256): per output row, K MACs (2 FLOP) over the activation
84 // vector plus one scale multiply. Bytes: 2-bit-packed weights (K/16 u32 =
85 // K/4 bytes per row), the shared K-length f32 x once, plus scale + output.
86 // n is the output-row count M; x is amortised (read once) so for large M
87 // this is memory-bound on the packed weights, the desired roofline answer.
88 BuiltinKernel::TernaryGemv => {
89 let k = 256u64;
90 let flops = n * (2 * k + 1);
91 let bytes = (n * (k / 16) + k + n + n) * 4;
92 RooflineEstimate::new(flops, bytes)
93 }
94 // Dense GEMM C[M×N] = A[M×K]·B[K×N] (K=64). `n` is the output-element
95 // count (M*N), one invocation each. Per output element: K MACs = 2K FLOP.
96 // Bytes: the K-length A-row and B-column read per element (2*K), plus the
97 // one output write — i.e. (n*(2*K + 1))*4. Large-N this is compute-bound,
98 // the desired roofline answer for a dense matmul.
99 BuiltinKernel::Gemm => {
100 let k = 64u64;
101 let flops = n * 2 * k;
102 let bytes = (n * (2 * k + 1)) * 4;
103 RooflineEstimate::new(flops, bytes)
104 }
105 // Dense GEMV y[M] = A[M×N]·x[N] (N=256). `n` is the output-row count M, one
106 // invocation each. Per output row: N MACs = 2N FLOP. Bytes: the whole matrix
107 // A (M*N) is read once, x (N) is read once (amortised), plus the M output
108 // writes — i.e. (M*N + N + M)*4. GEMV is memory-bound (each A element is read
109 // exactly once, AI ≈ 2 FLOP/byte/2 = ~0.5), the desired roofline answer for a
110 // matrix-vector product.
111 BuiltinKernel::Gemv => {
112 let row = n; // output-row count M
113 let n_cols = 256u64;
114 let flops = row * 2 * n_cols;
115 let bytes = (row * n_cols + n_cols + row) * 4;
116 RooflineEstimate::new(flops, bytes)
117 }
118 // Radix-2 FFT of `n` complex points: log2(n) stages, each doing n/2
119 // butterflies; one butterfly is ~10 FLOP (complex multiply + two complex
120 // add/sub), so ~5*n*log2(n) FLOP total. Bytes: read 2*n f32 in + write
121 // 2*n f32 out = 4*n f32 = 4*n*4 bytes (the transform itself stays in
122 // workgroup-shared memory). For modest n the log factor keeps arithmetic
123 // intensity low, so this reads as memory-bound at small sizes — the
124 // honest answer for a single-workgroup transform that is dominated by the
125 // shared-memory traffic, not global FLOPs.
126 BuiltinKernel::Fft => {
127 let log2n = (n.max(1)).next_power_of_two().trailing_zeros() as u64;
128 let flops = 5 * n * log2n;
129 let bytes = 4 * n * 4;
130 RooflineEstimate::new(flops, bytes)
131 }
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn affine_is_memory_bound_ffn_is_compute_bound() {
141 assert_eq!(
142 roofline_for(BuiltinKernel::AffineF32, 1_000_000).bound,
143 RooflineBound::Memory
144 );
145 assert_eq!(
146 roofline_for(BuiltinKernel::FusedFfn, 4096).bound,
147 RooflineBound::Compute
148 );
149 }
150
151 #[test]
152 fn intensity_is_flops_over_bytes() {
153 let estimate = roofline_for(BuiltinKernel::AffineF32, 10);
154 assert!((estimate.arithmetic_intensity - 0.25).abs() < 1e-9);
155 }
156}