metrale_model_layers/layers/
dense_ffn_m16_tc.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The tensor-core `w8a16_gemm_m16` arm of the FP8 dense-FFN ladder, 5..=32 rows.
4//!
5//! Owner: model-layers (dense FFN).
6//! Invariants:
7//! - `m16_tc_plan` returns a plan only when the lever is on, the kernel handle
8//!   is loaded, `k` is a multiple of 128 and `m` is in 5..=32.
9//! - Every launch of a plan covers at most 16 rows: 17..=32 runs as two
10//!   launches, the first `m.div_ceil(2)` rows and then the rest.
11//!
12//! In `dense_ffn.rs`'s `w8_gemm!` ladder this arm sits after
13//! `w8a16_gemv_batch4` (m <= 4) and ahead of the `w8a16_gemv_batch16` rungs,
14//! the W8A8 prefill arm and the tile GEMMs. An m16n8k16 MMA sums K in a
15//! different order from the scalar `w8a16_gemv`, so its outputs are graded by
16//! `within_m16_tc_budget` (2 ordinal BF16 ULP, or an absolute error under
17//! `m16_tc_acc_floor`) rather than by bit equality.
18//!
19//! The target declares the arm in `kernels/<hw>/HARDWARE.toml` `[defaults]
20//! ffn_m16_tc`; every target leaves it off, and the hopper row carries the H100
21//! serve receipt. `METRALE_FFN_M16_TC`, else the `METRALE_M16_TC` umbrella,
22//! overrides the declaration: `0`, `false`, `off` or `no` turns it off and any
23//! other value turns it on (`ops::target_defaults::resolve`).
24
25use anyhow::Result;
26use metrale_gpu_runtime::gpu::DevicePtr;
27
28/// 2026-09-25: The tier's numerics contract, shared by the GPU microtest
29/// (`examples/native_fp8_ffn_m16_tc_microtest.rs`) and the host simulation in
30/// `dense_ffn_m16_tc_m32_tests.rs`.
31#[path = "dense_ffn_m16_tc_oracle.rs"]
32pub mod oracle;
33
34pub use oracle::{
35    M16_TC_ACC_FLOOR_MARGIN, M16_TC_MAX_ULP, bf16_ord, m16_tc_acc_floor, within_m16_tc_budget,
36};
37
38use super::DenseFfnLayer;
39use crate::layer::ForwardContext;
40use crate::layers::ops;
41use crate::weight_map::Fp8Weight;
42use metrale_gpu_runtime::gpu::KernelHandle;
43
44/// 2026-09-25: The resolved `w8a16_gemm_m16` levers: which projection families
45/// take the tier, and the FFN arm's CTA N width. `DenseFfnLayer::new_with_activation`
46/// and the attention layer's init copy them into fields at construction.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct M16TcLevers {
49    /// 2026-09-25: The dense-FFN arm (`TargetLevers::ffn_m16_tc`).
50    pub ffn: bool,
51    /// 2026-09-25: The attention FP8 QKV and o_proj tiers
52    /// (`TargetLevers::attn_m16_tc`).
53    pub attn: bool,
54    /// 2026-09-25: CTA N width for the FFN arm: `ops::W8A16_GEMM_M16_N_TILE` (32),
55    /// or `ops::W8A16_GEMM_M16_N_TILE_WIDE` (64) when
56    /// `METRALE_FFN_M16_TC_NTILE=64`. The attention tiers always launch with 32.
57    pub ffn_n_tile: u32,
58}
59
60/// 2026-09-25: The pure resolver. `ffn` and `attn` are the resolved target
61/// levers; `n_tile` is the raw `METRALE_FFN_M16_TC_NTILE` value. Only `"64"`
62/// selects the wide tile. Any other value, or none, keeps 32 without failing
63/// the boot, and the route log prints the tile that ran.
64pub(crate) fn resolve_m16_tc_levers(ffn: bool, attn: bool, n_tile: Option<&str>) -> M16TcLevers {
65    M16TcLevers {
66        ffn,
67        attn,
68        ffn_n_tile: match n_tile {
69            Some("64") => ops::W8A16_GEMM_M16_N_TILE_WIDE,
70            _ => ops::W8A16_GEMM_M16_N_TILE,
71        },
72    }
73}
74
75/// 2026-09-25: The resolved levers for this process, computed once
76/// (`OnceLock`).
77pub fn m16_tc_levers() -> M16TcLevers {
78    static ON: std::sync::OnceLock<M16TcLevers> = std::sync::OnceLock::new();
79    *ON.get_or_init(|| {
80        let n_tile = std::env::var("METRALE_FFN_M16_TC_NTILE").ok();
81        resolve_m16_tc_levers(
82            // 2026-09-25: The target's `[defaults]` rows, with `METRALE_FFN_M16_TC`,
83            // `METRALE_ATTN_M16_TC` and the `METRALE_M16_TC` umbrella already
84            // applied by `ops::target_defaults::resolve`. When both a narrow
85            // variable and the umbrella are set, the narrow one wins.
86            ops::target_defaults::resolved().ffn_m16_tc.value,
87            ops::target_defaults::resolved().attn_m16_tc.value,
88            n_tile.as_deref(),
89        )
90    })
91}
92
93/// 2026-09-25: How the tier splits `m` rows into launches; `m16_tc_plan`
94/// returns `None` for a width it does not claim. Same shape as `Batch16Plan`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub(crate) enum M16TcPlan {
97    /// 2026-09-25: One launch over rows `0..m`, `m <= 16`.
98    Single,
99    /// 2026-09-25: Two launches on contiguous row halves: rows `0..first`, then
100    /// `first..m`. `first` is `ceil(m/2)`, so both halves are <= 16 for every
101    /// m <= 32 and the first half is the wider one (m=17 -> 9 + 8).
102    Halves { first: u32 },
103}
104
105/// 2026-09-25: The selection rule, as a pure function of the row count, the
106/// reduction depth, the handle's presence and the lever.
107///
108/// A `k` that is not a multiple of 128 is declined here, not refused at
109/// launch: the kernel reads one FP32 scale per 128-wide K block
110/// (`kernels/hopper/common/w8a16_gemm_m16.cu`), and `ops::w8a16_gemm_m16`
111/// returns an error for such a `k`.
112pub(crate) fn m16_tc_plan(m: u32, k: u32, loaded: bool, enabled: bool) -> Option<M16TcPlan> {
113    if !enabled || !loaded || !k.is_multiple_of(128) {
114        return None;
115    }
116    match m {
117        5..=16 => Some(M16TcPlan::Single),
118        // 2026-09-25: Both halves must be <= 16, the kernel's M tile. `div_ceil`
119        // puts the odd row in the first half.
120        17..=32 => Some(M16TcPlan::Halves {
121            first: m.div_ceil(2),
122        }),
123        _ => None,
124    }
125}
126
127/// 2026-09-25: Which contiguous instantiation the FFN arm launches: the `n64`
128/// kernel when the wide tile is asked for and its handle is loaded, otherwise
129/// the 32-wide kernel. The fallback does not warn; the route log prints the
130/// tile that ran.
131pub(crate) fn m16_tc_kernel(
132    n_tile: u32,
133    narrow: KernelHandle,
134    wide: KernelHandle,
135) -> (ops::ContiguousM16Gemm, KernelHandle, u32) {
136    if n_tile == ops::W8A16_GEMM_M16_N_TILE_WIDE && wide.0 != 0 {
137        (
138            ops::w8a16_gemm_m16_n64,
139            wide,
140            ops::W8A16_GEMM_M16_N_TILE_WIDE,
141        )
142    } else {
143        (ops::w8a16_gemm_m16, narrow, ops::W8A16_GEMM_M16_N_TILE)
144    }
145}
146
147impl DenseFfnLayer {
148    /// 2026-09-25: The plan for `m` rows at reduction depth `k` on this layer:
149    /// handle presence plus the lever.
150    pub(crate) fn ffn_m16_tc_plan(&self, m: u32, k: u32) -> Option<M16TcPlan> {
151        m16_tc_plan(m, k, self.w8a16_gemm_m16_k.0 != 0, self.m16_tc)
152    }
153
154    /// 2026-09-25: Run one dense-FFN projection through the kernel
155    /// `m16_tc_kernel` picks.
156    ///
157    /// `input` is `[m, k]` and `out` is `[m, n]`, both contiguous BF16, so the
158    /// second launch of a `Halves` plan starts `first` rows into each.
159    /// (`ops::w8a16_gemm_m16_strided` takes row pitches instead; the attention
160    /// QKV tier uses it.)
161    #[allow(clippy::too_many_arguments)]
162    pub(crate) fn w8a16_m16_tc_proj(
163        &self,
164        ctx: &ForwardContext,
165        plan: M16TcPlan,
166        w: &Fp8Weight,
167        input: DevicePtr,
168        out: DevicePtr,
169        m: u32,
170        n: u32,
171        k: u32,
172        stream: u64,
173    ) -> Result<()> {
174        let (gemm, kernel, n_tile) = m16_tc_kernel(
175            self.m16_tc_n_tile,
176            self.w8a16_gemm_m16_k,
177            self.w8a16_gemm_m16_n64_k,
178        );
179        self.log_m16_tc_route(ctx, plan, n_tile);
180        const BF16: usize = 2;
181        let launch = |rows: u32, first: u32| {
182            gemm(
183                ctx.gpu,
184                kernel,
185                input.offset(first as usize * k as usize * BF16),
186                w.weight,
187                w.row_scale,
188                out.offset(first as usize * n as usize * BF16),
189                rows,
190                n,
191                k,
192                stream,
193            )
194        };
195        match plan {
196            M16TcPlan::Single => launch(m, 0),
197            M16TcPlan::Halves { first } => {
198                launch(first, 0)?;
199                launch(m - first, first)
200            }
201        }
202    }
203
204    /// 2026-09-25: Logs the route once per model (`log:ffn_m16_tc_decode`), with
205    /// the tile and the launch split, so a report at 5..=32 rows can tell this
206    /// tier from the batch16 GEMV.
207    fn log_m16_tc_route(&self, ctx: &ForwardContext, plan: M16TcPlan, n_tile: u32) {
208        if ctx.stats.once("log:ffn_m16_tc_decode") {
209            let how = match plan {
210                M16TcPlan::Single => "one launch",
211                M16TcPlan::Halves { .. } => "two launches on contiguous row halves",
212            };
213            let asked = self.m16_tc_n_tile;
214            tracing::info!(
215                "[metrale] dense FFN decode: METRALE_FFN_M16_TC — tensor-core w8a16_gemm_m16 \
216                 N_TILE={n_tile} (asked {asked}) ({how}) for 5..=32 rows, ahead of \
217                 w8a16_gemv_batch16. One weight pass, m16n8k16 MMA, so outputs are \
218                 REASSOCIATED vs the scalar w8a16_gemv (<= 2 BF16 ULP), unlike the batch16 \
219                 tier. This lever no longer reaches the attention tiers — that is \
220                 METRALE_ATTN_M16_TC, and METRALE_M16_TC is both. Unset it to restore the \
221                 bit-exact tier (#927)."
222            );
223        }
224    }
225}
226
227#[cfg(test)]
228#[path = "dense_ffn_m16_tc_lever_tests.rs"]
229mod lever_tests;
230
231#[cfg(test)]
232#[path = "dense_ffn_m16_tc_tests.rs"]
233mod tests;
234
235/// 2026-09-25: Host simulation of the two-halves geometry at M=32, graded by
236/// the oracle's comparison.
237#[cfg(test)]
238#[path = "dense_ffn_m16_tc_m32_tests.rs"]
239mod m32_tests;