Skip to main content

int8_shape_bench/
int8_shape_bench.rs

1//! Interleaved W8A8-vs-f32 GEMV/GEMM bench at the model's exact decode shapes.
2//!
3//! This is the per-shape bench required by `frankentts-k-int8-kernels-qhy` and the seed data for
4//! the KernelPlan (`frankentts-b-w8a8-bench-1mu`). It measures the kernel seam only — activation
5//! quantization included on the W8A8 side, allocation excluded on both sides — with interleaved
6//! same-thermal-window rounds (NE-INH-007) and a cv% report so an incoherent capture is visible
7//! instead of averaged away.
8//!
9//! Numbers printed here are PROVISIONAL_LOCAL_WIN candidates at best: they compare routes inside
10//! this tree and never claim a pinned-incumbent ratio.
11//!
12//! ```sh
13//! cargo run --release --locked -p ftts-kernels --example int8_shape_bench
14//! ```
15
16use ftts_kernels::f32ref;
17use ftts_kernels::int8::{Int8Tier, QuantizedMatrix, linear_q8, quantize_row_q8};
18use std::hint::black_box;
19use std::time::Instant;
20
21/// (label, n, k) — the seven distinct decode-path projections plus the seq-16 verify regime.
22const SHAPES: &[(&str, usize, usize)] = &[
23    ("q_proj/head 2048x1024", 2048, 1024),
24    ("k/v_proj  1024x1024", 1024, 1024),
25    ("o_proj    1024x2048", 1024, 2048),
26    ("gate/up   3072x1024", 3072, 1024),
27    ("down_proj 1024x3072", 1024, 3072),
28];
29
30const ROUNDS: usize = 12;
31const WARMUP_ROUNDS: usize = 2;
32
33fn pseudo_random_f32(len: usize, seed: u64) -> Vec<f32> {
34    let mut state = seed;
35    (0..len)
36        .map(|_| {
37            state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
38            let mut z = state;
39            z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
40            z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
41            ((z >> 40) as f32 / (1u64 << 23) as f32) - 1.0
42        })
43        .collect()
44}
45
46struct Stats {
47    mean_us: f64,
48    cv_percent: f64,
49}
50
51fn stats(samples: &[f64]) -> Stats {
52    let mean = samples.iter().sum::<f64>() / samples.len() as f64;
53    let variance =
54        samples.iter().map(|s| (s - mean) * (s - mean)).sum::<f64>() / samples.len() as f64;
55    Stats {
56        mean_us: mean,
57        cv_percent: 100.0 * variance.sqrt() / mean,
58    }
59}
60
61#[allow(clippy::too_many_lines)]
62fn main() {
63    let tiers: Vec<Int8Tier> = Int8Tier::available();
64    println!("int8 shape bench — tiers available: {:?}", {
65        tiers.iter().map(|t| t.as_str()).collect::<Vec<_>>()
66    });
67    println!(
68        "interleaved rounds={ROUNDS} (+{WARMUP_ROUNDS} warmup); per-sample = mean over calls in one round; cv% over rounds"
69    );
70
71    for &m in &[1_usize, 16] {
72        println!(
73            "\n== m = {m} {} ==",
74            if m == 1 {
75                "(decode GEMV)"
76            } else {
77                "(seq-16 verify GEMM)"
78            }
79        );
80        for &(label, n, k) in SHAPES {
81            let calls: usize = (32 / m).max(2);
82            let weight = pseudo_random_f32(n * k, 0xbe0_0001 ^ (n as u64) << 20 ^ k as u64);
83            let x = pseudo_random_f32(m * k, 0xbe0_0002 ^ (m as u64) << 32 ^ k as u64);
84            let quantized = QuantizedMatrix::quantize(&weight, n, k);
85            let mut out = vec![0.0_f32; m * n];
86            let mut x_q = vec![0_i8; m * k];
87            let mut x_scales = vec![0.0_f32; m];
88
89            // One arm per route, all interleaved inside every round.
90            let mut f32_samples = Vec::with_capacity(ROUNDS);
91            let mut tier_samples: Vec<Vec<f64>> =
92                tiers.iter().map(|_| Vec::with_capacity(ROUNDS)).collect();
93
94            for round in 0..ROUNDS + WARMUP_ROUNDS {
95                // f32 arm
96                let start = Instant::now();
97                for _ in 0..calls {
98                    f32ref::linear(
99                        black_box(&x),
100                        black_box(&weight),
101                        None,
102                        m,
103                        k,
104                        n,
105                        black_box(&mut out),
106                    );
107                }
108                let f32_us = start.elapsed().as_secs_f64() * 1e6 / calls as f64;
109
110                // W8A8 arms, including dynamic activation quantization each call.
111                let mut this_round = Vec::with_capacity(tiers.len());
112                for &tier in &tiers {
113                    let start = Instant::now();
114                    for _ in 0..calls {
115                        for ((x_row, q_row), scale) in x
116                            .chunks_exact(k)
117                            .zip(x_q.chunks_exact_mut(k))
118                            .zip(x_scales.iter_mut())
119                        {
120                            *scale = quantize_row_q8(black_box(x_row), q_row);
121                        }
122                        linear_q8(
123                            black_box(&x_q),
124                            black_box(&x_scales),
125                            black_box(&quantized),
126                            None,
127                            m,
128                            black_box(&mut out),
129                            tier,
130                        );
131                    }
132                    this_round.push(start.elapsed().as_secs_f64() * 1e6 / calls as f64);
133                }
134
135                if round >= WARMUP_ROUNDS {
136                    f32_samples.push(f32_us);
137                    for (samples, sample) in tier_samples.iter_mut().zip(&this_round) {
138                        samples.push(*sample);
139                    }
140                }
141            }
142
143            let f32_stats = stats(&f32_samples);
144            // The f32 reference loops rows outermost, so it streams the weight matrix once per
145            // activation row (m times per call); the q8 kernel is weight-stationary and streams
146            // it exactly once per call. The column reports actual weight bytes moved per second.
147            let f32_bytes = (n * k * 4 * m) as f64;
148            println!(
149                "{label}  f32     {:9.1} us  cv {:4.1}%  ({:5.1} GB/s weight-stream)",
150                f32_stats.mean_us,
151                f32_stats.cv_percent,
152                f32_bytes / (f32_stats.mean_us * 1e-6) / 1e9,
153            );
154            for (tier, samples) in tiers.iter().zip(&tier_samples) {
155                let tier_stats = stats(samples);
156                let q8_bytes = (n * k) as f64;
157                let verdict = if tier_stats.cv_percent > 5.0 || f32_stats.cv_percent > 5.0 {
158                    "REFUSED (cv>5%)"
159                } else {
160                    ""
161                };
162                println!(
163                    "{label}  q8 {:9} {:9.1} us  cv {:4.1}%  ({:5.1} GB/s weight-stream)  x{:.2} vs f32 {verdict}",
164                    tier.as_str(),
165                    tier_stats.mean_us,
166                    tier_stats.cv_percent,
167                    q8_bytes / (tier_stats.mean_us * 1e-6) / 1e9,
168                    f32_stats.mean_us / tier_stats.mean_us,
169                );
170            }
171        }
172    }
173    println!(
174        "\nNOTE: ratios above compare routes inside this tree (self-comparison = maintenance),\nnever a pinned incumbent. cv%>5 rows are refused, not averaged."
175    );
176}