Skip to main content

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];