Skip to main content

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}