metrale_model_layers/circuit_exec/bindings.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-28: `CircuitBindings`, the supertrait of `TransformerLayer` through which a layer hands
4//! the circuit executor its weights by role and the facts its kernels need. Layers keep owning
5//! their weights; a binding is a copy of pointers.
6//!
7//! Owner: model-layers circuit executor.
8//! Invariants:
9//! - A layer lists in `unmodelled` every feature it carries that the circuit does not model
10//! (hyper-connections, LoRA, a projection format the rules do not cover, ...). The executor
11//! refuses a model with any, so a plan never runs over a layer it misdescribes.
12//! - A bound weight carries its storage format; the executor checks it against the format the
13//! plan's node was resolved to.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use anyhow::{Result, anyhow, ensure};
18use metrale_cache::kv_cache::KvCacheDtype;
19use metrale_circuit::{Circuit, LinearRole};
20
21use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
22
23use crate::layers::ops::{W8a8Kernels, W8a8Weight};
24use crate::weight_map::{DenseWeight, QuantizedWeight};
25
26/// 2026-09-28: Which weight of a layer a circuit node reads.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub enum WeightSlot {
29 /// 2026-09-28: The mixer's input RMSNorm.
30 InputNorm,
31 /// 2026-09-28: The FFN's input RMSNorm (`post_attention_layernorm`).
32 PostNorm,
33 /// 2026-09-28: A projection; `GateUp` is bound as [`WeightSlot::FfnGate`] and
34 /// [`WeightSlot::FfnUp`].
35 Linear(LinearRole),
36 /// 2026-09-28: The dense FFN's gate projection.
37 FfnGate,
38 /// 2026-09-28: The dense FFN's up projection.
39 FfnUp,
40 /// 2026-09-28: Attention's per-head Q RMSNorm.
41 QNorm,
42 /// 2026-09-28: Attention's per-head K RMSNorm.
43 KNorm,
44 /// 2026-09-28: GatedDeltaNet `A_log`.
45 GdnALog,
46 /// 2026-09-28: GatedDeltaNet `dt_bias`.
47 GdnDtBias,
48 /// 2026-09-28: GatedDeltaNet conv1d taps.
49 GdnConv1d,
50 /// 2026-09-28: GatedDeltaNet output norm.
51 GdnNorm,
52 /// 2026-09-28: The transposed twin of a projection, which the tile GEMMs read.
53 Transposed(LinearRole),
54 /// 2026-09-28: The dense FFN's gate, repacked for the NVFP4 MMQ GEMM.
55 FfnGateMmq,
56 /// 2026-09-28: The dense FFN's up, repacked for the NVFP4 MMQ GEMM.
57 FfnUpMmq,
58 /// 2026-09-28: The dense FFN's down, repacked for the NVFP4 MMQ GEMM.
59 FfnDownMmq,
60 /// 2026-09-29: The MTP draft head's norm of the token embedding (`pre_fc_norm_embedding`).
61 EmbedNorm,
62 /// 2026-09-29: The MTP draft head's norm of the target hidden (`pre_fc_norm_hidden`).
63 HiddenNorm,
64 /// 2026-09-29: The MTP draft head's final norm.
65 FinalNorm,
66 /// 2026-09-29: The MTP draft head's vocabulary projection.
67 LmHead,
68}
69
70/// 2026-09-28: A bound weight and its storage format.
71#[derive(Debug, Clone, Copy)]
72pub enum BoundWeight {
73 /// 2026-09-28: Unquantized (BF16 for projections; norms and GDN vectors as loaded).
74 Dense(DenseWeight),
75 /// 2026-09-28: NVFP4, group 16.
76 Nvfp4(QuantizedWeight),
77 /// 2026-09-28: NVFP4 repacked for the MMQ GEMM (`ops::nvfp4_mmq_repack`); its
78 /// `weight_scale_2` stays with the source weight.
79 Mmq(DevicePtr),
80 /// 2026-09-30: A declared W8A8 projection (`W8a8Mixer`, `W8a8Ffn`): the checkpoint's E4M3
81 /// weight with its scales, and the kernels its layer runs it with.
82 W8a8(W8a8Weight, W8a8Kernels),
83}
84
85impl BoundWeight {
86 /// 2026-09-28: The format family, spelled as the precision tables spell weights.
87 pub fn family(&self) -> &'static str {
88 match self {
89 BoundWeight::Dense(_) => "bf16",
90 BoundWeight::Nvfp4(_) | BoundWeight::Mmq(_) => "nvfp4",
91 BoundWeight::W8a8(..) => "fp8",
92 }
93 }
94}
95
96/// 2026-09-28: What a GatedDeltaNet layer's kernels need beyond its weights.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct GdnFacts {
99 /// 2026-09-28: The qkvz projection writes `[Q | K | V | Z]` itself (`sequential_qkvz`), so no
100 /// deinterleave follows it.
101 pub qkvz_deinterleaved: bool,
102 /// 2026-09-30: Bytes from one pool slot's h state to the next (`h_slot_stride_bytes`), the
103 /// pitch the batched arm's slot check and strided recurrence use.
104 pub h_slot_bytes: u64,
105 /// 2026-09-30: Bytes from one pool slot's conv window to the next (`conv_state_bytes`).
106 pub conv_state_bytes: u64,
107 /// 2026-09-30: The carried-state verify's buffers (`LayerWriteOnAccept::gdn_carry_bind`),
108 /// which the batched verify's GDN launches read; `None` until the model binds them.
109 pub carry: Option<crate::layer::GdnCarryBinding>,
110}
111
112/// 2026-09-28: A full-attention layer's rotary embedding.
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct RopeFacts {
115 /// 2026-09-28: Interleaved MRoPE (`mrope_interleaved` with its kernel loaded).
116 pub mrope_interleaved: bool,
117 /// 2026-09-28: `rope_theta`, after any per-layer override.
118 pub theta: f32,
119 /// 2026-09-28: Rotated dims per head, after any per-layer override.
120 pub rotary_dim: u32,
121}
122
123/// 2026-09-28: What a full-attention layer's kernels need beyond its weights.
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct AttnFacts {
126 /// 2026-09-28: This layer's index among the attention layers: its KV-cache layer.
127 pub attn_layer_idx: usize,
128 /// 2026-09-28: KV-cache dtype of this layer.
129 pub kv_dtype: KvCacheDtype,
130 /// 2026-09-28: Query heads.
131 pub num_q_heads: u32,
132 /// 2026-09-28: KV heads.
133 pub num_kv_heads: u32,
134 /// 2026-09-28: Head dim.
135 pub head_dim: u32,
136 /// 2026-09-28: Q carries an output gate (`[Q | Gate]` after the split).
137 pub gated: bool,
138 /// 2026-09-28: Rotary embedding.
139 pub rope: RopeFacts,
140 /// 2026-09-28: Sliding window in positions; 0 for none.
141 pub sliding_window: u32,
142 /// 2026-09-28: Softmax scale (`effective_attn_scale`).
143 pub softmax_scale: f32,
144 /// 2026-09-28: Bit `r - 1` is set when the layer's own decode routing at `r` rows picks
145 /// the plain non-split paged kernel (`paged_decode_k`): no split-K pair, no GQA-packed
146 /// kernel, no 512-wide head kernel.
147 pub paged_decode_plain_rows: u128,
148}
149
150impl AttnFacts {
151 /// 2026-09-28: The plain paged kernel serves `rows` rows (1..=128).
152 pub fn paged_decode_plain(&self, rows: u64) -> bool {
153 (1..=128).contains(&rows) && self.paged_decode_plain_rows >> (rows - 1) & 1 == 1
154 }
155}
156
157/// 2026-09-28: The mixer a layer runs.
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub enum MixerFacts {
160 /// 2026-09-28: GatedDeltaNet.
161 Gdn(GdnFacts),
162 /// 2026-09-28: Full attention.
163 Attention(AttnFacts),
164}
165
166/// 2026-09-28: One layer, as the executor sees it.
167#[derive(Debug, Clone)]
168pub struct CircuitLayer {
169 /// 2026-09-28: The mixer.
170 pub mixer: MixerFacts,
171 /// 2026-09-28: Weights by slot.
172 pub weights: BTreeMap<WeightSlot, BoundWeight>,
173 /// 2026-09-28: Features present on this layer that the circuit does not model.
174 pub unmodelled: Vec<String>,
175}
176
177/// 2026-09-28: A supertrait of `TransformerLayer`; see the module header.
178pub trait CircuitBindings {
179 /// 2026-09-28: Build the lazily built weights the bound kernels read (the MMQ repacks a
180 /// first wide batch would otherwise build), so a binding can hand them out. Queued on
181 /// `stream`.
182 fn circuit_prepare(
183 &self,
184 _gpu: &dyn GpuBackend,
185 _config: &metrale_config::ModelConfig,
186 _levers: &crate::layers::ops::ModelLevers,
187 _stream: u64,
188 ) -> anyhow::Result<()> {
189 Ok(())
190 }
191
192 /// 2026-09-28: This layer for the circuit executor; `None` for a layer type it does not bind.
193 /// `config` and `levers` are the model's, as its decode reads them.
194 fn circuit_layer(
195 &self,
196 _config: &metrale_config::ModelConfig,
197 _levers: &crate::layers::ops::ModelLevers,
198 ) -> Option<CircuitLayer> {
199 None
200 }
201}
202
203/// 2026-09-29: The MTP draft head as the executor binds it: its one layer (weights by slot,
204/// the attention facts of its own KV cache) and that cache.
205#[derive(Debug, Clone)]
206pub struct DraftBinding {
207 /// 2026-09-29: Weights by slot, the draft attention's facts (`attn_layer_idx` is its
208 /// cache's layer), and what the circuit does not model.
209 pub layer: CircuitLayer,
210 /// 2026-09-29: The draft cache's K pool.
211 pub k_pool: DevicePtr,
212 /// 2026-09-29: The draft cache's V pool.
213 pub v_pool: DevicePtr,
214 /// 2026-09-29: Tokens per block of the draft cache.
215 pub block_size: u32,
216 /// 2026-09-29: `PagedKvCache::cache_stride` of the draft cache.
217 pub cache_stride: u64,
218 /// 2026-09-29: The vocabulary rows the draft lm_head scores and the argmax reads (the
219 /// first `mtp_vocab_size`, or all).
220 pub vocab: u32,
221 /// 2026-09-30: What the head's n-row draft (`forward_batch_position`) reads beyond the
222 /// layer; `None` for a head outside the batched propose's scope.
223 pub rows: Option<DraftRows>,
224}
225
226/// 2026-09-30: The MTP head's n-row draft facts: where its batched metadata and confidences
227/// live, and the LM-head launch it chooses at each width (`MtpHead::lm_head_batch_kernel`,
228/// `mtp_head::lm_head_rows_arm`), which the executor checks the plan against.
229#[derive(Debug, Clone)]
230pub struct DraftRows {
231 /// 2026-09-30: The batched propose's metadata allocation (`MtpHead::propose_meta`).
232 pub meta: DevicePtr,
233 /// 2026-09-30: Byte offset in scratch of the per-row top-1 log-probabilities
234 /// (`LP_SCRATCH_OFF`); the ids sit at scratch's start.
235 pub lp_offset: usize,
236 /// 2026-09-30: `lm_head_batch_kernel(n)` at index `n`; the zero handle where none serves.
237 pub lm_head_gemv: Vec<metrale_gpu_runtime::gpu::KernelHandle>,
238 /// 2026-09-30: The tile GEMM on the transposed LM-head twin is available to the head.
239 pub lm_head_twin: bool,
240}
241
242/// 2026-09-28: The model's own weights the head block reads, filled by the model.
243#[derive(Debug, Clone)]
244pub struct HeadBinding {
245 /// 2026-09-28: The final RMSNorm.
246 pub final_norm: DenseWeight,
247 /// 2026-09-28: The vocabulary projection.
248 pub lm_head: BoundWeight,
249 /// 2026-09-28: Head features the circuit does not model (a logit overlay, softcapping,
250 /// FP32 logits, a vocab-parallel head, ...).
251 pub unmodelled: Vec<String>,
252 /// 2026-09-28: The widest padded batch the BF16 head serves with the batched GEMV
253 /// (`dense_gemv_bf16_batchm`); 0 when that arm is off. Wider batches take the GEMM.
254 pub batchm_max_rows: u32,
255}
256
257/// 2026-09-28: Refuse a model the circuit misdescribes: an unbound layer, a layer or head
258/// feature no rule models, or a layer whose mixer is not the circuit's.
259pub fn check_bindings(
260 circuit: &Circuit,
261 layers: &[Option<CircuitLayer>],
262 head: &HeadBinding,
263) -> Result<Vec<CircuitLayer>> {
264 let mut problems = BTreeSet::new();
265 let mut out = Vec::with_capacity(layers.len());
266 for (i, l) in layers.iter().enumerate() {
267 let Some(l) = l else {
268 problems.insert(format!("layer {i} has no circuit binding"));
269 continue;
270 };
271 for u in &l.unmodelled {
272 problems.insert(format!("layer {i}: {u}"));
273 }
274 let want_gdn =
275 circuit.layer_kinds.get(i) == Some(&metrale_circuit::LayerKind::LinearAttention);
276 let is_gdn = matches!(l.mixer, MixerFacts::Gdn(_));
277 if want_gdn != is_gdn {
278 problems.insert(format!(
279 "layer {i}: the circuit and the model disagree on its kind"
280 ));
281 }
282 out.push(l.clone());
283 }
284 for u in &head.unmodelled {
285 problems.insert(format!("head: {u}"));
286 }
287 if !problems.is_empty() {
288 return Err(anyhow!(
289 "the circuit does not model this model:\n {}",
290 problems.into_iter().collect::<Vec<_>>().join("\n ")
291 ));
292 }
293 ensure!(
294 out.len() == circuit.layer_kinds.len(),
295 "the model has {} layers, the circuit {}",
296 out.len(),
297 circuit.layer_kinds.len()
298 );
299 Ok(out)
300}