metrale_model_layers/layers/
dense_ffn_batch16_decode.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The opt-in 5..=32-row native-FP8 dense-FFN tier, `w8a16_gemv_batch16`:
4//! one weight pass for up to 16 rows, and two launches on row halves for 17..=32.
5//!
6//! Owner: model-layers (dense FFN).
7//! Invariants:
8//! - `batch16_plan` returns `None` unless the tier is armed and the handle resolved, and
9//!   for every `m` outside 5..=32.
10//! - Both launches of a `Halves` plan cover at most 16 rows, the kernel's `MAX_M`.
11//!
12//! The `w8_gemm!` ladder in `DenseFfnLayer::prefill_fp8` (reached from `forward_prefill_inner`) tries this tier after
13//! `w8a16_gemv_batch4` (m <= 4) and the tensor-core `m16_tc` tier, and before the W8A8
14//! and tile GEMMs. It selects by row count, not by phase, so a prefill chunk of 5..=32
15//! tokens takes it too. `w8a16_gemv_batch16` and `w8a16_gemv_batch4` are two `MAX_M`
16//! instantiations of one template (`w8a16_gemv_batch4.cu`), and
17//! `examples/native_fp8_ffn_batch16_microtest.rs` requires every row to equal the scalar
18//! `w8a16_gemv` byte for byte.
19//!
20//! Off by default. Measured 2026-09-11 on 1xH100, Qwen/Qwen3.8-27B-FP8, same binary,
21//! 1024x256 at C=16, tier on against off: aggregate 121.4 against 128.0 tok/s, TPOT p50
22//! 107.4 against 102.0 ms, and TTFT of a 28-token prompt 150 against 101 ms.
23
24use anyhow::Result;
25use metrale_gpu_runtime::gpu::DevicePtr;
26
27use super::DenseFfnLayer;
28use crate::layer::ForwardContext;
29use crate::layers::ops;
30use crate::weight_map::Fp8Weight;
31
32/// 2026-09-25: Whether `METRALE_FFN_BATCH16` is exactly `1`, which arms the tier; any
33/// other value, or none, leaves it off. Read once per process; each `DenseFfnLayer`
34/// copies it into `batch16_enabled` at construction, so the route cannot change between
35/// CUDA-graph replays.
36pub fn ffn_batch16_enabled() -> bool {
37    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
38    *ON.get_or_init(|| std::env::var("METRALE_FFN_BATCH16").as_deref() == Ok("1"))
39}
40
41/// 2026-09-25: How the batch16 tier serves `m` rows.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub(crate) enum Batch16Plan {
44    /// 2026-09-25: One launch covering rows `0..m` (m <= 16).
45    Single,
46    /// 2026-09-25: Two launches on contiguous row halves: rows `0..first`, then
47    /// `first..m`. `first` is `ceil(m/2)`, so both halves are <= 16 for every m <= 32 and
48    /// the first half is the wider one (m=17 -> 9 + 8).
49    Halves { first: u32 },
50}
51
52/// 2026-09-25: The batch16 selection rule, pure over the row count, the handle's
53/// presence and the opt-in, so the CPU tests can cover it without a `ForwardContext`.
54/// `enabled` is a parameter because the process-wide `OnceLock` behind
55/// `ffn_batch16_enabled` cannot be toggled per test.
56pub(crate) fn batch16_plan(m: u32, batch16_loaded: bool, enabled: bool) -> Option<Batch16Plan> {
57    if !enabled || !batch16_loaded {
58        return None;
59    }
60    match m {
61        5..=16 => Some(Batch16Plan::Single),
62        // 2026-09-25: Both halves must be <= 16, the kernel's `MAX_M`; `div_ceil` puts
63        // the odd row in the first half.
64        17..=32 => Some(Batch16Plan::Halves {
65            first: m.div_ceil(2),
66        }),
67        _ => None,
68    }
69}
70
71impl DenseFfnLayer {
72    /// 2026-09-25: The plan for `m` rows on this layer: its handle and the opt-in it
73    /// copied at construction.
74    pub(crate) fn ffn_batch16_plan(&self, m: u32) -> Option<Batch16Plan> {
75        batch16_plan(m, self.w8a16_gemv_batch16_k.0 != 0, self.batch16_enabled)
76    }
77
78    /// 2026-09-25: Run one dense-FFN projection through `w8a16_gemv_batch16`.
79    ///
80    /// `input` is `[m, k]` BF16 and `out` is `[m, n]` BF16, both contiguous, so the
81    /// `Halves` plan is two launches at byte offsets. Rows that are not contiguous need
82    /// `ops::w8a16_gemv_batch16_strided`, which the attention QKV path uses.
83    #[allow(clippy::too_many_arguments)]
84    pub(crate) fn w8a16_batch16_proj(
85        &self,
86        ctx: &ForwardContext,
87        plan: Batch16Plan,
88        w: &Fp8Weight,
89        input: DevicePtr,
90        out: DevicePtr,
91        m: u32,
92        n: u32,
93        k: u32,
94        stream: u64,
95    ) -> Result<()> {
96        self.log_batch16_decode_route(ctx, plan);
97        const BF16: usize = 2;
98        let launch = |rows: u32, first: u32| {
99            ops::w8a16_gemv_batch16(
100                ctx.gpu,
101                self.w8a16_gemv_batch16_k,
102                input.offset(first as usize * k as usize * BF16),
103                w.weight,
104                w.row_scale,
105                out.offset(first as usize * n as usize * BF16),
106                rows,
107                n,
108                k,
109                stream,
110            )
111        };
112        match plan {
113            Batch16Plan::Single => launch(m, 0),
114            Batch16Plan::Halves { first } => {
115                launch(first, 0)?;
116                launch(m - first, first)
117            }
118        }
119    }
120
121    /// 2026-09-25: Logs the first batch16 launch once per `ModelStats`
122    /// (`log:ffn_batch16_decode`), so a serve's log shows that the opt-in tier ran.
123    fn log_batch16_decode_route(&self, ctx: &ForwardContext, plan: Batch16Plan) {
124        if ctx.stats.once("log:ffn_batch16_decode") {
125            let how = match plan {
126                Batch16Plan::Single => "one launch",
127                Batch16Plan::Halves { .. } => "two launches on contiguous row halves",
128            };
129            tracing::info!(
130                "[metrale] dense FFN decode: native FP8 w8a16_gemv_batch16 ({how}) \
131                 for 5..=32 rows — one weight pass, bit-identical per row to the \
132                 M=1 w8a16_gemv. ARMED BY METRALE_FFN_BATCH16=1, off by default: it \
133                 measured -5.4% aggregate and +50 ms TTFT on H100, and has never \
134                 been measured on GB10 (#927)."
135            );
136        }
137    }
138}
139
140#[cfg(test)]
141#[path = "dense_ffn_batch16_decode_tests.rs"]
142mod tests;