logicaffeine_compile/codegen_cuda/knobs.rs
1//! The CUDA backend's optimization **knob lattice**.
2//!
3//! Design principle (the discipline this whole tier is held to):
4//!
5//! > **Every optimization is a trade-off.** If a transform is *strictly better* — Pareto-dominant, i.e.
6//! > faster (or smaller) with **no** downside for any valid program — it is baked in UNCONDITIONALLY and
7//! > gets **no knob** (see [`STRICTLY_BETTER`]). A knob exists *only* where the transform **wins** in some
8//! > regimes and **loses** in others. Each knob then **defaults to the best general case**, and records its
9//! > trade-off ([`CudaKnobs::tradeoffs`]) so an autotuner — or a human — can search the lattice with eyes
10//! > open, never blindly.
11//!
12//! This mirrors the machinery the codebase already uses: [`crate::codegen_sva::fabric_target::FabricTarget`]
13//! (a capability lattice), `spatial::passes::tile::best_tile_config` (a cost-model search over a `TileConfig`
14//! lattice), and the published Pareto frontier. The knobs here are the same idea aimed at the CUDA emitters:
15//! the GEMV/GEMM kernels *and* (as they land) the transformer orchestration (KV-cache, CUDA-graph capture,
16//! quant fusion). The set of legal knob assignments *is* a lattice; [`CudaKnobs::default`] is its
17//! best-general point and every other point is reachable by flipping a documented trade-off.
18
19use crate::codegen_cuda::chip::CudaChip;
20use crate::codegen_cuda::qwen_emit::Q8MatKind;
21use crate::codegen_sva::fabric_target::Precision;
22
23/// GEMV/GEMM weight-load width. Wider loads expose more memory-level parallelism (the Q8_0 forward is
24/// weight-**bandwidth**-bound), but cost registers per thread.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum LoadWidth {
27 /// 128-byte `char4` loads. Ties `Int4` on tiny N; lighter register pressure.
28 Char4,
29 /// 512-byte `int4` (16-byte) loads — measured winner on the large-N projections (92% of the 3060's peak
30 /// BW vs 87% for `char4`); heavier register pressure on small tiles.
31 Int4,
32}
33
34/// Contraction precision for a lowered matmul.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum MatmulPrecision {
37 /// Best **general** case: pick the always-correct path unless the value analysis *proves* a faster one is
38 /// safe. Today resolves to [`ResolvedPrecision::Int64`] (we do not yet carry a proof that operands fit
39 /// int8 through the recognizer), so `Auto` is exactly "the correct-for-any-program default".
40 Auto,
41 /// Signed-int8 `__dp4a` accumulation — faster, but VALID ONLY when operands provably fit int8 and the
42 /// contraction fits int32. Not strictly better (restricted domain) → a knob, off by default.
43 Int8Dp4a,
44 /// 64-bit integer accumulation — always correct (matches the VM's i64 semantics for any operands), slower.
45 Int64,
46 /// **bf16 tensor-core** GEMM (HMMA 16×16×16, bf16 operands, f32 accumulate) — the half-precision fast path
47 /// for a matmul over `Real` tensors on a 16-aligned shape (sm_80+). When the block tile *divides* the shape
48 /// and its footprint fits static shared memory, the lowering auto-selects the cp.async double-buffered
49 /// kernel (the measured 1.43× lever, `select_hmma_kernel`); otherwise the basic one-warp WMMA kernel. On an
50 /// incapable shape/arch it falls back to the always-correct int64 kernel. A knob (restricted domain), off by default.
51 Bf16Hmma,
52 /// **fp16 tensor-core** GEMM (HMMA 16×16×16, f16 operands, f32 accumulate) — the fp16 counterpart (sm_70+).
53 Fp16Hmma,
54}
55
56/// What a [`MatmulPrecision`] actually lowers to after `Auto` is resolved against what we can prove.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ResolvedPrecision {
59 Int8Dp4a,
60 Int64,
61 Bf16Hmma,
62 Fp16Hmma,
63}
64
65impl MatmulPrecision {
66 /// Resolve the knob to a concrete lowering. `Auto` conservatively picks the always-correct `Int64` — we
67 /// only emit a fast path (`Int8Dp4a`/`Bf16Hmma`/`Fp16Hmma`) when the caller *explicitly* requests it,
68 /// which is precisely "default to best general; opt into the trade-off".
69 pub fn resolved(self) -> ResolvedPrecision {
70 match self {
71 MatmulPrecision::Int8Dp4a => ResolvedPrecision::Int8Dp4a,
72 MatmulPrecision::Bf16Hmma => ResolvedPrecision::Bf16Hmma,
73 MatmulPrecision::Fp16Hmma => ResolvedPrecision::Fp16Hmma,
74 MatmulPrecision::Auto | MatmulPrecision::Int64 => ResolvedPrecision::Int64,
75 }
76 }
77}
78
79/// The CUDA backend's optimization knobs — one point in the lattice. Construct with [`CudaKnobs::default`]
80/// for the best-general point and flip individual knobs to trade off. Every field is a trade-off; there are
81/// deliberately **no** fields for strictly-better transforms (those are always applied, see [`STRICTLY_BETTER`]).
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct CudaKnobs {
84 /// Weight-load width for GEMV/GEMM. Default [`LoadWidth::Int4`] (measured best-general on the projections).
85 pub gemv_load: LoadWidth,
86 /// Contraction precision. Default [`MatmulPrecision::Auto`] (⇒ always-correct int64).
87 pub precision: MatmulPrecision,
88 /// KV-cache for autoregressive generation — skip recomputing past K/V. A large win for generation, but
89 /// costs VRAM and only applies to attention over a growing sequence. Default `true`.
90 pub kv_cache: bool,
91 /// Capture the steady-state (T=1) step as a CUDA graph and replay it — collapses ~540 launches/token to
92 /// one. Wins when many tiny kernels make launch latency dominate; neutral-to-negative for one big kernel.
93 /// Default `true`.
94 pub cuda_graphs: bool,
95 /// Fuse activation-quantization into the preceding norm/elementwise kernel — saves a global round-trip at
96 /// the cost of a slightly larger, less-reusable kernel. Default `true`.
97 pub fuse_quant: bool,
98 /// The activation precision of a projection by Q8_0 weights in the compiled generation lane: `f32` activations
99 /// with `f64` sums (`Dequant`), a high and a low int8 word per value (`TwoPassInt8`, ~14 bits), or one int8 word
100 /// (`Int8Act`, llama.cpp's precision). Default [`Q8MatKind::TwoPassInt8`].
101 pub activations: Q8MatKind,
102 /// The GPU part this kernel targets — its SM arch, shared-memory budget, and realized precisions. Default
103 /// [`CudaChip::rtx3060`] (the local dev GPU). Flipping it to [`CudaChip::b200`] retargets the SAME
104 /// recognized op to Blackwell. A trade-off (portability vs hardware exploitation), hence a knob.
105 pub chip: CudaChip,
106 /// The precision the compiled generation lane stores a gated-delta layer's recurrent memory in between steps:
107 /// `f32`, or bf16 — computed in `f32`, rounded to bf16 after every row the delta rule walks. Default
108 /// [`StatePrecision::F32`].
109 pub recurrent_state: StatePrecision,
110 /// The precision the compiled generation lane stores an attention's key and value cache in: `f32`, or binary16 —
111 /// each row computed in `f32` and rounded to the nearest as it is written, every read of the cache taking the
112 /// rounded row. Default [`CachePrecision::F32`].
113 pub cache_precision: CachePrecision,
114}
115
116/// The precision an attention's cache is stored in (`CudaKnobs::cache_precision`).
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum CachePrecision {
119 /// Four bytes a value, as the step computes it.
120 #[default]
121 F32,
122 /// Two bytes a value: binary16, rounded to the nearest, ties to even, as llama.cpp's default cache keeps it.
123 F16,
124}
125
126/// The precision a recurrent memory is stored in between steps (`CudaKnobs::recurrent_state`).
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub enum StatePrecision {
129 /// Four bytes a value, as the step computes it.
130 #[default]
131 F32,
132 /// Two bytes a value: bfloat16, rounded to the nearest, ties to even.
133 Bf16,
134}
135
136impl Default for CudaKnobs {
137 /// The best **general** point of the lattice: wide loads, always-correct precision, and the
138 /// generation-time wins (KV-cache, graph replay, quant fusion) enabled — each the right call for the
139 /// common case, each overridable.
140 fn default() -> Self {
141 CudaKnobs {
142 gemv_load: LoadWidth::Int4,
143 precision: MatmulPrecision::Auto,
144 kv_cache: true,
145 cuda_graphs: true,
146 fuse_quant: true,
147 activations: Q8MatKind::TwoPassInt8,
148 chip: CudaChip::rtx3060(),
149 recurrent_state: StatePrecision::F32,
150 cache_precision: CachePrecision::F32,
151 }
152 }
153}
154
155impl CudaKnobs {
156 /// Map a [`MatmulPrecision`] to the fabric-level [`Precision`] descriptor (for `FabricTarget` gating).
157 pub fn fabric_precision(&self) -> Precision {
158 match self.precision.resolved() {
159 ResolvedPrecision::Int8Dp4a => Precision::Int8,
160 ResolvedPrecision::Int64 => Precision::Int64,
161 ResolvedPrecision::Bf16Hmma => Precision::Bf16,
162 ResolvedPrecision::Fp16Hmma => Precision::Fp16,
163 }
164 }
165
166 /// The trade-off table — one row per knob, each naming the regime where the *aggressive* setting **wins**
167 /// and the regime where it **loses** (the reason it can't be baked in unconditionally). This is the
168 /// lattice's map; the knob-lattice lock asserts it stays complete and honest.
169 pub fn tradeoffs() -> &'static [KnobTradeoff] {
170 &[
171 KnobTradeoff {
172 knob: "gemv_load",
173 default: "Int4",
174 wins: "large-N projections — 512-byte loads hit 92% of peak bandwidth vs 87% for char4",
175 loses: "tiny N / small tiles — the wider load ties char4 but spends more registers",
176 },
177 KnobTradeoff {
178 knob: "precision",
179 default: "Auto (⇒ int64, always correct, scalar — no tensor cores)",
180 wins: "tensor-core precisions on a 16-aligned shape: int8 IMMA is fastest (density — 1-byte \
181 operands, ~2× the bf16 rate; MEASURED 2.10× on the 3060 at 1024³, cuda_precision_tradeoff_lock); \
182 bf16/f16 HMMA give an fp32-accumulate wide-exponent range for Real tensors. A tile-dividing \
183 shape auto-selects the cp.async double-buffered kernel (measured 1.43× over basic WMMA)",
184 loses: "int8 needs operands that provably fit [-128,127] and a contraction that fits int32 (else wrong \
185 answer); bf16/f16 halve throughput vs int8 for the range; a ragged/small-arch shape falls back \
186 to the always-correct scalar int64 kernel — no tensor cores at all",
187 },
188 KnobTradeoff {
189 knob: "kv_cache",
190 default: "on",
191 wins: "autoregressive generation — past K/V are reused instead of recomputed each token",
192 loses: "prefill / one-shot with no growing sequence — pure VRAM cost, no reuse",
193 },
194 KnobTradeoff {
195 knob: "cuda_graphs",
196 default: "on",
197 wins: "many tiny per-token kernels — one graph launch replaces ~540 launches",
198 loses: "a single large kernel — capture/replay overhead with nothing to amortize it over",
199 },
200 KnobTradeoff {
201 knob: "fuse_quant",
202 default: "on",
203 wins: "quant-then-consume chains — one global round-trip saved",
204 loses: "when the un-fused quantized activation is reused by several consumers",
205 },
206 KnobTradeoff {
207 knob: "activations",
208 default: "TwoPassInt8 (a high and a low int8 word per activation, ~14 bits)",
209 wins: "batch-1 decode is bound on the weight bytes, so the second word's dp4a is free: Qwen3.5-0.8B and \
210 Llama-3.2-1B follow llama.cpp's f32 run on every step of their 32-prompt corpora (llama.cpp's \
211 shipped run: 93% and 91%), Qwen3-8B decodes at 38.5 tok/s against 38.8 for one word",
212 loses: "a compute-bound prefill runs two tensor-core mma per block — Int8Act halves that work at \
213 llama.cpp's precision; Dequant (f32 activations, f64 sums) is the exact reference at a tenth \
214 the speed",
215 },
216 KnobTradeoff {
217 knob: "recurrent_state",
218 default: "F32",
219 wins: "a batch of gated-delta sequences moves every memory each step: bf16 halves those bytes — \
220 Qwen3.5-0.8B's delta rule at 64 sequences, 412 µs a layer to 245 on the 3060",
221 loses: "the memory keeps eight bits of mantissa: every step rounds it, so the logits drift from the f32 \
222 memory's by up to about 1e-2 of their scale on the tiny models, and long generations part sooner",
223 },
224 KnobTradeoff {
225 knob: "cache_precision",
226 default: "F32",
227 wins: "a batch's attention reads every sequence's whole cache each step: binary16 halves those bytes",
228 loses: "the cache keeps eleven bits of significand: every key and value the attention reads is rounded, \
229 so the logits move from the f32 cache's by the rounding's share of each score",
230 },
231 KnobTradeoff {
232 knob: "chip",
233 default: "rtx3060 (the local Ampere sm_86 dev GPU)",
234 wins: "targeting the actual deployment silicon — a B200 kernel uses sm_100 5th-gen tensor cores, fp8/nvfp4, and a larger shared-memory budget",
235 loses: "portability — a kernel built for a newer arch will not run on an older host, and one built for an older arch leaves the newer part's features unused",
236 },
237 ]
238 }
239}
240
241/// A single knob's trade-off — why it is a knob and not a baked-in transform.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub struct KnobTradeoff {
244 /// The `CudaKnobs` field this row describes.
245 pub knob: &'static str,
246 /// Its default value, in words (the best-general choice).
247 pub default: &'static str,
248 /// The regime where the aggressive setting wins.
249 pub wins: &'static str,
250 /// The regime where it loses — the reason it cannot be baked in unconditionally.
251 pub loses: &'static str,
252}
253
254/// Transforms that are **strictly better** — Pareto-dominant, never worse for any valid program — and are
255/// therefore applied UNCONDITIONALLY with **no knob**. If any entry here ever acquires a downside, it must
256/// grow a `CudaKnobs` field and move out of this list (the knob-lattice lock enforces the disjointness).
257pub const STRICTLY_BETTER: &[&str] = &[
258 "coalesced global loads — contiguous per-warp addressing (never worse than strided)",
259 "scale folded into the accumulator — no separate dequant pass over the weights",
260 "no host-side recompute of device-resident results — read back once",
261 "native-arch codegen (-O2, compiled for the target's own SM arch) — no reason to deoptimize",
262];