frink_models/decoder.rs
1//! Generic decoder-only transformer forward pass, assembled from a
2//! ModelConfig. Each layer is: RMSNorm -> GQA attention (+RoPE) ->
3//! residual -> RMSNorm -> MoE FFN (router + routed experts + shared
4//! experts) -> residual. This is the standard decoder block shape
5//! shared by the LLaMA/DeepSeek/GLM/Kimi family of open-weight models.
6//!
7//! Weight loading from a real GGUF checkpoint lives in `loader`
8//! (`Decoder::from_gguf`); `Decoder::new_random` builds
9//! correctly-shaped, randomly initialized weights so the full pipeline
10//! -- embedding lookup, N decoder layers, output head -- can be
11//! exercised end to end with real assertions about shapes, finiteness,
12//! and determinism, without requiring a multi-hundred-gigabyte
13//! checkpoint to be present.
14
15mod attn_block;
16#[cfg(feature = "cuda")]
17mod cuda_prefill;
18#[cfg(feature = "metal")]
19mod device_attention;
20mod entry;
21mod ffn_block;
22#[cfg(feature = "metal")]
23mod fused_attention;
24#[cfg(feature = "metal")]
25mod fused_recurrent;
26#[cfg(any(feature = "metal", feature = "cuda"))]
27mod fused_view;
28pub mod kv_window;
29mod lm_head;
30mod pre_norm;
31mod qk_norm;
32mod qkv_bias;
33mod recurrent_block;
34mod rope;
35
36use std::sync::atomic::{AtomicU64, Ordering};
37
38use crate::skip_stream::SkipStream;
39pub(crate) use attn_block::KvStep;
40use frink_core::attention::{
41 causal_gqa_attention_prefill_shared_kv_split, causal_gqa_attention_row,
42 causal_gqa_attention_softcap,
43};
44use frink_core::cache::{KvCache, PagedKvCache, PagedStoreExhausted, SharedPagedKv};
45use frink_core::matmul::rms_norm;
46pub use kv_window::{KvWindowPolicy, KV_WINDOW_ENV};
47#[cfg(feature = "metal")]
48use lm_head::FoldedLmHead;
49use lm_head::Logits;
50use rayon::prelude::*;
51
52/// `Decoder::alibi_slopes` from the config, the ONE derivation, so a
53/// constructor cannot carry a bias without its slopes.
54pub(crate) fn config_alibi_slopes(config: &ModelConfig) -> Option<Vec<f32>> {
55 config
56 .alibi_max_bias
57 .and_then(|b| frink_core::alibi::slopes(config.n_heads, b))
58}
59
60/// Whether the CUDA `gqa_decode` kernel should serve the per-token GQA
61/// reduction (`FRINK_CUDA_GQA=1`). Off by default and only compiled with
62/// `--features cuda`; the host path is byte-identical when unset.
63#[cfg(feature = "cuda")]
64fn cuda_gqa_enabled() -> bool {
65 use std::sync::OnceLock;
66 static ENABLED: OnceLock<bool> = OnceLock::new();
67 *ENABLED.get_or_init(|| {
68 matches!(
69 std::env::var("FRINK_CUDA_GQA").ok().as_deref(),
70 Some("1") | Some("true") | Some("on")
71 )
72 })
73}
74use frink_core::tensor::Tensor;
75use frink_core::weight_matrix::WeightMatrix;
76use frink_moe::{
77 combine_expert_outputs, route_top_k, run_expert, run_expert_placed, ExpertPlacement,
78 ExpertWeights, GluAct, PlacementPlan,
79};
80
81use crate::config::ModelConfig;
82use crate::norm::NormOp;
83use crate::scalar_multipliers::residual_add;
84
85pub struct AttnWeights {
86 pub q_proj: WeightMatrix, // [n_heads*head_dim, hidden_dim]
87 pub k_proj: WeightMatrix, // [n_kv_heads*head_dim, hidden_dim]
88 pub v_proj: WeightMatrix, // [n_kv_heads*head_dim, hidden_dim]
89 pub o_proj: WeightMatrix, // [hidden_dim, n_heads*head_dim]
90 /// The PRE-attention norm, or [`NormOp::None`] for the
91 /// post-norm-only topology (`olmo2` / `exaone4`), which projects
92 /// Q/K/V straight off the raw residual. See [`crate::norm`].
93 pub norm_weight: NormOp,
94 /// OLMoE-style QK-RMSNorm (`attn_q_norm`/`attn_k_norm` GGUF tensors),
95 /// applied to the *whole* q_proj/k_proj output (width `n_heads*head_dim`
96 /// / `n_kv_heads*head_dim`) before RoPE -- confirmed against
97 /// `OlmoeAttention.forward` in `transformers/models/olmoe/modeling_olmoe.py`
98 /// (`q_norm(q_proj(x))`, `k_norm(k_proj(x))`, both plain whole-vector
99 /// RMSNorm, not per-head). `None` for every model that doesn't ship
100 /// these tensors -- absent, not zero/identity-weighted, so existing
101 /// presets/fixtures are byte-for-byte unaffected.
102 ///
103 /// Qwen3 / Gemma3 ship the same tensor names with length `head_dim`
104 /// (per-head). Which style is used is selected by
105 /// [`ModelConfig::qk_norm_style`] (refined at load from weight length).
106 pub q_norm: Option<Vec<f32>>,
107 pub k_norm: Option<Vec<f32>>,
108 /// Qwen2/Qwen2-MoE-family QKV attention bias (`attn_{q,k,v}.bias`
109 /// GGUF tensors, real `config.qkv_bias`), added elementwise to the
110 /// corresponding projection's output before QK-norm/RoPE -- confirmed
111 /// against the real `transformers` source
112 /// (`Qwen2MoeAttention.__init__`: `q_proj = nn.Linear(..., bias=
113 /// config.qkv_bias)`, same for `k_proj`/`v_proj`; `o_proj` has no
114 /// bias). Found as a real, previously-unhandled architecture gap:
115 /// frink's generic GGUF loader silently ignored these real tensors
116 /// entirely, producing fluent-but-wrong output on a real downloaded
117 /// Qwen1.5-MoE checkpoint (same failure class as OLMoE's missing
118 /// QK-norm). `None` for every model that doesn't ship these tensors.
119 pub q_bias: Option<Vec<f32>>,
120 pub k_bias: Option<Vec<f32>>,
121 pub v_bias: Option<Vec<f32>>,
122 /// Gemma 2+/3 post-attention RMSNorm (`blk.N.post_attention_norm.weight`
123 /// / llama.cpp `attn_post_norm`). Applied to attention output before
124 /// the residual add. `None` for Llama/Qwen/OLMoE.
125 pub post_attn_norm: Option<Vec<f32>>,
126 /// Gemma 2+/3 post-FFN RMSNorm (`blk.N.post_ffw_norm.weight`).
127 pub post_ffn_norm: Option<Vec<f32>>,
128 /// The learned output gate, `blk.N.attn_gate.weight`, applied to the
129 /// attention output before `o_proj` (`afmoe`, `laguna`, `step35`).
130 /// See [`crate::attn_gate`] for the two axes it varies on and the
131 /// one input it always reads. `None` for every architecture whose
132 /// graph has no such op; a file carrying the tensor on one of those
133 /// is refused as unconsumed rather than gated.
134 pub output_gate: Option<crate::attn_gate::AttnGate>,
135 /// `blk.N.attn_sinks.weight`, one learned logit per query head that
136 /// joins every softmax and contributes nothing to the output
137 /// (`ggml_soft_max_add_sinks`, `llama-graph.cpp:2600`).
138 ///
139 /// Used to live on the gpt-oss side table alone, which spelled the
140 /// rule as "arch is gpt-oss". Four llama.cpp graphs pass this
141 /// tensor into `build_attn` -- `openai-moe.cpp:115`,
142 /// `mimo2.cpp:177`, `dflash.cpp`, `deepseek4.cpp` -- through the
143 /// SAME `build_attn_mha` path, so the rule is "the tensor is
144 /// present". gpt-oss requires it (`openai-moe.cpp:44`) and
145 /// `loader.rs` still refuses a gpt-oss file without one; the fused
146 /// Metal launches refuse any layer that has one, by the exhaustive
147 /// destructure in `Decoder::metal_attn_view`.
148 pub sinks: Option<Vec<f32>>,
149 /// BitNet's `blk.N.attn_sub_norm.weight`, `[hidden_dim]`: an RMSNorm
150 /// on the attention output BEFORE `o_proj` (`bitnet.cpp:101-106`),
151 /// the other side of that matmul from `post_attn_norm`. Loaded
152 /// only for a model whose `ModelConfig::block_sub_norms` says so
153 /// (`crate::sub_norms`); the fused Metal launches refuse the model
154 /// through `metal_can_serve_model` and the layer through the
155 /// exhaustive destructure in `Decoder::metal_attn_view`.
156 pub attn_sub_norm: Option<Vec<f32>>,
157 /// `blk.N.attn_output.scale`, the `{1}` companion `build_lora_mm`
158 /// multiplies the attention branch by right after `wo`
159 /// (`llama-graph.cpp:1492-1494`; `talkie` writes it on every export,
160 /// `crate::weight_scales`). The fused Metal launches refuse a layer
161 /// that has one through the destructure in `metal_attn_view`.
162 pub o_scale: Option<f32>,
163 /// `blk.N.attn_output.bias`, added right after `wo` (and after
164 /// `o_scale`, the order `build_attn` has). Loaded for the
165 /// architectures whose graph creates the tensor
166 /// (`crate::proj_bias::ATTN_OUT_BIAS_CREATORS`), which includes
167 /// gpt-oss, whose bias used to live on its side table; `None`
168 /// everywhere else, where a present tensor is refused as unread.
169 /// The fused Metal launches refuse a layer that has one through
170 /// the destructure in `metal_attn_view`.
171 pub o_bias: Option<Vec<f32>>,
172 /// LFM2's short convolution, `Some` on exactly the layers whose
173 /// shape is `AttnShape::ShortConv` (`crate::shortconv`); the four
174 /// projections above are empty on such a layer. The host bodies
175 /// branch on the SHAPE and reach this field through it; the fused
176 /// Metal launches refuse the model (a short-conv model is never
177 /// uniform) and the layer (the destructure in `metal_attn_view`).
178 pub shortconv: Option<crate::shortconv::ShortConv>,
179 /// The state-space block (`crate::ssm_block`): `Some` on exactly the
180 /// layers whose shape is `AttnShape::Mamba1` / `Mamba2`, and on
181 /// every GQA layer of a `ModelConfig::parallel_ssm` model
182 /// (`crate::mamba2::PARALLEL_WITH_ATTENTION`); the same rules as
183 /// `shortconv` otherwise.
184 pub ssm: Option<crate::ssm_block::SsmBlock>,
185 /// `attn_q` is `2 * n_heads * head_dim` wide, each head's `[q, gate]`
186 /// interleaved, and `sigmoid(gate)` multiplies the attention output
187 /// before `wo` (`crate::attn_gate::Q_INTERLEAVED_GATE_ARCHS`). The
188 /// projection stays one matrix; the three host bodies split its
189 /// output. The fused Metal launches refuse the layer.
190 pub q_gate_interleaved: bool,
191}
192
193/// How a layer's routed experts are held. `Resident` is the original
194/// always-in-memory form (owned f32 or zero-copy mmap views).
195/// `Stored` holds only byte-range layouts; each use acquires the
196/// expert's bytes from a bounded, lease-protected
197/// `frink_core::expert_store::ExpertStore` shared by every layer
198/// (one global byte budget), builds temporary `WeightMatrix` views
199/// over the leased buffer (`WeightBytes::Shared`, which pins the
200/// cache entry for the views' lifetime), and drops them after the
201/// expert runs. Dequantized math over identical bytes is identical,
202/// so the two backings are bit-equivalent by construction -- pinned
203/// by an integration test against the MoE fixture.
204pub enum ExpertBacking {
205 Resident(Vec<ExpertWeights>),
206 Stored {
207 store:
208 std::sync::Arc<frink_core::expert_store::ExpertStore<crate::loader::GgufExpertSource>>,
209 layouts: Vec<crate::loader::StoredExpertLayout>,
210 layer: u32,
211 },
212}
213
214impl ExpertBacking {
215 pub fn n_experts(&self) -> usize {
216 match self {
217 ExpertBacking::Resident(v) => v.len(),
218 ExpertBacking::Stored { layouts, .. } => layouts.len(),
219 }
220 }
221}
222
223pub struct MoeWeights {
224 pub router: WeightMatrix, // [n_experts, hidden_dim]
225 pub experts: ExpertBacking,
226 pub shared_experts: Vec<ExpertWeights>,
227 /// Qwen2-MoE-specific: when present, the shared experts' combined
228 /// output is scaled by `sigmoid(shared_expert_gate . x)` before
229 /// being added to the routed output, instead of added unconditionally
230 /// -- confirmed against the real `transformers` source
231 /// (`Qwen2MoeSparseMoeBlock.forward`: `shared_expert_output =
232 /// F.sigmoid(self.shared_expert_gate(hidden_states)) *
233 /// shared_expert_output`) and llama.cpp's real `qwen2moe.cpp`
234 /// (`ffn_gate_inp_shexp` dotted against the hidden state, sigmoid,
235 /// multiplied into the shared-expert branch before the final add).
236 /// Real on-disk shape is `[hidden_dim]` (a `Linear(hidden_dim, 1,
237 /// bias=false)`'s weight, flattened -- ggml's real `create_tensor`
238 /// call declares it as `{n_embd}`, not a 2D matrix), so this is a
239 /// plain owned vector dotted with the normed hidden state directly,
240 /// not a `WeightMatrix`. `None` for every other architecture
241 /// (DeepSeek-V3's shared experts, for one real confirmed contrast,
242 /// add unconditionally with no gate at all).
243 pub shared_expert_gate: Option<Vec<f32>>,
244 /// The PRE-FFN norm, or [`NormOp::None`] for the post-norm-only
245 /// topology (`olmo2` / `exaone4`), which runs the FFN on the raw
246 /// post-attention residual. See [`crate::norm`].
247 pub norm_weight: NormOp,
248 /// DeepSeek-V3's aux-loss-free expert-selection bias, on disk as
249 /// `blk.{N}.exp_probs_b.bias` (llama.cpp's `LLM_TENSOR_FFN_EXP_PROBS_B`
250 /// -- note the on-disk name has no `ffn_` prefix, `llama-arch.cpp:416`).
251 /// It is added to the *selection* score only: the top-k is taken over
252 /// `gating(logit) + bias[expert]`, while each winner's combine weight
253 /// comes from the unbiased `gating(logit)`
254 /// (`build_moe_ffn`: "leave probs unbiased as it's later used to get
255 /// expert weights"). Biasing the weight too would silently skew every
256 /// routed contribution away from what the router learned.
257 ///
258 /// `None` for every checkpoint that does not ship the tensor. When it
259 /// *is* present, the GPU MoE fast paths refuse the layer rather than
260 /// route without it -- their kernels have no bias input.
261 pub exp_probs_bias: Option<Vec<f32>>,
262 /// BitNet's `blk.N.ffn_sub_norm.weight`, `[ffn_dim]`: an RMSNorm on
263 /// `silu(gate) * up` BEFORE `down` (`bitnet.cpp:135-140`), inside
264 /// the dense FFN. `Some` only for a model whose
265 /// `ModelConfig::block_sub_norms` says so (`crate::sub_norms`), and
266 /// only on a dense layer: no MoE graph has this site. Read by
267 /// `Decoder::run_dense_expert` and `Decoder::dense_ffn_batch`, the
268 /// two dense FFN bodies; the fused kernels never see it because
269 /// `metal_can_serve_model` refuses the model.
270 pub ffn_sub_norm: Option<Vec<f32>>,
271 /// `blk.N.ffn_down.scale`, the `{1}` companion multiplied onto the
272 /// dense FFN's output right after `down` (`crate::weight_scales`);
273 /// refused on a routed layer, whose experts carry their own.
274 pub down_scale: Option<f32>,
275 /// The dense FFN's `blk.N.ffn_{up,gate,down}.bias`
276 /// (`crate::proj_bias`), on a dense layer whose architecture's
277 /// graph creates them and whose file carries at least one; `None`
278 /// otherwise. Applied by `run_dense_expert` and `dense_ffn_batch`,
279 /// whose fused Metal launch has no bias site and is fenced on it.
280 pub dense_bias: Option<frink_moe::DenseBias>,
281 /// Arctic's `blk.N.ffn_norm_exps.weight`, `[hidden_dim]`: the SECOND
282 /// per-layer norm, applied to the layer INPUT to make the routed
283 /// branch's operand (`arctic.cpp:45,136-139`;
284 /// `RouterInput::NormedLayerInput`). REQUIRED on a routed layer of
285 /// such a model, `None` everywhere else. Read by
286 /// `Decoder::router_operand`, the one constructor of the operand.
287 pub exps_norm: Option<Vec<f32>>,
288 /// The factor on `ffn_out + moe_out` for a layer whose dense FFN is
289 /// summed with its experts (`crate::parallel_dense_ffn`): `Some`
290 /// only when the loader filled `shared_experts` from the dense
291 /// names AND the row scales the sum (`grok.cpp:180`, `sqrt(2)/2`).
292 /// Applied by the FFN bodies to the whole branch output before the
293 /// post-FFN norm.
294 pub parallel_sum_scale: Option<f32>,
295 /// `Some` when this layer is a PARALLEL residual, `x + attn(norm(x))
296 /// + ffn(norm(x))`, and under which norm the FFN reads the layer
297 /// input (`crate::parallel_residual`): the vector attention read
298 /// (`SharedNorm`, and then `norm_weight` is `NormOp::None` because
299 /// there is no tensor) or its own `ffn_norm` of the layer input
300 /// (`TwoNorms`). `None` is the sequential layer, `ffn_norm(h)` over
301 /// the post-attention residual. Read by ONE constructor,
302 /// `Decoder::branch_inputs`, before attention runs.
303 pub parallel: Option<crate::parallel_residual::ParallelNorm>,
304 /// How many times each routed expert (index into `experts`) has been
305 /// selected by `route_top_k` across every `forward_token`/
306 /// `forward_batch` call so far. Real observed hotness, not a
307 /// placeholder -- feeds `placement_plan` below, which is what
308 /// `PlacementPlan::from_budget` needs to prioritize actually-hot
309 /// experts for GPU residency instead of guessing by index.
310 pub activation_counts: Vec<AtomicU64>,
311 /// Verified-at-load contiguous expert planes for Metal MoE
312 /// (`mul_mm_sg` gather/id). Built in `loader` when every routed expert
313 /// is mmap-backed with a simdgroup-GEMM quant (Q4_0 / Q4_K / Q8_0 / …)
314 /// and back-to-back gate/up/down slices. Gate/up/down kinds may differ
315 /// (Qwen1.5-MoE: Q4_K gate/up + Q8_0 down). `None` for store-backed,
316 /// F32, or non-contiguous layouts.
317 #[cfg(feature = "metal")]
318 pub packed_q4: Option<MoePackedQ4Planes>,
319}
320
321/// Load-time validated contiguous expert tensor planes (any `mul_mm_sg` quant).
322#[cfg(feature = "metal")]
323pub struct MoePackedQ4Planes {
324 gate: frink_core::weight_matrix::WeightBytes,
325 up: frink_core::weight_matrix::WeightBytes,
326 down: frink_core::weight_matrix::WeightBytes,
327 gate_stride: usize,
328 up_stride: usize,
329 down_stride: usize,
330 n_experts: usize,
331 ffn_rows: usize,
332 hidden_rows: usize,
333 gate_row_bytes: usize,
334 down_row_bytes: usize,
335 gate_kind: &'static str,
336 up_kind: &'static str,
337 down_kind: &'static str,
338}
339
340#[cfg(feature = "metal")]
341impl MoePackedQ4Planes {
342 #[allow(clippy::too_many_arguments)]
343 pub(crate) fn new(
344 gate: frink_core::weight_matrix::WeightBytes,
345 up: frink_core::weight_matrix::WeightBytes,
346 down: frink_core::weight_matrix::WeightBytes,
347 gate_stride: usize,
348 up_stride: usize,
349 down_stride: usize,
350 n_experts: usize,
351 ffn_rows: usize,
352 hidden_rows: usize,
353 gate_kind: &'static str,
354 up_kind: &'static str,
355 down_kind: &'static str,
356 ) -> Self {
357 Self {
358 gate,
359 up,
360 down,
361 gate_stride,
362 up_stride,
363 down_stride,
364 n_experts,
365 ffn_rows,
366 hidden_rows,
367 gate_row_bytes: gate_stride / ffn_rows,
368 down_row_bytes: down_stride / hidden_rows,
369 gate_kind,
370 up_kind,
371 down_kind,
372 }
373 }
374
375 pub fn view(&self) -> frink_metal::gpu::MoePackedQ4<'_> {
376 frink_metal::gpu::MoePackedQ4 {
377 gate: self.gate.as_slice(),
378 up: self.up.as_slice(),
379 down: self.down.as_slice(),
380 gate_stride: self.gate_stride,
381 up_stride: self.up_stride,
382 down_stride: self.down_stride,
383 n_experts: self.n_experts,
384 ffn_rows: self.ffn_rows,
385 hidden_rows: self.hidden_rows,
386 gate_row_bytes: self.gate_row_bytes,
387 down_row_bytes: self.down_row_bytes,
388 gate_kind: self.gate_kind,
389 up_kind: self.up_kind,
390 down_kind: self.down_kind,
391 }
392 }
393}
394
395impl MoeWeights {
396 pub fn n_experts(&self) -> usize {
397 self.experts.n_experts()
398 }
399
400 /// This routed expert's weight byte footprint, from resident
401 /// matrices or the stored layout -- identical numbers either way,
402 /// so residency planning is backing-independent.
403 pub fn expert_bytes(&self, e: usize) -> usize {
404 match &self.experts {
405 ExpertBacking::Resident(v) => {
406 let ex = &v[e];
407 ex.gate.resident_bytes() + ex.up.resident_bytes() + ex.down.resident_bytes()
408 }
409 ExpertBacking::Stored { layouts, .. } => layouts[e].total_bytes(),
410 }
411 }
412
413 /// Runs `f` against expert `e`'s weights, materializing them from
414 /// the store first when this layer is store-backed. The lease (and
415 /// therefore the cache entry's pin) lives exactly as long as `f`'s
416 /// borrow.
417 pub fn with_expert<R>(&self, e: usize, f: impl FnOnce(&ExpertWeights) -> R) -> R {
418 match &self.experts {
419 ExpertBacking::Resident(v) => f(&v[e]),
420 ExpertBacking::Stored {
421 store,
422 layouts,
423 layer,
424 } => {
425 let lease = store
426 .acquire(frink_core::expert_store::ExpertKey {
427 layer: *layer,
428 expert: e as u32,
429 })
430 .unwrap_or_else(|err| {
431 panic!(
432 "expert store read failed for layer {layer} expert {e}: {err} \
433 (checkpoint file unreadable mid-decode)"
434 )
435 });
436 let tmp = layouts[e].materialize(&lease);
437 f(&tmp)
438 }
439 }
440 }
441
442 /// The one expert a DENSE layer runs, recorded as the host body
443 /// records it, so a fused layer's hotness counters do not depend on
444 /// which backend ran it (`crate::fused_layer`).
445 #[cfg(feature = "metal")]
446 pub(crate) fn record_activations_dense(&self) {
447 self.record_activations(&[0]);
448 }
449
450 fn record_activations(&self, expert_ids: &[usize]) {
451 for &eid in expert_ids {
452 if let Some(counter) = self.activation_counts.get(eid) {
453 counter.fetch_add(1, Ordering::Relaxed);
454 }
455 }
456 }
457
458 /// A real VRAM-budget-and-hotness-driven placement plan for this
459 /// layer's routed experts, built from each expert's actual resident
460 /// byte size (`WeightMatrix::resident_bytes()` summed across its
461 /// gate/up/down matrices, so it reflects the real quantization
462 /// format in use, not an estimate) and the activation counts
463 /// observed so far. See `frink_moe::PlacementPlan::from_budget`.
464 pub fn placement_plan(&self, vram_budget_bytes: u64) -> PlacementPlan {
465 let sizes: Vec<usize> = (0..self.n_experts())
466 .map(|e| self.expert_bytes(e))
467 .collect();
468 let counts: Vec<u64> = self
469 .activation_counts
470 .iter()
471 .map(|c| c.load(Ordering::Relaxed))
472 .collect();
473 let has_observations = counts.iter().any(|&c| c > 0);
474 PlacementPlan::from_budget(
475 &sizes,
476 has_observations.then_some(counts.as_slice()),
477 vram_budget_bytes,
478 )
479 }
480}
481
482pub struct LayerWeights {
483 pub attn: AttnWeights,
484 pub moe: MoeWeights,
485 /// Talkie's `blk.N.layer_output_scale.weight`, the scalar its
486 /// embedding skip stream is multiplied by before joining this
487 /// layer's output (`talkie.cpp:123-126`, `crate::skip_stream`).
488 /// `Some` only on a `ModelConfig::skip_stream` model.
489 pub out_scale: Option<f32>,
490}
491
492/// The per-layer weights the gpt-oss graph carries and the generic GQA
493/// layer structs do not.
494///
495/// Held as a side table on [`Decoder`] rather than as new `Option`
496/// fields on [`AttnWeights`]/[`MoeWeights`] for two reasons. The first
497/// is mechanical: those two structs have thirty construction sites
498/// across seven loaders and every dedicated engine, and none of them
499/// will ever set these. The second is the point of the exercise — a
500/// checkpoint either has the whole gpt-oss graph or none of it, so
501/// `Decoder::gpt_oss.is_some()` is a single, checkable predicate for
502/// "this model needs the gpt-oss path", which is what the CPU-only and
503/// paged-attention refusals below key off. Scattering four independent
504/// `Option`s would make "half the graph is wired" representable, and
505/// that state is precisely the silent-wrong-answer bug this work exists
506/// to remove.
507///
508/// The attention sinks used to be the fifth field here and are
509/// [`AttnWeights::sinks`] now, because they are NOT gpt-oss-only:
510/// `mimo2.cpp:58,177` passes the same tensor into the same
511/// `build_attn_mha`. What keeps the whole-graph rule intact is that
512/// the loader refuses a gpt-oss file whose sinks are absent.
513pub struct GptOssLayer {
514 /// `blk.N.ffn_gate_inp.bias`, added to the router logits.
515 /// (`attn_output.bias` used to be here too and is
516 /// [`AttnWeights::o_bias`] now: `crate::proj_bias` fills that slot
517 /// for every graph that creates the tensor, gpt-oss among them.)
518 pub router_bias: Vec<f32>,
519 /// `blk.N.ffn_{gate,up,down}_exps.bias`, one entry per expert.
520 pub expert_bias: Vec<frink_moe::ExpertBias>,
521}
522
523/// gpt-oss side table: one entry per layer, in layer order.
524pub struct GptOssWeights {
525 pub layers: Vec<GptOssLayer>,
526}
527
528pub struct Decoder {
529 pub config: ModelConfig,
530 /// `[vocab_size, hidden_dim]`. A `WeightMatrix` rather than an
531 /// eagerly-widened f32 `Tensor`, so a quantized `token_embd.weight`
532 /// stays quantized on disk/mmap and token lookup dequantizes one
533 /// row at a time (`WeightMatrix::dequant_row`) -- a large-vocab
534 /// model's embedding table is multi-GB in f32 and only ever read
535 /// row-wise.
536 pub embedding: WeightMatrix,
537 /// The learned position table, `[context_length, hidden_dim]`, for
538 /// a graph that adds one to the embeddings (`crate::position_embd`;
539 /// `gpt2`, `starcoder`), read by [`Self::embed_token`] and nothing
540 /// else. `None` for every architecture that encodes position by
541 /// rotation or not at all.
542 pub position_embd: Option<WeightMatrix>,
543 /// The norm on the token embeddings before layer 0
544 /// (`norm_sites::EMBEDDING_NORM_ARCHITECTURES`: `bloom`'s
545 /// `token_embd_norm`, a biased LayerNorm), or [`NormOp::None`] for
546 /// every other architecture. Applied in [`Self::embed_token`], the
547 /// one embedding site; the GPU embedding gather has no norm and is
548 /// not taken for a model that has one.
549 pub embedding_norm: NormOp,
550 /// HRM-Text's learned LOW stream (`hrm.z_l_init`,
551 /// `hrm-text.cpp:46`, one `[n_embd]` row), `None` for every other
552 /// architecture. `crate::hrm` is the state the bodies carry.
553 pub hrm_z_l_init: Option<Vec<f32>>,
554 pub layers: Vec<LayerWeights>,
555 /// The norm before the LM head.
556 ///
557 /// A [`NormOp`] rather than a `Vec<f32>` because `olmo` is the first
558 /// architecture whose final norm is not an RMSNorm: `olmo.cpp:128-130`
559 /// is `build_norm(cur, NULL, NULL, LLM_NORM, -1)`, and `olmo.cpp:15-36`
560 /// creates no `output_norm` tensor for it to weight. The fused Metal
561 /// stacks that fold `final_norm + lm_head + argmax` had
562 /// `Some(&self.final_norm)` written into them unconditionally; now
563 /// they ask [`NormOp::rms_weights`] and fall back to the host body
564 /// when there is nothing to hand over.
565 pub final_norm: NormOp,
566 pub output_head: WeightMatrix, // [vocab_size, hidden_dim]
567 /// `output.bias`, `[vocab_size]`, added to the logits right after
568 /// the head (`crate::proj_bias::OUTPUT_BIAS_CREATORS`: `phi2` and
569 /// `phimoe` REQUIRE it, `qwen2` creates it optional). Applied in
570 /// `decoder::lm_head::Logits`, the one place the head's
571 /// post-projection transforms run; a head with a bias is never
572 /// folded into a fused Metal decode stack (`FoldedLmHead::permit`).
573 pub output_bias: Option<Vec<f32>>,
574 /// ALiBi's per-head slopes, `[n_heads]`, `Some` exactly when
575 /// `ModelConfig::alibi_max_bias` is (`frink_core::alibi::slopes`),
576 /// handed to every host attention kernel as its additive per-key
577 /// bias. Derived from the config at construction so the two cannot
578 /// disagree; no fused GPU path serves a model that has them.
579 pub alibi_slopes: Option<Vec<f32>>,
580 /// Real VRAM budget for GPU-resident routed experts.
581 /// `None` (both constructors below
582 /// set it) means every expert always runs on CPU -- the exact
583 /// behavior this field's absence had before it existed. `Some(bytes)`
584 /// makes each forward call build ONE global `ResidencyPlan`
585 /// (`Decoder::residency_plan`) across every layer's actual
586 /// resident expert sizes and observed activation counts against
587 /// this single budget -- the budget is never re-spent per layer --
588 /// dispatching device-placed routed experts through
589 /// `frink_moe::run_expert_placed` (a real CUDA kernel when the
590 /// `cuda` feature is compiled in and the expert's quant kind has
591 /// one; a correct CPU fallback otherwise, so setting this on a
592 /// non-`cuda` build is harmless, just never GPU-accelerated).
593 /// Shared experts and a dense layer's sole expert always run on
594 /// CPU regardless -- every token activates them, so there's no
595 /// routing decision to offload the way routed-expert placement is.
596 /// Rebuilding the plan on every forward call is real but not yet
597 /// performance-tuned; a real, disclosed limit, not a correctness
598 /// gap.
599 pub gpu_vram_budget_bytes: Option<u64>,
600 /// `Some` only for the gpt-oss family. See [`GptOssWeights`]. When
601 /// set, every layer runs the gpt-oss CPU graph (attention sinks,
602 /// alternating SWA, biased router + experts, `swiglu_oai`), GPU
603 /// offload is refused at load time, and the paged-KV decode path is
604 /// refused at call time — neither implements sinks, and answering
605 /// with a different distribution is the failure this replaces.
606 pub gpt_oss: Option<GptOssWeights>,
607 /// Does this architecture norm Q and K AFTER RoPE rather than
608 /// before? `maincoder` and `hunyuan-moe` do; see [`qk_norm`] for
609 /// the llama.cpp lines and for why no GGUF key can answer this.
610 /// Set by the loader from the architecture string; refused by
611 /// `layer_supports_metal_attn`, because no fused kernel can express
612 /// the order.
613 pub qk_norm_after_rope: bool,
614 /// Per-layer Metal-resident KV for fused decode/prefill attention
615 /// (`FRINK_METAL_ATTN`). Lazily allocated. After
616 /// [`frink_metal::attn::launch_decode_dense_stack`], Metal KV is
617 /// authoritative for the next decode step; host [`KvCache`] may lag
618 /// until [`Self::sync_metal_attn_kv_to_host`] or a CPU fallback.
619 /// Prefill / prefix restore still upload host → Metal when lengths
620 /// diverge for other reasons.
621 #[cfg(feature = "metal")]
622 pub(crate) metal_attn_kv: std::sync::Mutex<Option<Vec<frink_metal::attn::MetalKvBuffers>>>,
623 /// Load-time execution plan (family, fused-op caps, SWA/RoPE
624 /// policy). Built once; hot path must not re-resolve architecture
625 /// strings. See [`crate::execution_plan`].
626 pub execution_plan: crate::execution_plan::ExecutionPlan,
627 /// May a windowed layer's host [`KvCache`] drop rows that have
628 /// fallen behind its window? Off unless `FRINK_KV_WINDOW` says
629 /// otherwise; see [`kv_window`] for what else turns it off. A field
630 /// rather than a cached global so a test can run both arms in one
631 /// process and compare tokens.
632 pub kv_window: KvWindowPolicy,
633 /// LoRA adapters attached after load, by id. Each one's deltas
634 /// live INSIDE the `WeightMatrix` values above
635 /// (`WeightMatrix::Adapted`); this list is what a server lists and
636 /// rescales. See [`crate::lora_attach`].
637 pub lora_adapters: Vec<crate::lora_attach::LoraAttached>,
638 /// Cache key hit → fused caps last used for that geometry (enables
639 /// decode/prefill plan reuse without rebuilding residency).
640 pub plan_cache: std::sync::Mutex<
641 std::collections::HashMap<
642 crate::execution_plan::PlanGeometry,
643 crate::execution_plan::FusedOpCaps,
644 >,
645 >,
646}
647
648/// Simple deterministic pseudo-random generator so tests are
649/// reproducible without pulling in an external `rand` dependency.
650struct Lcg(u64);
651impl Lcg {
652 fn new(seed: u64) -> Self {
653 Lcg(seed)
654 }
655 fn next_f32(&mut self) -> f32 {
656 // xorshift64*
657 self.0 ^= self.0 << 13;
658 self.0 ^= self.0 >> 7;
659 self.0 ^= self.0 << 17;
660 ((self.0 >> 40) as f32 / (1u64 << 24) as f32) - 0.5
661 }
662 fn vec(&mut self, n: usize) -> Vec<f32> {
663 (0..n).map(|_| self.next_f32() * 0.1).collect()
664 }
665}
666
667/// Where a batch of independent sequences keeps its KV.
668///
669/// `forward_multi_seq` batches the projections across sequences but
670/// must attend per sequence, because each has its own length and its
671/// own history. That per-sequence step is the ONLY place the batched
672/// path touches a cache, which is why paging it is a parameter here
673/// rather than a second copy of a 300-line function -- the lesson the
674/// paged decode path taught by losing five model features one at a
675/// time to exactly that kind of copy.
676pub enum MultiSeqKv<'a> {
677 Contiguous(&'a mut [Vec<KvCache>]),
678 Paged {
679 caches: &'a mut [Vec<PagedKvCache>],
680 stores: &'a SharedPagedKv,
681 },
682}
683
684impl MultiSeqKv<'_> {
685 /// Sequences in the batch.
686 pub fn len(&self) -> usize {
687 match self {
688 MultiSeqKv::Contiguous(c) => c.len(),
689 MultiSeqKv::Paged { caches, .. } => caches.len(),
690 }
691 }
692
693 pub fn is_empty(&self) -> bool {
694 self.len() == 0
695 }
696
697 /// Layers each sequence carries, for the shape assertion.
698 fn layers_per_seq(&self, seq: usize) -> usize {
699 match self {
700 MultiSeqKv::Contiguous(c) => c[seq].len(),
701 MultiSeqKv::Paged { caches, .. } => caches[seq].len(),
702 }
703 }
704
705 /// Sequence `b`'s layer-`l` cache as the one-row step the attention
706 /// and short-conv bodies take. The only place this enum's arms are
707 /// matched for a cache, so paging changes where rows live and
708 /// nothing else.
709 fn step(&mut self, b: usize, l: usize) -> KvStep<'_> {
710 match self {
711 // `Batched`, not `Decode`: the CUDA resident per-layer KV
712 // holds ONE sequence's history, and this path never seeds
713 // it. See `KvStep::Batched`.
714 MultiSeqKv::Contiguous(caches) => KvStep::Batched(&mut caches[b][l]),
715 MultiSeqKv::Paged { caches, stores } => KvStep::Paged {
716 cache: &mut caches[b][l],
717 stores,
718 },
719 }
720 }
721}
722
723impl Decoder {
724 /// Eagerly resolve every kernel lookup this model's dispatch paths
725 /// will make, and record it in
726 /// [`frink_core::kernel_registry`] before anything runs.
727 ///
728 /// Call once, at the end of loading, immediately before
729 /// [`frink_core::kernel_registry::seal`]. Nothing here dispatches
730 /// or decides anything: it asks the same predicates the hot path
731 /// asks and writes the answers down, so a kernel that is missing
732 /// becomes a startup line instead of an unexplained benchmark row.
733 ///
734 /// Routed experts held in an [`ExpertBacking::Stored`] layer are not
735 /// probed -- they exist only as byte ranges until a token routes to
736 /// them, and materialising every expert here would defeat the
737 /// bounded expert store. Their kinds are the same as the resident
738 /// case, and a dispatch-site miss still trips the sealed registry.
739 pub fn probe_kernels(&self) {
740 use frink_core::kernel_registry as reg;
741
742 if !reg::enabled() {
743 return;
744 }
745 self.embedding.probe_kernels("token_embd");
746 self.output_head.probe_kernels("output_head");
747 for layer in &self.layers {
748 layer.attn.q_proj.probe_kernels("attn_q");
749 layer.attn.k_proj.probe_kernels("attn_k");
750 layer.attn.v_proj.probe_kernels("attn_v");
751 layer.attn.o_proj.probe_kernels("attn_o");
752 layer.moe.router.probe_kernels("moe_router");
753 for e in &layer.moe.shared_experts {
754 e.gate.probe_kernels("shexp_gate");
755 e.up.probe_kernels("shexp_up");
756 e.down.probe_kernels("shexp_down");
757 }
758 if let ExpertBacking::Resident(experts) = &layer.moe.experts {
759 for e in experts {
760 e.gate.probe_kernels("ffn_gate");
761 e.up.probe_kernels("ffn_up");
762 e.down.probe_kernels("ffn_down");
763 }
764 }
765 }
766 // The generic decoder has a real batched prefill
767 // (`forward_hidden_batch`), so a `pp512` here is one GEMM per
768 // projection, not 512 matvecs. Recorded as a hit so that an
769 // engine which lacks it stands out as a miss rather than as an
770 // absence.
771 reg::record_build(
772 reg::Lookup::new(
773 frink_core::weight_matrix::active_backend(),
774 reg::op::ENGINE_PREFILL_BATCH,
775 None,
776 )
777 .with_role("generic_decoder"),
778 reg::Outcome::Hit,
779 );
780 }
781
782 /// Builds a decoder with correctly-shaped, randomly initialized
783 /// weights for `config`, but overrides `n_layers` and `vocab_size`
784 /// with small test-scale numbers so it can actually be allocated and
785 /// run inside a CI sandbox. Use this to validate the forward-pass
786 /// plumbing only, never to draw conclusions about real model
787 /// quality.
788 pub fn new_random_small(config: ModelConfig, n_layers: usize, vocab_size: usize) -> Self {
789 let mut rng = Lcg::new(42);
790 let mut config = config;
791 config.n_layers = n_layers;
792 config.vocab_size = vocab_size;
793 let hidden = config.hidden_dim;
794 let head_dim = config.head_dim;
795 let n_heads = config.n_heads;
796 let n_kv_heads = config.n_kv_heads;
797
798 let embedding = WeightMatrix::F32(Tensor::new(
799 rng.vec(vocab_size * hidden),
800 vec![vocab_size, hidden],
801 ));
802
803 let wm = |data: Vec<f32>, shape: Vec<usize>| WeightMatrix::F32(Tensor::new(data, shape));
804
805 let mut layers = Vec::with_capacity(n_layers);
806 for layer_idx in 0..n_layers {
807 let attn = AttnWeights {
808 q_proj: wm(
809 rng.vec(n_heads * head_dim * hidden),
810 vec![n_heads * head_dim, hidden],
811 ),
812 k_proj: wm(
813 rng.vec(n_kv_heads * head_dim * hidden),
814 vec![n_kv_heads * head_dim, hidden],
815 ),
816 v_proj: wm(
817 rng.vec(n_kv_heads * head_dim * hidden),
818 vec![n_kv_heads * head_dim, hidden],
819 ),
820 o_proj: wm(
821 rng.vec(hidden * n_heads * head_dim),
822 vec![hidden, n_heads * head_dim],
823 ),
824 norm_weight: NormOp::Rms(vec![1.0; hidden]),
825 q_norm: None,
826 k_norm: None,
827 q_bias: None,
828 k_bias: None,
829 v_bias: None,
830 post_attn_norm: None,
831 post_ffn_norm: None,
832 output_gate: None,
833 sinks: None,
834 attn_sub_norm: None,
835 o_scale: None,
836 o_bias: None,
837 shortconv: None,
838 ssm: None,
839 q_gate_interleaved: false,
840 };
841
842 // Leading dense layers (see ModelConfig::layer_is_dense's
843 // doc comment) get a single-expert, no-shared-expert
844 // dense-equivalent FFN regardless of this model's global
845 // MoE topology, matching the DeepSeek-2/3-family
846 // convention found in ik_llama.cpp's source.
847 let is_dense_layer = config.layer_is_dense(layer_idx);
848 let n_experts = if is_dense_layer {
849 1
850 } else {
851 config.moe.n_experts
852 };
853 let n_shared = if is_dense_layer {
854 0
855 } else {
856 config.moe.n_shared_experts
857 };
858 let ffn_dim = config.moe.expert_ffn_dim;
859 let make_expert = |rng: &mut Lcg| ExpertWeights {
860 gate: WeightMatrix::F32(Tensor::new(
861 rng.vec(ffn_dim * hidden),
862 vec![ffn_dim, hidden],
863 )),
864 up: WeightMatrix::F32(Tensor::new(
865 rng.vec(ffn_dim * hidden),
866 vec![ffn_dim, hidden],
867 )),
868 down: WeightMatrix::F32(Tensor::new(
869 rng.vec(hidden * ffn_dim),
870 vec![hidden, ffn_dim],
871 )),
872 };
873 let experts: Vec<ExpertWeights> =
874 (0..n_experts).map(|_| make_expert(&mut rng)).collect();
875 let shared_experts = (0..n_shared).map(|_| make_expert(&mut rng)).collect();
876 let activation_counts = (0..experts.len()).map(|_| AtomicU64::new(0)).collect();
877
878 let moe = MoeWeights {
879 exp_probs_bias: None,
880 ffn_sub_norm: None,
881 down_scale: None,
882 exps_norm: None,
883 parallel_sum_scale: None,
884 parallel: None,
885 dense_bias: None,
886 router: wm(rng.vec(n_experts * hidden), vec![n_experts, hidden]),
887 experts: ExpertBacking::Resident(experts),
888 shared_experts,
889 shared_expert_gate: None,
890 norm_weight: NormOp::Rms(vec![1.0; hidden]),
891 activation_counts,
892 #[cfg(feature = "metal")]
893 packed_q4: None,
894 };
895
896 layers.push(LayerWeights {
897 attn,
898 moe,
899 out_scale: None,
900 });
901 }
902
903 let final_norm = NormOp::Rms(vec![1.0; hidden]);
904 let output_head = wm(rng.vec(vocab_size * hidden), vec![vocab_size, hidden]);
905 let execution_plan = crate::execution_plan::ExecutionPlan::from_config(
906 &config,
907 crate::capability::DecoderFamily::StandardGqa,
908 crate::capability::MemoryKind::KvGqa,
909 crate::execution_plan::ExecutionPlan::probe_metal_caps(),
910 );
911
912 let alibi_slopes = config_alibi_slopes(&config);
913 Decoder {
914 config,
915 embedding,
916 position_embd: None,
917 embedding_norm: NormOp::None,
918 hrm_z_l_init: None,
919 layers,
920 final_norm,
921 output_head,
922 output_bias: None,
923 alibi_slopes,
924 gpu_vram_budget_bytes: None,
925 // Synthetic-weights constructor: no checkpoint, no gpt-oss.
926 gpt_oss: None,
927 // Synthetic-weights constructor: the preset families it
928 // serves all norm before RoPE.
929 qk_norm_after_rope: false,
930 #[cfg(feature = "metal")]
931 metal_attn_kv: std::sync::Mutex::new(None),
932 execution_plan,
933 kv_window: KvWindowPolicy::from_env(),
934 plan_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
935 lora_adapters: Vec::new(),
936 }
937 }
938
939 /// Metal's launch description for `m`, or `None` when no kernel
940 /// serves its storage. Delegates to `crate::metal_launch`, which is
941 /// where both this file and `crate::gdn` read it from.
942 #[cfg(feature = "metal")]
943 fn metal_matvec_launch<'a>(m: &'a WeightMatrix) -> Option<frink_metal::gpu::MatvecLaunch<'a>> {
944 crate::metal_launch::matvec(m)
945 }
946 /// HRM-Text's two streams for a decode of `rows` rows, or `None`
947 /// for every other architecture (`crate::hrm`).
948 ///
949 /// Called by every body that walks layers, so the state is built
950 /// in one place from one rule; a body that forgot it would run the
951 /// stacks on a single stream and answer fluently.
952 pub(crate) fn hrm_streams(&self, embedded: &[f32]) -> Option<crate::hrm::HrmStreams> {
953 let init = self.hrm_z_l_init.as_ref()?;
954 crate::hrm::hrm_schedule(self.config.layer_loops)?;
955 Some(crate::hrm::HrmStreams::new(embedded, init))
956 }
957
958 /// The residual layer `l` reads: `zH + zL` at a stack boundary,
959 /// and whatever the previous layer produced everywhere else.
960 pub(crate) fn hrm_stack_input(
961 &self,
962 streams: Option<&crate::hrm::HrmStreams>,
963 l: usize,
964 hidden: &mut Vec<f32>,
965 ) {
966 let Some(streams) = streams else { return };
967 let Some(loops) = self.config.layer_loops else {
968 return;
969 };
970 if loops.stack_starts_at(l) {
971 *hidden = streams.stack_input();
972 }
973 }
974
975 /// Store a finished stack's output in the stream it writes.
976 ///
977 /// Runs AFTER the FFN body's weightless pass norm
978 /// (`Decoder::apply_loop_norm`), because `hrm-text.cpp:162` norms
979 /// inside `build_stack` and `:186,193` store what it returned.
980 pub(crate) fn hrm_store(
981 &self,
982 streams: Option<&mut crate::hrm::HrmStreams>,
983 l: usize,
984 hidden: &[f32],
985 ) {
986 let Some(streams) = streams else { return };
987 let Some(loops) = self.config.layer_loops else {
988 return;
989 };
990 if let Some(stream) = loops.stream_after(l) {
991 streams.store(stream, hidden);
992 }
993 }
994
995 /// The per-model facts no fused Metal kernel implements, as ONE
996 /// predicate the four Metal eligibility checks share.
997 ///
998 /// Five today. `residual_scale`: every fused launch that folds a
999 /// residual add in -- the dense decode stack, the resident MoE
1000 /// decode stack, and both prefill stacks -- adds the branch output
1001 /// to the stream on device, with no uniform for a multiplier, so a
1002 /// Granite layer served by any of them would be scaled by the host
1003 /// bodies and not by the GPU. `clamp_kqv`: the fused launches apply
1004 /// the QKV bias inside their kernels (`AttnExtras`) and clamp
1005 /// nothing, so a DBRX or clamped-OLMo layer served by any of them
1006 /// would run unclamped projections while the host bodies clamp
1007 /// (`decoder/qkv_bias.rs`). An FFN activation the kernels cannot
1008 /// spell: every fused dense launch takes a `gelu: bool`, so the
1009 /// ungated ReLU-squared FFN (`GluAct::Reglu`, arcee) has no value to
1010 /// pass and every site that asks `fused_kernel_gelu_flag` gets
1011 /// `None` -- this predicate keeps the whole model off the stacks so
1012 /// that a layer is never half-served. And per-layer shapes: the
1013 /// stacks size Q/K/V and the Metal KV plane from ONE head count
1014 /// (`n_heads` is a launch argument, `MetalKvBuffers` one geometry),
1015 /// so a model whose layers disagree (`crate::layer_shapes`, deci /
1016 /// openelm) stays on the host bodies, which read each layer's own.
1017 /// And the per-position attention temperature
1018 /// (`crate::attn_temperature`): no fused launch takes a per-token Q
1019 /// scale, so a Ministral-3 layer served by any of them would attend
1020 /// at temperature 1 while the host bodies step it with position.
1021 /// Either way it is the same weights answering differently
1022 /// depending on which backend took the token, the exact failure
1023 /// `attention_scale` is fenced off for next door.
1024 ///
1025 /// It is one function rather than four spellings because the GPU
1026 /// router's eligibility check has already drifted four ways in this
1027 /// file -- prefill tested three conditions, fused decode two, and
1028 /// the whole-stack decode NONE.
1029 ///
1030 /// `lora_attached` is the fifth fact, and the first that is not a
1031 /// property of the file: a LoRA delta lives inside a
1032 /// `WeightMatrix::Adapted` and is served by that type's methods,
1033 /// while every fused stack takes its weights as raw bytes through
1034 /// `metal_matvec_launch` / `mul_mm_sg_launch` (both answer `None`
1035 /// for an adapted matrix). Fencing the whole model here, too, is
1036 /// what keeps a model with an adapter on one layer from being served
1037 /// half by a stack and half by the host.
1038 ///
1039 /// Named for the backend that had fused stacks first. The CUDA
1040 /// resident prefill layer (`decoder/cuda_prefill.rs`, #259) asks the
1041 /// same question through `fused_view`, because the facts are
1042 /// properties of the model and not of the kernel language.
1043 #[cfg(any(feature = "metal", feature = "cuda"))]
1044 fn metal_can_serve_model(config: &ModelConfig, lora_attached: bool) -> bool {
1045 !lora_attached
1046 && config.residual_scale.is_none()
1047 // `minimax-01.cpp:249,428`: the residual a fused kernel
1048 // adds its branch to is the PRE-NORM's output, which never
1049 // leaves the device in any of these stacks
1050 // (`crate::normed_residual`).
1051 && config.normed_residual_scale.is_none()
1052 && config.clamp_kqv.is_none()
1053 && config.attn_temperature.is_none()
1054 // BitNet's two inner norms (`crate::sub_norms`): no fused
1055 // kernel norms between attention and `wo`, or between the
1056 // activation and `down`.
1057 && !config.block_sub_norms
1058 // Every fused launch bakes the pre-FFN norm over the
1059 // POST-ATTENTION residual into its kernel; a parallel layer
1060 // norms the layer INPUT (`crate::parallel_residual`).
1061 && !config.parallel_residual
1062 // The GPU embedding gather has no add and no stack sees
1063 // `pos` for it (`crate::position_embd`).
1064 && !config.learned_positions
1065 // Every fused launch runs attention alone on its layer; a
1066 // parallel Mamba-2 block (`crate::mamba2`) has no kernel.
1067 && !config.parallel_ssm
1068 // Every fused launch takes ONE sliding window per layer; a
1069 // chunked layer's window is per query
1070 // (`crate::chunked_swa`), and no fused kernel norms Q and K
1071 // without a weight after RoPE (`crate::weightless_qk_norm`).
1072 && !config.swa_chunked
1073 && !config.weightless_qk_norm
1074 // No fused kernel adds a per-key bias (`crate::alibi`).
1075 && config.alibi_max_bias.is_none()
1076 // Every fused launch takes ONE epsilon for the whole layer;
1077 // `muse-glimmer.cpp:63` runs its post-norms at a literal
1078 // 1e-8 and its pre-norms at the model's
1079 // (`crate::norm::POST_NORM_EPS_LITERAL`), so a model whose
1080 // two epsilons differ stays on the host rather than having
1081 // its post-norms silently run at the wrong one.
1082 && config.post_norm_eps() == config.rms_norm_eps
1083 // Every fused launch takes ONE head width for K and V (the
1084 // KV buffers, the attention tile, the `wo` fold); MiMo-V2's
1085 // split widths stay on the host (`crate::kv_head_dims`).
1086 && !config.kv_head_dims_split()
1087 // No fused kernel scales the branch after its `wo` fold
1088 // (`crate::attn_value_scale`).
1089 && config.attn_value_scale.is_none()
1090 // Every fused launch indexes `layers[l]` and `metal_kvs[l]`
1091 // with ONE `l`; a looped model's logical layers outnumber
1092 // its weights (`crate::layer_loops`).
1093 && config.layer_loops.is_none()
1094 // Talkie: an embedding norm no gather kernel applies, a
1095 // second residual no stack carries, a per-head SCALAR Q gain
1096 // and a weightless K norm no `AttnExtras` spells
1097 // (`crate::skip_stream`, `QkNormStyle::PerHeadScalar`).
1098 && !config.skip_stream
1099 && config.qk_norm_style != crate::capability::QkNormStyle::PerHeadScalar
1100 // `model_ffn_act` is `None` for an activation that varies
1101 // by layer (xIELU's parameters), which no fused kernel
1102 // takes; `fused_kernel_gelu_flag` is `None` for one no
1103 // kernel spells.
1104 && config
1105 .model_ffn_act()
1106 .and_then(GluAct::fused_kernel_gelu_flag)
1107 .is_some()
1108 && config.layer_shapes.is_uniform()
1109 // Each fused launch takes ONE rotary width (`MetalRope::
1110 // rot_dim`); a model whose sliding layers rotate a
1111 // different width from its full ones (`rope_dim_swa`,
1112 // Step-3.5 and Laguna-XS.2) stays on the host bodies, which
1113 // read each layer's own through `layer_rope`.
1114 && !config.rope_dim_varies_by_layer()
1115 }
1116
1117 /// The blocked prefill attention, on the GPU when this shape is one
1118 /// the Metal flash kernel serves and on the Rayon host kernel
1119 /// otherwise, which is the ONE place that choice is made.
1120 ///
1121 /// It is reached only by layers the FUSED Metal attention block
1122 /// cannot take -- a gated softmax attention, a V width that differs
1123 /// from K's, a projection with no Metal launch -- so the layer's
1124 /// Q, K and V are on the host either way and there is nothing to
1125 /// fuse; what is left is whether the `n_q x n_kv` score matrix is
1126 /// built by six cores or by the GPU. On Bonsai's 16 attention
1127 /// layers that matrix is the largest single host cost of a prefill
1128 /// (`dot_f32`, `pv_tile` and `qk_tile` are 11178 of a sampled
1129 /// prefill's top-of-stack against 1504 for the next thing).
1130 ///
1131 /// The refusals are the kernel's, not a guess: it takes no sliding
1132 /// window, no ALiBi slopes, no attention sink and one head width
1133 /// for K and V, and a shape it does not serve falls through to the
1134 /// host body below rather than being approximated.
1135 #[allow(clippy::too_many_arguments)]
1136 fn prefill_attention_blocked(
1137 &self,
1138 q_batch: &[f32],
1139 cache_k: &[f32],
1140 cache_v: &[f32],
1141 n_heads: usize,
1142 n_kv_heads: usize,
1143 head_dim: usize,
1144 v_head_dim: usize,
1145 batch_size: usize,
1146 base_seq_len: usize,
1147 softcap: Option<f32>,
1148 window: Option<usize>,
1149 ) -> Vec<f32> {
1150 #[cfg(feature = "metal")]
1151 if window.is_none()
1152 && self.alibi_slopes.is_none()
1153 && v_head_dim == head_dim
1154 && frink_core::weight_matrix::metal_dense_enabled()
1155 {
1156 if let Ok(out) = frink_metal::attn::launch_gqa_prefill_host_ex(
1157 q_batch,
1158 cache_k,
1159 cache_v,
1160 n_heads,
1161 n_kv_heads,
1162 head_dim,
1163 batch_size,
1164 base_seq_len,
1165 softcap,
1166 ) {
1167 return out;
1168 }
1169 }
1170 causal_gqa_attention_prefill_shared_kv_split(
1171 q_batch,
1172 cache_k,
1173 cache_v,
1174 n_heads,
1175 n_kv_heads,
1176 head_dim,
1177 v_head_dim,
1178 batch_size,
1179 base_seq_len,
1180 softcap,
1181 window,
1182 self.alibi_slopes.as_deref(),
1183 )
1184 }
1185
1186 /// True when this layer can use the fused Metal attention block
1187 /// (Norm or NeoX RoPE, quantized projections; QKV bias + QK-norm
1188 /// via [`frink_metal::attn::AttnExtras`]).
1189 #[cfg(feature = "metal")]
1190 fn layer_supports_metal_attn(&self, layer: &LayerWeights) -> bool {
1191 // The backend-neutral questions are `fused_view`'s; what is
1192 // left is whether Metal has a launch for every projection.
1193 self.layer_supports_fused_attn(layer)
1194 && Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
1195 && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
1196 && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
1197 && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
1198 }
1199
1200 /// Layer features only the fused dense stack implements — the
1201 /// per-layer Metal launches would silently skip them (wrong output).
1202 #[cfg(feature = "metal")]
1203 fn layer_needs_metal_stack(&self, layer: &LayerWeights, layer_idx: usize) -> bool {
1204 layer.attn.post_attn_norm.is_some()
1205 || layer.attn.post_ffn_norm.is_some()
1206 || self.config.layer_sliding_window(layer_idx).is_some()
1207 || !self.config.layer_ffn_acts(layer_idx).all_swiglu()
1208 // `Some(base)` when this layer rotates at the model's own
1209 // base, so this ONE comparison covers both stack-only RoPE
1210 // facts: a per-layer base (Gemma-3's sliding layers) and a
1211 // layer that does not rotate at all (`crate::rope_layers`,
1212 // EXAONE-4 32B's full-attention layers). A second predicate
1213 // beside it is exactly the drift this file keeps paying for.
1214 || self.config.layer_rope_theta(layer_idx) != Some(self.config.rope_theta)
1215 }
1216
1217 /// BOTH halves of layer `il`'s RoPE, in the shape the Metal
1218 /// launches take.
1219 ///
1220 /// One conversion, every Metal call site, because
1221 /// `ModelConfig::layer_rope` returning a pair and the launches
1222 /// taking a pair is only worth anything while nothing in between
1223 /// gets to take one half and default the other. That is exactly
1224 /// what the fused stacks used to do: a per-layer `rope_theta` beside
1225 /// ONE `freq_factors` slice for the whole run, which refused
1226 /// Gemma-3 4B/12B/27B off the fused path entirely rather than rope
1227 /// five layers in six at the wrong scale.
1228 #[cfg(feature = "metal")]
1229 fn metal_layer_rope(&self, layer_idx: usize) -> Option<frink_metal::attn::LayerRope<'_>> {
1230 // Exhaustive on purpose: the third field is the rotary width,
1231 // which the Metal kernels take as ONE uniform for every layer
1232 // (`MetalRope::rot_dim`, from `metal_rope`). A layer whose width
1233 // differs from the model's must never reach a launch, and
1234 // `metal_can_serve_model` refuses such a model up front; this
1235 // is the check that the fence held, not a second fence.
1236 let crate::config::LayerRopeParams {
1237 theta,
1238 freq_factors,
1239 rot_dim,
1240 } = self.config.layer_rope(layer_idx)?;
1241 assert_eq!(
1242 rot_dim,
1243 self.config.rope_dim.filter(|w| *w < self.config.head_dim),
1244 "layer {layer_idx} rotates a different width from the model's; \
1245 `metal_can_serve_model` must have refused this model"
1246 );
1247 Some(frink_metal::attn::LayerRope {
1248 theta,
1249 freq_factors,
1250 })
1251 }
1252
1253 /// [`Self::fused_attn_extras`] -- the ONE exhaustive destructure of
1254 /// `AttnWeights`, in `fused_view` -- in Metal's spelling.
1255 #[cfg(feature = "metal")]
1256 fn metal_attn_view<'a>(
1257 &self,
1258 layer: &'a LayerWeights,
1259 ) -> Option<frink_metal::attn::AttnExtras<'a>> {
1260 let fused_view::FusedAttnExtras {
1261 q_bias,
1262 k_bias,
1263 v_bias,
1264 q_norm,
1265 k_norm,
1266 } = Self::fused_attn_extras(layer)?;
1267 Some(frink_metal::attn::AttnExtras {
1268 q_bias,
1269 k_bias,
1270 v_bias,
1271 q_norm,
1272 k_norm,
1273 attn_logit_softcap: self.config.attn_logit_softcap,
1274 })
1275 }
1276
1277 /// Optional QKV bias / QK-norm ops for the Metal attn paths, for a
1278 /// layer [`Self::layer_supports_metal_attn`] has admitted. The
1279 /// `expect` can only fire when a launch site runs without asking
1280 /// that predicate, which is the drift this file's eligibility
1281 /// checks exist to stop, and a panic there beats a silent answer
1282 /// without the gate or the sinks.
1283 #[cfg(feature = "metal")]
1284 fn metal_attn_extras<'a>(&self, layer: &'a LayerWeights) -> frink_metal::attn::AttnExtras<'a> {
1285 self.metal_attn_view(layer)
1286 .expect("layer_supports_metal_attn admits this layer, so its weights have a Metal view")
1287 }
1288
1289 /// GPU expert residency only when Metal attention stays on-device
1290 /// (when the Metal dense+attn path is active). Avoids CPU-attention
1291 /// ↔ GPU-expert activation ping-pong on Metal MoE.
1292 #[cfg(feature = "metal")]
1293 fn expert_residency_plan(&self, use_metal_attn: bool) -> Option<frink_moe::ResidencyPlan> {
1294 if frink_core::metal_dense_enabled()
1295 && frink_metal::attn::metal_attn_enabled()
1296 && !use_metal_attn
1297 {
1298 return None;
1299 }
1300 self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
1301 }
1302
1303 #[cfg(not(feature = "metal"))]
1304 fn expert_residency_plan(&self, _use_metal_attn: bool) -> Option<frink_moe::ResidencyPlan> {
1305 self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
1306 }
1307
1308 /// Map this checkpoint's RoPE onto the Metal kernel uniforms:
1309 /// pairing convention, rotary width (`n_rot`), and ggml `rope_yarn`'s
1310 /// `mscale`. The last two are what
1311 /// [`Decoder::apply_rope_head_theta`] and
1312 /// [`Decoder::apply_rope_attn_factor`] do on the CPU side, so the
1313 /// two backends stay one graph.
1314 #[cfg(feature = "metal")]
1315 fn metal_rope(&self) -> frink_metal::attn::MetalRope {
1316 use crate::config::RopeLayout;
1317 let layout = match self.config.rope_layout {
1318 RopeLayout::Norm => frink_metal::attn::MetalRopeLayout::Norm,
1319 RopeLayout::Neox => frink_metal::attn::MetalRopeLayout::Neox,
1320 };
1321 frink_metal::attn::MetalRope {
1322 layout,
1323 rot_dim: self
1324 .config
1325 .rope_dim
1326 .filter(|rot| *rot < self.config.head_dim),
1327 attn_factor: self.config.rope_attn_factor,
1328 }
1329 }
1330
1331 /// Dense FFN (single expert) with Metal-capable gate/up/down.
1332 #[cfg(feature = "metal")]
1333 fn layer_supports_metal_dense_ffn(layer: &LayerWeights) -> bool {
1334 Self::is_dense_layer(layer)
1335 // No fused FFN kernel scales after `down` (`crate::weight_scales`)
1336 // or adds a bias anywhere (`crate::proj_bias`).
1337 && layer.moe.down_scale.is_none()
1338 && layer.moe.dense_bias.is_none()
1339 && layer.moe.with_expert(0, |ex| {
1340 Self::metal_matvec_launch(&ex.gate).is_some()
1341 && Self::metal_matvec_launch(&ex.up).is_some()
1342 && Self::metal_matvec_launch(&ex.down).is_some()
1343 })
1344 }
1345
1346 /// Dense layer eligible for the one-CB `mul_mm_sg` prefill stack.
1347 /// QKV bias / QK-norm are applied on-GPU via [`AttnExtras`] (same as
1348 /// decode); SWA fit is checked separately.
1349 #[cfg(feature = "metal")]
1350 fn metal_prefill_dense_layer_eligible(
1351 layer: &LayerWeights,
1352 config: &ModelConfig,
1353 lora_attached: bool,
1354 ) -> bool {
1355 Self::fused_prefill_dense_layer_eligible(layer, config, lora_attached)
1356 }
1357
1358 #[cfg(feature = "metal")]
1359 fn metal_prefill_dense_swa_fits(
1360 &self,
1361 layer_idx: usize,
1362 start_pos: usize,
1363 batch_size: usize,
1364 ) -> bool {
1365 match self.config.layer_sliding_window(layer_idx) {
1366 Some(window) => start_pos + batch_size <= window,
1367 None => true,
1368 }
1369 }
1370
1371 /// Routed-expert FFN for the fused prefill stack, or `None` when this
1372 /// layer must keep the host-routed path (`launch_moe_prefill_q4_0`).
1373 ///
1374 /// Note: routing happens on the GPU here, so prefill no longer feeds
1375 /// `record_activations`. Expert hotness for `inspect-plan` comes from
1376 /// decode, which still routes on the host.
1377 #[cfg(feature = "metal")]
1378 fn metal_prefill_moe<'a>(
1379 layer: &'a LayerWeights,
1380 config: &ModelConfig,
1381 lora_attached: bool,
1382 ) -> Option<frink_metal::gpu::PrefillMoeMetal<'a>> {
1383 if !Self::metal_can_serve_model(config, lora_attached)
1384 || Self::is_dense_layer(layer)
1385 || !layer.moe.shared_experts.is_empty()
1386 || !config.model_ffn_act().is_some_and(GluAct::is_swiglu)
1387 // See `gpu_router_matches_host_routing`: the GPU router
1388 // takes router weights and nothing else.
1389 || !Self::gpu_router_matches_host_routing(layer, config)
1390 {
1391 return None;
1392 }
1393 let frink_core::weight_matrix::WeightMatrix::F32(router) = &layer.moe.router else {
1394 return None;
1395 };
1396 let packed = Self::moe_packed_q4(&layer.moe)?;
1397 let moe = frink_metal::gpu::PrefillMoeMetal {
1398 router_w: &router.data,
1399 top_k: config.moe.n_experts_active,
1400 renormalize: config.moe.norm_topk_prob,
1401 packed,
1402 };
1403 moe.is_supported().then_some(moe)
1404 }
1405
1406 /// FFN half of a fused-prefill-stack layer: dense `mul_mm_sg` launches
1407 /// or (MoE) the routed-expert description.
1408 #[cfg(feature = "metal")]
1409 fn metal_prefill_ffn<'a>(
1410 layer: &'a LayerWeights,
1411 config: &ModelConfig,
1412 lora_attached: bool,
1413 ) -> Option<frink_metal::attn::PrefillFfnMetal<'a>> {
1414 if let Some(moe) = Self::metal_prefill_moe(layer, config, lora_attached) {
1415 return Some(frink_metal::attn::PrefillFfnMetal::Moe(moe));
1416 }
1417 if !Self::is_dense_layer(layer) {
1418 return None;
1419 }
1420 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1421 return None;
1422 };
1423 let ex = experts.first()?;
1424 Some(frink_metal::attn::PrefillFfnMetal::Dense {
1425 gate: ex.gate.mul_mm_sg_launch()?,
1426 up: ex.up.mul_mm_sg_launch()?,
1427 down: ex.down.mul_mm_sg_launch()?,
1428 })
1429 }
1430
1431 /// Length of a consecutive run of Metal prefill-stack layers from
1432 /// `start`, or `None` when fewer than two layers qualify.
1433 #[cfg(feature = "metal")]
1434 fn metal_prefill_dense_stack_run_len(
1435 &self,
1436 start: usize,
1437 start_pos: usize,
1438 batch_size: usize,
1439 kv_caches: &[KvCache],
1440 metal_kvs: Option<&[frink_metal::attn::MetalKvBuffers]>,
1441 ) -> Option<usize> {
1442 // See `layer_supports_metal_attn`: gpt-oss stays on CPU.
1443 if self.gpt_oss.is_some() {
1444 return None;
1445 }
1446 let metal_kvs = metal_kvs?;
1447 let mut run = 0usize;
1448 for li in start..self.layers.len() {
1449 let layer = &self.layers[li];
1450 let cache = &kv_caches[li];
1451 if !self.metal_prefill_dense_swa_fits(li, start_pos, batch_size) {
1452 break;
1453 }
1454 // POSITIONS: compared against `start_pos`, and against
1455 // Metal's own count of the same sequence.
1456 if metal_kvs[li].seq_len != cache.positions() || start_pos != cache.positions() {
1457 break;
1458 }
1459 let ok = layer.attn.q_proj.mul_mm_sg_launch().is_some()
1460 && layer.attn.k_proj.mul_mm_sg_launch().is_some()
1461 && layer.attn.v_proj.mul_mm_sg_launch().is_some()
1462 && layer.attn.o_proj.mul_mm_sg_launch().is_some()
1463 && Self::metal_prefill_ffn(layer, &self.config, self.lora_attached()).is_some();
1464 if !ok {
1465 break;
1466 }
1467 run += 1;
1468 }
1469 (run >= 2).then_some(run)
1470 }
1471
1472 /// Try [`frink_metal::attn::launch_prefill_dense_stack`] for
1473 /// `run_len` layers starting at `start`. Advances host + Metal KV
1474 /// on success.
1475 #[cfg(feature = "metal")]
1476 #[allow(clippy::too_many_arguments)]
1477 fn try_metal_prefill_dense_stack(
1478 &self,
1479 start: usize,
1480 run_len: usize,
1481 hidden_batch: &[f32],
1482 start_pos: usize,
1483 batch_size: usize,
1484 n_heads: usize,
1485 metal_kvs: &mut [frink_metal::attn::MetalKvBuffers],
1486 kv_caches: &mut [KvCache],
1487 host_kv_authoritative: bool,
1488 ) -> Option<Vec<f32>> {
1489 // `None` for an activation no fused kernel implements: the
1490 // stack does not launch, rather than running it as GELU.
1491 let gelu = self
1492 .config
1493 .model_ffn_act()
1494 .and_then(GluAct::fused_kernel_gelu_flag)?;
1495 let mut prefill_layers = Vec::with_capacity(run_len);
1496 for li in start..start + run_len {
1497 let layer = &self.layers[li];
1498 let ffn = Self::metal_prefill_ffn(layer, &self.config, self.lora_attached())?;
1499 if matches!(ffn, frink_metal::attn::PrefillFfnMetal::Dense { .. }) {
1500 layer.moe.record_activations(&[0]);
1501 }
1502 let (q, k, v, o) = (
1503 layer.attn.q_proj.mul_mm_sg_launch()?,
1504 layer.attn.k_proj.mul_mm_sg_launch()?,
1505 layer.attn.v_proj.mul_mm_sg_launch()?,
1506 layer.attn.o_proj.mul_mm_sg_launch()?,
1507 );
1508 prefill_layers.push(frink_metal::attn::PrefillDenseLayerMetal {
1509 // `?`, not a `&`: the kernel applies the RMSNorm itself
1510 // and the post-norm-only topology has no weight to give
1511 // it, so the stack declines and the host body runs. See
1512 // `crate::norm`.
1513 attn_norm_w: layer.attn.norm_weight.rms_weights()?,
1514 ffn_norm_w: layer.moe.norm_weight.rms_weights()?,
1515 q,
1516 k,
1517 v,
1518 o,
1519 ffn,
1520 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
1521 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
1522 extras: self.metal_attn_extras(layer),
1523 rope: self.metal_layer_rope(li),
1524 layer_idx: li as u32,
1525 });
1526 }
1527 let kvs = &mut metal_kvs[start..start + run_len];
1528 let h_out = frink_metal::attn::launch_prefill_dense_stack(
1529 hidden_batch,
1530 &prefill_layers,
1531 kvs,
1532 n_heads,
1533 batch_size,
1534 self.metal_rope(),
1535 start_pos,
1536 self.config.rms_norm_eps,
1537 gelu,
1538 self.config.attn_logit_softcap,
1539 )
1540 .ok()?;
1541 for (mkv, cache) in kvs.iter().zip(&mut kv_caches[start..start + run_len]) {
1542 Self::advance_host_kv_after_metal_prefill(
1543 mkv,
1544 cache,
1545 batch_size,
1546 host_kv_authoritative,
1547 );
1548 }
1549 Some(h_out)
1550 }
1551
1552 /// True when a plain top-k softmax over the raw router logits picks
1553 /// the SAME experts with the SAME weights that
1554 /// [`Self::route_for_layer`] would.
1555 ///
1556 /// Every Metal MoE path either routes on the GPU (which implements
1557 /// exactly that plain top-k and takes no other input) or, in
1558 /// `launch_moe_decode_pre`'s case, used to re-implement it on the
1559 /// host. `route_for_layer` has three arms this does not: grouped
1560 /// routing, a per-expert router bias (`exp_probs_bias`), and
1561 /// `expert_weights_scale`. A checkpoint carrying any of them routes
1562 /// to DIFFERENT experts with DIFFERENT weights depending on which
1563 /// backend served the token -- not an error, a different model.
1564 ///
1565 /// One predicate rather than the four hand-copied `.is_none()` lists
1566 /// this used to be, because those lists had already drifted three
1567 /// ways: the prefill sites checked all three conditions, the fused
1568 /// decode layer checked two of them, and the whole-stack decode
1569 /// checked none. Mirror `route_for_layer` arm for arm when either
1570 /// changes.
1571 // Read by the three Metal MoE eligibility predicates, and by
1572 // `the_gpu_router_predicate_admits_only_routing_it_reproduces`. A
1573 // CPU-only build has no Metal path to gate, so it is dead there.
1574 #[cfg_attr(not(feature = "metal"), allow(dead_code))]
1575 fn gpu_router_matches_host_routing(layer: &LayerWeights, config: &ModelConfig) -> bool {
1576 matches!(config.moe.gating, frink_moe::GatingFunction::Softmax)
1577 // Every GPU router reads `normed2`; a model whose router
1578 // reads the raw layer input (`crate::router_input`) would
1579 // route on the wrong tensor at full speed.
1580 && config.router_input == crate::router_input::RouterInput::NormedFfnInput
1581 // Conservative on purpose: `route_for_layer` only takes its
1582 // grouped arm for `n_groups > 1`, but a checkpoint that
1583 // declares the key at all is one this kernel was never
1584 // checked against.
1585 && config.moe.expert_group_count.is_none()
1586 && layer.moe.exp_probs_bias.is_none()
1587 && config.moe.expert_weights_scale == 1.0
1588 }
1589
1590 /// MoE layer eligible for resident Metal decode (attn+router+experts
1591 /// without host residual ping-pong). Requires SwiGLU, no shared
1592 /// experts, Resident expert backing, Metal router/QKV/O, and a
1593 /// routing decision the GPU router reproduces exactly.
1594 #[cfg(feature = "metal")]
1595 fn layer_supports_metal_moe_resident(
1596 layer: &LayerWeights,
1597 config: &ModelConfig,
1598 lora_attached: bool,
1599 ) -> bool {
1600 Self::metal_can_serve_model(config, lora_attached)
1601 && !Self::is_dense_layer(layer)
1602 && layer.moe.shared_experts.is_empty()
1603 && Self::gpu_router_matches_host_routing(layer, config)
1604 && config.model_ffn_act().is_some_and(GluAct::is_swiglu)
1605 // Streamed experts are eligible too. They were excluded
1606 // while the fused launch could not hold all of top-k at
1607 // once; it can now, by materialising each expert into an
1608 // owned view that carries its own pin on the store entry.
1609 && matches!(
1610 layer.moe.experts,
1611 ExpertBacking::Resident(_) | ExpertBacking::Stored { .. }
1612 )
1613 && Self::metal_matvec_launch(&layer.moe.router).is_some()
1614 && Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
1615 && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
1616 && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
1617 && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
1618 }
1619
1620 /// One Metal CB for all top-k routed experts (weighted sum). Returns
1621 /// `None` if any expert lacks a Metal launch (caller falls back).
1622 #[cfg(feature = "metal")]
1623 fn try_metal_moe_topk(
1624 layer: &LayerWeights,
1625 normed2: &[f32],
1626 decision: &frink_moe::RoutingDecision,
1627 ) -> Option<Vec<f32>> {
1628 if decision.expert_ids.is_empty() {
1629 return Some(vec![0f32; normed2.len()]);
1630 }
1631 // Build launches while holding each expert briefly; collect owned
1632 // weight refs via with_expert into temporary MatvecLaunch list.
1633 let mut launches: Vec<frink_metal::gpu::MoeExpertLaunch<'_>> =
1634 Vec::with_capacity(decision.expert_ids.len());
1635 // Lifetime: MatvecLaunch borrows WeightMatrix bytes that live in
1636 // layer.moe for the duration of this call. Collect via a scoped
1637 // approach — we need all launches alive together.
1638 // Use indices + rebuild inside a single with_experts loop.
1639 struct Pending {
1640 eid: usize,
1641 weight: f32,
1642 }
1643 let pending: Vec<Pending> = decision
1644 .expert_ids
1645 .iter()
1646 .zip(decision.weights.iter())
1647 .map(|(&eid, &w)| Pending { eid, weight: w })
1648 .collect();
1649
1650 // Validate all experts have Metal launches first.
1651 for p in &pending {
1652 let ok = layer.moe.with_expert(p.eid, |ex| {
1653 Self::metal_matvec_launch(&ex.gate).is_some()
1654 && Self::metal_matvec_launch(&ex.up).is_some()
1655 && Self::metal_matvec_launch(&ex.down).is_some()
1656 });
1657 if !ok {
1658 return None;
1659 }
1660 }
1661
1662 // A `MatvecLaunch` borrows the expert's bytes, so every expert
1663 // in the batch has to stay alive until the command buffer is
1664 // encoded. Resident experts live in a `Vec` and can simply be
1665 // indexed. Streamed experts used to fall back to the CPU here,
1666 // on the reasoning that `with_expert` lends one at a time so
1667 // all of top-k could not be held at once.
1668 //
1669 // That was true of `with_expert` and not of the store beneath
1670 // it: `StoredExpertLayout::materialize` returns an OWNED
1671 // `ExpertWeights` whose `WeightBytes::Shared` clones the
1672 // lease's `Arc`, so each one carries its own pin and the store
1673 // cannot evict it while the view is alive. Materialising all of
1674 // top-k into a vector that outlives the launches is therefore
1675 // sound, and the vector is what holds the pins.
1676 //
1677 // The fallback was not a small loss. Expert streaming is how a
1678 // model larger than memory runs at all, so refusing the fused
1679 // path here meant that turning streaming on silently disabled
1680 // the Metal MoE kernels: exactly the configuration where the
1681 // GPU matters most ran on the CPU instead.
1682 let streamed: Vec<ExpertWeights>;
1683 match &layer.moe.experts {
1684 ExpertBacking::Resident(experts) => {
1685 for p in &pending {
1686 let ex = &experts[p.eid];
1687 launches.push(frink_metal::gpu::MoeExpertLaunch {
1688 gate: Self::metal_matvec_launch(&ex.gate)?,
1689 up: Self::metal_matvec_launch(&ex.up)?,
1690 down: Self::metal_matvec_launch(&ex.down)?,
1691 weight: p.weight,
1692 });
1693 }
1694 }
1695 ExpertBacking::Stored {
1696 store,
1697 layouts,
1698 layer: layer_idx,
1699 } => {
1700 // Ask for the whole batch before touching any of it, so
1701 // a miss on the last expert cannot evict the first: the
1702 // store is bounded, and top-k reads are what compete
1703 // for it.
1704 let keys: Vec<_> = pending
1705 .iter()
1706 .map(|p| frink_core::expert_store::ExpertKey {
1707 layer: *layer_idx,
1708 expert: p.eid as u32,
1709 })
1710 .collect();
1711 store.prefetch(&keys);
1712
1713 let mut held = Vec::with_capacity(pending.len());
1714 for (p, key) in pending.iter().zip(keys) {
1715 // A read failure here is not fatal: the CPU path
1716 // reads the same bytes and will report it. Falling
1717 // back beats panicking mid-decode.
1718 let lease = store.acquire(key).ok()?;
1719 held.push(layouts[p.eid].materialize(&lease));
1720 }
1721 streamed = held;
1722
1723 for (p, ex) in pending.iter().zip(streamed.iter()) {
1724 launches.push(frink_metal::gpu::MoeExpertLaunch {
1725 gate: Self::metal_matvec_launch(&ex.gate)?,
1726 up: Self::metal_matvec_launch(&ex.up)?,
1727 down: Self::metal_matvec_launch(&ex.down)?,
1728 weight: p.weight,
1729 });
1730 }
1731 }
1732 }
1733
1734 match frink_metal::gpu::launch_moe_topk_swiglu(normed2, &launches) {
1735 Ok(out) => Some(out),
1736 Err(e) => {
1737 eprintln!("frink: Metal MoE top-k fuse failed, falling back: {e}");
1738 None
1739 }
1740 }
1741 }
1742
1743 /// Contiguous Q4_0 expert planes for llama-style `mul_mv_id` MoE.
1744 #[cfg(feature = "metal")]
1745 fn moe_packed_q4(moe: &MoeWeights) -> Option<frink_metal::gpu::MoePackedQ4<'_>> {
1746 moe.packed_q4.as_ref().map(MoePackedQ4Planes::view)
1747 }
1748
1749 /// Prefill MoE FFN on Metal: host route over T, then one packed-id CB
1750 /// (`launch_moe_prefill_q4_0`). Shared experts (if any) run as dense
1751 /// batch FFN on the host/GPU path afterwards — not through `mul_mm_id`.
1752 /// Returns FFN outs `[T, H]` or `None`.
1753 #[cfg(feature = "metal")]
1754 fn try_metal_moe_prefill_batch(
1755 layer_idx: usize,
1756 layer: &LayerWeights,
1757 normed2_batch: &[f32],
1758 router_logits_batch: &[f32],
1759 batch_size: usize,
1760 hidden_dim: usize,
1761 config: &ModelConfig,
1762 ) -> Option<Vec<f32>> {
1763 let acts = config.layer_ffn_acts(layer_idx);
1764 if batch_size == 0
1765 || !frink_core::metal_dense_enabled()
1766 || !acts.routed.is_swiglu()
1767 // The GPU router kernels take router weights and nothing
1768 // else: no `exp_probs_b` input, no `expert_weights_scale`
1769 // uniform, no groups. See `gpu_router_matches_host_routing`.
1770 || !Self::gpu_router_matches_host_routing(layer, config)
1771 {
1772 return None;
1773 }
1774 let ExpertBacking::Resident(_) = &layer.moe.experts else {
1775 return None;
1776 };
1777 let packed = Self::moe_packed_q4(&layer.moe)?;
1778 if !frink_metal::gpu::moe_packed_mul_mv_id_supported(
1779 packed.gate_kind,
1780 packed.up_kind,
1781 packed.down_kind,
1782 ) {
1783 return None;
1784 }
1785 let top_k = config.moe.n_experts_active;
1786 if top_k == 0 || top_k > 8 || packed.hidden_rows != hidden_dim {
1787 return None;
1788 }
1789 let n_experts = layer.moe.n_experts().max(1);
1790 let mut ids = Vec::with_capacity(batch_size * top_k);
1791 let mut route = Vec::with_capacity(batch_size * top_k);
1792 for b in 0..batch_size {
1793 let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
1794 let decision = route_top_k(logits, top_k, config.moe.gating, config.moe.norm_topk_prob);
1795 layer.moe.record_activations(&decision.expert_ids);
1796 if decision.expert_ids.len() != top_k {
1797 return None;
1798 }
1799 for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
1800 ids.push(eid as i32);
1801 route.push(w);
1802 }
1803 }
1804 let mut out = match frink_metal::gpu::launch_moe_prefill_q4_0(
1805 normed2_batch,
1806 batch_size,
1807 &packed,
1808 &ids,
1809 &route,
1810 top_k,
1811 ) {
1812 Ok(out) => out,
1813 Err(e) => {
1814 eprintln!("frink: Metal MoE prefill failed, CPU fallback: {e}");
1815 return None;
1816 }
1817 };
1818 Self::accumulate_shared_experts_batch(
1819 layer,
1820 normed2_batch,
1821 batch_size,
1822 hidden_dim,
1823 &mut out,
1824 // The shared experts are `build_ffn`'s site, not the routed
1825 // experts' the fence above checked; on the host either way.
1826 acts.dense,
1827 );
1828 Some(out)
1829 }
1830
1831 /// Shared expert as dense batch FFN (llama qwen2moe: not through
1832 /// `mul_mat_id`). Optional sigmoid gate scales per token.
1833 fn accumulate_shared_experts_batch(
1834 layer: &LayerWeights,
1835 normed2_batch: &[f32],
1836 batch_size: usize,
1837 hidden_dim: usize,
1838 acc: &mut [f32],
1839 act: GluAct,
1840 ) {
1841 for shex in &layer.moe.shared_experts {
1842 // Prefer one Metal FFN CB (gate∥up→SiLU→down) over three
1843 // `apply_batch` round-trips — Qwen shexp is 4× routed width.
1844 #[cfg(feature = "metal")]
1845 let down = if frink_core::metal_dense_enabled() && batch_size >= 4 {
1846 match (
1847 shex.gate.mul_mm_sg_launch(),
1848 shex.up.mul_mm_sg_launch(),
1849 shex.down.mul_mm_sg_launch(),
1850 // The launch's last argument selects GELU over
1851 // SiLU inside the kernel; hardcoding `false` here
1852 // ran a shared expert as SwiGLU on a GeGLU model,
1853 // and `!is_swiglu()` would run a third activation
1854 // as GELU. `None` keeps the host path.
1855 act.fused_kernel_gelu_flag(),
1856 ) {
1857 (Some(g), Some(u), Some(d), Some(gelu)) => {
1858 frink_metal::gpu::launch_dense_ffn_swiglu_batch(
1859 &g,
1860 &u,
1861 &d,
1862 normed2_batch,
1863 batch_size,
1864 gelu,
1865 )
1866 .ok()
1867 }
1868 _ => None,
1869 }
1870 } else {
1871 None
1872 };
1873 #[cfg(not(feature = "metal"))]
1874 let down: Option<Vec<f32>> = None;
1875 // Without `metal` the binding above is a literal `None`; the
1876 // fallback is the only arm and clippy flags the unwrap.
1877 #[cfg_attr(not(feature = "metal"), allow(clippy::unnecessary_literal_unwrap))]
1878 let down = down.unwrap_or_else(|| {
1879 let ffn_acts = shex.gate.quantize_batch_acts(normed2_batch, batch_size);
1880 // One rotation for the pair when they share a fold
1881 // (`apply_batch_pair_with_acts`); two independent calls
1882 // otherwise, which is every unfolded model.
1883 let (gate, up) = WeightMatrix::apply_batch_pair_with_acts(
1884 &shex.gate,
1885 &shex.up,
1886 normed2_batch,
1887 batch_size,
1888 ffn_acts.as_ref(),
1889 );
1890 let activated = act.apply(&gate, &up);
1891 shex.down.apply_batch(&activated, batch_size)
1892 });
1893 if let Some(gate_w) = &layer.moe.shared_expert_gate {
1894 for b in 0..batch_size {
1895 let x = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
1896 let logit: f32 = gate_w.iter().zip(x.iter()).map(|(g, v)| g * v).sum();
1897 let scale = 1.0 / (1.0 + (-logit).exp());
1898 let out = &down[b * hidden_dim..(b + 1) * hidden_dim];
1899 let row = &mut acc[b * hidden_dim..(b + 1) * hidden_dim];
1900 for (a, &o) in row.iter_mut().zip(out.iter()) {
1901 *a += scale * o;
1902 }
1903 }
1904 } else {
1905 for (a, &o) in acc.iter_mut().zip(down.iter()) {
1906 *a += o;
1907 }
1908 }
1909 }
1910 }
1911
1912 /// Phase-2 of resident MoE decode: experts on GPU `x2`, add into GPU `h`.
1913 #[cfg(feature = "metal")]
1914 fn try_metal_moe_experts_resident(
1915 layer: &LayerWeights,
1916 decision: &frink_moe::RoutingDecision,
1917 ) -> Option<()> {
1918 if decision.expert_ids.is_empty() {
1919 return Some(());
1920 }
1921 let pending: Vec<(usize, f32)> = decision
1922 .expert_ids
1923 .iter()
1924 .zip(decision.weights.iter())
1925 .map(|(&eid, &w)| (eid, w))
1926 .collect();
1927 // Bail on the backing BEFORE validating the experts. The
1928 // validation loop below calls `with_expert`, which for
1929 // `Stored` backing acquires a lease and so can read from the
1930 // checkpoint file. Refusing afterwards meant a streamed layer
1931 // paid one read per top-k expert and then threw all of them
1932 // away, before `try_metal_moe_topk` read the same experts
1933 // again. This path stays resident-only for now, but it must
1934 // decline for free.
1935 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1936 return None;
1937 };
1938 for &(eid, _) in &pending {
1939 let ex = &experts[eid];
1940 if Self::metal_matvec_launch(&ex.gate).is_none()
1941 || Self::metal_matvec_launch(&ex.up).is_none()
1942 || Self::metal_matvec_launch(&ex.down).is_none()
1943 {
1944 return None;
1945 }
1946 }
1947 let mut launches = Vec::with_capacity(pending.len());
1948 for &(eid, weight) in &pending {
1949 let ex = &experts[eid];
1950 launches.push(frink_metal::gpu::MoeExpertLaunch {
1951 gate: Self::metal_matvec_launch(&ex.gate)?,
1952 up: Self::metal_matvec_launch(&ex.up)?,
1953 down: Self::metal_matvec_launch(&ex.down)?,
1954 weight,
1955 });
1956 }
1957 match frink_metal::attn::launch_moe_decode_experts(&launches) {
1958 Ok(()) => Some(()),
1959 Err(e) => {
1960 eprintln!("frink: Metal MoE experts failed, falling back: {e}");
1961 None
1962 }
1963 }
1964 }
1965
1966 /// Advance the host [`KvCache`] over the positions a Metal prefill
1967 /// kernel just wrote to the device.
1968 ///
1969 /// Two ways to do that, and which one is right depends on whether
1970 /// anyone will READ the host rows.
1971 ///
1972 /// The contiguous path never does: Metal stays authoritative from
1973 /// prefill through decode, so [`KvCache::advance_len`]'s zero fill
1974 /// is a placeholder that only has to keep `seq_len` in step for the
1975 /// sync checks, and skipping the download is the whole point.
1976 ///
1977 /// The PAGED path does. `forward_batch_last_paged` scatters these
1978 /// rows into the page store, and a caller that reads placeholders
1979 /// gets a prompt the model never saw -- which is exactly how paged
1980 /// KV on Metal came to answer fluent nonsense while paged-on-CPU
1981 /// and contiguous-on-Metal were each correct. So it asks for the
1982 /// real rows and pays one download per layer, against a gather and
1983 /// a scatter it was already paying.
1984 ///
1985 /// Done here, per layer, immediately after the launch, rather than
1986 /// once at the end: the Metal KV buffers are dropped outright when
1987 /// a later layer's launch fails, and rows nobody downloaded before
1988 /// that are simply gone.
1989 #[cfg(feature = "metal")]
1990 fn advance_host_kv_after_metal_prefill(
1991 mkv: &frink_metal::attn::MetalKvBuffers,
1992 cache: &mut KvCache,
1993 batch_size: usize,
1994 host_kv_authoritative: bool,
1995 ) {
1996 if host_kv_authoritative {
1997 Self::catch_up_host_kv_from_metal(mkv, cache);
1998 debug_assert_eq!(cache.positions(), mkv.seq_len);
1999 } else {
2000 cache
2001 .advance_len(batch_size)
2002 .expect("unbounded/planned KvCache growth is infallible");
2003 }
2004 }
2005
2006 /// Append host [`KvCache`] positions that Metal already holds but host
2007 /// skipped (dense-stack fast path). No-op when `cache.seq_len` is caught up.
2008 #[cfg(feature = "metal")]
2009 fn catch_up_host_kv_from_metal(mkv: &frink_metal::attn::MetalKvBuffers, cache: &mut KvCache) {
2010 // ROWS on both sides: this fills the host buffer with rows
2011 // Metal already holds, and `push` below advances positions with
2012 // them. Neither store evicts, so the two agree; when one learns
2013 // to (#61) this is a place that has to say which it meant.
2014 if cache.rows() >= mkv.seq_len {
2015 return;
2016 }
2017 let start = cache.rows();
2018 let n = mkv.seq_len - start;
2019 let (k, v) = mkv.tokens_host(start, n);
2020 let per = cache.n_kv_heads * cache.head_dim;
2021 for i in 0..n {
2022 let off = i * per;
2023 cache
2024 .push(&k[off..off + per], &v[off..off + per])
2025 .expect("unbounded/planned KvCache growth is infallible");
2026 }
2027 }
2028
2029 /// Pull every layer's Metal-ahead suffix into `kv_caches` (prefix-cache
2030 /// Poison-tolerant lock for the shared Metal KV arena. A panicked
2031 /// holder must not permanently brick every later decode.
2032 #[cfg(feature = "metal")]
2033 fn lock_metal_attn_kv(
2034 mutex: &std::sync::Mutex<Option<Vec<frink_metal::attn::MetalKvBuffers>>>,
2035 ) -> std::sync::MutexGuard<'_, Option<Vec<frink_metal::attn::MetalKvBuffers>>> {
2036 mutex
2037 .lock()
2038 .unwrap_or_else(|poisoned| poisoned.into_inner())
2039 }
2040
2041 /// True once a fused Metal attention launch has allocated this
2042 /// decoder's per-layer Metal KV, i.e. once a token has actually been
2043 /// served by a fused launch rather than by the host bodies.
2044 ///
2045 /// For tests that switch Metal ON and need to know whether the
2046 /// switch reached a kernel: a model the eligibility predicates keep
2047 /// on the host answers `false` forever, and a test asserting logits
2048 /// alone could not tell that from a launch that happened to agree.
2049 #[cfg(feature = "metal")]
2050 pub fn metal_attn_kv_allocated(&self) -> bool {
2051 Self::lock_metal_attn_kv(&self.metal_attn_kv).is_some()
2052 }
2053
2054 /// store, continuous-batch / CPU readers). Safe no-op without Metal KV.
2055 #[cfg(feature = "metal")]
2056 pub fn sync_metal_attn_kv_to_host(&self, kv_caches: &mut [KvCache]) {
2057 assert_eq!(kv_caches.len(), self.config.n_layers);
2058 let guard = Self::lock_metal_attn_kv(&self.metal_attn_kv);
2059 let Some(metal_kvs) = guard.as_ref() else {
2060 return;
2061 };
2062 if metal_kvs.len() != kv_caches.len() {
2063 return;
2064 }
2065 for (mkv, cache) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
2066 Self::catch_up_host_kv_from_metal(mkv, cache);
2067 }
2068 }
2069
2070 /// GQA decode reduction for one token. Uses the CUDA `gqa_decode`
2071 /// kernel when built with `--features cuda` and `FRINK_CUDA_GQA=1`
2072 /// (falling back to the host path on any launch error), else the
2073 /// portable [`causal_gqa_attention`]. With residency enabled the
2074 /// K/V append stays in [`frink_cuda::attn::CudaKvBuffers`] so only
2075 /// Q crosses the bus per call (plus a prefix refresh on append).
2076 #[allow(clippy::too_many_arguments)]
2077 fn gqa_attention(
2078 &self,
2079 layer: usize,
2080 q: &[f32],
2081 k: &[f32],
2082 v: &[f32],
2083 n_heads: usize,
2084 n_kv_heads: usize,
2085 head_dim: usize,
2086 seq_len: usize,
2087 ) -> Vec<f32> {
2088 #[cfg(feature = "cuda")]
2089 {
2090 if cuda_gqa_enabled()
2091 && self.config.layer_shapes.is_uniform()
2092 && self.alibi_slopes.is_none()
2093 {
2094 match frink_cuda::attn::launch_gqa_decode_resident(
2095 layer, q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
2096 ) {
2097 Ok(out) => return out,
2098 Err(e) => {
2099 eprintln!(
2100 "frink: CUDA GQA resident decode failed, trying full upload: {e}"
2101 );
2102 }
2103 }
2104 match frink_cuda::attn::launch_gqa_decode(
2105 q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
2106 ) {
2107 Ok(out) => return out,
2108 Err(e) => {
2109 eprintln!("frink: CUDA GQA decode failed, host fallback: {e}");
2110 }
2111 }
2112 }
2113 }
2114 let _ = layer;
2115 causal_gqa_attention_softcap(
2116 q,
2117 k,
2118 v,
2119 n_heads,
2120 n_kv_heads,
2121 head_dim,
2122 seq_len,
2123 self.config.attn_logit_softcap,
2124 )
2125 }
2126
2127 /// The body of [`Self::forward_token`], already running on a
2128 /// CPU-pool worker. See `entry.rs` for why the split exists.
2129 fn forward_token_on_worker(
2130 &self,
2131 token_id: usize,
2132 pos: usize,
2133 kv_caches: &mut [KvCache],
2134 ) -> Vec<f32> {
2135 // Clear stale dense-stack activation TLS. MoE scratch buffers are
2136 // reused across tokens (re-seeded); cleared after lm_head below.
2137 #[cfg(feature = "metal")]
2138 frink_metal::gpu::clear_resident_activation();
2139
2140 assert_eq!(kv_caches.len(), self.config.n_layers);
2141 // Read only by the Metal arms below: the host layer body moved
2142 // into `attn_block` / `ffn_block_row`, which read the geometry
2143 // off `self.config` themselves.
2144 #[cfg(feature = "metal")]
2145 let hidden_dim = self.config.hidden_dim;
2146 #[cfg(feature = "metal")]
2147 let head_dim = self.config.head_dim;
2148 #[cfg(feature = "metal")]
2149 let n_heads = self.config.n_heads;
2150 #[cfg(feature = "metal")]
2151 let n_kv_heads = self.config.n_kv_heads;
2152
2153 #[cfg(feature = "metal")]
2154 let metal_embd_kind = {
2155 let metal_path = frink_core::metal_dense_enabled()
2156 && frink_metal::attn::metal_attn_enabled()
2157 && self
2158 .layers
2159 .iter()
2160 .all(|l| self.layer_supports_metal_attn(l))
2161 && self.layers.iter().all(Self::layer_supports_metal_dense_ffn);
2162 // Gemma scales the embedding row (`embedding_scale`) — the GPU
2163 // gather has no scale op, so dequant + scale on the host; nor
2164 // has it a norm (`embedding_norm`) or a position table (the
2165 // latter already fenced through `metal_can_serve_model`).
2166 if metal_path
2167 && self.config.embedding_scale.is_none()
2168 && matches!(self.embedding_norm, NormOp::None)
2169 {
2170 Self::metal_matvec_launch(&self.embedding)
2171 .and_then(|l| frink_metal::embd::EmbdKind::from_fn_name(l.fn_name))
2172 } else {
2173 None
2174 }
2175 };
2176 // `metal_embd_kind` is only `Some` when `embedding_scale` is
2177 // `None` (the GPU gather has no scale op), so the empty vector
2178 // this leaves behind is one the scale would not have touched.
2179 #[cfg(feature = "metal")]
2180 let mut hidden = if metal_embd_kind.is_some() {
2181 Vec::new()
2182 } else {
2183 self.embed_token(token_id, pos)
2184 };
2185 #[cfg(not(feature = "metal"))]
2186 let mut hidden = self.embed_token(token_id, pos);
2187 #[cfg(feature = "cuda")]
2188 if cuda_gqa_enabled()
2189 && self.config.layer_shapes.is_uniform()
2190 && self.alibi_slopes.is_none()
2191 {
2192 // Fixed capacity so ensure_layer_kv does not recreate (and
2193 // wipe) mid-sequence as pos grows. ONE geometry for every
2194 // layer, which is why a per-layer-shape model never seeds it
2195 // (`gqa_attention` skips the resident hook on the same
2196 // predicate).
2197 const CUDA_KV_CAP: usize = 4096;
2198 if let Err(e) = frink_cuda::attn::ensure_layer_kv(
2199 self.layers.len(),
2200 self.config.n_kv_heads,
2201 self.config.head_dim,
2202 CUDA_KV_CAP,
2203 ) {
2204 eprintln!("frink: CUDA KV residency init failed: {e}");
2205 }
2206 if pos == 0 {
2207 frink_cuda::attn::clear_layer_kv();
2208 }
2209 }
2210
2211 #[cfg(feature = "metal")]
2212 let use_metal_attn = frink_core::metal_dense_enabled()
2213 && frink_metal::attn::metal_attn_enabled()
2214 && self
2215 .layers
2216 .iter()
2217 .all(|l| self.layer_supports_metal_attn(l));
2218
2219 #[cfg(not(feature = "metal"))]
2220 let use_metal_attn = false;
2221
2222 let residency = self.expert_residency_plan(use_metal_attn);
2223
2224 #[cfg(feature = "metal")]
2225 let mut metal_kv_guard: Option<
2226 std::sync::MutexGuard<'_, Option<Vec<frink_metal::attn::MetalKvBuffers>>>,
2227 > = if use_metal_attn {
2228 Some(Self::lock_metal_attn_kv(&self.metal_attn_kv))
2229 } else {
2230 None
2231 };
2232
2233 #[cfg(feature = "metal")]
2234 if let Some(guard) = metal_kv_guard.as_mut() {
2235 let need = self.layers.len();
2236 let cap = kv_caches
2237 .iter()
2238 // POSITIONS: sized against `pos`, which is a position.
2239 .map(|c| c.positions().max(pos + 1).saturating_add(256))
2240 .max()
2241 .unwrap_or(512)
2242 .max(512)
2243 .max(pos + 1);
2244 let reset = match guard.as_ref() {
2245 None => true,
2246 Some(v) => {
2247 if v.len() != need || v.iter().any(|m| m.capacity() < pos + 1) {
2248 // Growing / reshaping: preserve Metal-ahead tokens on host first.
2249 if v.len() == need {
2250 for (m, c) in v.iter().zip(kv_caches.iter_mut()) {
2251 Self::catch_up_host_kv_from_metal(m, c);
2252 }
2253 }
2254 true
2255 } else if v.iter().all(|m| m.seq_len == pos) {
2256 // Metal already holds tokens [0, pos). Host may lag
2257 // after dense-stack decode — do not re-upload from host.
2258 false
2259 } else {
2260 // Stale Metal (new request / prefix restore): rebuild from host.
2261 true
2262 }
2263 }
2264 };
2265 if reset {
2266 let mut bufs = Vec::with_capacity(need);
2267 for _ in 0..need {
2268 match frink_metal::attn::MetalKvBuffers::with_capacity(
2269 n_kv_heads, head_dim, cap,
2270 ) {
2271 Ok(b) => bufs.push(b),
2272 Err(_) => {
2273 **guard = None;
2274 break;
2275 }
2276 }
2277 }
2278 if bufs.len() == need {
2279 // Sync from host after CPU prefill / prefix restore / capacity grow.
2280 let mut ok = true;
2281 for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
2282 // ROWS: this uploads `c.k` / `c.v` themselves, so the
2283 // count must describe those buffers.
2284 if c.rows() > 0 && m.upload_from_host(&c.k, &c.v, c.rows()).is_err() {
2285 ok = false;
2286 break;
2287 }
2288 }
2289 if ok {
2290 **guard = Some(bufs);
2291 } else {
2292 **guard = None;
2293 }
2294 } else {
2295 **guard = None;
2296 }
2297 }
2298 }
2299
2300 #[cfg(feature = "metal")]
2301 let mut metal_stack_done = false;
2302 #[cfg(feature = "metal")]
2303 let mut final_norm_done_in_stack = false;
2304 // OLMoE: all MoE layers in one CB (llama graph style).
2305 #[cfg(feature = "metal")]
2306 if use_metal_attn
2307 && self.layers.iter().enumerate().all(|(i, l)| {
2308 // Both halves are required. `layer_supports_metal_moe_resident`
2309 // answers "is this an MoE layer the GPU router can serve",
2310 // and says nothing about the four features
2311 // `MoeLayerMetal` has no fields for: a per-layer
2312 // `rope_theta`, a sliding `window`, `post_attn_norm`
2313 // and `post_ffn_norm`. `DenseLayerMetal` carries all
2314 // four and `launch_decode_dense_stack` implements
2315 // them; the MoE stack does neither, and nothing here
2316 // refused, so a windowed or sandwich-normed MoE
2317 // checkpoint would have answered as a different
2318 // model with no error.
2319 //
2320 // The per-layer path already pairs these two checks
2321 // (see the `metal_moe_resident` branch in the decode
2322 // loop). Only the whole-stack path was missing it.
2323 // Latent today because OLMoE and Qwen3-MoE ship none
2324 // of the four, which is exactly how `attention_scale`
2325 // stayed latent.
2326 Self::layer_supports_metal_moe_resident(l, &self.config, self.lora_attached())
2327 && !self.layer_needs_metal_stack(l, i)
2328 })
2329 && !self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
2330 {
2331 if let Some(guard) = metal_kv_guard.as_mut() {
2332 if let Some(metal_kvs) = guard.as_mut() {
2333 if metal_kvs.iter().all(|m| m.seq_len == pos) {
2334 let mut moe_layers = Vec::with_capacity(self.layers.len());
2335 let mut ok = true;
2336 for layer in &self.layers {
2337 let ExpertBacking::Resident(_) = &layer.moe.experts else {
2338 ok = false;
2339 break;
2340 };
2341 let Some(packed) = Self::moe_packed_q4(&layer.moe) else {
2342 ok = false;
2343 break;
2344 };
2345 let (Some(q), Some(k), Some(v), Some(o), Some(r), Some(an), Some(fnw)) = (
2346 Self::metal_matvec_launch(&layer.attn.q_proj),
2347 Self::metal_matvec_launch(&layer.attn.k_proj),
2348 Self::metal_matvec_launch(&layer.attn.v_proj),
2349 Self::metal_matvec_launch(&layer.attn.o_proj),
2350 Self::metal_matvec_launch(&layer.moe.router),
2351 // The kernel bakes both RMSNorms in; the
2352 // post-norm-only topology has neither.
2353 layer.attn.norm_weight.rms_weights(),
2354 layer.moe.norm_weight.rms_weights(),
2355 ) else {
2356 ok = false;
2357 break;
2358 };
2359 moe_layers.push(frink_metal::attn::MoeLayerMetal {
2360 attn_norm_w: an,
2361 ffn_norm_w: fnw,
2362 q,
2363 k,
2364 v,
2365 o,
2366 router: r,
2367 packed,
2368 extras: self.metal_attn_extras(layer),
2369 });
2370 }
2371 if ok {
2372 // Greedy: fold lm_head+argmax into the stack like the
2373 // dense path does, and download one u32 instead of a
2374 // hidden vector.
2375 let greedy_gpu = frink_metal::attn::metal_greedy_argmax_active();
2376 let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
2377 // One value carries both "lm_head runs in the
2378 // stack" and "the stack returns an argmax id",
2379 // so the second cannot drift off the first.
2380 // See `decoder::lm_head`.
2381 let folded = FoldedLmHead::permit(
2382 greedy_gpu,
2383 &self.final_norm,
2384 self.output_bias.as_deref(),
2385 lm_head_gpu_launch,
2386 );
2387 // The MoE stack used to be handed
2388 // `Some(&self.final_norm)` unconditionally,
2389 // which is unrepresentable now: a model whose
2390 // final norm has no RMS weights leaves the
2391 // norm to the host body below, and
2392 // `final_norm_done_in_stack` is read off the
2393 // SAME value rather than hardcoded `true`.
2394 let final_norm_w = self.final_norm.rms_weights();
2395 let embd_launch = Self::metal_matvec_launch(&self.embedding);
2396 // Gemma scales embd on host; GPU gather has no scale.
2397 let embd_gather = if self.config.embedding_scale.is_some() {
2398 None
2399 } else {
2400 match (metal_embd_kind, embd_launch.as_ref()) {
2401 (Some(kind), Some(launch)) => {
2402 Some(frink_metal::attn::EmbdGatherMetal {
2403 kind,
2404 weights: launch.weights,
2405 rows: launch.rows,
2406 row_bytes: launch.row_bytes,
2407 n_cols: hidden_dim,
2408 token_id,
2409 })
2410 }
2411 _ => None,
2412 }
2413 };
2414 if embd_gather.is_none() && hidden.is_empty() {
2415 hidden = self.embedding.dequant_row(token_id);
2416 if let Some(scale) = self.config.embedding_scale {
2417 for v in hidden.iter_mut() {
2418 *v *= scale;
2419 }
2420 }
2421 }
2422 let seed = if embd_gather.is_some() {
2423 frink_metal::attn::moe_decode_ensure(hidden_dim)
2424 } else {
2425 frink_metal::attn::moe_decode_seed(&hidden)
2426 };
2427 let hidden_ref: &[f32] =
2428 if embd_gather.is_some() { &[] } else { &hidden };
2429 match seed.and_then(|_| {
2430 // A stack whose layer 0 does not rotate
2431 // cannot ride this launch; refuse rather
2432 // than rotate, and the CPU body serves
2433 // the token.
2434 let stack_rope = self
2435 .metal_layer_rope(0)
2436 .ok_or(frink_metal::gpu::MetalError::CommandFailed)?;
2437 frink_metal::attn::launch_moe_decode_stack(
2438 hidden_ref,
2439 &moe_layers,
2440 metal_kvs,
2441 self.config.moe.n_experts_active,
2442 self.config.moe.norm_topk_prob,
2443 n_heads,
2444 self.metal_rope(),
2445 // ONE `LayerRope` for the whole
2446 // stack, sound because no layer here
2447 // `layer_needs_metal_stack`: none
2448 // slides, none has a base of its
2449 // own, and none is unrotated -- that
2450 // predicate now covers all three
2451 // through the same accessor, so the
2452 // stack-wide answer and the
2453 // per-layer one cannot disagree.
2454 stack_rope,
2455 pos,
2456 self.config.rms_norm_eps,
2457 final_norm_w,
2458 folded.as_ref().map(FoldedLmHead::launch),
2459 folded.as_ref().is_some_and(FoldedLmHead::argmax_only),
2460 true,
2461 embd_gather.as_ref(),
2462 )
2463 }) {
2464 Ok((out, per_layer_ids)) => {
2465 for (layer, ids) in self.layers.iter().zip(per_layer_ids.iter())
2466 {
2467 if !ids.is_empty() {
2468 layer.moe.record_activations(ids);
2469 }
2470 }
2471 if let Some(folded) = folded.as_ref() {
2472 #[cfg(feature = "metal")]
2473 frink_metal::gpu::clear_resident_activation();
2474 // Softcaps anything vocabulary-shaped;
2475 // passes a 1-element argmax id through.
2476 return folded.interpret(
2477 out,
2478 self.output_head.rows(),
2479 self.config.final_logit_softcap,
2480 self.config.logit_multiplier,
2481 );
2482 }
2483 hidden = out;
2484 final_norm_done_in_stack = final_norm_w.is_some();
2485 metal_stack_done = true;
2486 }
2487 Err(e) => {
2488 eprintln!(
2489 "frink: Metal MoE stack failed, per-layer fallback: {e}"
2490 );
2491 if hidden.is_empty() {
2492 hidden = self.embedding.dequant_row(token_id);
2493 if let Some(scale) = self.config.embedding_scale {
2494 for v in hidden.iter_mut() {
2495 *v *= scale;
2496 }
2497 }
2498 }
2499 }
2500 }
2501 }
2502 }
2503 }
2504 }
2505 }
2506 #[cfg(feature = "metal")]
2507 if !metal_stack_done
2508 && use_metal_attn
2509 && self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
2510 {
2511 if let Some(guard) = metal_kv_guard.as_mut() {
2512 let mut clear_metal_after_stack = false;
2513 if let Some(metal_kvs) = guard.as_mut() {
2514 let seq_ok = metal_kvs.iter().all(|m| m.seq_len == pos);
2515 if seq_ok {
2516 // Build launches only for resident dense experts (Llama path).
2517 let mut dense_layers = Vec::with_capacity(self.layers.len());
2518 // The stack's activation uniform, or no stack
2519 // at all for an activation it cannot spell.
2520 let stack_gelu = self
2521 .config
2522 .model_ffn_act()
2523 .and_then(GluAct::fused_kernel_gelu_flag);
2524 let mut ok = stack_gelu.is_some();
2525 for (li, layer) in self.layers.iter().enumerate() {
2526 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
2527 ok = false;
2528 break;
2529 };
2530 let ex = &experts[0];
2531 let (
2532 Some(q),
2533 Some(k),
2534 Some(v),
2535 Some(o),
2536 Some(g),
2537 Some(u),
2538 Some(d),
2539 Some(an),
2540 Some(fnw),
2541 ) = (
2542 Self::metal_matvec_launch(&layer.attn.q_proj),
2543 Self::metal_matvec_launch(&layer.attn.k_proj),
2544 Self::metal_matvec_launch(&layer.attn.v_proj),
2545 Self::metal_matvec_launch(&layer.attn.o_proj),
2546 Self::metal_matvec_launch(&ex.gate),
2547 Self::metal_matvec_launch(&ex.up),
2548 Self::metal_matvec_launch(&ex.down),
2549 // The kernel bakes both RMSNorms in; the
2550 // post-norm-only topology has neither.
2551 layer.attn.norm_weight.rms_weights(),
2552 layer.moe.norm_weight.rms_weights(),
2553 )
2554 else {
2555 ok = false;
2556 break;
2557 };
2558 dense_layers.push(frink_metal::attn::DenseLayerMetal {
2559 attn_norm_w: an,
2560 ffn_norm_w: fnw,
2561 q,
2562 k,
2563 v,
2564 o,
2565 gate: g,
2566 up: u,
2567 down: d,
2568 extras: self.metal_attn_extras(layer),
2569 rope: self.metal_layer_rope(li),
2570 window: self.config.layer_sliding_window(li),
2571 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
2572 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
2573 });
2574 }
2575 if ok {
2576 // Greedy GPU argmax-in-stack (1×u32 download) when
2577 // generate marked this thread for temperature<=0.
2578 // Otherwise host lm_head after the hidden download,
2579 // which measured ~2x the tok/s of a full-vocab one.
2580 let greedy_gpu = frink_metal::attn::metal_greedy_argmax_active();
2581 let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
2582 // See `decoder::lm_head`: folding lm_head into
2583 // the stack and the stack returning an argmax id
2584 // are one decision, held in one value.
2585 let folded = FoldedLmHead::permit(
2586 greedy_gpu,
2587 &self.final_norm,
2588 self.output_bias.as_deref(),
2589 lm_head_gpu_launch,
2590 );
2591 // Pass final_norm_w when: (1) lm_head runs in stack (folded),
2592 // OR (2) lm_head will route to GPU after stack (lm_head_gpu_launch
2593 // but no fold) so we can skip download→reupload via TLS.
2594 //
2595 // `rms_weights()` is what makes the second
2596 // case safe for a non-RMS final norm: it is
2597 // `None` there, so the stack does not norm
2598 // and the host body does.
2599 let final_norm_w = if folded.is_some() || lm_head_gpu_launch.is_some() {
2600 self.final_norm.rms_weights()
2601 } else {
2602 None
2603 };
2604 let embd_launch = Self::metal_matvec_launch(&self.embedding);
2605 // Gemma scales the embedding row on the host
2606 // (`hidden` already carries sqrt(hidden_dim));
2607 // the GPU gather has no scale op — skip it.
2608 let embd_gather = if self.config.embedding_scale.is_some() {
2609 None
2610 } else {
2611 match (metal_embd_kind, embd_launch.as_ref()) {
2612 (Some(kind), Some(launch)) => {
2613 Some(frink_metal::attn::EmbdGatherMetal {
2614 kind,
2615 weights: launch.weights,
2616 rows: launch.rows,
2617 row_bytes: launch.row_bytes,
2618 n_cols: hidden_dim,
2619 token_id,
2620 })
2621 }
2622 _ => None,
2623 }
2624 };
2625 let hidden_ref: &[f32] =
2626 if embd_gather.is_some() { &[] } else { &hidden };
2627 match frink_metal::attn::launch_decode_dense_stack(
2628 hidden_ref,
2629 &dense_layers,
2630 metal_kvs,
2631 n_heads,
2632 self.metal_rope(),
2633 pos,
2634 self.config.rms_norm_eps,
2635 final_norm_w,
2636 folded.as_ref().map(FoldedLmHead::launch),
2637 folded.as_ref().is_some_and(FoldedLmHead::argmax_only),
2638 embd_gather.as_ref(),
2639 stack_gelu.expect("`ok` is false without it"),
2640 ) {
2641 Ok(out) => {
2642 // Metal KV advanced in-place. Skip host
2643 // last_token_host+push — host may lag until
2644 // sync_metal_attn_kv_to_host / CPU fallback.
2645 // Dense stack has no MoE routing; skip
2646 // per-layer hotness atomics on the hot path.
2647 if let Some(folded) = folded.as_ref() {
2648 // Skip host final_norm/lm_head. Clear TLS.
2649 #[cfg(feature = "metal")]
2650 frink_metal::gpu::clear_resident_activation();
2651 // `interpret` is what keeps
2652 // `final_logit_softcap` applied: the id
2653 // shape passes through, anything
2654 // vocabulary-shaped gets capped.
2655 return folded.interpret(
2656 out,
2657 self.output_head.rows(),
2658 self.config.final_logit_softcap,
2659 self.config.logit_multiplier,
2660 );
2661 }
2662 // Stack downloaded hidden (possibly normalized if
2663 // final_norm_w was Some). Track whether host should
2664 // skip final_norm.
2665 final_norm_done_in_stack = final_norm_w.is_some();
2666 hidden = out;
2667 metal_stack_done = true;
2668 }
2669 Err(e) => {
2670 eprintln!(
2671 "frink: Metal dense stack failed, per-layer fallback: {e}"
2672 );
2673 if hidden.is_empty() {
2674 hidden = self.embedding.dequant_row(token_id);
2675 if let Some(scale) = self.config.embedding_scale {
2676 for v in hidden.iter_mut() {
2677 *v *= scale;
2678 }
2679 }
2680 }
2681 // Preserve any prior Metal-ahead tokens on host
2682 // before dropping the device buffers.
2683 for (m, c) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
2684 Self::catch_up_host_kv_from_metal(m, c);
2685 }
2686 clear_metal_after_stack = true;
2687 }
2688 }
2689 }
2690 }
2691 }
2692 if clear_metal_after_stack {
2693 **guard = None;
2694 }
2695 }
2696 }
2697
2698 #[cfg(feature = "metal")]
2699 let run_cpu_layers = !metal_stack_done;
2700 #[cfg(not(feature = "metal"))]
2701 let run_cpu_layers = true;
2702
2703 // When true, residual lives in Metal MoE scratch — host `hidden` is stale.
2704 #[cfg(feature = "metal")]
2705 let mut metal_moe_resident = false;
2706
2707 #[cfg(feature = "metal")]
2708 if run_cpu_layers && hidden.is_empty() && !metal_moe_resident {
2709 // GPU embedding gather or a skipped Metal dense stack can leave
2710 // `hidden` empty; CPU fallback must not call rms_norm on it.
2711 hidden = self.embed_token(token_id, pos);
2712 }
2713
2714 // The skip source: the (normed) embedding, before any layer
2715 // touches `hidden` (`crate::skip_stream`). Captured here and
2716 // not at `embed_token`, because the Metal arms above leave
2717 // `hidden` empty on purpose -- and a skip-stream model never
2718 // reaches them (`metal_can_serve_model`).
2719 let skip_rows = self.config.skip_stream.then(|| hidden.clone());
2720 let mut hrm = self.hrm_streams(&hidden);
2721 if run_cpu_layers {
2722 // Indexed rather than iterated, because a RUN of consecutive
2723 // recurrent layers is submitted together
2724 // (`frink_metal::gdn_branch::GdnRun`) and needs several
2725 // caches at once. `fused_through` is how many layers that
2726 // consumed; every `continue` below is a `for`, so it cannot
2727 // spin.
2728 // `fused_through` is only ever written under `metal`; the
2729 // binding is unconditional so the loop reads the same either
2730 // way.
2731 #[allow(unused_mut, unused_assignments)]
2732 let mut fused_through = 0usize;
2733 // The Q/K/V a recurrent run encoded for the attention layer
2734 // that follows it, carried one iteration so that layer does
2735 // not project them again in a submission of its own.
2736 #[cfg(feature = "metal")]
2737 let mut pending_qkv: crate::decoder::fused_recurrent::PendingQkv = None;
2738 #[allow(clippy::needless_range_loop)]
2739 for l in 0..kv_caches.len() {
2740 self.hrm_stack_input(hrm.as_ref(), l, &mut hidden);
2741 if l < fused_through {
2742 continue;
2743 }
2744 // Consecutive layers the fused launch serves whole pass
2745 // their residual stream to each other on the device and
2746 // wait ONCE, which is the submission count the decode
2747 // gap is made of (`docs/plans/gdn-resident-state.md`).
2748 #[cfg(feature = "metal")]
2749 if let Some(end) =
2750 self.fused_recurrent_run(l, &mut hidden, kv_caches, &mut pending_qkv)
2751 {
2752 fused_through = end;
2753 continue;
2754 }
2755 let cache = &mut kv_caches[l];
2756 let layer = self.layer_for(l);
2757 // --- attention block ---
2758 #[cfg(feature = "metal")]
2759 if metal_moe_resident
2760 && (!Self::layer_supports_metal_moe_resident(
2761 layer,
2762 &self.config,
2763 self.lora_attached(),
2764 ) || self.layer_needs_metal_stack(layer, l))
2765 {
2766 if let Some(h) = frink_metal::attn::moe_decode_take_hidden() {
2767 hidden = h;
2768 }
2769 metal_moe_resident = false;
2770 }
2771
2772 // The router's operand, captured where llama.cpp reads it:
2773 // BEFORE attention (`smallthinker.cpp:111`). A resident
2774 // Metal stack never serves that shape
2775 // (`gpu_router_matches_host_routing`), so `hidden` is not
2776 // stale for the one architecture that reads it here.
2777 let inputs = self.branch_inputs(layer, &hidden, 1);
2778 #[cfg(feature = "metal")]
2779 let normed = if metal_moe_resident {
2780 // Residual is on-device; host rms_norm would use stale hidden.
2781 Vec::new()
2782 } else {
2783 self.pre_norm_residual(&layer.attn.norm_weight, &mut hidden, 1)
2784 };
2785 #[cfg(not(feature = "metal"))]
2786 let normed = self.pre_norm_residual(&layer.attn.norm_weight, &mut hidden, 1);
2787
2788 #[cfg(feature = "metal")]
2789 {
2790 let mut did_metal_attn = false;
2791 let mut did_metal_dense = false;
2792 let mut did_metal_moe = false;
2793 let mut clear_metal_kv = false;
2794 if let Some(guard) = metal_kv_guard.as_mut() {
2795 if let Some(metal_kvs) = guard.as_mut() {
2796 // Metal-authoritative: host may lag after dense-stack skip.
2797 // Stack-only features (SWA / sandwich norms / GeGLU /
2798 // per-layer theta) are NOT encoded by the per-layer
2799 // launches — those layers must go to CPU here.
2800 if metal_kvs[l].seq_len == pos
2801 && !self.layer_needs_metal_stack(layer, l)
2802 {
2803 // The two pre-norm weights join the four
2804 // projection launches in ONE gate, because every
2805 // fused launch below bakes the RMSNorm into its
2806 // kernel and there is no weight to bake for the
2807 // post-norm-only topology (`olmo2` / `exaone4`).
2808 // `NormOp::rms_weights` returning `None` sends
2809 // the whole layer to the host body, which reads
2810 // the raw residual the way llama.cpp does.
2811 // `metal_layer_rope` joins the six because
2812 // these four launches all rope
2813 // unconditionally, and a layer llama.cpp
2814 // does not rotate must reach the host
2815 // body instead. It is the same `None`
2816 // `layer_needs_metal_stack` reads two
2817 // lines up, from the same accessor, so
2818 // the two cannot disagree about which
2819 // layers those are.
2820 if let (
2821 Some(q_l),
2822 Some(k_l),
2823 Some(v_l),
2824 Some(o_l),
2825 Some(attn_norm_w),
2826 Some(ffn_norm_w),
2827 Some(layer_rope),
2828 ) = (
2829 Self::metal_matvec_launch(&layer.attn.q_proj),
2830 Self::metal_matvec_launch(&layer.attn.k_proj),
2831 Self::metal_matvec_launch(&layer.attn.v_proj),
2832 Self::metal_matvec_launch(&layer.attn.o_proj),
2833 layer.attn.norm_weight.rms_weights(),
2834 layer.moe.norm_weight.rms_weights(),
2835 self.metal_layer_rope(l),
2836 ) {
2837 // Full dense layer on one CB when FFN is Metal-capable.
2838 if Self::layer_supports_metal_dense_ffn(layer) {
2839 let dense_ok = layer.moe.with_expert(0, |ex| {
2840 let (Some(g_l), Some(u_l), Some(d_l)) = (
2841 Self::metal_matvec_launch(&ex.gate),
2842 Self::metal_matvec_launch(&ex.up),
2843 Self::metal_matvec_launch(&ex.down),
2844 ) else {
2845 return false;
2846 };
2847 match frink_metal::attn::launch_decode_dense_layer(
2848 &hidden,
2849 attn_norm_w,
2850 &q_l,
2851 &k_l,
2852 &v_l,
2853 &o_l,
2854 &mut metal_kvs[l],
2855 ffn_norm_w,
2856 &g_l,
2857 &u_l,
2858 &d_l,
2859 n_heads,
2860 self.metal_rope(),
2861 layer_rope,
2862 pos,
2863 self.config.rms_norm_eps,
2864 &self.metal_attn_extras(layer),
2865 ) {
2866 Ok(new_h) => {
2867 // Catch up any dense-stack lag + this token.
2868 Self::catch_up_host_kv_from_metal(
2869 &metal_kvs[l],
2870 cache,
2871 );
2872 layer.moe.record_activations(&[0]);
2873 hidden = new_h;
2874 true
2875 }
2876 Err(e) => {
2877 eprintln!(
2878 "frink: Metal dense layer failed, CPU fallback: {e}"
2879 );
2880 false
2881 }
2882 }
2883 });
2884 if dense_ok {
2885 did_metal_dense = true;
2886 did_metal_attn = true;
2887 } else if metal_kvs[l].seq_len != cache.rows() {
2888 // Dense path may have advanced Metal KV before failing.
2889 Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
2890 clear_metal_kv = true;
2891 }
2892 }
2893
2894 // Resident MoE: attn+router on GPU, host top-k only,
2895 // then batched experts — no hidden download/upload.
2896 if !did_metal_dense
2897 && !clear_metal_kv
2898 && Self::layer_supports_metal_moe_resident(
2899 layer,
2900 &self.config,
2901 self.lora_attached(),
2902 )
2903 {
2904 if let Some(router_l) =
2905 Self::metal_matvec_launch(&layer.moe.router)
2906 {
2907 let seed_ok = if metal_moe_resident {
2908 true
2909 } else {
2910 match frink_metal::attn::moe_decode_seed(&hidden) {
2911 Ok(()) => {
2912 metal_moe_resident = true;
2913 true
2914 }
2915 Err(e) => {
2916 eprintln!(
2917 "frink: Metal MoE seed failed: {e}"
2918 );
2919 false
2920 }
2921 }
2922 };
2923 if seed_ok {
2924 // Prefer one-CB fused path (GPU top-k + packed experts).
2925 // See
2926 // `layer_supports_metal_moe_resident`:
2927 // the fused decode kernel
2928 // routes on the GPU and
2929 // has no `exp_probs_b` /
2930 // `expert_weights_scale`
2931 // input either.
2932 let fused_ok = match &layer.moe.experts {
2933 ExpertBacking::Resident(_) => {
2934 if let Some(packed) =
2935 Self::moe_packed_q4(&layer.moe)
2936 {
2937 match frink_metal::attn::launch_moe_decode_layer_fused(
2938 attn_norm_w,
2939 &q_l,
2940 &k_l,
2941 &v_l,
2942 &o_l,
2943 &mut metal_kvs[l],
2944 ffn_norm_w,
2945 &router_l,
2946 &packed,
2947 self.config.moe.n_experts_active,
2948 self.config.moe.norm_topk_prob,
2949 n_heads,
2950 self.metal_rope(),
2951 layer_rope,
2952 pos,
2953 self.config.rms_norm_eps,
2954 &self.metal_attn_extras(layer),
2955 ) {
2956 Ok(ids) => {
2957 layer.moe.record_activations(&ids);
2958 did_metal_moe = true;
2959 did_metal_attn = true;
2960 true
2961 }
2962 Err(e) => {
2963 eprintln!(
2964 "frink: Metal MoE fused layer failed: {e}"
2965 );
2966 false
2967 }
2968 }
2969 } else {
2970 false
2971 }
2972 }
2973 _ => false,
2974 };
2975
2976 if !fused_ok {
2977 match frink_metal::attn::launch_moe_decode_pre(
2978 attn_norm_w,
2979 &q_l,
2980 &k_l,
2981 &v_l,
2982 &o_l,
2983 &mut metal_kvs[l],
2984 ffn_norm_w,
2985 &router_l,
2986 n_heads,
2987 self.metal_rope(),
2988 layer_rope,
2989 pos,
2990 self.config.rms_norm_eps,
2991 &self.metal_attn_extras(layer),
2992 ) {
2993 Ok(logits) => {
2994 // Routing happens HERE, on the host,
2995 // so there is no kernel limitation to
2996 // excuse a second router: call the
2997 // one every other host path calls.
2998 let decision = Self::route_for_layer(
2999 layer,
3000 &logits,
3001 &self.config,
3002 );
3003 layer.moe.record_activations(
3004 &decision.expert_ids,
3005 );
3006 if let Some(()) = Self::try_metal_moe_experts_resident(
3007 layer,
3008 &decision,
3009 ) {
3010 did_metal_moe = true;
3011 did_metal_attn = true;
3012 } else if let Some(h) =
3013 frink_metal::attn::moe_decode_take_hidden()
3014 {
3015 hidden = h;
3016 metal_moe_resident = false;
3017 // KV already advanced; finish FFN on host.
3018 let normed2 = self
3019 .pre_norm_residual(
3020 &layer.moe.norm_weight,
3021 &mut hidden,
3022 1,
3023 );
3024 let ffn_out = Self::combine_ffn_outputs_for_position(
3025 l,
3026 layer,
3027 &normed2,
3028 &normed2,
3029 &logits,
3030 &self.config,
3031 hidden_dim,
3032 residency.as_ref().map(|p| p.layer_plan(self.physical_index(l))),
3033 );
3034 residual_add(
3035 &mut hidden,
3036 &ffn_out,
3037 self.config.residual_scale,
3038 );
3039 did_metal_attn = true;
3040 did_metal_moe = true; // skip second FFN
3041 }
3042 }
3043 Err(e) => {
3044 eprintln!(
3045 "frink: Metal MoE pre failed, fallback: {e}"
3046 );
3047 if let Some(h) =
3048 frink_metal::attn::moe_decode_take_hidden()
3049 {
3050 hidden = h;
3051 }
3052 metal_moe_resident = false;
3053 if metal_kvs[l].seq_len != cache.rows()
3054 {
3055 Self::catch_up_host_kv_from_metal(
3056 &metal_kvs[l],
3057 cache,
3058 );
3059 clear_metal_kv = true;
3060 }
3061 }
3062 }
3063 }
3064 }
3065 }
3066 }
3067
3068 if !did_metal_dense && !did_metal_moe && !clear_metal_kv {
3069 match frink_metal::attn::launch_decode_attn_block(
3070 &normed,
3071 &q_l,
3072 &k_l,
3073 &v_l,
3074 &o_l,
3075 &mut metal_kvs[l],
3076 n_heads,
3077 self.metal_rope(),
3078 layer_rope,
3079 pos,
3080 &self.metal_attn_extras(layer),
3081 self.config.rms_norm_eps,
3082 ) {
3083 Ok(projected) => {
3084 // Keep Metal KV authoritative — skip per-layer
3085 // host catch-up (dense-stack style). Host is
3086 // flushed on CPU fallback / prefix sync.
3087 residual_add(
3088 &mut hidden,
3089 &projected,
3090 self.config.residual_scale,
3091 );
3092 did_metal_attn = true;
3093 }
3094 Err(e) => {
3095 eprintln!(
3096 "frink: Metal attn block failed, CPU fallback: {e}"
3097 );
3098 Self::catch_up_host_kv_from_metal(
3099 &metal_kvs[l],
3100 cache,
3101 );
3102 clear_metal_kv = true;
3103 }
3104 }
3105 }
3106 }
3107 } else if metal_kvs[l].seq_len > cache.rows() {
3108 // Leaving Metal path: host must see full KV for CPU attn.
3109 Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
3110 }
3111 }
3112 if clear_metal_kv {
3113 **guard = None;
3114 }
3115 }
3116 if did_metal_attn {
3117 if !did_metal_dense && !did_metal_moe {
3118 self.ffn_block_row(
3119 l,
3120 layer,
3121 &mut hidden,
3122 None,
3123 residency
3124 .as_ref()
3125 .map(|p| p.layer_plan(self.physical_index(l))),
3126 inputs,
3127 skip_rows.as_deref().map(|rows| SkipStream { rows }),
3128 );
3129 }
3130 continue;
3131 }
3132 }
3133
3134 let oai = self
3135 .gpt_oss
3136 .as_ref()
3137 .map(|g| &g.layers[self.physical_index(l)]);
3138 // A recurrent layer whose whole shape the fused Metal
3139 // launch serves runs END TO END in one submission --
3140 // branch, residual, norm, FFN, residual -- and this
3141 // loop moves to the next layer. Everything it does not
3142 // serve it refuses (`crate::fused_layer`,
3143 // `decoder::fused_recurrent`), and the two bodies below
3144 // run exactly as before.
3145 #[cfg(feature = "metal")]
3146 if let Some(out) =
3147 self.fused_recurrent_layer(l, layer, &normed, &hidden, &mut cache.recurrent)
3148 {
3149 hidden = out;
3150 cache
3151 .advance_len(1)
3152 .expect("unbounded/planned KvCache growth is infallible");
3153 continue;
3154 }
3155 // An ATTENTION layer whose tail the fused launch serves
3156 // hands `wo` over to it, so `wo`, the residual add, the
3157 // FFN norm and the FFN are ONE command buffer instead
3158 // of two (`crate::decoder::fused_attention`). The
3159 // attention itself still runs on the host: the KV lives
3160 // there.
3161 #[cfg(feature = "metal")]
3162 let mut deferred: Option<Vec<f32>> = None;
3163 #[cfg(feature = "metal")]
3164 let mut ready: Option<attn_block::AttnReady> = None;
3165 #[cfg(feature = "metal")]
3166 let residual_in = hidden.clone();
3167 #[cfg(feature = "metal")]
3168 let tail = if self.fused_attention_tail_eligible(l, layer) {
3169 attn_block::AttnTail::Defer {
3170 branch: &mut deferred,
3171 // The attention itself goes to the device when
3172 // this layer's shape allows it; otherwise only
3173 // the tail is fused and the host attends.
3174 ready: self
3175 .device_attention_shape_ok(l, layer)
3176 .then_some(&mut ready),
3177 }
3178 } else {
3179 attn_block::AttnTail::apply()
3180 };
3181 #[cfg(not(feature = "metal"))]
3182 let tail = attn_block::AttnTail::apply();
3183 // The projections the preceding run encoded for THIS
3184 // layer, if it did: then nothing here projects again.
3185 #[cfg(feature = "metal")]
3186 let precomputed = match pending_qkv.take() {
3187 Some((at, qkv)) if at == l => Some(qkv),
3188 // A run encoded a head for a different layer than
3189 // the one we reached, which only a refusal in
3190 // between can cause: drop it rather than use it.
3191 _ => None,
3192 };
3193 #[cfg(not(feature = "metal"))]
3194 let precomputed = None;
3195 let projected = self.attn_block_tail(
3196 l,
3197 layer,
3198 &normed,
3199 pos,
3200 KvStep::Decode(&mut *cache),
3201 tail,
3202 precomputed,
3203 );
3204 // The attention layer's inputs came back instead of its
3205 // output: run the whole layer on the device, at the head
3206 // of the next recurrent run when there is one.
3207 #[cfg(feature = "metal")]
3208 if let Some(r) = ready {
3209 let gate = r.gate.as_deref();
3210 if let Some(end) = self.device_attention_layer_then_run(
3211 l,
3212 layer,
3213 &r.q,
3214 &r.k,
3215 &r.v,
3216 gate,
3217 &mut hidden,
3218 kv_caches,
3219 &mut pending_qkv,
3220 ) {
3221 fused_through = end;
3222 continue;
3223 }
3224 let cache = &mut kv_caches[l];
3225 if let Some(out) = self.device_attention_layer(
3226 l,
3227 layer,
3228 cache,
3229 &r.q,
3230 &r.k,
3231 &r.v,
3232 gate,
3233 &residual_in,
3234 ) {
3235 cache
3236 .push(&r.k, &r.v)
3237 .expect("unbounded/planned KvCache growth is infallible");
3238 hidden = out;
3239 continue;
3240 }
3241 // Both device shapes refused after the layer had
3242 // already been prepared, so finish it on the host
3243 // from the very same inputs.
3244 let mut attn_out = self.push_and_attend_row(
3245 KvStep::Decode(&mut kv_caches[l]),
3246 l,
3247 layer,
3248 &r.k,
3249 &r.v,
3250 &r.q,
3251 );
3252 if let Some(g) = gate {
3253 crate::attn_gate::apply_interleaved_gate(&mut attn_out, g);
3254 }
3255 let projected = self.project_attn_rows(layer, &attn_out, 1);
3256 residual_add(&mut hidden, &projected, self.config.residual_scale);
3257 self.ffn_block_row(
3258 l,
3259 layer,
3260 &mut hidden,
3261 oai,
3262 residency
3263 .as_ref()
3264 .map(|p| p.layer_plan(self.physical_index(l))),
3265 inputs,
3266 skip_rows.as_deref().map(|rows| SkipStream { rows }),
3267 );
3268 continue;
3269 }
3270 #[cfg(feature = "metal")]
3271 if let Some(branch) = deferred {
3272 // The tail feeds the recurrent layers after it, so
3273 // it rides in THEIR command buffer when there are
3274 // any: one wait for the four layers instead of two.
3275 if let Some(end) = self.fused_attention_tail_then_run(
3276 l,
3277 layer,
3278 &branch,
3279 &mut hidden,
3280 kv_caches,
3281 &mut pending_qkv,
3282 ) {
3283 fused_through = end;
3284 continue;
3285 }
3286 if let Some(out) = self.fused_attention_tail(l, layer, &branch, &hidden) {
3287 hidden = out;
3288 continue;
3289 }
3290 // The launch refused after the predicate said yes,
3291 // which only a device error does: finish the layer
3292 // on the host from the branch it handed back.
3293 let p = self.project_attn_rows(layer, &branch, 1);
3294 residual_add(&mut hidden, &p, self.config.residual_scale);
3295 }
3296 if let Some(projected) = projected {
3297 residual_add(&mut hidden, &projected, self.config.residual_scale);
3298 }
3299 self.ffn_block_row(
3300 l,
3301 layer,
3302 &mut hidden,
3303 oai,
3304 residency
3305 .as_ref()
3306 .map(|p| p.layer_plan(self.physical_index(l))),
3307 inputs,
3308 skip_rows.as_deref().map(|rows| SkipStream { rows }),
3309 );
3310 self.hrm_store(hrm.as_mut(), l, &hidden);
3311 }
3312 } // run_cpu_layers
3313
3314 #[cfg(feature = "metal")]
3315 if metal_moe_resident {
3316 if let Some(h) = frink_metal::attn::moe_decode_take_hidden() {
3317 hidden = h;
3318 }
3319 }
3320
3321 // If Metal stack already ran final_norm, hidden is normalized; else
3322 // normalize here.
3323 #[cfg(feature = "metal")]
3324 let final_normed = if final_norm_done_in_stack {
3325 hidden.clone()
3326 } else {
3327 self.final_norm.apply(&hidden, self.config.rms_norm_eps)
3328 };
3329 #[cfg(not(feature = "metal"))]
3330 let final_normed = self.final_norm.apply(&hidden, self.config.rms_norm_eps);
3331
3332 let logits = self.logits_from_normed(&final_normed);
3333 // Clear dense-stack activation TLS after lm_head (may have consumed it).
3334 // Keep MoE scratch buffers alive across tokens — `moe_decode_seed`
3335 // overwrites `h` each token; clearing here forced full realloc.
3336 #[cfg(feature = "metal")]
3337 frink_metal::gpu::clear_resident_activation();
3338 logits
3339 }
3340
3341 /// The body of [`Self::forward_token_paged`], already running on a
3342 /// CPU-pool worker. See `entry.rs` for why the split exists.
3343 fn forward_token_paged_on_worker(
3344 &self,
3345 token_id: usize,
3346 pos: usize,
3347 kv_caches: &mut [PagedKvCache],
3348 stores: &SharedPagedKv,
3349 ) -> Result<Vec<f32>, PagedStoreExhausted> {
3350 assert_eq!(kv_caches.len(), self.config.n_layers);
3351 assert_eq!(stores.layer_count(), self.config.n_layers);
3352 // All layers advance or none do. Pushing per layer with `?` and
3353 // failing at layer 3 of 4 leaves layers 0..2 holding a position
3354 // the rest do not, and nothing downstream reports it: the next
3355 // step simply attends over a shorter history in the tail
3356 // layers. Reserving one position everywhere first turns that
3357 // into a clean refusal.
3358 //
3359 // The guards span the check AND the push for the same reason
3360 // the prefill path holds them: otherwise another request takes
3361 // the blocks in between.
3362 {
3363 let mut guards = stores.write_all();
3364 for (cache, store) in kv_caches.iter().zip(guards.iter()) {
3365 if cache.blocks_needed_for(store, 1) > store.free_block_count() {
3366 return Err(PagedStoreExhausted);
3367 }
3368 }
3369 // Reserve by taking the blocks now, so the per-layer pushes
3370 // below cannot fail. `PagedKvCache::reserve` grows the block
3371 // table without advancing `seq_len`, leaving each push a
3372 // pure write into a block this sequence already owns.
3373 for (cache, store) in kv_caches.iter_mut().zip(guards.iter_mut()) {
3374 cache
3375 .reserve(store, 1)
3376 .expect("checked against free_block_count under this same guard");
3377 }
3378 }
3379
3380 let mut hidden = self.embed_token(token_id, pos);
3381 let skip_rows = self.config.skip_stream.then(|| hidden.clone());
3382 let mut hrm = self.hrm_streams(&hidden);
3383 let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
3384
3385 for (l, cache) in kv_caches.iter_mut().enumerate() {
3386 self.hrm_stack_input(hrm.as_ref(), l, &mut hidden);
3387 let layer = self.layer_for(l);
3388 // --- attention block ---
3389 let inputs = self.branch_inputs(layer, &hidden, 1);
3390 let normed = self.pre_norm_residual(&layer.attn.norm_weight, &mut hidden, 1);
3391
3392 // The same body the contiguous path runs, with the paged
3393 // backing as its one parameter. It used to be a copy, and
3394 // the copy had silently dropped `attention_scale`,
3395 // `post_attn_norm`, `post_ffn_norm`, gpt-oss's `o_bias` and
3396 // `gpt_oss_ffn` -- five features that each produce a
3397 // plausible distribution rather than an error.
3398 let oai = self
3399 .gpt_oss
3400 .as_ref()
3401 .map(|g| &g.layers[self.physical_index(l)]);
3402 if let Some(projected) = self.attn_block(
3403 l,
3404 layer,
3405 &normed,
3406 pos,
3407 KvStep::Paged {
3408 cache: &mut *cache,
3409 stores,
3410 },
3411 ) {
3412 residual_add(&mut hidden, &projected, self.config.residual_scale);
3413 }
3414 self.ffn_block_row(
3415 l,
3416 layer,
3417 &mut hidden,
3418 oai,
3419 residency
3420 .as_ref()
3421 .map(|p| p.layer_plan(self.physical_index(l))),
3422 inputs,
3423 skip_rows.as_deref().map(|rows| SkipStream { rows }),
3424 );
3425 self.hrm_store(hrm.as_mut(), l, &hidden);
3426 }
3427
3428 let final_normed = self.final_norm.apply(&hidden, self.config.rms_norm_eps);
3429 Ok(self.logits_from_normed(&final_normed))
3430 }
3431
3432 /// The shared expert store's live counters, when this model runs
3433 /// with store-backed (streamed) routed experts -- `None` for fully
3434 /// resident models. Every store-backed layer shares one store, so
3435 /// the first one found speaks for the whole model.
3436 pub fn expert_store_stats(&self) -> Option<frink_core::expert_store::ExpertStoreStats> {
3437 self.layers.iter().find_map(|l| match &l.moe.experts {
3438 ExpertBacking::Stored { store, .. } => Some(store.stats()),
3439 ExpertBacking::Resident(_) => None,
3440 })
3441 }
3442
3443 /// Builds one global device-residency plan across ALL layers'
3444 /// routed experts against the single configured VRAM budget --
3445 /// every `(layer, expert)` candidate competes in one hotness-
3446 /// ordered pass and the running byte total is shared, so the
3447 /// budget cannot be re-spent per layer (the accounting bug the
3448 /// earlier per-layer `placement_plan` calls had: N layers would
3449 /// plan N x the configured bytes). Dense layers contribute no
3450 /// candidates (their sole expert always runs on CPU). Rebuilt per
3451 /// forward call so it tracks observed hotness; not yet
3452 /// performance-tuned, a disclosed limit.
3453 fn residency_plan(&self, vram_budget_bytes: u64) -> frink_moe::ResidencyPlan {
3454 let mut sizes_per_layer: Vec<Vec<usize>> = Vec::with_capacity(self.layers.len());
3455 let mut counts_per_layer: Vec<Vec<u64>> = Vec::with_capacity(self.layers.len());
3456 let mut any_observed = false;
3457 for layer in &self.layers {
3458 if Self::is_dense_layer(layer) {
3459 sizes_per_layer.push(Vec::new());
3460 counts_per_layer.push(Vec::new());
3461 continue;
3462 }
3463 sizes_per_layer.push(
3464 (0..layer.moe.n_experts())
3465 .map(|e| layer.moe.expert_bytes(e))
3466 .collect(),
3467 );
3468 let counts: Vec<u64> = layer
3469 .moe
3470 .activation_counts
3471 .iter()
3472 .map(|c| c.load(Ordering::Relaxed))
3473 .collect();
3474 any_observed |= counts.iter().any(|&c| c > 0);
3475 counts_per_layer.push(counts);
3476 }
3477 PlacementPlan::plan_layers_against_global_budget(
3478 &sizes_per_layer,
3479 any_observed.then_some(counts_per_layer.as_slice()),
3480 vram_budget_bytes,
3481 )
3482 }
3483
3484 /// True if this layer has nothing to route: exactly one expert and
3485 /// no shared experts, the shape every non-MoE model (and every
3486 /// DeepSeek-style "leading dense layer") loads as. Top-1 selection
3487 /// out of one expert always picks it, and its weight is always
3488 /// exactly 1.0 regardless of gating function (softmax over one
3489 /// logit is trivially 1.0; sigmoid-then-renormalize divides the
3490 /// selected score by itself) -- so skipping the router matmul,
3491 /// `route_top_k`'s sort/exp/renormalize work, and
3492 /// `combine_expert_outputs`'s Vec-wrapping for this case is not an
3493 /// approximation, it produces the exact same result.
3494 fn is_dense_layer(layer: &LayerWeights) -> bool {
3495 layer.moe.n_experts() == 1 && layer.moe.shared_experts.is_empty()
3496 }
3497
3498 /// llama.cpp `mul_mat_id` style: shared Q8 act + one flat parallel
3499 /// region over `(slot, row_pair)` for gate∥up (2-row SDOT), then
3500 /// SwiGLU, then per-slot down. Three regions, none nested, all
3501 /// through `frink_core::par` so they follow whichever scheduler
3502 /// `FRINK_CPU_POOL` selected.
3503 fn cpu_moe_topk_parallel_slots(
3504 experts: &[ExpertWeights],
3505 normed2: &[f32],
3506 decision: &frink_moe::RoutingDecision,
3507 hidden_dim: usize,
3508 act: GluAct,
3509 ) -> Option<Vec<(Vec<f32>, f32)>> {
3510 if !frink_core::weight_matrix::cpu_int_dot_for(
3511 frink_core::weight_matrix::IntDotShape::Matvec,
3512 ) || !normed2.len().is_multiple_of(32)
3513 {
3514 return None;
3515 }
3516 let n_slots = decision.expert_ids.len();
3517 if n_slots == 0 {
3518 return Some(Vec::new());
3519 }
3520 for &eid in &decision.expert_ids {
3521 let ex = experts.get(eid)?;
3522 if ex.gate.rows() == 0
3523 || ex.up.rows() != ex.gate.rows()
3524 || ex.down.rows() != hidden_dim
3525 || ex.gate.cols() != normed2.len()
3526 || ex.up.cols() != normed2.len()
3527 || ex.down.cols() != ex.gate.rows()
3528 {
3529 return None;
3530 }
3531 if !matches!(
3532 &ex.gate,
3533 WeightMatrix::Quantized {
3534 kind: frink_core::QuantKind::Q4_0 | frink_core::QuantKind::Q8_0,
3535 ..
3536 }
3537 ) || !matches!(
3538 &ex.up,
3539 WeightMatrix::Quantized {
3540 kind: frink_core::QuantKind::Q4_0 | frink_core::QuantKind::Q8_0,
3541 ..
3542 }
3543 ) {
3544 return None;
3545 }
3546 }
3547 let ffn_rows = experts[decision.expert_ids[0]].gate.rows();
3548 // Even ffn_rows: par_chunks_mut(2) never crosses a slot boundary.
3549 if !ffn_rows.is_multiple_of(2) {
3550 return None;
3551 }
3552 let q8 = frink_quant::quantize_activations_q8(normed2);
3553 let eids = &decision.expert_ids;
3554 let mut gate = vec![0f32; n_slots * ffn_rows];
3555 let mut up = vec![0f32; n_slots * ffn_rows];
3556 frink_core::par::chunks_mut2(&mut gate, &mut up, 2, 1, |p, gc, uc| {
3557 let row0 = p * 2;
3558 let slot = row0 / ffn_rows;
3559 let r = row0 % ffn_rows;
3560 let ex = &experts[eids[slot]];
3561 if let (Some((g0, g1)), Some((u0, u1))) = (
3562 ex.gate.dot_pair_cpu_q8(r, &q8),
3563 ex.up.dot_pair_cpu_q8(r, &q8),
3564 ) {
3565 gc[0] = g0;
3566 gc[1] = g1;
3567 uc[0] = u0;
3568 uc[1] = u1;
3569 } else {
3570 gc[0] = ex.gate.dot_row_cpu_q8(r, &q8).unwrap_or(0.0);
3571 gc[1] = ex.gate.dot_row_cpu_q8(r + 1, &q8).unwrap_or(0.0);
3572 uc[0] = ex.up.dot_row_cpu_q8(r, &q8).unwrap_or(0.0);
3573 uc[1] = ex.up.dot_row_cpu_q8(r + 1, &q8).unwrap_or(0.0);
3574 }
3575 });
3576 let mut activated = vec![0f32; n_slots * ffn_rows];
3577 // The combine here is always parallel (decode's `n_slots *
3578 // ffn_rows` sits under `frink_core::matmul`'s own fork
3579 // threshold), which is why this does not just call `act.apply`.
3580 // `GluAct::combine` is that function one element at a time --
3581 // this used to match the variant here and multiply `f(gate) *
3582 // up` itself, a second spelling of the activation that a
3583 // parameterised variant (xIELU reads `up` alone) could not fit.
3584 frink_core::par::items_mut(&mut activated, 1, |idx, a| {
3585 *a = act.combine(gate[idx], up[idx])
3586 });
3587 let mut outs: Vec<(Vec<f32>, f32)> = decision
3588 .weights
3589 .iter()
3590 .map(|&w| (vec![0f32; hidden_dim], w))
3591 .collect();
3592 frink_core::par::items_mut(&mut outs, 1, |slot, (out, _)| {
3593 let ex = &experts[eids[slot]];
3594 let act_slot = &activated[slot * ffn_rows..(slot + 1) * ffn_rows];
3595 if act_slot.len().is_multiple_of(32) {
3596 let down_q8 = frink_quant::quantize_activations_q8(act_slot);
3597 if let Some(d) = ex.down.apply_cpu_q8(&down_q8) {
3598 *out = d;
3599 return;
3600 }
3601 }
3602 *out = ex.down.apply(act_slot);
3603 });
3604 Some(outs)
3605 }
3606
3607 /// Fallback: serial top-k with shared Q8 act (pre-mul_mat_id path).
3608 ///
3609 /// `weight_before_ffn` (`crate::routed_weight_site`): each slot
3610 /// reads its own scaled input and the shared activation is not
3611 /// built.
3612 fn cpu_moe_serial_experts(
3613 layer: &LayerWeights,
3614 normed2: &[f32],
3615 decision: &frink_moe::RoutingDecision,
3616 plan: Option<&PlacementPlan>,
3617 act: GluAct,
3618 weight_before_ffn: bool,
3619 ) -> Vec<(Vec<f32>, f32)> {
3620 let shared_act = if !weight_before_ffn
3621 && frink_core::weight_matrix::cpu_int_dot_for(
3622 frink_core::weight_matrix::IntDotShape::Matvec,
3623 )
3624 && normed2.len().is_multiple_of(32)
3625 && plan
3626 .map(|p| {
3627 decision
3628 .expert_ids
3629 .iter()
3630 .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
3631 })
3632 .unwrap_or(true)
3633 {
3634 Some(frink_quant::quantize_activations_q8(normed2))
3635 } else {
3636 None
3637 };
3638 decision
3639 .expert_ids
3640 .iter()
3641 .zip(decision.weights.iter())
3642 .map(|(&eid, &w)| {
3643 let placement = plan
3644 .map(|p| p.placement_for(eid))
3645 .unwrap_or(ExpertPlacement::Cpu);
3646 let (input, w) =
3647 crate::routed_weight_site::routed_slot(normed2, w, weight_before_ffn);
3648 let out = layer.moe.with_expert(eid, |ex| {
3649 if let Some(ref q8) = shared_act {
3650 if let (Some(gate), Some(up)) =
3651 (ex.gate.apply_cpu_q8(q8), ex.up.apply_cpu_q8(q8))
3652 {
3653 let activated = act.apply(&gate, &up);
3654 return ex.down.apply(&activated);
3655 }
3656 }
3657 run_expert_placed(&input, ex, placement, act)
3658 });
3659 (out, w)
3660 })
3661 .collect()
3662 }
3663
3664 /// Runs one position's normalized hidden state through this
3665 /// layer's MoE FFN block, given already-computed router logits for
3666 /// that position, returning the combined output to add back into
3667 /// the residual stream. Shared by `forward_token` (router computed
3668 /// via a single `apply` call, since there's only one position) and
3669 /// `forward_batch`'s per-position loop (router computed via one
3670 /// batched `apply_batch` call up front, sliced per position here --
3671 /// see `forward_batch`'s doc comment for why that batching matters
3672 /// and must not be lost by calling this per position instead).
3673 /// `gpu_vram_budget_bytes`: see `Decoder::gpu_vram_budget_bytes`'s
3674 /// doc comment -- `None` dispatches every routed expert through
3675 /// `run_expert_placed` with `ExpertPlacement::Cpu`, which is
3676 /// exactly `run_expert`'s own behavior, so this is a real
3677 /// zero-behavior-change default, not just "probably fine."
3678 /// One token's routing decision for one MoE layer.
3679 ///
3680 /// Three shapes, in the order llama.cpp's `build_moe_ffn` decides
3681 /// them: grouped selection when the checkpoint declares expert
3682 /// groups; the biased/scaled port when the layer carries
3683 /// `exp_probs_b` or the model carries a non-unit
3684 /// `expert_weights_scale`; otherwise the plain top-k this decoder has
3685 /// always used. The last arm is kept rather than folded into
3686 /// `route_top_k_biased` so that every checkpoint without those two
3687 /// features routes through byte-identical code to before.
3688 ///
3689 /// `exp_probs_b` together with expert groups is refused at load
3690 /// (`loader.rs`), so that combination cannot reach here.
3691 fn route_for_layer(
3692 layer: &LayerWeights,
3693 router_logits: &[f32],
3694 config: &ModelConfig,
3695 ) -> frink_moe::RoutingDecision {
3696 match (
3697 config.moe.expert_group_count,
3698 config.moe.expert_group_used_count,
3699 ) {
3700 (Some(n_groups), Some(k_per_group)) if n_groups > 1 && k_per_group > 0 => {
3701 frink_moe::route_top_k_grouped(
3702 router_logits,
3703 n_groups,
3704 k_per_group,
3705 config.moe.n_experts_active,
3706 config.moe.gating,
3707 config.moe.norm_topk_prob,
3708 )
3709 }
3710 _ if layer.moe.exp_probs_bias.is_some() || config.moe.expert_weights_scale != 1.0 => {
3711 frink_moe::route_top_k_biased(
3712 router_logits,
3713 layer.moe.exp_probs_bias.as_deref(),
3714 config.moe.n_experts_active,
3715 config.moe.gating,
3716 config.moe.norm_topk_prob,
3717 config.moe.expert_weights_scale,
3718 )
3719 }
3720 _ => route_top_k(
3721 router_logits,
3722 config.moe.n_experts_active,
3723 config.moe.gating,
3724 config.moe.norm_topk_prob,
3725 ),
3726 }
3727 }
3728
3729 /// `normed2` is what the DENSE half (the shared experts) reads;
3730 /// `routed_input` is what the routed experts read -- the same slice
3731 /// for every architecture but Arctic, whose routed branch reads
3732 /// `ffn_norm_exps(inpSA)` (`crate::router_input`). Two arguments
3733 /// rather than one so a caller cannot hand the experts the wrong
3734 /// vector without saying so.
3735 #[allow(clippy::too_many_arguments)]
3736 fn combine_ffn_outputs_for_position(
3737 layer_idx: usize,
3738 layer: &LayerWeights,
3739 normed2: &[f32],
3740 routed_input: &[f32],
3741 router_logits: &[f32],
3742 config: &ModelConfig,
3743 hidden_dim: usize,
3744 plan: Option<&PlacementPlan>,
3745 ) -> Vec<f32> {
3746 let decision = Self::route_for_layer(layer, router_logits, config);
3747 let acts = config.layer_ffn_acts(layer_idx);
3748 let act = acts.routed;
3749 layer.moe.record_activations(&decision.expert_ids);
3750 // Best-effort warm of the routed experts for this layer into
3751 // the store cache (SSD streaming overlap). Resident-backed
3752 // layers skip this entirely.
3753 if let ExpertBacking::Stored {
3754 store,
3755 layer: layer_id,
3756 ..
3757 } = &layer.moe.experts
3758 {
3759 let keys: Vec<frink_core::expert_store::ExpertKey> = decision
3760 .expert_ids
3761 .iter()
3762 .map(|&eid| frink_core::expert_store::ExpertKey {
3763 layer: *layer_id,
3764 expert: eid as u32,
3765 })
3766 .collect();
3767 store.prefetch(&keys);
3768 }
3769
3770 // Metal: fuse all top-k experts into one CB (one wait) when every
3771 // routed expert has Metal matvec launches. Shared experts (rare
3772 // for OLMoE) still run on the host after.
3773 // `launch_moe_topk_swiglu` is SwiGLU-only, so a GeGLU MoE layer
3774 // keeps the host path rather than taking a kernel that computes
3775 // a different activation.
3776 #[cfg(feature = "metal")]
3777 if frink_core::metal_dense_enabled()
3778 && act.is_swiglu()
3779 && layer.moe.shared_experts.is_empty()
3780 {
3781 if let Some(fused) = Self::try_metal_moe_topk(layer, routed_input, &decision) {
3782 return fused;
3783 }
3784 }
3785
3786 let routed_outputs: Vec<(Vec<f32>, f32)> = {
3787 // llama.cpp mul_mat_id: one shared Q8 act + flat (slot,row)
3788 // parallel over all top-k experts (not serial expert loops each
3789 // with their own rayon fork-join).
3790 let all_cpu = plan
3791 .map(|p| {
3792 decision
3793 .expert_ids
3794 .iter()
3795 .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
3796 })
3797 .unwrap_or(true);
3798 // The parallel-slot kernel quantises ONE input for every
3799 // slot; a model that weights the input per slot
3800 // (`crate::routed_weight_site`) takes the serial site.
3801 let before = config.moe.routed_weight_before_ffn;
3802 if let (true, false, ExpertBacking::Resident(experts)) =
3803 (all_cpu, before, &layer.moe.experts)
3804 {
3805 if let Some(outs) = Self::cpu_moe_topk_parallel_slots(
3806 experts,
3807 routed_input,
3808 &decision,
3809 hidden_dim,
3810 act,
3811 ) {
3812 outs
3813 } else {
3814 Self::cpu_moe_serial_experts(layer, routed_input, &decision, plan, act, before)
3815 }
3816 } else {
3817 Self::cpu_moe_serial_experts(layer, routed_input, &decision, plan, act, before)
3818 }
3819 };
3820 // Shared experts fire on every token regardless of routing, so
3821 // there's no offload decision to make for them the way there
3822 // is for routed experts -- always CPU, matching `run_expert`.
3823 let mut shared_outputs: Vec<Vec<f32>> = layer
3824 .moe
3825 .shared_experts
3826 .iter()
3827 .map(|e| run_expert(normed2, e, acts.dense))
3828 .collect();
3829 // Qwen2-MoE-specific: see `MoeWeights::shared_expert_gate`'s doc
3830 // comment. Scaling here (before `combine_expert_outputs`, which
3831 // is architecture-agnostic and knows nothing about this gate)
3832 // keeps the gate a decoder-level detail, not a frink-moe API
3833 // change.
3834 if let Some(gate) = &layer.moe.shared_expert_gate {
3835 let gate_logit: f32 = gate.iter().zip(normed2.iter()).map(|(g, x)| g * x).sum();
3836 let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
3837 for out in shared_outputs.iter_mut() {
3838 for x in out.iter_mut() {
3839 *x *= gate_value;
3840 }
3841 }
3842 }
3843
3844 combine_expert_outputs(&routed_outputs, &shared_outputs, hidden_dim)
3845 }
3846
3847 /// The dense FFN for a whole batch of positions in three batched
3848 /// matmuls (gate, up, down) instead of three per position.
3849 ///
3850 /// This is the counterpart of what `forward_hidden_batch` already
3851 /// did for Q/K/V and the router, and it is where a dense model's
3852 /// prefill time actually goes: `WeightMatrix::apply_batch` reads
3853 /// each weight row once and dots it against every position, rather
3854 /// than re-reading the whole FFN for each one.
3855 ///
3856 /// `None` for anything that is not a plain dense layer -- MoE
3857 /// routing is per position by construction, so those keep the
3858 /// sequential path.
3859 ///
3860 /// On a GPU backend the per-position alternative is one *fused*
3861 /// gate+up+SiLU+down launch (`apply_gpu_dense_ffn_swiglu`), so this
3862 /// used to be gated off there: three separate batched launches lost
3863 /// to it while `apply_batch` was still a batched *matvec*.
3864 ///
3865 /// That stopped being true once the simdgroup GEMM landed, and the
3866 /// old gate turned out to be the dominant cost of Metal prefill --
3867 /// a 512-token prompt ran the FFN one position at a time, 512 x
3868 /// n_layers fused launches, which a profile put at 90% of prefill
3869 /// while the GEMM it bypassed accounted for 21%.
3870 ///
3871 /// Decode (`batch_size == 1`) still takes the fused per-position
3872 /// launch, which is the right shape there.
3873 fn dense_ffn_batch(
3874 layer_idx: usize,
3875 layer: &LayerWeights,
3876 normed2_batch: &[f32],
3877 batch_size: usize,
3878 config: &ModelConfig,
3879 ) -> Option<Vec<f32>> {
3880 let act = config.layer_ffn_acts(layer_idx).dense;
3881 // Match the GPU `mul_mm` threshold: below it the per-call launch
3882 // overhead outweighs the weight reuse.
3883 if !Self::is_dense_layer(layer) || batch_size < 4 {
3884 return None;
3885 }
3886 // On a GPU backend this only wins when the weights have a real
3887 // batched GEMM; otherwise `apply_batch` is a batched matvec and
3888 // loses to the fused per-position launch.
3889 #[cfg(any(feature = "metal", feature = "cuda"))]
3890 {
3891 #[cfg(feature = "metal")]
3892 let gpu_dense = frink_core::weight_matrix::metal_dense_enabled();
3893 #[cfg(not(feature = "metal"))]
3894 let gpu_dense = false;
3895 #[cfg(feature = "cuda")]
3896 let gpu_dense = gpu_dense || frink_core::weight_matrix::cuda_dense_enabled();
3897 if gpu_dense {
3898 let all_gemm = layer.moe.with_expert(0, |ex| {
3899 ex.gate.prefers_gpu_batch()
3900 && ex.up.prefers_gpu_batch()
3901 && ex.down.prefers_gpu_batch()
3902 });
3903 if !all_gemm {
3904 return None;
3905 }
3906 }
3907 }
3908 layer.moe.record_activations(&[0]);
3909 // One command buffer for the whole FFN when every matrix has a
3910 // simdgroup GEMM: gate and up feed the activation and the down
3911 // projection without the intermediates ever touching the host.
3912 // Three separate launches cost three round trips per layer plus
3913 // four copies of a `batch x ffn_dim` tensor. Not for a layer
3914 // with a norm between the activation and `down`
3915 // (`ffn_sub_norm`): the kernel has no such site.
3916 // ...nor a bias at any of its three sites (`crate::proj_bias`).
3917 #[cfg(feature = "metal")]
3918 if let (true, Some(gelu), None, None) = (
3919 frink_core::weight_matrix::metal_dense_enabled(),
3920 act.fused_kernel_gelu_flag(),
3921 layer.moe.ffn_sub_norm.as_ref(),
3922 layer.moe.dense_bias.as_ref(),
3923 ) {
3924 let fused = layer.moe.with_expert(0, |ex| {
3925 let (g, u, d) = (
3926 ex.gate.mul_mm_sg_launch()?,
3927 ex.up.mul_mm_sg_launch()?,
3928 ex.down.mul_mm_sg_launch()?,
3929 );
3930 frink_metal::gpu::launch_dense_ffn_swiglu_batch(
3931 &g,
3932 &u,
3933 &d,
3934 normed2_batch,
3935 batch_size,
3936 gelu,
3937 )
3938 .ok()
3939 });
3940 if let Some(out) = fused {
3941 return Some(out);
3942 }
3943 }
3944 Some(layer.moe.with_expert(0, |ex| {
3945 let ffn_acts = ex.gate.quantize_batch_acts(normed2_batch, batch_size);
3946 let (mut gate, mut up) = WeightMatrix::apply_batch_pair_with_acts(
3947 &ex.gate,
3948 &ex.up,
3949 normed2_batch,
3950 batch_size,
3951 ffn_acts.as_ref(),
3952 );
3953 // `build_ffn`: `up_b` / `gate_b` before the activation
3954 // (`crate::proj_bias`). On an ungated layer `gate` is an
3955 // alias of `up` and only `up_b` exists; `act.apply` reads
3956 // `up` alone for it, so the gate copy going unbiased is not
3957 // read.
3958 if let Some(bias) = &layer.moe.dense_bias {
3959 bias.add_pre_activation(&mut gate, &mut up, batch_size);
3960 }
3961 let mut activated = act.apply(&gate, &up);
3962 // bitnet.cpp:135-140, per row, on the same vector the row
3963 // body norms in `frink_moe::run_expert_sub_normed`.
3964 if let Some(w) = &layer.moe.ffn_sub_norm {
3965 let width = w.len();
3966 activated = activated
3967 .chunks(width)
3968 .flat_map(|row| rms_norm(row, w, config.rms_norm_eps))
3969 .collect();
3970 }
3971 let mut down = ex.down.apply_batch(&activated, batch_size);
3972 if let Some(bias) = &layer.moe.dense_bias {
3973 bias.add_post_down(&mut down, batch_size);
3974 }
3975 down
3976 }))
3977 }
3978
3979 /// CPU MoE prefill: bucket tokens by expert, then one
3980 /// `apply_batch` per expert with tokens instead of per-token
3981 /// `combine_ffn_outputs_for_position`. Shared experts append via
3982 /// [`Self::accumulate_shared_experts_batch`]. `None` when gates fail
3983 /// (small batch, dense, Metal preferred, non-resident, or any
3984 /// GPU-placed expert). Both gated activations are served here --
3985 /// the combine goes through [`GluAct`], so GeGLU no longer falls out
3986 /// to the per-position path.
3987 /// `routed_batch` is what the experts read, `normed2_batch` what the
3988 /// shared experts read: the same rows for every architecture but
3989 /// Arctic (see `combine_ffn_outputs_for_position`).
3990 #[allow(clippy::too_many_arguments)]
3991 fn moe_ffn_batch(
3992 layer_idx: usize,
3993 layer: &LayerWeights,
3994 normed2_batch: &[f32],
3995 routed_batch: &[f32],
3996 router_logits_batch: &[f32],
3997 batch_size: usize,
3998 config: &ModelConfig,
3999 plan: Option<&PlacementPlan>,
4000 ) -> Option<Vec<f32>> {
4001 let hidden_dim = config.hidden_dim;
4002 if batch_size < 32 || Self::is_dense_layer(layer) {
4003 return None;
4004 }
4005 // Metal prefill owns MoE when dense Metal is on
4006 // (`try_metal_moe_prefill_batch`); do not steal the path.
4007 #[cfg(feature = "metal")]
4008 if frink_core::metal_dense_enabled() {
4009 return None;
4010 }
4011 let acts = config.layer_ffn_acts(layer_idx);
4012 let act = acts.routed;
4013 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
4014 return None;
4015 };
4016 let n_experts = experts.len();
4017 let all_cpu = plan
4018 .map(|p| (0..n_experts).all(|eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu)))
4019 .unwrap_or(true);
4020 if !all_cpu || n_experts == 0 {
4021 return None;
4022 }
4023
4024 let mut buckets: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n_experts];
4025 for b in 0..batch_size {
4026 let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4027 let decision = Self::route_for_layer(layer, logits, config);
4028 layer.moe.record_activations(&decision.expert_ids);
4029 for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
4030 buckets[eid].push((b, w));
4031 }
4032 }
4033
4034 let mut acc = vec![0f32; batch_size * hidden_dim];
4035 for (eid, toks) in buckets.iter().enumerate() {
4036 if toks.is_empty() {
4037 continue;
4038 }
4039 let n = toks.len();
4040 let mut gathered = vec![0f32; n * hidden_dim];
4041 // Each gathered row is one (token, slot): the slot's input
4042 // and the weight its output carries come from the one
4043 // helper the row body uses (`crate::routed_weight_site`).
4044 let mut out_weights = Vec::with_capacity(n);
4045 for (i, &(tok, w)) in toks.iter().enumerate() {
4046 let (input, w) = crate::routed_weight_site::routed_slot(
4047 &routed_batch[tok * hidden_dim..(tok + 1) * hidden_dim],
4048 w,
4049 config.moe.routed_weight_before_ffn,
4050 );
4051 gathered[i * hidden_dim..(i + 1) * hidden_dim].copy_from_slice(&input);
4052 out_weights.push(w);
4053 }
4054 let ex = &experts[eid];
4055 let ffn_acts = ex.gate.quantize_batch_acts(&gathered, n);
4056 let gate = ex
4057 .gate
4058 .apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
4059 let up = ex.up.apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
4060 let activated = act.apply(&gate, &up);
4061 let down = ex.down.apply_batch(&activated, n);
4062 for (i, (&(tok, _), &w)) in toks.iter().zip(out_weights.iter()).enumerate() {
4063 let out = &down[i * hidden_dim..(i + 1) * hidden_dim];
4064 let row = &mut acc[tok * hidden_dim..(tok + 1) * hidden_dim];
4065 for (a, &o) in row.iter_mut().zip(out.iter()) {
4066 *a += w * o;
4067 }
4068 }
4069 }
4070
4071 Self::accumulate_shared_experts_batch(
4072 layer,
4073 normed2_batch,
4074 batch_size,
4075 hidden_dim,
4076 &mut acc,
4077 acts.dense,
4078 );
4079 Some(acc)
4080 }
4081
4082 /// gpt-oss's MoE FFN for one position.
4083 ///
4084 /// A separate function rather than another branch inside
4085 /// `combine_ffn_outputs_for_position` on purpose: that path carries
4086 /// expert-store prefetch, residency placement, a Metal top-k fusion
4087 /// and a batched parallel-slot kernel, and every one of them would
4088 /// need its own gpt-oss variant to stay honest. This is the whole
4089 /// gpt-oss FFN in one readable block, checked end-to-end against
4090 /// llama.cpp, and slow — routed experts run serially. It is the
4091 /// correct-first shape; making it fast is a separate change with its
4092 /// own A/B, not something to smuggle in under a correctness fix.
4093 ///
4094 /// Ported from `llama-graph.cpp::build_moe_ffn` with
4095 /// `gating_op = LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX_WEIGHT`,
4096 /// `type_op = LLM_FFN_SWIGLU_OAI_MOE`, `norm_w = false`,
4097 /// `w_scale = 1`, all four bias tensors present.
4098 fn gpt_oss_ffn(
4099 layer: &LayerWeights,
4100 oai: &GptOssLayer,
4101 normed2: &[f32],
4102 config: &ModelConfig,
4103 hidden_dim: usize,
4104 ) -> Vec<f32> {
4105 let mut router_logits = layer.moe.router.apply(normed2);
4106 for (x, b) in router_logits.iter_mut().zip(oai.router_bias.iter()) {
4107 *x += b;
4108 }
4109 // Selection on the raw biased logits, softmax over the winners
4110 // only -- see `route_top_k_softmax_weight`.
4111 let decision =
4112 frink_moe::route_top_k_softmax_weight(&router_logits, config.moe.n_experts_active);
4113 layer.moe.record_activations(&decision.expert_ids);
4114
4115 let mut out = vec![0f32; hidden_dim];
4116 for (slot, &eid) in decision.expert_ids.iter().enumerate() {
4117 let w = decision.weights[slot];
4118 let expert_out = layer.moe.with_expert(eid, |ex| {
4119 frink_moe::run_expert_oai(
4120 normed2,
4121 ex,
4122 &oai.expert_bias[eid],
4123 frink_moe::SWIGLU_OAI_ALPHA,
4124 frink_moe::SWIGLU_OAI_LIMIT,
4125 )
4126 });
4127 for (o, e) in out.iter_mut().zip(expert_out.iter()) {
4128 *o += w * e;
4129 }
4130 }
4131 out
4132 }
4133
4134 /// `forward_token`'s MoE FFN block for one position: the dense
4135 /// fast path (see `is_dense_layer`) or the full router+combine path
4136 /// with the router computed inline via a single-position `apply`.
4137 fn run_ffn_block(
4138 layer_idx: usize,
4139 layer: &LayerWeights,
4140 normed2: &[f32],
4141 config: &ModelConfig,
4142 hidden_dim: usize,
4143 plan: Option<&PlacementPlan>,
4144 operand: ffn_block::RouterOperand,
4145 ) -> Vec<f32> {
4146 if Self::is_dense_layer(layer) {
4147 layer.moe.record_activations(&[0]);
4148 // One expert, run exactly the way a routed one is. The GeGLU
4149 // arm used to be spelled out here and nowhere else, which is
4150 // precisely how the routed paths ended up SwiGLU-only.
4151 let act = config.layer_ffn_acts(layer_idx).dense;
4152 return Self::run_dense_expert(layer, normed2, act, config.rms_norm_eps);
4153 }
4154 // What the router reads is the caller's fact (`crate::router_input`):
4155 // the normed FFN input here, logits computed before attention, or
4156 // Arctic's normed layer input, which its experts read too.
4157 let (router_logits, routed_input): (Vec<f32>, &[f32]) = match &operand {
4158 ffn_block::RouterOperand::FfnInput => (layer.moe.router.apply(normed2), normed2),
4159 ffn_block::RouterOperand::Precomputed(logits) => (logits.clone(), normed2),
4160 ffn_block::RouterOperand::BranchInput(x) => (layer.moe.router.apply(x), x.as_slice()),
4161 };
4162 Self::combine_ffn_outputs_for_position(
4163 layer_idx,
4164 layer,
4165 normed2,
4166 routed_input,
4167 &router_logits,
4168 config,
4169 hidden_dim,
4170 plan,
4171 )
4172 }
4173
4174 /// One token's embedding row, scaled if this checkpoint scales it.
4175 ///
4176 /// `embedding_scale` is `sqrt(hidden_dim)` on the Gemma family and
4177 /// `None` everywhere else, so a path that dequantizes the row and
4178 /// forgets the multiply is wrong on exactly one family and right on
4179 /// every other -- which is why it survived as a drift for as long as
4180 /// it did. The lookup and the scale live in one function so a caller
4181 /// cannot obtain the row without it.
4182 /// The embedding row, scaled by `embedding_scale` where the
4183 /// architecture has one, and -- for a skip-stream model
4184 /// (`crate::skip_stream`) -- RMS-normed without a weight, as
4185 /// `talkie.cpp:50` norms `inpL` before layer 0. The ONE embedding
4186 /// site; what it returns is both layer 0's input and the skip
4187 /// source.
4188 ///
4189 /// `pos` is the token's position, which a learned position table
4190 /// (`crate::position_embd`) indexes: `gpt2.cpp:74-77` add row `pos`
4191 /// after `build_inp_embd`'s scale and before anything else.
4192 fn embed_token(&self, token_id: usize, pos: usize) -> Vec<f32> {
4193 let mut row = self.embedding.dequant_row(token_id);
4194 if let Some(scale) = self.config.embedding_scale {
4195 for v in row.iter_mut() {
4196 *v *= scale;
4197 }
4198 }
4199 if let Some(table) = &self.position_embd {
4200 assert!(
4201 pos < table.rows(),
4202 "position {pos} is past the {}-row learned position table \
4203 (`position_embd.weight`); the trained context is the model's limit",
4204 table.rows()
4205 );
4206 for (v, p) in row.iter_mut().zip(table.dequant_row(pos)) {
4207 *v += p;
4208 }
4209 }
4210 // `bloom.cpp:77-80`: the embedding normed before layer 0. No
4211 // graph has both a position table and this norm, so their
4212 // order is not a graph's; it is the order the two arrived in.
4213 if !matches!(self.embedding_norm, NormOp::None) {
4214 row = self.embedding_norm.apply(&row, self.config.rms_norm_eps);
4215 }
4216 if self.config.skip_stream {
4217 row = crate::norm::rms_norm_no_params(&row, self.config.rms_norm_eps);
4218 }
4219 row
4220 }
4221
4222 /// [`Self::embed_token`] for a whole batch: `[batch, hidden]`,
4223 /// flattened row-major, row `b` at `position(b)`.
4224 fn embed_tokens(&self, tokens: &[usize], position: impl Fn(usize) -> usize) -> Vec<f32> {
4225 tokens
4226 .iter()
4227 .enumerate()
4228 .flat_map(|(b, &t)| self.embed_token(t, position(b)))
4229 .collect()
4230 }
4231
4232 /// The `output_head` half of a single-position forward: project the
4233 /// final-normed hidden state and softcap the result if this
4234 /// checkpoint softcaps it.
4235 ///
4236 /// The counterpart to [`Self::logits_from_flat_hidden`] for the
4237 /// one-row case, and held here for the same reason: Gemma-2 caps its
4238 /// final logits at 30.0, so a path that projects and returns without
4239 /// capping produces a different distribution -- not an error, just a
4240 /// quietly wrong one.
4241 fn logits_from_normed(&self, final_normed: &[f32]) -> Vec<f32> {
4242 Logits::project(
4243 &self.output_head,
4244 final_normed,
4245 self.output_bias.as_deref(),
4246 self.config.final_logit_softcap,
4247 self.config.logit_multiplier,
4248 )
4249 .into_vec()
4250 }
4251
4252 /// The `output_head` half of [`Self::forward_batch`], split out so
4253 /// the hidden-state-returning variant cannot drift from it (a
4254 /// second copy of the softcap would be a silent quality bug).
4255 fn logits_from_flat_hidden(&self, flat: Vec<f32>, batch_size: usize) -> Vec<Vec<f32>> {
4256 let vocab_size = self.output_head.rows();
4257 let logits_batch = Logits::from_output_head(
4258 self.output_head.apply_batch(&flat, batch_size),
4259 self.output_bias.as_deref(),
4260 self.config.final_logit_softcap,
4261 self.config.logit_multiplier,
4262 );
4263 logits_batch
4264 .as_slice()
4265 .chunks(vocab_size)
4266 .map(|c| c.to_vec())
4267 .collect()
4268 }
4269
4270 /// [`Self::forward_batch_last`], plus the choice of whether the host
4271 /// caches have to hold the real K/V when it returns. See
4272 /// [`Self::advance_host_kv_after_metal_prefill`] for why that is a
4273 /// choice at all.
4274 fn forward_batch_last_inner(
4275 &self,
4276 tokens: &[usize],
4277 start_pos: usize,
4278 kv_caches: &mut [KvCache],
4279 host_kv_authoritative: bool,
4280 ) -> Vec<f32> {
4281 let hiddens =
4282 self.forward_hidden_batch_inner(tokens, start_pos, kv_caches, host_kv_authoritative);
4283 let Some(last) = hiddens.last() else {
4284 return Vec::new();
4285 };
4286 self.logits_from_normed(last)
4287 }
4288
4289 /// The body of [`Self::forward_batch_last_paged`], already running on a
4290 /// CPU-pool worker. See `entry.rs` for why the split exists.
4291 ///
4292 /// `every_row` is the ONE difference between prefilling a paged
4293 /// prompt and SCORING one: the gather, the reservation and the
4294 /// scatter are identical, and what changes is whether the lm_head
4295 /// is projected once or at every position. A second copy of this
4296 /// function to vary that is how this repo has lost model features
4297 /// before, so it is a parameter.
4298 fn forward_batch_paged_on_worker(
4299 &self,
4300 tokens: &[usize],
4301 start_pos: usize,
4302 kv_caches: &mut [PagedKvCache],
4303 stores: &SharedPagedKv,
4304 every_row: bool,
4305 ) -> Result<Vec<Vec<f32>>, PagedStoreExhausted> {
4306 assert_eq!(kv_caches.len(), self.config.n_layers);
4307 assert_eq!(stores.layer_count(), self.config.n_layers);
4308 if tokens.is_empty() {
4309 return Ok(Vec::new());
4310 }
4311
4312 // Reserve every layer up front, under guards spanning the check
4313 // AND the take. Each layer has its own store, so one having
4314 // room says nothing about the next -- and under concurrency,
4315 // checking and then taking as separate steps lets another
4316 // request slip in between and leave this one half-written.
4317 //
4318 // Reserving before the forward rather than after also means a
4319 // request that cannot fit is refused before it burns a prefill.
4320 {
4321 let mut guards = stores.write_all();
4322 for (cache, store) in kv_caches.iter().zip(guards.iter()) {
4323 if cache.blocks_needed_for(store, tokens.len()) > store.free_block_count() {
4324 return Err(PagedStoreExhausted);
4325 }
4326 }
4327 for (cache, store) in kv_caches.iter_mut().zip(guards.iter_mut()) {
4328 cache
4329 .reserve(store, tokens.len())
4330 .expect("checked against free_block_count under this same guard");
4331 }
4332 }
4333
4334 // Gather under read guards, one layer at a time: the forward
4335 // below is the expensive part and holds nothing.
4336 let mut scratch: Vec<KvCache> = kv_caches
4337 .iter()
4338 .enumerate()
4339 .map(|(l, cache)| cache.to_contiguous(&stores.read(l)))
4340 .collect();
4341
4342 // `host_kv_authoritative`: the scatter below READS these caches,
4343 // and a Metal prefill otherwise leaves them holding
4344 // `advance_len` placeholders while the real K/V sits on the
4345 // device. Copying those placeholders into the page store is
4346 // what made paged KV on Metal answer fluent nonsense from a
4347 // prompt the model never attended over.
4348 let logits = if every_row {
4349 // One logit row per POSITION, which is what scoring a
4350 // prompt needs and what prefilling one must not pay for:
4351 // on a 6000-token prompt this is 6000 vocabulary-wide
4352 // matmuls instead of one.
4353 let hiddens = self.forward_hidden_batch_inner(tokens, start_pos, &mut scratch, true);
4354 let batch_size = hiddens.len();
4355 let flat: Vec<f32> = hiddens.into_iter().flatten().collect();
4356 self.logits_from_flat_hidden(flat, batch_size)
4357 } else {
4358 vec![self.forward_batch_last_inner(tokens, start_pos, &mut scratch, true)]
4359 };
4360
4361 // Scatter into blocks this sequence already owns. Nothing here
4362 // can fail, which is the point of reserving above.
4363 for (l, (cache, gathered)) in kv_caches.iter_mut().zip(&scratch).enumerate() {
4364 let mut store = stores.write(l);
4365 let width = store.n_kv_heads() * store.head_dim();
4366 let base = cache.seq_len() * width;
4367 cache
4368 .append_contiguous(
4369 &mut store,
4370 &gathered.k[base..],
4371 &gathered.v[base..],
4372 tokens.len(),
4373 )
4374 .expect("blocks reserved above are still held by this sequence");
4375 // A recurrent layer's state moved in the scratch copy and
4376 // is not rows (`frink_core::recurrent_state`).
4377 cache.recurrent = gathered.recurrent.clone();
4378 }
4379 Ok(logits)
4380 }
4381
4382 /// [`Self::forward_hidden_batch`] with one extra promise the public
4383 /// signature cannot express.
4384 ///
4385 /// `host_kv_authoritative` says whether the caller will READ
4386 /// `kv_caches` afterwards. Metal prefill normally leaves K/V on the
4387 /// device and fills the host rows with a `advance_len` placeholder,
4388 /// which is correct only because the contiguous decode path then
4389 /// reads the device buffers too. `forward_batch_last_paged` reads
4390 /// the host rows -- it copies them into the page store -- so it
4391 /// passes `true` and pays for the download.
4392 fn forward_hidden_batch_inner(
4393 &self,
4394 tokens: &[usize],
4395 start_pos: usize,
4396 kv_caches: &mut [KvCache],
4397 host_kv_authoritative: bool,
4398 ) -> Vec<Vec<f32>> {
4399 // Read only by the Metal arms below; a CPU-only build fills the
4400 // host cache with real rows on every path and has nothing to
4401 // choose between.
4402 let _ = host_kv_authoritative;
4403 assert_eq!(kv_caches.len(), self.config.n_layers);
4404 let batch_size = tokens.len();
4405 if batch_size == 0 {
4406 return Vec::new();
4407 }
4408
4409 let hidden_dim = self.config.hidden_dim;
4410 let head_dim = self.config.head_dim;
4411 let v_head_dim = self.config.v_head_dim();
4412 // The Metal KV plane holds ONE geometry, and `use_metal_attn`
4413 // below is false for a per-layer-shape model
4414 // (`metal_can_serve_model`), so the widest layer's count is
4415 // every layer's wherever this is read.
4416 #[cfg(feature = "metal")]
4417 let n_kv_heads = self.config.n_kv_heads;
4418
4419 // [batch, hidden], flattened row-major.
4420 let mut hidden_batch: Vec<f32> = self.embed_tokens(tokens, |b| start_pos + b);
4421 let skip_rows = self.config.skip_stream.then(|| hidden_batch.clone());
4422
4423 #[cfg(feature = "metal")]
4424 let use_metal_attn = frink_core::metal_dense_enabled()
4425 && frink_metal::attn::metal_attn_enabled()
4426 && self
4427 .layers
4428 .iter()
4429 .all(|l| self.layer_supports_metal_attn(l));
4430
4431 #[cfg(not(feature = "metal"))]
4432 let use_metal_attn = false;
4433
4434 let residency = self.expert_residency_plan(use_metal_attn);
4435
4436 #[cfg(feature = "metal")]
4437 let mut metal_kv_guard: Option<
4438 std::sync::MutexGuard<'_, Option<Vec<frink_metal::attn::MetalKvBuffers>>>,
4439 > = if use_metal_attn {
4440 Some(Self::lock_metal_attn_kv(&self.metal_attn_kv))
4441 } else {
4442 None
4443 };
4444
4445 #[cfg(feature = "metal")]
4446 if let Some(guard) = metal_kv_guard.as_mut() {
4447 let need = self.layers.len();
4448 let need_cap = start_pos
4449 .saturating_add(batch_size)
4450 .saturating_add(256)
4451 .max(512);
4452 let reset = match guard.as_ref() {
4453 None => true,
4454 Some(v) => {
4455 v.len() != need
4456 || v.iter().any(|m| m.capacity() < need_cap)
4457 || v.iter()
4458 .zip(kv_caches.iter())
4459 // ROWS: Metal holds rows, and this asks whether the
4460 // host buffer matches them.
4461 .any(|(m, c)| m.seq_len != c.rows())
4462 }
4463 };
4464 if reset {
4465 let mut bufs = Vec::with_capacity(need);
4466 for _ in 0..need {
4467 match frink_metal::attn::MetalKvBuffers::with_capacity(
4468 n_kv_heads, head_dim, need_cap,
4469 ) {
4470 Ok(b) => bufs.push(b),
4471 Err(_) => {
4472 **guard = None;
4473 break;
4474 }
4475 }
4476 }
4477 if bufs.len() == need {
4478 let mut ok = true;
4479 for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
4480 if c.rows() > 0 && m.upload_from_host(&c.k, &c.v, c.rows()).is_err() {
4481 ok = false;
4482 break;
4483 }
4484 }
4485 if ok {
4486 **guard = Some(bufs);
4487 } else {
4488 **guard = None;
4489 }
4490 } else {
4491 **guard = None;
4492 }
4493 }
4494 }
4495
4496 let n_layers = self.config.n_layers;
4497 let mut hrm = self.hrm_streams(&hidden_batch);
4498 let mut l = 0usize;
4499 // Labelled for the Metal arm inside the `'attention` block below,
4500 // whose `continue` must name the loop it leaves.
4501 #[allow(unused_labels)]
4502 'layers: while l < n_layers {
4503 self.hrm_stack_input(hrm.as_ref(), l, &mut hidden_batch);
4504 let layer = self.layer_for(l);
4505 // THIS layer's head counts. Zero for the two attention-less
4506 // shapes, which leave the loop below before a width is used;
4507 // the Metal arms only run on a uniform model
4508 // (`metal_can_serve_model`), where every layer's counts are
4509 // the config's.
4510 let shape = self.config.layer_shape(l);
4511 let (n_heads, n_kv_heads) = (shape.attention.n_heads(), shape.attention.n_kv_heads());
4512 let q_width = n_heads * head_dim;
4513 let kv_width = n_kv_heads * head_dim;
4514 let v_width = n_kv_heads * v_head_dim;
4515 let out_width = n_heads * v_head_dim;
4516
4517 // Multi-layer dense prefill: one CB, activations stay on GPU.
4518 #[cfg(feature = "metal")]
4519 if use_metal_attn && batch_size >= 4 {
4520 if let Some(guard) = metal_kv_guard.as_mut() {
4521 if let Some(metal_kvs) = guard.as_mut() {
4522 if let Some(run_len) = self.metal_prefill_dense_stack_run_len(
4523 l,
4524 start_pos,
4525 batch_size,
4526 kv_caches,
4527 Some(metal_kvs.as_slice()),
4528 ) {
4529 if let Some(h_out) = self.try_metal_prefill_dense_stack(
4530 l,
4531 run_len,
4532 &hidden_batch,
4533 start_pos,
4534 batch_size,
4535 n_heads,
4536 metal_kvs,
4537 kv_caches,
4538 host_kv_authoritative,
4539 ) {
4540 hidden_batch = h_out;
4541 l += run_len;
4542 continue;
4543 }
4544 }
4545 }
4546 }
4547 }
4548
4549 // A run of dense layers on the device, the hidden batch
4550 // resident across them, instead of seven round trips per
4551 // layer (#259). Declines before touching any cache; the
4552 // host body below then runs the layer.
4553 #[cfg(feature = "cuda")]
4554 if let Some((h_out, run_len)) = self.try_cuda_prefill_dense_stack(
4555 l,
4556 &hidden_batch,
4557 start_pos,
4558 batch_size,
4559 kv_caches,
4560 ) {
4561 hidden_batch = h_out;
4562 l += run_len;
4563 continue;
4564 }
4565
4566 let cache = &mut kv_caches[l];
4567
4568 // One-CB dense prefill (RMSNorm→QKV GEMM→attn→O→FFN) when every
4569 // projection has mul_mm_sg and the layer has no QKV bias / QK-norm.
4570 #[cfg(feature = "metal")]
4571 if use_metal_attn
4572 && batch_size >= 4
4573 && Self::metal_prefill_dense_layer_eligible(
4574 layer,
4575 &self.config,
4576 self.lora_attached(),
4577 )
4578 {
4579 let swa_fits = self.metal_prefill_dense_swa_fits(l, start_pos, batch_size);
4580 if swa_fits {
4581 if let Some(guard) = metal_kv_guard.as_mut() {
4582 if let Some(metal_kvs) = guard.as_mut() {
4583 // POSITIONS: compared against `start_pos`.
4584 if metal_kvs[l].seq_len == cache.positions()
4585 && start_pos == cache.positions()
4586 {
4587 layer.moe.record_activations(&[0]);
4588 let fused = layer.moe.with_expert(0, |ex| {
4589 let (q, k, v, o) = (
4590 layer.attn.q_proj.mul_mm_sg_launch()?,
4591 layer.attn.k_proj.mul_mm_sg_launch()?,
4592 layer.attn.v_proj.mul_mm_sg_launch()?,
4593 layer.attn.o_proj.mul_mm_sg_launch()?,
4594 );
4595 let ffn = frink_metal::attn::PrefillFfnMetal::Dense {
4596 gate: ex.gate.mul_mm_sg_launch()?,
4597 up: ex.up.mul_mm_sg_launch()?,
4598 down: ex.down.mul_mm_sg_launch()?,
4599 };
4600 let gelu = self
4601 .config
4602 .model_ffn_act()
4603 .and_then(GluAct::fused_kernel_gelu_flag)?;
4604 let prefill_layer = frink_metal::attn::PrefillDenseLayerMetal {
4605 // See `try_metal_prefill_dense_stack`:
4606 // no weight, no fused launch.
4607 attn_norm_w: layer.attn.norm_weight.rms_weights()?,
4608 ffn_norm_w: layer.moe.norm_weight.rms_weights()?,
4609 q,
4610 k,
4611 v,
4612 o,
4613 ffn,
4614 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
4615 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
4616 extras: self.metal_attn_extras(layer),
4617 rope: self.metal_layer_rope(l),
4618 layer_idx: l as u32,
4619 };
4620 frink_metal::attn::launch_prefill_dense_layer(
4621 &hidden_batch,
4622 &prefill_layer,
4623 &mut metal_kvs[l],
4624 n_heads,
4625 batch_size,
4626 self.metal_rope(),
4627 start_pos,
4628 self.config.rms_norm_eps,
4629 gelu,
4630 self.config.attn_logit_softcap,
4631 )
4632 .ok()
4633 });
4634 if let Some(h_out) = fused {
4635 Self::advance_host_kv_after_metal_prefill(
4636 &metal_kvs[l],
4637 cache,
4638 batch_size,
4639 host_kv_authoritative,
4640 );
4641 hidden_batch = h_out;
4642 l += 1;
4643 continue;
4644 }
4645 }
4646 }
4647 }
4648 }
4649 }
4650
4651 // --- attention block ---
4652 let inputs = self.branch_inputs(layer, &hidden_batch, batch_size);
4653 let normed_batch: Vec<f32> =
4654 self.pre_norm_residual(&layer.attn.norm_weight, &mut hidden_batch, batch_size);
4655 let oai = self
4656 .gpt_oss
4657 .as_ref()
4658 .map(|g| &g.layers[self.physical_index(l)]);
4659
4660 // The GQA body, or the two shapes that have none of it
4661 // (`crate::layer_shapes::AttnShape`): a labelled block so the
4662 // FFN half below is reached by all three without a copy of
4663 // it, and so the Metal arms inside keep their `continue`s.
4664 'attention: {
4665 match shape.attention {
4666 // deci.cpp:107-109: the residual passes straight through.
4667 crate::layer_shapes::AttnShape::Absent => break 'attention,
4668 // deci.cpp:115-118: `attn_norm` then `wo`, nothing else.
4669 crate::layer_shapes::AttnShape::Linear => {
4670 let projected = layer.attn.o_proj.apply_batch(&normed_batch, batch_size);
4671 residual_add(&mut hidden_batch, &projected, self.config.residual_scale);
4672 break 'attention;
4673 }
4674 // lfm2.cpp:197 / granite-hybrid.cpp:163: the rows are
4675 // consecutive positions of one sequence, on this
4676 // layer's cache.
4677 crate::layer_shapes::AttnShape::ShortConv
4678 | crate::layer_shapes::AttnShape::Mamba2
4679 | crate::layer_shapes::AttnShape::Mamba1
4680 | crate::layer_shapes::AttnShape::Plamo2Ssm
4681 | crate::layer_shapes::AttnShape::Gdn
4682 | crate::layer_shapes::AttnShape::Lightning => {
4683 let out = self.recurrent_block(
4684 l,
4685 layer,
4686 &normed_batch,
4687 batch_size,
4688 KvStep::Batched(cache),
4689 );
4690 residual_add(&mut hidden_batch, &out, self.config.residual_scale);
4691 break 'attention;
4692 }
4693 crate::layer_shapes::AttnShape::Gqa { .. } => {}
4694 }
4695
4696 // falcon-h1.cpp:156-160: the parallel Mamba-2 block over the
4697 // same normed rows, on this layer's cache, before the push.
4698 let parallel_ssm = self.parallel_ssm_rows(
4699 l,
4700 layer,
4701 &normed_batch,
4702 batch_size,
4703 &mut cache.recurrent,
4704 );
4705 // One shared activation-quant pass for q/k/v (plan 1e): the
4706 // three projections read the same normed batch, so quantize it
4707 // once instead of once per projection. A kind mismatch inside
4708 // the group just re-quantizes locally.
4709 let qkv_acts = layer
4710 .attn
4711 .q_proj
4712 .quantize_batch_acts(&normed_batch, batch_size);
4713 let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
4714 &normed_batch,
4715 batch_size,
4716 qkv_acts.as_ref(),
4717 );
4718 // qwen35.cpp:191-199: the gate rides in `wq`; split it
4719 // off before anything reads a Q width.
4720 let q_gate = layer.attn.q_gate_interleaved.then(|| {
4721 let (q, gate) = crate::attn_gate::split_interleaved_q_gate(
4722 &q_batch, batch_size, n_heads, head_dim,
4723 );
4724 q_batch = q;
4725 gate
4726 });
4727 let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
4728 &normed_batch,
4729 batch_size,
4730 qkv_acts.as_ref(),
4731 );
4732 let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
4733 &normed_batch,
4734 batch_size,
4735 qkv_acts.as_ref(),
4736 );
4737 drop(qkv_acts);
4738
4739 self.apply_qkv_bias_and_clamp(
4740 layer,
4741 &mut q_batch,
4742 &mut k_batch,
4743 &mut v_batch,
4744 q_width,
4745 kv_width,
4746 v_width,
4747 );
4748
4749 self.apply_qk_norms_pre_rope(layer, &mut q_batch, &mut k_batch, q_width, kv_width);
4750 // Host-side `mscale`, applied before either backend ropes.
4751 // The Metal branch below therefore hands its kernels
4752 // `attn_factor_applied_by_caller()` — folding it into cos/sin
4753 // there as well would square it.
4754 self.apply_rope_attn_factor(&mut q_batch, &mut k_batch, l);
4755
4756 #[cfg(feature = "metal")]
4757 {
4758 let mut did_metal_prefill = false;
4759 // The Metal prefill kernel is full-causal: only safe on a
4760 // SWA layer while every causal position is still inside
4761 // the window. Longer prompts fall back to CPU attention.
4762 let swa_fits = match self.config.layer_sliding_window(l) {
4763 Some(window) => start_pos + batch_size <= window,
4764 None => true,
4765 };
4766 // Metal prefill applies attn softcap in FA-vec / legacy GQA.
4767 if let Some(guard) = metal_kv_guard.as_mut() {
4768 if let Some(metal_kvs) = guard.as_mut() {
4769 // POSITIONS: compared against `start_pos`.
4770 // `layer_rope` is `None` where llama.cpp does
4771 // not rotate this layer at all
4772 // (`crate::rope_layers`); the Metal prefill
4773 // block always ropes, so such a layer takes the
4774 // CPU body below rather than a rotation the
4775 // checkpoint never trained.
4776 if let (true, Some(layer_rope)) = (
4777 metal_kvs[l].seq_len == cache.positions()
4778 && start_pos == cache.positions()
4779 && swa_fits,
4780 self.metal_layer_rope(l),
4781 ) {
4782 let prefill_res = {
4783 frink_metal::attn::launch_prefill_attn_block(
4784 &q_batch,
4785 &k_batch,
4786 &v_batch,
4787 &mut metal_kvs[l],
4788 n_heads,
4789 batch_size,
4790 self.metal_rope().attn_factor_applied_by_caller(),
4791 layer_rope,
4792 start_pos,
4793 self.config.attn_logit_softcap,
4794 false,
4795 )
4796 .map(
4797 |(attn_out_batch, _, _)| {
4798 Self::advance_host_kv_after_metal_prefill(
4799 &metal_kvs[l],
4800 cache,
4801 batch_size,
4802 host_kv_authoritative,
4803 );
4804 let projected_batch = layer
4805 .attn
4806 .o_proj
4807 .apply_batch(&attn_out_batch, batch_size);
4808 let projected_batch =
4809 if let Some(post) = &layer.attn.post_attn_norm {
4810 projected_batch
4811 .chunks(hidden_dim)
4812 .flat_map(|row| {
4813 rms_norm(
4814 row,
4815 post,
4816 self.config.rms_norm_eps,
4817 )
4818 })
4819 .collect::<Vec<_>>()
4820 } else {
4821 projected_batch
4822 };
4823 residual_add(
4824 &mut hidden_batch,
4825 &projected_batch,
4826 self.config.residual_scale,
4827 );
4828 true
4829 },
4830 )
4831 };
4832 match prefill_res {
4833 Ok(true) => {
4834 did_metal_prefill = true;
4835 }
4836 Ok(false) => {}
4837 Err(e) => {
4838 eprintln!(
4839 "frink: Metal prefill attn failed, CPU fallback: {e}"
4840 );
4841 **guard = None;
4842 }
4843 }
4844 }
4845 }
4846 }
4847 if did_metal_prefill {
4848 self.ffn_block_batch(
4849 l,
4850 layer,
4851 &mut hidden_batch,
4852 batch_size,
4853 oai,
4854 residency
4855 .as_ref()
4856 .map(|p| p.layer_plan(self.physical_index(l))),
4857 inputs,
4858 ffn_block::BatchedFfnKernels::Prefill,
4859 skip_rows.as_deref().map(|rows| SkipStream { rows }),
4860 );
4861 l += 1;
4862 continue 'layers;
4863 }
4864 }
4865
4866 // RoPE per token is independent; parallelize for CPU pp512.
4867 q_batch
4868 .par_chunks_mut(q_width)
4869 .zip(k_batch.par_chunks_mut(kv_width))
4870 .enumerate()
4871 .for_each(|(b, (q_row, k_row))| {
4872 let pos = start_pos + b;
4873 for h in 0..n_heads {
4874 self.apply_rope_head_layer(
4875 &mut q_row[h * head_dim..(h + 1) * head_dim],
4876 pos,
4877 l,
4878 );
4879 }
4880 for h in 0..n_kv_heads {
4881 self.apply_rope_head_layer(
4882 &mut k_row[h * head_dim..(h + 1) * head_dim],
4883 pos,
4884 l,
4885 );
4886 }
4887 });
4888 // `maincoder` / `hunyuan-moe` norm HERE instead. Reachable
4889 // only on the host path, which is why
4890 // `layer_supports_metal_attn` refuses the layer outright
4891 // rather than letting the Metal arms above consume a batch
4892 // that has not been normed yet.
4893 self.apply_qk_norms_post_rope(
4894 layer,
4895 l,
4896 &mut q_batch,
4897 &mut k_batch,
4898 q_width,
4899 kv_width,
4900 );
4901 // Elementwise, so the whole Q batch in one call. Like the
4902 // multi-sequence path, this body did not apply it at all
4903 // until the decoration audit. It is placed AFTER the Metal
4904 // arms above deliberately: none of the seven fused launches
4905 // has an `attention_scale` uniform, and Q never returns to
4906 // the host inside `launch_prefill_dense_layer` /
4907 // `launch_prefill_dense_stack` for it to be scaled. The
4908 // refusal that keeps those arms out of reach when
4909 // `attention_scale` is set is in `layer_supports_metal_attn`.
4910 self.apply_attention_scale(&mut q_batch);
4911 // Same placement and same fence, per row: row `b` of this
4912 // batch is position `start_pos + b`, which is what the
4913 // RoPE loop above used for it.
4914 self.apply_attn_temperature(l, &mut q_batch, q_width, |b| start_pos + b);
4915
4916 // ROWS, not positions: it is added to `b + 1` below to give
4917 // each query in the batch the length of the KV it attends
4918 // over, which is a count of resident rows.
4919 let base_seq_len = cache.rows();
4920 for b in 0..batch_size {
4921 cache
4922 .push(
4923 &k_batch[b * kv_width..(b + 1) * kv_width],
4924 &v_batch[b * v_width..(b + 1) * v_width],
4925 )
4926 .expect("unbounded/planned KvCache growth is infallible");
4927 }
4928
4929 // Prefill attention over the just-written KV prefix. Parallel
4930 // over query positions — the serial loop was a dominant CPU
4931 // pp512 bottleneck (each query still attends only its causal
4932 // prefix; K/V slices are immutable after the pushes above).
4933 let cache_k = &cache.k;
4934 let cache_v = &cache.v;
4935 let softcap = self.config.attn_logit_softcap;
4936 let batch_window = self.config.batch_window(l, start_pos, batch_size);
4937 // A layer with sinks takes the per-query path, windowed
4938 // or not: the blocked kernel has no sink term. So does a
4939 // chunked layer whose queries straddle a chunk boundary
4940 // (`crate::chunked_swa`): the blocked kernel takes ONE
4941 // window. Everything
4942 // else goes through the blocked kernel, which is Rayon over
4943 // `[query-block x head]` against one shared KV buffer,
4944 // windowed or not. SWA layers used to take a per-query
4945 // `causal_gqa_attention_windowed_softcap` instead, which is
4946 // `online_attn_accumulate`: two scalar `exp` and a
4947 // head_dim-wide rescale per KV position, with the head axis
4948 // serial inside each task. On Gemma-3-1B (22 of 26 layers
4949 // are SWA) that arm was 19.6% of non-idle CPU `pp512`
4950 // samples while doing the *same* KV work as this one - at
4951 // `pp512` the 512-wide window covers the whole prompt.
4952 let sinks = layer.attn.sinks.as_deref();
4953 let blocked = match (batch_window, sinks) {
4954 (crate::config::BatchWindow::Uniform(window), None) => Some(window),
4955 (crate::config::BatchWindow::Uniform(_), Some(_))
4956 | (crate::config::BatchWindow::PerQuery, _) => None,
4957 };
4958 let mut attn_out_batch = match blocked {
4959 Some(window) => self.prefill_attention_blocked(
4960 &q_batch,
4961 cache_k,
4962 cache_v,
4963 n_heads,
4964 n_kv_heads,
4965 head_dim,
4966 v_head_dim,
4967 batch_size,
4968 base_seq_len,
4969 softcap,
4970 window,
4971 ),
4972 None => {
4973 let mut out = vec![0f32; batch_size * out_width];
4974 out.par_chunks_mut(out_width)
4975 .enumerate()
4976 .for_each(|(b, dest)| {
4977 let seq_len_b = base_seq_len + b + 1;
4978 let attn_out = causal_gqa_attention_row(
4979 &q_batch[b * q_width..(b + 1) * q_width],
4980 &cache_k[..seq_len_b * kv_width],
4981 &cache_v[..seq_len_b * v_width],
4982 n_heads,
4983 n_kv_heads,
4984 head_dim,
4985 v_head_dim,
4986 seq_len_b,
4987 // Row `b` is position `start_pos + b`,
4988 // as the RoPE loop above had it.
4989 self.config.layer_window_for_query(l, start_pos + b),
4990 sinks,
4991 // The sink arm carries no softcap, as
4992 // `push_and_attend_row` has it.
4993 if sinks.is_some() { None } else { softcap },
4994 self.alibi_slopes.as_deref(),
4995 );
4996 dest.copy_from_slice(&attn_out);
4997 });
4998 out
4999 }
5000 };
5001
5002 // Every query in this batch has now been answered, so the
5003 // rows behind the window are rows nothing will read again
5004 // (#61). This is why eviction is not inside `KvCache::push`:
5005 // `base_seq_len` above was captured BEFORE the batch's
5006 // pushes and every query's KV length is derived from it, so
5007 // a drop between the push loop and here would attend the
5008 // whole prompt over shifted keys.
5009 //
5010 // Per layer rather than after the stack, and that is where
5011 // most of the prefill saving is: a windowed layer hands its
5012 // prompt rows back before the next layer allocates its own,
5013 // so a 32k prompt holds ONE layer's full history at a time
5014 // instead of every windowed layer's at once.
5015 self.evict_layer_kv(l, cache);
5016
5017 let mut projected_batch = self.attn_out_to_residual_rows(
5018 layer,
5019 &normed_batch,
5020 &mut attn_out_batch,
5021 batch_size,
5022 q_gate.as_deref(),
5023 );
5024 Self::add_parallel_ssm(&mut projected_batch, parallel_ssm);
5025 residual_add(
5026 &mut hidden_batch,
5027 &projected_batch,
5028 self.config.residual_scale,
5029 );
5030 } // 'attention
5031
5032 // --- FFN block ---
5033 self.ffn_block_batch(
5034 l,
5035 layer,
5036 &mut hidden_batch,
5037 batch_size,
5038 oai,
5039 residency
5040 .as_ref()
5041 .map(|p| p.layer_plan(self.physical_index(l))),
5042 inputs,
5043 ffn_block::BatchedFfnKernels::Prefill,
5044 skip_rows.as_deref().map(|rows| SkipStream { rows }),
5045 );
5046 self.hrm_store(hrm.as_mut(), l, &hidden_batch);
5047 l += 1;
5048 }
5049
5050 hidden_batch
5051 .chunks(hidden_dim)
5052 .map(|h| self.final_norm.apply(h, self.config.rms_norm_eps))
5053 .collect()
5054 }
5055
5056 /// Appends one position to sequence `b`'s layer-`l` KV, then
5057 /// attends over everything that sequence holds.
5058 ///
5059 /// The only place `forward_multi_seq_kv` touches a cache, and so
5060 /// the only place the backing matters.
5061 ///
5062 /// Selects sequence `b`'s layer-`l` cache and hands it to
5063 /// [`Decoder::push_and_attend_row`], the one attend body the whole
5064 /// crate shares. This used to spell that body out a second time; the
5065 /// contiguous arm of the copy differed from `forward_token`'s by
5066 /// exactly one call (the CUDA resident hook), which is the kind of
5067 /// difference nobody notices until it is a wrong answer.
5068 #[allow(clippy::too_many_arguments)] // one per thing the step needs
5069 fn push_and_attend(
5070 &self,
5071 kv: &mut MultiSeqKv<'_>,
5072 b: usize,
5073 l: usize,
5074 layer: &LayerWeights,
5075 k: &[f32],
5076 v: &[f32],
5077 q: &[f32],
5078 ) -> Vec<f32> {
5079 self.push_and_attend_row(kv.step(b, l), l, layer, k, v, q)
5080 }
5081
5082 /// The body of [`Self::forward_multi_seq_kv`], already running on a
5083 /// CPU-pool worker. See `entry.rs` for why the split exists.
5084 fn forward_multi_seq_kv_on_worker(
5085 &self,
5086 tokens: &[usize],
5087 positions: &[usize],
5088 kv: &mut MultiSeqKv<'_>,
5089 ) -> Vec<Vec<f32>> {
5090 assert_eq!(tokens.len(), positions.len());
5091 assert_eq!(tokens.len(), kv.len());
5092 let batch_size = tokens.len();
5093 if batch_size == 0 {
5094 return Vec::new();
5095 }
5096 for seq in 0..batch_size {
5097 assert_eq!(kv.layers_per_seq(seq), self.config.n_layers);
5098 }
5099
5100 let hidden_dim = self.config.hidden_dim;
5101 let head_dim = self.config.head_dim;
5102 let v_head_dim = self.config.v_head_dim();
5103
5104 // [batch, hidden], flattened row-major.
5105 let mut hidden_batch: Vec<f32> = self.embed_tokens(tokens, |b| positions[b]);
5106 let skip_rows = self.config.skip_stream.then(|| hidden_batch.clone());
5107 let mut hrm = self.hrm_streams(&hidden_batch);
5108
5109 let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
5110
5111 for l in 0..self.config.n_layers {
5112 self.hrm_stack_input(hrm.as_ref(), l, &mut hidden_batch);
5113 let layer = self.layer_for(l);
5114 // THIS layer's head counts; see the prefill body.
5115 let shape = self.config.layer_shape(l);
5116 let (n_heads, n_kv_heads) = (shape.attention.n_heads(), shape.attention.n_kv_heads());
5117 // --- attention block ---
5118 let inputs = self.branch_inputs(layer, &hidden_batch, batch_size);
5119 let normed_batch: Vec<f32> =
5120 self.pre_norm_residual(&layer.attn.norm_weight, &mut hidden_batch, batch_size);
5121 let oai = self
5122 .gpt_oss
5123 .as_ref()
5124 .map(|g| &g.layers[self.physical_index(l)]);
5125
5126 'attention: {
5127 match shape.attention {
5128 crate::layer_shapes::AttnShape::Absent => break 'attention,
5129 crate::layer_shapes::AttnShape::Linear => {
5130 let projected = layer.attn.o_proj.apply_batch(&normed_batch, batch_size);
5131 residual_add(&mut hidden_batch, &projected, self.config.residual_scale);
5132 break 'attention;
5133 }
5134 // lfm2.cpp:197 / granite-hybrid.cpp:163: each row is
5135 // ONE position of its own sequence, so each runs on
5136 // its own cache.
5137 crate::layer_shapes::AttnShape::ShortConv
5138 | crate::layer_shapes::AttnShape::Mamba2
5139 | crate::layer_shapes::AttnShape::Mamba1
5140 | crate::layer_shapes::AttnShape::Plamo2Ssm
5141 | crate::layer_shapes::AttnShape::Gdn
5142 | crate::layer_shapes::AttnShape::Lightning => {
5143 let mut out = Vec::with_capacity(batch_size * hidden_dim);
5144 for b in 0..batch_size {
5145 let step = kv.step(b, l);
5146 out.extend(self.recurrent_block(
5147 l,
5148 layer,
5149 &normed_batch[b * hidden_dim..(b + 1) * hidden_dim],
5150 1,
5151 step,
5152 ));
5153 }
5154 residual_add(&mut hidden_batch, &out, self.config.residual_scale);
5155 break 'attention;
5156 }
5157 crate::layer_shapes::AttnShape::Gqa { .. } => {}
5158 }
5159
5160 // falcon-h1.cpp:156-160: the parallel Mamba-2 block, each
5161 // row on its own sequence's cache, before the push.
5162 let parallel_ssm = layer.attn.ssm.as_ref().map(|_| {
5163 let mut out = Vec::with_capacity(batch_size * hidden_dim);
5164 for b in 0..batch_size {
5165 let mut step = kv.step(b, l);
5166 out.extend(
5167 self.parallel_ssm_rows(
5168 l,
5169 layer,
5170 &normed_batch[b * hidden_dim..(b + 1) * hidden_dim],
5171 1,
5172 step.recurrent_slot(),
5173 )
5174 .expect("the layer has the block"),
5175 );
5176 }
5177 out
5178 });
5179 // One shared activation-quant pass for q/k/v (plan 1e): the
5180 // three projections read the same normed batch, so quantize it
5181 // once instead of once per projection. A kind mismatch inside
5182 // the group just re-quantizes locally.
5183 let qkv_acts = layer
5184 .attn
5185 .q_proj
5186 .quantize_batch_acts(&normed_batch, batch_size);
5187 let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
5188 &normed_batch,
5189 batch_size,
5190 qkv_acts.as_ref(),
5191 );
5192 // qwen35.cpp:191-199: the gate rides in `wq`; split it
5193 // off before anything reads a Q width.
5194 let q_gate = layer.attn.q_gate_interleaved.then(|| {
5195 let (q, gate) = crate::attn_gate::split_interleaved_q_gate(
5196 &q_batch, batch_size, n_heads, head_dim,
5197 );
5198 q_batch = q;
5199 gate
5200 });
5201 let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
5202 &normed_batch,
5203 batch_size,
5204 qkv_acts.as_ref(),
5205 );
5206 let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
5207 &normed_batch,
5208 batch_size,
5209 qkv_acts.as_ref(),
5210 );
5211 drop(qkv_acts);
5212
5213 let q_width = n_heads * head_dim;
5214 let kv_width = n_kv_heads * head_dim;
5215 let v_width = n_kv_heads * v_head_dim;
5216 let out_width = n_heads * v_head_dim;
5217
5218 self.apply_qkv_bias_and_clamp(
5219 layer,
5220 &mut q_batch,
5221 &mut k_batch,
5222 &mut v_batch,
5223 q_width,
5224 kv_width,
5225 v_width,
5226 );
5227
5228 self.apply_qk_norms_pre_rope(layer, &mut q_batch, &mut k_batch, q_width, kv_width);
5229 self.apply_rope_attn_factor(&mut q_batch, &mut k_batch, l);
5230
5231 for b in 0..batch_size {
5232 let pos = positions[b];
5233 let q_row = &mut q_batch[b * q_width..(b + 1) * q_width];
5234 for h in 0..n_heads {
5235 self.apply_rope_head_layer(
5236 &mut q_row[h * head_dim..(h + 1) * head_dim],
5237 pos,
5238 l,
5239 );
5240 }
5241 let k_row = &mut k_batch[b * kv_width..(b + 1) * kv_width];
5242 for h in 0..n_kv_heads {
5243 self.apply_rope_head_layer(
5244 &mut k_row[h * head_dim..(h + 1) * head_dim],
5245 pos,
5246 l,
5247 );
5248 }
5249 }
5250 self.apply_qk_norms_post_rope(
5251 layer,
5252 l,
5253 &mut q_batch,
5254 &mut k_batch,
5255 q_width,
5256 kv_width,
5257 );
5258 // Applied to the whole Q batch at once because it is
5259 // elementwise. This path did not apply it at all until the
5260 // decoration audit: `attention_scale` reached only
5261 // `forward_token`'s CPU arm and `forward_token_paged`, so a
5262 // checkpoint carrying one answered at one temperature when
5263 // decoded alone and another when batched with its neighbours.
5264 self.apply_attention_scale(&mut q_batch);
5265 self.apply_attn_temperature(l, &mut q_batch, q_width, |b| positions[b]);
5266
5267 let mut attn_out_batch = vec![0f32; batch_size * out_width];
5268 for b in 0..batch_size {
5269 let attn_out = self.push_and_attend(
5270 kv,
5271 b,
5272 l,
5273 layer,
5274 &k_batch[b * kv_width..(b + 1) * kv_width],
5275 &v_batch[b * v_width..(b + 1) * v_width],
5276 &q_batch[b * q_width..(b + 1) * q_width],
5277 );
5278 attn_out_batch[b * out_width..(b + 1) * out_width].copy_from_slice(&attn_out);
5279 }
5280
5281 let mut projected_batch = self.attn_out_to_residual_rows(
5282 layer,
5283 &normed_batch,
5284 &mut attn_out_batch,
5285 batch_size,
5286 q_gate.as_deref(),
5287 );
5288 Self::add_parallel_ssm(&mut projected_batch, parallel_ssm);
5289 residual_add(
5290 &mut hidden_batch,
5291 &projected_batch,
5292 self.config.residual_scale,
5293 );
5294 } // 'attention
5295
5296 // --- FFN block --- per row, as this body has always run it;
5297 // see `BatchedFfnKernels::PerRow`.
5298 self.ffn_block_batch(
5299 l,
5300 layer,
5301 &mut hidden_batch,
5302 batch_size,
5303 oai,
5304 residency
5305 .as_ref()
5306 .map(|p| p.layer_plan(self.physical_index(l))),
5307 inputs,
5308 ffn_block::BatchedFfnKernels::PerRow,
5309 skip_rows.as_deref().map(|rows| SkipStream { rows }),
5310 );
5311 self.hrm_store(hrm.as_mut(), l, &hidden_batch);
5312 }
5313
5314 let final_normed_batch: Vec<f32> = hidden_batch
5315 .par_chunks(hidden_dim)
5316 .map(|h| self.final_norm.apply(h, self.config.rms_norm_eps))
5317 .flatten()
5318 .collect();
5319 self.logits_from_flat_hidden(final_normed_batch, batch_size)
5320 }
5321}
5322
5323#[cfg(test)]
5324mod tests {
5325 use super::*;
5326 use crate::config::glm_5_2;
5327 use frink_core::cache::PagedKvStore;
5328
5329 /// Small config used purely to keep the test fast: same
5330 /// architecture *shape* (GQA ratio, MoE topology) as GLM-5.2, but
5331 /// with tiny dims so the whole thing runs in milliseconds.
5332 fn tiny_test_config() -> ModelConfig {
5333 let mut cfg = glm_5_2();
5334 cfg.hidden_dim = 16;
5335 cfg.n_heads = 4;
5336 cfg.n_kv_heads = 2;
5337 cfg.head_dim = 4;
5338 cfg.moe.hidden_dim = 16;
5339 cfg.moe.n_experts = 6;
5340 cfg.moe.n_experts_active = 2;
5341 cfg.moe.n_shared_experts = 1;
5342 cfg.moe.expert_ffn_dim = 8;
5343 cfg
5344 }
5345
5346 /// A GeGLU model's ROUTED experts must run GeGLU.
5347 ///
5348 /// `run_ffn_block` used to consult `ffn_activation` only in its dense
5349 /// arm; `combine_ffn_outputs_for_position` and everything under it
5350 /// was unconditionally SwiGLU, so a GeGLU MoE would have produced
5351 /// fluent, wrong logits with nothing in the tree to notice. That is
5352 /// not hypothetical: llama.cpp's `grok` passes `LLM_FFN_GELU` to
5353 /// `build_moe_ffn` (`.scratch/llama.cpp/src/models/grok.cpp`), and
5354 /// `grok` sits on `ArchPath::GenericGqa` in `capability.rs`.
5355 ///
5356 /// The reference is written out here in plain loops -- its own GELU
5357 /// and SiLU, not `frink_core`'s -- so it cannot agree with the code
5358 /// under test by sharing its bug. The second assertion is the one
5359 /// that makes this a test rather than a smoke check: the SwiGLU
5360 /// answer must be visibly different, so an implementation that
5361 /// ignores the activation cannot pass.
5362 #[test]
5363 fn a_geglu_moe_layer_runs_geglu_in_its_routed_experts_not_swiglu() {
5364 let mut cfg = tiny_test_config();
5365 cfg.ffn_activation = crate::config::FfnActivation::Gelu;
5366 let decoder = Decoder::new_random_small(cfg, 2, 8);
5367 let hidden_dim = decoder.config.hidden_dim;
5368 let layer = &decoder.layers[1];
5369 assert!(
5370 !Decoder::is_dense_layer(layer),
5371 "this test is about the ROUTED path; layer 1 must be a real MoE layer"
5372 );
5373
5374 // Larger than the usual unit inputs on purpose: GELU and SiLU
5375 // are close near zero, and a reference that cannot tell them
5376 // apart cannot catch the bug this test exists for.
5377 let normed2: Vec<f32> = (0..hidden_dim)
5378 .map(|i| (i as f32 * 0.37).sin() * 12.0)
5379 .collect();
5380
5381 let gelu = |x: f32| {
5382 let t = (0.797_884_6f32 * (x + 0.044_715 * x * x * x)).tanh();
5383 0.5 * x * (1.0 + t)
5384 };
5385 let silu = |x: f32| x / (1.0 + (-x).exp());
5386 let expert_ref = |ex: &ExpertWeights, f: &dyn Fn(f32) -> f32| -> Vec<f32> {
5387 let g = ex.gate.apply(&normed2);
5388 let u = ex.up.apply(&normed2);
5389 let a: Vec<f32> = g.iter().zip(u.iter()).map(|(&g, &u)| f(g) * u).collect();
5390 ex.down.apply(&a)
5391 };
5392
5393 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
5394 panic!("new_random_small builds resident experts");
5395 };
5396 let router_logits = layer.moe.router.apply(&normed2);
5397 let decision = Decoder::route_for_layer(layer, &router_logits, &decoder.config);
5398 let block_ref = |f: &dyn Fn(f32) -> f32| -> Vec<f32> {
5399 let mut out = vec![0f32; hidden_dim];
5400 for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
5401 for (o, e) in out.iter_mut().zip(expert_ref(&experts[eid], f).iter()) {
5402 *o += w * e;
5403 }
5404 }
5405 assert!(
5406 layer.moe.shared_expert_gate.is_none(),
5407 "tiny_test_config's shared experts are ungated; reference assumes it"
5408 );
5409 for shex in &layer.moe.shared_experts {
5410 for (o, e) in out.iter_mut().zip(expert_ref(shex, f).iter()) {
5411 *o += e;
5412 }
5413 }
5414 out
5415 };
5416 let expected_geglu = block_ref(&gelu);
5417 let expected_swiglu = block_ref(&silu);
5418
5419 let got = Decoder::run_ffn_block(
5420 1,
5421 layer,
5422 &normed2,
5423 &decoder.config,
5424 hidden_dim,
5425 None,
5426 ffn_block::RouterOperand::FfnInput,
5427 );
5428 assert_eq!(got.len(), hidden_dim);
5429 for (i, (a, b)) in got.iter().zip(expected_geglu.iter()).enumerate() {
5430 assert!(
5431 (a - b).abs() < 1e-4 * b.abs().max(1.0),
5432 "routed GeGLU FFN element {i}: got {a}, expected {b}"
5433 );
5434 }
5435 assert!(
5436 expected_geglu
5437 .iter()
5438 .zip(expected_swiglu.iter())
5439 .any(|(a, b)| (a - b).abs() > 1e-3),
5440 "GeGLU and SwiGLU must differ measurably on this input, or this test \
5441 could not detect a routed expert that silently ran SwiGLU"
5442 );
5443 }
5444
5445 #[test]
5446 fn forward_pass_produces_finite_logits_of_correct_shape() {
5447 let vocab = 10;
5448 let decoder = Decoder::new_random_small(tiny_test_config(), 2, vocab);
5449 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
5450
5451 let logits = decoder.forward_token(3, 0, &mut caches);
5452 assert_eq!(logits.len(), vocab);
5453 assert!(
5454 logits.iter().all(|v| v.is_finite()),
5455 "logits must not contain NaN/Inf"
5456 );
5457 }
5458
5459 /// `gpu_vram_budget_bytes` must be a real zero-behavior-change
5460 /// default at `None`, and a *real placement plan that places
5461 /// nothing* (a zero VRAM budget, so `PlacementPlan::from_budget`
5462 /// fits no expert at all) must produce byte-identical output to
5463 /// `None` too -- proving the new plumbing (building a plan,
5464 /// looking up each routed expert's placement, dispatching through
5465 /// `run_expert_placed`) doesn't change results when nothing is
5466 /// actually GPU-placed, without needing real CUDA hardware to
5467 /// check (that hardware-dependent half is
5468 /// `frink-moe`'s/`frink-core`'s own `#[ignore]`d tests).
5469 #[test]
5470 fn gpu_vram_budget_bytes_with_nothing_placed_matches_the_default() {
5471 let mut decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
5472 let mut caches_default: Vec<KvCache> = decoder.config.new_kv_caches();
5473 let default_logits = decoder.forward_token(3, 0, &mut caches_default);
5474
5475 decoder.gpu_vram_budget_bytes = Some(0);
5476 let mut caches_zero_budget: Vec<KvCache> = decoder.config.new_kv_caches();
5477 let zero_budget_logits = decoder.forward_token(3, 0, &mut caches_zero_budget);
5478
5479 assert_eq!(
5480 default_logits, zero_budget_logits,
5481 "a placement plan that places nothing on GPU must match the None default exactly"
5482 );
5483 }
5484
5485 /// Qwen2-MoE's real shared-expert sigmoid gate
5486 /// (`MoeWeights::shared_expert_gate`): exact math check by mutating
5487 /// `layer.moe.shared_expert_gate` in place on an already-built
5488 /// decoder (no need to reconstruct a `LayerWeights`/`MoeWeights`
5489 /// from scratch) and comparing against a hand-derived expectation:
5490 /// the *only* thing the gate changes is the shared experts' own
5491 /// contribution, scaled by `sigmoid(gate . x)` -- so
5492 /// `gated_shared_output == ungated_shared_output * sigmoid_value`
5493 /// exactly, computed independently here via `run_expert` on the
5494 /// same layer's shared expert.
5495 #[test]
5496 fn shared_expert_gate_scales_shared_output_by_sigmoid_of_the_gate_logit() {
5497 let cfg = tiny_test_config();
5498 let mut decoder = Decoder::new_random_small(cfg, 2, 8);
5499 let hidden_dim = decoder.config.hidden_dim;
5500 assert_eq!(
5501 decoder.layers[1].moe.shared_experts.len(),
5502 1,
5503 "test assumes tiny_test_config's real MoE layer has exactly one shared expert"
5504 );
5505
5506 let normed2: Vec<f32> = (0..hidden_dim).map(|i| (i as f32 * 0.37).sin()).collect();
5507 let gate_vec: Vec<f32> = (0..hidden_dim).map(|i| i as f32 * 0.13 - 0.5).collect();
5508
5509 // Independently compute what the shared expert alone produces,
5510 // and what sigmoid(gate . x) should scale it by -- this is the
5511 // ground truth the gated code path must reproduce exactly.
5512 let shared_out_raw = run_expert(
5513 &normed2,
5514 &decoder.layers[1].moe.shared_experts[0],
5515 decoder.config.layer_ffn_acts(1).dense,
5516 );
5517 let gate_logit: f32 = gate_vec
5518 .iter()
5519 .zip(normed2.iter())
5520 .map(|(g, x)| g * x)
5521 .sum();
5522 let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
5523 let expected_gated_shared: Vec<f32> =
5524 shared_out_raw.iter().map(|x| x * gate_value).collect();
5525
5526 // Run the real FFN combine path twice (gate absent, then
5527 // present) and recover each run's shared-only contribution by
5528 // subtracting the routed contribution, which the gate never
5529 // touches and is identical between the two runs (same router,
5530 // same experts, same input).
5531 let router_logits = decoder.layers[1].moe.router.apply(&normed2);
5532 let ungated_total = Decoder::combine_ffn_outputs_for_position(
5533 1,
5534 &decoder.layers[1],
5535 &normed2,
5536 &normed2,
5537 &router_logits,
5538 &decoder.config,
5539 hidden_dim,
5540 None,
5541 );
5542 decoder.layers[1].moe.shared_expert_gate = Some(gate_vec);
5543 let gated_total = Decoder::combine_ffn_outputs_for_position(
5544 1,
5545 &decoder.layers[1],
5546 &normed2,
5547 &normed2,
5548 &router_logits,
5549 &decoder.config,
5550 hidden_dim,
5551 None,
5552 );
5553
5554 for (i, ((u, g), expected_shared)) in ungated_total
5555 .iter()
5556 .zip(gated_total.iter())
5557 .zip(expected_gated_shared.iter())
5558 .enumerate()
5559 {
5560 let routed_contribution = u - shared_out_raw[i];
5561 let gated_shared_recovered = g - routed_contribution;
5562 assert!(
5563 (gated_shared_recovered - expected_shared).abs() < 1e-4,
5564 "index {i}: recovered gated shared output {gated_shared_recovered} != expected {expected_shared} (sigmoid({gate_logit})={gate_value})"
5565 );
5566 }
5567 }
5568
5569 #[test]
5570 fn kv_cache_grows_by_one_position_per_layer_per_step() {
5571 let decoder = Decoder::new_random_small(tiny_test_config(), 3, 5);
5572 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
5573
5574 decoder.forward_token(0, 0, &mut caches);
5575 decoder.forward_token(1, 1, &mut caches);
5576 decoder.forward_token(2, 2, &mut caches);
5577
5578 for cache in &caches {
5579 assert_eq!(cache.positions(), 3);
5580 }
5581 }
5582
5583 #[test]
5584 fn same_token_same_position_is_deterministic() {
5585 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
5586 let mut caches_a: Vec<KvCache> = decoder.config.new_kv_caches();
5587 let mut caches_b: Vec<KvCache> = decoder.config.new_kv_caches();
5588
5589 let out_a = decoder.forward_token(4, 0, &mut caches_a);
5590 let out_b = decoder.forward_token(4, 0, &mut caches_b);
5591 assert_eq!(out_a, out_b, "identical input state must yield identical output (no hidden randomness in the forward pass)");
5592 }
5593
5594 #[test]
5595 fn multi_step_decode_stays_finite_across_positions() {
5596 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
5597 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
5598
5599 for pos in 0..16 {
5600 let logits = decoder.forward_token(pos % 8, pos, &mut caches);
5601 assert!(
5602 logits.iter().all(|v| v.is_finite()),
5603 "position {pos}: logits must stay finite across an extended decode run"
5604 );
5605 }
5606 }
5607
5608 /// `forward_token_paged` must produce bit-identical output to
5609 /// `forward_token` across a multi-step decode (each layer's paged
5610 /// store sized generously so no layer ever exhausts its blocks) --
5611 /// the block-table indirection is a storage-layout detail, not a
5612 /// math change.
5613 #[test]
5614 fn forward_token_paged_matches_forward_token_bit_identical() {
5615 paged_matches_contiguous(tiny_test_config());
5616 }
5617
5618 /// Every arm of the attention dispatch, not just the plain one.
5619 ///
5620 /// The paged path used to implement only full causal attention, and
5621 /// `forward_token_paged` asserted rather than run gpt-oss, because a
5622 /// missing sink term would have changed the distribution silently.
5623 /// Now that it mirrors all three arms, each one has to be held to
5624 /// the same bar the plain arm always was: BIT-identical, not close.
5625 ///
5626 /// A sliding window and a softcap are both driven from the config
5627 /// here, so a future edit that wires one arm and forgets another
5628 /// fails on the arm it forgot rather than on a model nobody tests.
5629 #[test]
5630 fn every_paged_attention_arm_is_bit_identical_to_its_contiguous_twin() {
5631 let windowed = || {
5632 let mut cfg = tiny_test_config();
5633 // Smaller than the decode length below, so the window really
5634 // drops positions rather than degenerating to full causal.
5635 cfg.sliding_window = Some(2);
5636 cfg.swa_layers = crate::swa_layers::SwaLayers::All;
5637 cfg
5638 };
5639 let softcapped = || {
5640 let mut cfg = tiny_test_config();
5641 // Small enough that `sc * tanh(s / sc)` actually compresses.
5642 // A realistic 30.0 is numerically indistinguishable from no
5643 // cap at these tiny weights, so a test using it would pass
5644 // whether or not the arm was wired -- checked by breaking
5645 // the arm on purpose and watching it still pass.
5646 cfg.attn_logit_softcap = Some(0.05);
5647 cfg
5648 };
5649 let both = || {
5650 let mut cfg = windowed();
5651 cfg.attn_logit_softcap = Some(0.05);
5652 cfg
5653 };
5654 // Alternating window/full layers: the per-layer arm choice has
5655 // to be honoured per layer, not decided once for the model.
5656 let alternating = || {
5657 let mut cfg = tiny_test_config();
5658 cfg.sliding_window = Some(2);
5659 cfg.swa_layers = crate::swa_layers::SwaLayers::period(2, false);
5660 cfg
5661 };
5662
5663 for cfg in [windowed(), softcapped(), both(), alternating()] {
5664 paged_matches_contiguous(cfg);
5665 }
5666 }
5667
5668 /// Five MORE model features the paged path had lost the same way
5669 /// the first five went: by being a copy of the contiguous loop that
5670 /// nothing forced to stay in step.
5671 ///
5672 /// Found by running Gemma-2-2B through paged KV and watching it
5673 /// answer differently from the same model on the same backend with
5674 /// a contiguous cache -- on CPU, with no GPU involved at all. None
5675 /// of the arm tests above could see it, because `tiny_test_config`
5676 /// sets none of these and `new_random_small` builds every layer
5677 /// without the two sandwich norms.
5678 ///
5679 /// - `attention_scale`: Gemma scales Q itself and asks the kernel
5680 /// for a score scale of 1.0, so the built-in `1/sqrt(head_dim)`
5681 /// has to be compensated for. Missing, the model answers at a
5682 /// different temperature.
5683 /// - `post_attn_norm` / `post_ffn_norm`: Gemma-2's sandwich norms,
5684 /// applied to each branch before it rejoins the residual.
5685 /// - gpt-oss's `o_bias`, and its own FFN (`gpt_oss_ffn`, which
5686 /// biases the router and runs the clamped OAI SwiGLU) instead of
5687 /// the generic one.
5688 ///
5689 /// Every one of them produces a plausible distribution rather than
5690 /// an error, which is exactly why they are pinned rather than
5691 /// trusted. Values are chosen so each really bites: a scale of 1.0
5692 /// or an all-ones norm would let this pass either way.
5693 #[test]
5694 fn the_paged_path_keeps_every_per_layer_feature_the_contiguous_one_applies() {
5695 // Gemma's query pre-attention scalar, well away from the
5696 // kernel's own 1/sqrt(head_dim).
5697 let mut scaled = tiny_test_config();
5698 scaled.attention_scale = Some(0.37);
5699 paged_matches_contiguous_with(scaled, |_| {});
5700
5701 // Sandwich norms, one at a time and then together, so a wired
5702 // half is not covered for by the other.
5703 for (attn, ffn) in [(true, false), (false, true), (true, true)] {
5704 paged_matches_contiguous_with(tiny_test_config(), with_sandwich_norms(attn, ffn));
5705 }
5706
5707 // gpt-oss: the O bias and the OAI FFN, which the paged path was
5708 // substituting the generic router+SwiGLU for.
5709 paged_matches_contiguous_with(tiny_test_config(), with_gpt_oss_graph);
5710 }
5711
5712 /// The same feature list as
5713 /// [`the_paged_path_keeps_every_per_layer_feature_the_contiguous_one_applies`],
5714 /// checked against `forward_hidden_batch_inner` instead.
5715 ///
5716 /// Necessary because `forward_token` and `forward_token_paged` now
5717 /// share ONE body (`Decoder::attn_block`) that differs only in its
5718 /// `KvStep`, so the paged test can no longer see a decoration
5719 /// dropped from that body -- deleting `post_attn_norm` or gpt-oss's
5720 /// `o_bias` from it leaves the whole suite green, which was measured
5721 /// rather than assumed. `forward_hidden_batch_inner` is deliberately
5722 /// NOT collapsed into the same body, so it is the independent
5723 /// ground truth that keeps these features pinned.
5724 #[test]
5725 fn the_batched_path_keeps_every_per_layer_feature_the_token_path_applies() {
5726 for (attn, ffn) in [(true, false), (false, true), (true, true)] {
5727 batched_matches_contiguous_with(tiny_test_config(), with_sandwich_norms(attn, ffn));
5728 }
5729 batched_matches_contiguous_with(tiny_test_config(), with_gpt_oss_graph);
5730 }
5731
5732 /// Gemma-2's two sandwich norms, as a switch both parity helpers
5733 /// take, so the paged and batched tests cannot drift over WHICH
5734 /// features they claim to cover.
5735 ///
5736 /// Per-layer values, so a path that applied layer 0's norm
5737 /// everywhere would still fail.
5738 fn with_sandwich_norms(attn: bool, ffn: bool) -> impl Fn(&mut Decoder) {
5739 move |d: &mut Decoder| {
5740 let hidden = d.config.hidden_dim;
5741 for (i, layer) in d.layers.iter_mut().enumerate() {
5742 let w: Vec<f32> = (0..hidden)
5743 .map(|j| 0.5 + (i * hidden + j) as f32 * 0.01)
5744 .collect();
5745 if attn {
5746 layer.attn.post_attn_norm = Some(w.clone());
5747 }
5748 if ffn {
5749 layer.attn.post_ffn_norm = Some(w);
5750 }
5751 }
5752 }
5753 }
5754
5755 /// The whole gpt-oss graph: attention sinks on every layer's
5756 /// attention weights, and the side table -- the O bias, the router
5757 /// bias and the per-expert biases `gpt_oss_ffn` reads.
5758 fn with_gpt_oss_graph(d: &mut Decoder) {
5759 let hidden = d.config.hidden_dim;
5760 let n_heads = d.config.n_heads;
5761 let n_experts = d.config.moe.n_experts;
5762 let ffn = d.config.moe.expert_ffn_dim;
5763 let n_layers = d.layers.len();
5764 for (l, layer) in d.layers.iter_mut().enumerate() {
5765 layer.attn.sinks = Some((0..n_heads).map(|h| 0.1 + (l + h) as f32 * 0.05).collect());
5766 layer.attn.o_bias = Some((0..hidden).map(|j| 0.02 * (j as f32 - 8.0)).collect());
5767 }
5768 d.gpt_oss = Some(GptOssWeights {
5769 layers: (0..n_layers)
5770 .map(|_| GptOssLayer {
5771 router_bias: (0..n_experts).map(|e| 0.03 * e as f32).collect(),
5772 expert_bias: (0..n_experts)
5773 .map(|e| frink_moe::ExpertBias {
5774 gate: vec![0.01 * (e + 1) as f32; ffn],
5775 up: vec![-0.02 * (e + 1) as f32; ffn],
5776 down: vec![0.005 * (e + 1) as f32; hidden],
5777 })
5778 .collect(),
5779 })
5780 .collect(),
5781 });
5782 }
5783
5784 /// [`paged_matches_contiguous_with`] for `forward_batch` against
5785 /// sequential `forward_token`.
5786 ///
5787 /// Not bit-identity: batched prefill runs the blocked three-pass
5788 /// softmax while decode keeps the online accumulator, so the two
5789 /// agree to a tolerance rather than to the bit -- the same reason
5790 /// `decoder_via_engine_trait_matches_forward_batch_ground_truth`
5791 /// gives. 1e-5 is four orders below the ~1e-1 a dropped decoration
5792 /// moves these logits by.
5793 fn batched_matches_contiguous_with(config: ModelConfig, prepare: impl Fn(&mut Decoder)) {
5794 let n_layers = 2;
5795 let vocab = 10;
5796 let tokens = [3usize, 5, 7, 2, 9, 1];
5797
5798 let mut seq_decoder = Decoder::new_random_small(config.clone(), n_layers, vocab);
5799 prepare(&mut seq_decoder);
5800 let mut seq_caches: Vec<KvCache> = seq_decoder.config.new_kv_caches();
5801 let sequential: Vec<Vec<f32>> = tokens
5802 .iter()
5803 .enumerate()
5804 .map(|(pos, &t)| seq_decoder.forward_token(t, pos, &mut seq_caches))
5805 .collect();
5806
5807 // Same seed -> identical weights before `prepare`, and `prepare`
5808 // is deterministic, so this is a like-for-like comparison.
5809 let mut batch_decoder = Decoder::new_random_small(config, n_layers, vocab);
5810 prepare(&mut batch_decoder);
5811 let mut batch_caches: Vec<KvCache> = batch_decoder.config.new_kv_caches();
5812 let batched = batch_decoder.forward_batch(&tokens, 0, &mut batch_caches);
5813
5814 assert_eq!(sequential.len(), batched.len());
5815 for (pos, (a, b)) in sequential.iter().zip(batched.iter()).enumerate() {
5816 assert_eq!(a.len(), b.len(), "position {pos}: logit count");
5817 for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
5818 assert!(
5819 (x - y).abs() < 1e-5,
5820 "position {pos}, logit {i}: token path={x} batched={y}"
5821 );
5822 }
5823 }
5824 }
5825
5826 /// The two rules that live OUTSIDE the layer loop, which the arm
5827 /// test above cannot reach.
5828 ///
5829 /// The paged path had drifted from the contiguous one at both ends
5830 /// of the stack, and neither drift was visible to any existing test
5831 /// because `tiny_test_config` sets neither field:
5832 ///
5833 /// - it called `embedding.dequant_row` directly instead of scaling
5834 /// the row by `embedding_scale`, so every Gemma token entered the
5835 /// stack `sqrt(hidden_dim)` times too small;
5836 /// - it returned `output_head.apply(..)` raw instead of applying
5837 /// `final_logit_softcap`, so Gemma-2's 30.0 cap never ran.
5838 ///
5839 /// Both produce a plausible distribution rather than an error, which
5840 /// is the whole reason to pin them: a wrong answer that still looks
5841 /// like an answer is what a parity test is for. Values here are
5842 /// chosen so each one actually bites -- a scale of 1.0 or a cap far
5843 /// above the logit range would let this pass either way.
5844 #[test]
5845 fn the_paged_path_scales_embeddings_and_softcaps_logits_like_the_contiguous_one() {
5846 let scaled = || {
5847 let mut cfg = tiny_test_config();
5848 cfg.embedding_scale = Some(7.5);
5849 cfg
5850 };
5851 let capped = || {
5852 let mut cfg = tiny_test_config();
5853 // Small enough that `sc * tanh(x / sc)` really compresses at
5854 // this model's logit magnitudes, on the same reasoning as
5855 // the attention softcap above.
5856 cfg.final_logit_softcap = Some(0.05);
5857 cfg
5858 };
5859 let both = || {
5860 let mut cfg = scaled();
5861 cfg.final_logit_softcap = Some(0.05);
5862 cfg
5863 };
5864
5865 for cfg in [scaled(), capped(), both()] {
5866 paged_matches_contiguous(cfg);
5867 }
5868 }
5869
5870 /// Paged prefill must agree with contiguous prefill, and must leave
5871 /// the KV in a state a paged DECODE can continue from.
5872 ///
5873 /// The second half is the one worth having. `forward_batch_last`
5874 /// returns only the last row's logits, so a gather/scatter that
5875 /// mangled the KV -- wrote the rows in the wrong order, dropped the
5876 /// part-full tail block, mis-sized a copy -- could still return the
5877 /// right logits for THIS call and only surface on the next token.
5878 /// Decoding four more tokens after the prefill is what makes the
5879 /// stored KV observable, so both paths are compared over the whole
5880 /// continuation rather than at the seam.
5881 ///
5882 /// A block size of 2 against a 5-token prompt is deliberate: it
5883 /// leaves the tail block part-full, which is the case
5884 /// `blocks_needed_for` exists for and the one a `n / block_size`
5885 /// reservation would get wrong.
5886 fn paged_prefill_matches_contiguous(config: ModelConfig) {
5887 let n_layers = 2;
5888 let decoder = Decoder::new_random_small(config, n_layers, 10);
5889 let prompt = [3usize, 1, 4, 1, 5];
5890 let continuation = [9usize, 2, 6, 5];
5891
5892 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
5893 let mut plain = vec![decoder.forward_batch_last(&prompt, 0, &mut caches)];
5894 for (i, &tok) in continuation.iter().enumerate() {
5895 plain.push(decoder.forward_token(tok, prompt.len() + i, &mut caches));
5896 }
5897
5898 let mut paged_caches: Vec<PagedKvCache> =
5899 (0..n_layers).map(|_| PagedKvCache::new()).collect();
5900 let stores = SharedPagedKv::from_stores(
5901 (0..n_layers)
5902 .map(|_| {
5903 PagedKvStore::new(
5904 /* block_size = */ 2,
5905 /* total_blocks = */ 16,
5906 decoder.config.n_kv_heads,
5907 decoder.config.head_dim,
5908 )
5909 })
5910 .collect(),
5911 );
5912 let mut paged = vec![decoder
5913 .forward_batch_last_paged(&prompt, 0, &mut paged_caches, &stores)
5914 .expect("store sized generously, must not exhaust")];
5915 for (i, &tok) in continuation.iter().enumerate() {
5916 paged.push(
5917 decoder
5918 .forward_token_paged(tok, prompt.len() + i, &mut paged_caches, &stores)
5919 .expect("store sized generously, must not exhaust"),
5920 );
5921 }
5922
5923 assert_eq!(
5924 paged_caches[0].seq_len(),
5925 prompt.len() + continuation.len(),
5926 "paged prefill must advance seq_len by exactly the batch size"
5927 );
5928 assert_eq!(plain.len(), paged.len());
5929 for (step, (a, b)) in plain.iter().zip(paged.iter()).enumerate() {
5930 assert_eq!(a.len(), b.len(), "step {step}: logit count");
5931 for (x, y) in a.iter().zip(b.iter()) {
5932 assert_eq!(
5933 x.to_bits(),
5934 y.to_bits(),
5935 "step {step}: paged prefill + decode must be bit-identical to contiguous"
5936 );
5937 }
5938 }
5939 }
5940
5941 /// Every arm again, this time through the prefill entry point. The
5942 /// gather is shared, but the kernel the gathered buffer reaches is
5943 /// the BLOCKED prefill one rather than the per-query decode one, so
5944 /// arm coverage here is not implied by the decode tests above.
5945 #[test]
5946 fn paged_prefill_is_bit_identical_across_every_arm() {
5947 let windowed = || {
5948 let mut cfg = tiny_test_config();
5949 cfg.sliding_window = Some(2);
5950 cfg.swa_layers = crate::swa_layers::SwaLayers::All;
5951 cfg
5952 };
5953 let scaled_and_capped = || {
5954 let mut cfg = tiny_test_config();
5955 cfg.embedding_scale = Some(7.5);
5956 cfg.final_logit_softcap = Some(0.05);
5957 cfg.attn_logit_softcap = Some(0.05);
5958 cfg
5959 };
5960 let alternating = || {
5961 let mut cfg = tiny_test_config();
5962 cfg.sliding_window = Some(2);
5963 cfg.swa_layers = crate::swa_layers::SwaLayers::period(2, false);
5964 cfg
5965 };
5966
5967 for cfg in [
5968 tiny_test_config(),
5969 windowed(),
5970 scaled_and_capped(),
5971 alternating(),
5972 ] {
5973 paged_prefill_matches_contiguous(cfg);
5974 }
5975 }
5976
5977 /// A prefill the stores cannot hold refuses having written NOTHING
5978 /// -- checked on the case that actually needs the up-front loop.
5979 ///
5980 /// Each layer owns its own store, so layer 0 having room says
5981 /// nothing about layer 1. `append_contiguous` already refuses
5982 /// rather than half-writing a single layer, so a test whose layers
5983 /// are sized alike passes with the cross-layer reservation deleted
5984 /// -- it would be asserting a property it never exercises. Here
5985 /// layer 0 has room for the whole prompt and layer 1 does not, so
5986 /// without the up-front check layer 0 is written, layer 1 refuses,
5987 /// and the sequence ends up with its layers at DIFFERENT lengths.
5988 /// No caller can recover from that, and nothing downstream would
5989 /// report it: the next decode step simply attends over a shorter
5990 /// history in one layer than the others.
5991 ///
5992 /// Verified by deleting the reservation loop and watching this fail
5993 /// on `layer 1 must be untouched`.
5994 /// Three requests sharing one set of per-layer stores must get
5995 /// exactly what they would get alone.
5996 ///
5997 /// This is the property the RwLock exists for, and it cannot be
5998 /// asserted single-threaded. Every request writes only blocks it
5999 /// owns, so sharing changes where rows live and nothing else --
6000 /// bit-identical, not close. A store that let one request's rows
6001 /// land in another's blocks shows up here and nowhere else.
6002 #[test]
6003 fn concurrent_decodes_against_one_shared_store_match_running_them_alone() {
6004 use std::sync::Arc;
6005
6006 let decoder = Arc::new(Decoder::new_random_small(tiny_test_config(), 2, 10));
6007 let prompts: [&[usize]; 3] = [&[3, 1, 4], &[1, 5, 9], &[2, 6, 5]];
6008 let continuation = [7usize, 8, 3];
6009
6010 // Each request run alone, against its own store, is the answer
6011 // sharing must not change.
6012 let solo: Vec<Vec<Vec<f32>>> = prompts
6013 .iter()
6014 .map(|prompt| {
6015 let stores = SharedPagedKv::new(
6016 2,
6017 4,
6018 32,
6019 decoder.config.n_kv_heads,
6020 decoder.config.head_dim,
6021 );
6022 let mut caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
6023 run_one(&decoder, prompt, &continuation, &mut caches, &stores)
6024 })
6025 .collect();
6026
6027 // The same three, concurrently, sharing ONE set of per-layer
6028 // stores. Every request writes only blocks it owns, so the
6029 // answers must be identical -- not close, identical. A store
6030 // that let one request's rows land in another's blocks would
6031 // show up here and nowhere else.
6032 let shared = Arc::new(SharedPagedKv::new(
6033 2,
6034 4,
6035 96,
6036 decoder.config.n_kv_heads,
6037 decoder.config.head_dim,
6038 ));
6039 let together: Vec<Vec<Vec<f32>>> = std::thread::scope(|scope| {
6040 let handles: Vec<_> = prompts
6041 .iter()
6042 .map(|prompt| {
6043 let decoder = Arc::clone(&decoder);
6044 let shared = Arc::clone(&shared);
6045 scope.spawn(move || {
6046 let mut caches: Vec<PagedKvCache> =
6047 (0..2).map(|_| PagedKvCache::new()).collect();
6048 run_one(&decoder, prompt, &continuation, &mut caches, &shared)
6049 })
6050 })
6051 .collect();
6052 handles.into_iter().map(|h| h.join().unwrap()).collect()
6053 });
6054
6055 for (r, (alone, concurrent)) in solo.iter().zip(together.iter()).enumerate() {
6056 assert_eq!(alone.len(), concurrent.len(), "request {r}: step count");
6057 for (step, (a, b)) in alone.iter().zip(concurrent.iter()).enumerate() {
6058 for (x, y) in a.iter().zip(b.iter()) {
6059 assert_eq!(
6060 x.to_bits(),
6061 y.to_bits(),
6062 "request {r} step {step}: sharing a store changed the answer"
6063 );
6064 }
6065 }
6066 }
6067 }
6068
6069 /// Prefill then decode, returning every step's logits.
6070 fn run_one(
6071 decoder: &Decoder,
6072 prompt: &[usize],
6073 continuation: &[usize],
6074 caches: &mut [PagedKvCache],
6075 stores: &SharedPagedKv,
6076 ) -> Vec<Vec<f32>> {
6077 let mut out = vec![decoder
6078 .forward_batch_last_paged(prompt, 0, caches, stores)
6079 .expect("sized generously")];
6080 for (i, &tok) in continuation.iter().enumerate() {
6081 out.push(
6082 decoder
6083 .forward_token_paged(tok, prompt.len() + i, caches, stores)
6084 .expect("sized generously"),
6085 );
6086 }
6087 out
6088 }
6089
6090 /// A decode step the stores cannot hold advances NO layer.
6091 ///
6092 /// This was a real defect until the reservation moved into
6093 /// `forward_token_paged`: it pushed per layer with `?`, so a store
6094 /// exhausting at layer 1 of 2 left layer 0 holding a position layer
6095 /// 1 did not. Nothing downstream reports that -- the next step just
6096 /// attends over a shorter history in the tail layers -- and the
6097 /// prefill path had the guard while decode never did.
6098 ///
6099 /// Layer 0 is given room and layer 1 none, so the bug is reachable:
6100 /// with the reservation removed, layer 0 advances and layer 1
6101 /// refuses.
6102 #[test]
6103 fn a_decode_step_the_stores_cannot_hold_advances_no_layer() {
6104 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
6105 let mut caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
6106 // Block size 1 so "one more position" always needs a block.
6107 // Layer 0 gets two, layer 1 exactly one: the prompt fills layer
6108 // 1 completely, so the decode step below cannot fit there.
6109 let stores = SharedPagedKv::from_stores(
6110 [2usize, 1]
6111 .into_iter()
6112 .map(|blocks| {
6113 PagedKvStore::new(
6114 1,
6115 blocks,
6116 decoder.config.n_kv_heads,
6117 decoder.config.head_dim,
6118 )
6119 })
6120 .collect(),
6121 );
6122
6123 decoder
6124 .forward_batch_last_paged(&[1usize], 0, &mut caches, &stores)
6125 .expect("one position fits in both layers");
6126 assert_eq!(caches[0].seq_len(), 1);
6127 assert_eq!(caches[1].seq_len(), 1);
6128
6129 let result = decoder.forward_token_paged(2, 1, &mut caches, &stores);
6130 assert!(result.is_err(), "layer 1 has no block left");
6131 assert_eq!(
6132 caches[0].seq_len(),
6133 1,
6134 "layer 0 must not advance past a layer that could not"
6135 );
6136 assert_eq!(caches[1].seq_len(), 1);
6137 }
6138
6139 #[test]
6140 fn a_prefill_the_stores_cannot_hold_refuses_before_writing_any_layer() {
6141 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
6142 let prompt = [1usize, 2, 3, 4, 5, 6];
6143 let mut paged_caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
6144 // Layer 0 fits the prompt with room to spare; layer 1's two
6145 // blocks of 2 hold 4 positions against a prompt of 6.
6146 let stores = SharedPagedKv::from_stores(
6147 [8usize, 2]
6148 .into_iter()
6149 .map(|blocks| {
6150 PagedKvStore::new(
6151 2,
6152 blocks,
6153 decoder.config.n_kv_heads,
6154 decoder.config.head_dim,
6155 )
6156 })
6157 .collect(),
6158 );
6159
6160 let result = decoder.forward_batch_last_paged(&prompt, 0, &mut paged_caches, &stores);
6161 assert!(result.is_err(), "layer 1's store cannot hold the prompt");
6162 for (i, cache) in paged_caches.iter().enumerate() {
6163 assert_eq!(cache.seq_len(), 0, "layer {i} must be untouched");
6164 assert!(cache.block_table().is_empty(), "layer {i} holds no block");
6165 }
6166 for (i, expected) in [8usize, 2].into_iter().enumerate() {
6167 assert_eq!(stores.free_blocks(i), expected, "layer {i} leaked no block");
6168 }
6169 }
6170
6171 /// Chunked prefill: two calls appending into the same sequence must
6172 /// equal one call over the concatenation.
6173 ///
6174 /// This is the case the part-full tail block breaks if
6175 /// `to_contiguous` or the reservation is wrong, and it is how the
6176 /// serving path actually prefills long prompts.
6177 #[test]
6178 fn two_paged_prefill_chunks_equal_one_call_over_the_whole_prompt() {
6179 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
6180 let prompt = [3usize, 1, 4, 1, 5, 9, 2];
6181 let split = 3;
6182
6183 let run = |chunks: &[&[usize]]| {
6184 let mut caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
6185 let stores = SharedPagedKv::from_stores(
6186 (0..2)
6187 .map(|_| {
6188 PagedKvStore::new(2, 16, decoder.config.n_kv_heads, decoder.config.head_dim)
6189 })
6190 .collect(),
6191 );
6192 let mut pos = 0;
6193 let mut last = Vec::new();
6194 for chunk in chunks {
6195 last = decoder
6196 .forward_batch_last_paged(chunk, pos, &mut caches, &stores)
6197 .expect("sized generously");
6198 pos += chunk.len();
6199 }
6200 last
6201 };
6202
6203 let whole = run(&[&prompt]);
6204 let chunked = run(&[&prompt[..split], &prompt[split..]]);
6205 assert_eq!(whole.len(), chunked.len());
6206 for (x, y) in whole.iter().zip(chunked.iter()) {
6207 assert_eq!(
6208 x.to_bits(),
6209 y.to_bits(),
6210 "a chunked prefill must equal one call over the same tokens"
6211 );
6212 }
6213 }
6214
6215 fn paged_matches_contiguous(config: ModelConfig) {
6216 paged_matches_contiguous_with(config, |_| {});
6217 }
6218
6219 /// [`paged_matches_contiguous`] for the features that live on the
6220 /// WEIGHTS rather than in the config, and so cannot be switched on
6221 /// by handing a different `ModelConfig` in.
6222 fn paged_matches_contiguous_with(config: ModelConfig, prepare: impl FnOnce(&mut Decoder)) {
6223 let n_layers = 2;
6224 let mut decoder = Decoder::new_random_small(config, n_layers, 10);
6225 prepare(&mut decoder);
6226 let decoder = decoder;
6227
6228 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
6229 let steps = [3usize, 5, 7, 2, 9, 1];
6230 let mut plain_logits = Vec::new();
6231 for (pos, &tok) in steps.iter().enumerate() {
6232 plain_logits.push(decoder.forward_token(tok, pos, &mut caches));
6233 }
6234
6235 let block_size = 2;
6236 let mut paged_caches: Vec<PagedKvCache> =
6237 (0..n_layers).map(|_| PagedKvCache::new()).collect();
6238 let stores = SharedPagedKv::from_stores(
6239 (0..n_layers)
6240 .map(|_| {
6241 PagedKvStore::new(
6242 block_size,
6243 /* total_blocks = */ 16,
6244 decoder.config.n_kv_heads,
6245 decoder.config.head_dim,
6246 )
6247 })
6248 .collect(),
6249 );
6250 let mut paged_logits = Vec::new();
6251 for (pos, &tok) in steps.iter().enumerate() {
6252 paged_logits.push(
6253 decoder
6254 .forward_token_paged(tok, pos, &mut paged_caches, &stores)
6255 .expect("store sized generously, must not exhaust"),
6256 );
6257 }
6258
6259 assert_eq!(plain_logits.len(), paged_logits.len());
6260 for (a, b) in plain_logits.iter().zip(paged_logits.iter()) {
6261 assert_eq!(a.len(), b.len());
6262 for (x, y) in a.iter().zip(b.iter()) {
6263 assert_eq!(
6264 x.to_bits(),
6265 y.to_bits(),
6266 "paged decode must be bit-identical to contiguous decode"
6267 );
6268 }
6269 }
6270 }
6271
6272 /// The single most important correctness property of
6273 /// `forward_batch`: batching positions together for shared matmuls
6274 /// must produce EXACTLY the same result as processing them one at
6275 /// a time with `forward_token`, since causal masking guarantees
6276 /// position `i` only ever sees positions `<= i`. If this test
6277 /// fails, `forward_batch` is not a safe drop-in replacement for
6278 /// sequential decode, which would make speculative decoding built
6279 /// on top of it produce silently wrong output.
6280 /// How far a batched forward may sit from the per-token one.
6281 ///
6282 /// MEASURED rather than chosen: over these fixtures the largest
6283 /// deviation is 2.98e-8, single-ulp f32 on logits around 0.05,
6284 /// and it comes from the reduction order of a batched matmul
6285 /// against a per-token one. The bound is loose enough for that and
6286 /// for another machine's reduction order, and tight enough that a
6287 /// real drift in the batched path cannot hide under it.
6288 ///
6289 /// It was 1e-3 in four separate literals, five orders above the
6290 /// thing they were nominally checking, and two of the tests using
6291 /// it had "exactly" in their names.
6292 const BATCH_VS_SEQUENTIAL_ULP: f32 = 1e-6;
6293
6294 /// Batched and sequential agree to about one f32 ulp, NOT exactly.
6295 ///
6296 /// The name used to say "exactly" and the assertion allowed 1e-3,
6297 /// which was wrong in both directions at once: the two paths are
6298 /// not bit-identical (the sibling tests that really are say
6299 /// `bit_identical`, and mean it), and the real difference is five
6300 /// orders tighter than the bound that was nominally checking it.
6301 /// Measured over this fixture: the largest deviation is 1.5e-8,
6302 /// single-ulp on values around 0.05, arising from the reduction
6303 /// order of a batched matmul against a per-token one.
6304 ///
6305 /// The bound is 1e-6 now: loose enough for that ulp and for a
6306 /// different machine's reduction order, tight enough that an
6307 /// actual drift in the batched path cannot hide under it. This
6308 /// matters beyond hygiene -- the server verifies speculated tokens
6309 /// against logits from THIS function, so a near-tie inside this
6310 /// margin is the one case where a speculated token can disagree
6311 /// with what sequential decoding would have drawn.
6312 #[test]
6313 fn forward_batch_matches_sequential_forward_token_to_one_ulp() {
6314 let cfg = tiny_test_config();
6315 let vocab = 8;
6316 let tokens = [1usize, 3, 5, 2, 7];
6317
6318 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6319 let mut caches_a: Vec<KvCache> = decoder_a.config.new_kv_caches();
6320 let sequential: Vec<Vec<f32>> = tokens
6321 .iter()
6322 .enumerate()
6323 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
6324 .collect();
6325
6326 // A second decoder built with the same seed produces identical
6327 // weights (Decoder::new_random_small is deterministic), so
6328 // this is a fair like-for-like comparison against a fresh
6329 // cache rather than reusing decoder_a's now-mutated cache.
6330 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6331 let mut caches_b: Vec<KvCache> = decoder_b.config.new_kv_caches();
6332 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
6333
6334 assert_eq!(batched.len(), sequential.len());
6335 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
6336 assert_eq!(seq_logits.len(), batch_logits.len());
6337 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6338 assert!(
6339 (s - b).abs() < 1e-6,
6340 "position {pos}, logit {i}: sequential={s} batched={b}"
6341 );
6342 }
6343 }
6344 }
6345
6346 /// `forward_batch_last` exists to skip the vocabulary projection for
6347 /// every position but the last, so the one thing that must hold is
6348 /// that the row it *does* produce is the same row `forward_batch`
6349 /// would have produced. It must also leave the KV cache in the same
6350 /// state -- prefill's whole purpose -- which is checked by decoding
6351 /// one more token from each cache and comparing.
6352 #[test]
6353 fn forward_batch_last_matches_the_final_row_of_forward_batch() {
6354 let cfg = tiny_test_config();
6355 let vocab = 16;
6356 let tokens = vec![1usize, 4, 7, 2, 9];
6357
6358 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6359 let mut caches_a: Vec<KvCache> = decoder_a.config.new_kv_caches();
6360 let all_rows = decoder_a.forward_batch(&tokens, 0, &mut caches_a);
6361
6362 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6363 let mut caches_b: Vec<KvCache> = decoder_b.config.new_kv_caches();
6364 let last = decoder_b.forward_batch_last(&tokens, 0, &mut caches_b);
6365
6366 let expected = all_rows.last().expect("one row per prompt token");
6367 assert_eq!(last.len(), expected.len());
6368 for (i, (a, b)) in expected.iter().zip(last.iter()).enumerate() {
6369 assert!(
6370 a == b,
6371 "logit {i}: forward_batch={a} forward_batch_last={b}"
6372 );
6373 }
6374
6375 // Same KV state: the next token's logits must agree too.
6376 let next_a = decoder_a.forward_token(3, tokens.len(), &mut caches_a);
6377 let next_b = decoder_b.forward_token(3, tokens.len(), &mut caches_b);
6378 for (i, (a, b)) in next_a.iter().zip(next_b.iter()).enumerate() {
6379 assert!(a == b, "post-prefill decode logit {i}: {a} vs {b}");
6380 }
6381
6382 // Empty prompt is the degenerate case both paths must survive.
6383 let mut caches_c: Vec<KvCache> = decoder_b.config.new_kv_caches();
6384 assert!(decoder_b
6385 .forward_batch_last(&[], 0, &mut caches_c)
6386 .is_empty());
6387 }
6388
6389 /// `forward_multi_seq`'s core correctness property: batching N
6390 /// independent sequences (different token histories, different
6391 /// current positions, different KV caches) together must produce
6392 /// EXACTLY the same per-sequence output as running each sequence
6393 /// through `forward_token` alone, one step at a time. This is what
6394 /// makes continuous batching safe -- no sequence's attention may
6395 /// ever be perturbed by another sequence sharing its batched
6396 /// matmul step.
6397 /// The PAGED batch step must equal the contiguous one, bit for bit.
6398 ///
6399 /// Continuous batching and paging are independent choices, so a
6400 /// deployment can have either, both or neither; if they disagree,
6401 /// the answer depends on two switches nobody thinks of as changing
6402 /// the model. Every sequence here is at a different position with a
6403 /// different length, which is the case the batched path exists for
6404 /// and the one where a shared-KV mistake would surface.
6405 #[test]
6406 fn a_paged_multi_seq_step_is_bit_identical_to_the_contiguous_one() {
6407 for cfg in [
6408 tiny_test_config(),
6409 {
6410 let mut c = tiny_test_config();
6411 c.sliding_window = Some(2);
6412 c.swa_layers = crate::swa_layers::SwaLayers::All;
6413 c
6414 },
6415 {
6416 let mut c = tiny_test_config();
6417 c.embedding_scale = Some(7.5);
6418 c.final_logit_softcap = Some(0.05);
6419 c.attn_logit_softcap = Some(0.05);
6420 c
6421 },
6422 ] {
6423 let n_layers = 2;
6424 let decoder = Decoder::new_random_small(cfg, n_layers, 10);
6425 let histories: [&[usize]; 3] = [&[1, 3, 5], &[2, 7], &[4, 4, 4, 6]];
6426 let next = [6usize, 1, 2];
6427
6428 // Contiguous: build each sequence's history, then one step.
6429 let mut contiguous: Vec<Vec<KvCache>> = histories
6430 .iter()
6431 .map(|h| {
6432 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
6433 for (pos, &tok) in h.iter().enumerate() {
6434 decoder.forward_token(tok, pos, &mut caches);
6435 }
6436 caches
6437 })
6438 .collect();
6439 let positions: Vec<usize> = histories.iter().map(|h| h.len()).collect();
6440 let want = decoder.forward_multi_seq(&next, &positions, &mut contiguous);
6441
6442 // Paged: same histories through the paged decode path, then
6443 // one batched step over the shared store.
6444 let stores = SharedPagedKv::new(
6445 n_layers,
6446 /* block_size = */ 2,
6447 /* blocks_per_layer = */ 64,
6448 decoder.config.n_kv_heads,
6449 decoder.config.head_dim,
6450 );
6451 let mut paged: Vec<Vec<PagedKvCache>> = histories
6452 .iter()
6453 .map(|h| {
6454 let mut caches: Vec<PagedKvCache> =
6455 (0..n_layers).map(|_| PagedKvCache::new()).collect();
6456 for (pos, &tok) in h.iter().enumerate() {
6457 decoder
6458 .forward_token_paged(tok, pos, &mut caches, &stores)
6459 .expect("sized generously");
6460 }
6461 caches
6462 })
6463 .collect();
6464 let got = decoder.forward_multi_seq_kv(
6465 &next,
6466 &positions,
6467 &mut MultiSeqKv::Paged {
6468 caches: &mut paged,
6469 stores: &stores,
6470 },
6471 );
6472
6473 assert_eq!(want.len(), got.len());
6474 for (s, (a, b)) in want.iter().zip(got.iter()).enumerate() {
6475 assert_eq!(a.len(), b.len(), "sequence {s}: logit count");
6476 for (x, y) in a.iter().zip(b.iter()) {
6477 assert_eq!(
6478 x.to_bits(),
6479 y.to_bits(),
6480 "sequence {s}: paged batching changed the answer"
6481 );
6482 }
6483 }
6484 }
6485 }
6486
6487 #[test]
6488 fn forward_multi_seq_matches_independent_forward_token_per_sequence() {
6489 let cfg = tiny_test_config();
6490 let vocab = 8;
6491 // 3 independent sequences, deliberately different lengths/
6492 // histories/current tokens, so no two sequences are at the
6493 // same position when batched together.
6494 let seq_histories: [&[usize]; 3] = [&[1, 3, 5], &[2, 7], &[4, 4, 4, 6]];
6495
6496 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6497 let mut independent_logits: Vec<Vec<f32>> = Vec::new();
6498 for history in seq_histories.iter() {
6499 let mut caches: Vec<KvCache> = decoder_a.config.new_kv_caches();
6500 let mut logits = Vec::new();
6501 for (pos, &tok) in history.iter().enumerate() {
6502 logits = decoder_a.forward_token(tok, pos, &mut caches);
6503 }
6504 independent_logits.push(logits);
6505 }
6506
6507 // Same seed -> identical weights, fresh caches for a fair
6508 // comparison (mirrors forward_batch_matches_sequential_forward_token_exactly).
6509 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6510 let mut per_seq_caches: Vec<Vec<KvCache>> = seq_histories
6511 .iter()
6512 .map(|_| decoder_b.config.new_kv_caches())
6513 .collect();
6514
6515 // Feed every sequence's prefix (all but its last token)
6516 // through forward_multi_seq one shared step at a time, then
6517 // do a final batched step for the last token of every
6518 // sequence so all three arrive at their final position in
6519 // the same batched call -- exercising genuinely different
6520 // per-sequence positions/histories within one batch, not just
6521 // parallel identical-length sequences.
6522 let max_len = seq_histories.iter().map(|h| h.len()).max().unwrap();
6523 let mut batched_logits: Vec<Vec<f32>> = vec![Vec::new(); seq_histories.len()];
6524 for step in 0..max_len {
6525 let mut tokens = Vec::new();
6526 let mut positions = Vec::new();
6527 let mut active: Vec<usize> = Vec::new();
6528 for (s, history) in seq_histories.iter().enumerate() {
6529 if step < history.len() {
6530 tokens.push(history[step]);
6531 positions.push(step);
6532 active.push(s);
6533 }
6534 }
6535 if tokens.is_empty() {
6536 continue;
6537 }
6538 let mut active_caches: Vec<Vec<KvCache>> = active
6539 .iter()
6540 .map(|&s| std::mem::take(&mut per_seq_caches[s]))
6541 .collect();
6542 let step_logits = decoder_b.forward_multi_seq(&tokens, &positions, &mut active_caches);
6543 for ((&s, caches), logits) in active.iter().zip(active_caches).zip(step_logits) {
6544 per_seq_caches[s] = caches;
6545 batched_logits[s] = logits;
6546 }
6547 }
6548
6549 assert_eq!(batched_logits.len(), independent_logits.len());
6550 for (s, (seq_logits, batch_logits)) in independent_logits
6551 .iter()
6552 .zip(batched_logits.iter())
6553 .enumerate()
6554 {
6555 assert_eq!(seq_logits.len(), batch_logits.len());
6556 for (i, (a, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6557 assert!(
6558 (a - b).abs() < 1e-3,
6559 "sequence {s}, logit {i}: independent={a} batched={b}"
6560 );
6561 }
6562 }
6563 }
6564
6565 /// The gap the decoration audit found, from the side the existing
6566 /// guard could not see.
6567 ///
6568 /// `the_paged_path_keeps_every_per_layer_feature_the_contiguous_one_applies`
6569 /// sets `attention_scale` and compares `forward_token` against
6570 /// `forward_token_paged` -- the two bodies that AGREED. It never
6571 /// compared them against `forward_hidden_batch_inner`, which applied
6572 /// the scale nowhere, so a Gemma-shaped checkpoint would answer at
6573 /// one temperature when decoded a token at a time and at another
6574 /// when its prompt was prefilled. Not an error; a plausible
6575 /// distribution from the wrong model.
6576 ///
6577 /// The first assertion is the one that makes this a guard rather
6578 /// than an assertion: 0.37 is well away from the kernel's own
6579 /// `1/sqrt(head_dim)`, so if setting it does not move the logits
6580 /// then both sides are ignoring it and the comparison below proves
6581 /// nothing.
6582 /// The Metal attention kernels infer Q/K norm style from the weight
6583 /// LENGTH; the host branches on `ModelConfig::qk_norm_style`. Two
6584 /// mechanisms for one decision, so they have to agree.
6585 ///
6586 /// They do, and not by luck: `loader.rs`'s `refined_qk_norm` DERIVES
6587 /// the enum from the same length rule, and refuses to load anything
6588 /// that matches neither width. This pins that, because the failure
6589 /// would be silent and would land on audited architectures --
6590 /// OLMoE is whole-vector, Qwen3 and Gemma-3 are per-head, and all
6591 /// three are in `AUDITED_GENERIC_GQA`, so an inference that assumed
6592 /// one style would answer wrong on the others at full speed.
6593 ///
6594 /// Raised by the decoration audit as unverifiable from the host
6595 /// side, which is exactly why it is written down here rather than
6596 /// left as a comment on one of the two sides.
6597 #[test]
6598 fn the_metal_qk_norm_length_rule_is_the_one_the_loader_derives_the_style_from() {
6599 use crate::capability::QkNormStyle;
6600 let head_dim = 8usize;
6601 let n_heads = 4usize;
6602
6603 // The rule `frink-metal/src/attn.rs` applies, transcribed.
6604 let metal_says_per_head = |len: usize| len == head_dim;
6605 // The rule `loader.rs::refined_qk_norm` applies, transcribed.
6606 let loader_style = |len: usize| -> Option<QkNormStyle> {
6607 if len == head_dim {
6608 Some(QkNormStyle::PerHead)
6609 } else if len == n_heads * head_dim {
6610 Some(QkNormStyle::WholeVector)
6611 } else {
6612 None
6613 }
6614 };
6615
6616 for len in [head_dim, n_heads * head_dim] {
6617 let style = loader_style(len).expect("both widths load");
6618 assert_eq!(
6619 metal_says_per_head(len),
6620 style == QkNormStyle::PerHead,
6621 "length {len} loads as {style:?} but Metal would infer the other style"
6622 );
6623 }
6624
6625 // A width neither side handles must be refused at load rather
6626 // than reaching a kernel that would pick a branch anyway.
6627 assert!(
6628 loader_style(head_dim + 1).is_none(),
6629 "an unrecognised norm width must be a load error, not a coin flip"
6630 );
6631
6632 // The one ambiguous case, and it is harmless: with a single
6633 // head the two widths coincide, so both rules take their PerHead
6634 // branch and per-head RMS over one head IS whole-vector RMS.
6635 let single_head = |len: usize| len == head_dim;
6636 assert!(single_head(head_dim));
6637 assert_eq!(
6638 loader_style(head_dim),
6639 Some(QkNormStyle::PerHead),
6640 "with n_heads == 1 both widths are head_dim, and both sides must land \
6641 on the same branch rather than one falling through"
6642 );
6643 }
6644
6645 #[test]
6646 fn the_batched_path_applies_attention_scale_like_the_contiguous_one() {
6647 let vocab = 8;
6648 let tokens = [1usize, 3, 5, 2, 7];
6649 let scaled = || {
6650 let mut cfg = tiny_test_config();
6651 // Far from the kernel's own 1/sqrt(head_dim) on purpose:
6652 // at this model's scale a scalar near 1 moves the logits by
6653 // ~2e-4, which is below the noise a tolerance test can see.
6654 cfg.attention_scale = Some(8.0);
6655 cfg
6656 };
6657 let fresh_caches = |d: &Decoder| -> Vec<KvCache> { d.config.new_kv_caches() };
6658
6659 // Same seed -> identical weights, so the only difference between
6660 // these three decoders is the config field under test.
6661 let seq_decoder = Decoder::new_random_small(scaled(), 2, vocab);
6662 let mut seq_caches = fresh_caches(&seq_decoder);
6663 let sequential: Vec<Vec<f32>> = tokens
6664 .iter()
6665 .enumerate()
6666 .map(|(pos, &t)| seq_decoder.forward_token(t, pos, &mut seq_caches))
6667 .collect();
6668
6669 let batch_decoder = Decoder::new_random_small(scaled(), 2, vocab);
6670 let mut batch_caches = fresh_caches(&batch_decoder);
6671 let batched = batch_decoder.forward_batch(&tokens, 0, &mut batch_caches);
6672
6673 let plain_decoder = Decoder::new_random_small(tiny_test_config(), 2, vocab);
6674 let mut plain_caches = fresh_caches(&plain_decoder);
6675 let unscaled = plain_decoder.forward_batch(&tokens, 0, &mut plain_caches);
6676 assert!(
6677 batched
6678 .iter()
6679 .zip(unscaled.iter())
6680 .any(|(s, u)| s.iter().zip(u.iter()).any(|(a, b)| (a - b).abs() > 1e-3)),
6681 "attention_scale must change the batched answer, or this test cannot fail"
6682 );
6683
6684 assert_eq!(batched.len(), sequential.len());
6685 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
6686 assert_eq!(seq_logits.len(), batch_logits.len());
6687 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6688 assert!(
6689 (s - b).abs() < 1e-5,
6690 "position {pos}, logit {i}: sequential={s} batched={b}"
6691 );
6692 }
6693 }
6694 }
6695
6696 /// [`the_batched_path_applies_attention_scale_like_the_contiguous_one`]
6697 /// for the fourth host body.
6698 ///
6699 /// `forward_multi_seq_kv` did not apply `attention_scale` either, so
6700 /// a served request answered differently the moment it was batched
6701 /// with another request -- the same weights, the same position, a
6702 /// different temperature, decided by how busy the server was.
6703 #[test]
6704 fn the_multi_seq_path_applies_attention_scale_like_the_contiguous_one() {
6705 let vocab = 8;
6706 let histories: [&[usize]; 3] = [&[1, 3, 5], &[2, 7], &[4, 4, 4, 6]];
6707 let next = [6usize, 1, 2];
6708 let n_layers = 2;
6709 let scaled = || {
6710 let mut cfg = tiny_test_config();
6711 // See the batched twin: a scalar near 1 does not move this
6712 // model's logits far enough for a tolerance to see it.
6713 cfg.attention_scale = Some(8.0);
6714 cfg
6715 };
6716
6717 // Builds every sequence's history with `forward_token`, then
6718 // takes the next step either per sequence or as one batch.
6719 let run = |cfg: ModelConfig, batched: bool| -> Vec<Vec<f32>> {
6720 let decoder = Decoder::new_random_small(cfg, n_layers, vocab);
6721 let mut per_seq: Vec<Vec<KvCache>> = histories
6722 .iter()
6723 .map(|h| {
6724 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
6725 for (pos, &tok) in h.iter().enumerate() {
6726 decoder.forward_token(tok, pos, &mut caches);
6727 }
6728 caches
6729 })
6730 .collect();
6731 let positions: Vec<usize> = histories.iter().map(|h| h.len()).collect();
6732 if batched {
6733 decoder.forward_multi_seq(&next, &positions, &mut per_seq)
6734 } else {
6735 next.iter()
6736 .zip(positions.iter())
6737 .zip(per_seq.iter_mut())
6738 .map(|((&tok, &pos), caches)| decoder.forward_token(tok, pos, caches))
6739 .collect()
6740 }
6741 };
6742
6743 let want = run(scaled(), false);
6744 let got = run(scaled(), true);
6745 let unscaled = run(tiny_test_config(), true);
6746
6747 assert!(
6748 got.iter()
6749 .zip(unscaled.iter())
6750 .any(|(g, u)| g.iter().zip(u.iter()).any(|(a, b)| (a - b).abs() > 1e-3)),
6751 "attention_scale must change the multi-seq answer, or this test cannot fail"
6752 );
6753
6754 assert_eq!(want.len(), got.len());
6755 for (s, (a, b)) in want.iter().zip(got.iter()).enumerate() {
6756 assert_eq!(a.len(), b.len(), "sequence {s}: logit count");
6757 for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
6758 assert!(
6759 (x - y).abs() < 1e-5,
6760 "sequence {s}, logit {i}: independent={x} batched={y}"
6761 );
6762 }
6763 }
6764 }
6765
6766 /// The predicate that decides whether a MoE layer may be routed by
6767 /// the GPU must admit ONLY the routing the GPU actually computes.
6768 ///
6769 /// Every Metal MoE path -- `launch_moe_decode_stack`,
6770 /// `launch_moe_decode_layer_fused`, `launch_moe_prefill_q4_0` and
6771 /// the fused prefill stack -- routes with a plain top-k softmax over
6772 /// the raw router logits. `Decoder::route_for_layer` has three more
6773 /// arms: grouped routing, a per-expert router bias, and
6774 /// `expert_weights_scale`. The audit found those four call sites
6775 /// disagreeing about which of the three to refuse -- prefill checked
6776 /// all three, the fused decode layer checked two, the whole-stack
6777 /// decode checked none -- so a Softmax-gated MoE checkpoint carrying
6778 /// a router bias would have routed to different experts on Metal
6779 /// than on CPU, with no error.
6780 ///
6781 /// This asserts the invariant directly rather than the predicate's
6782 /// spelling: whenever it says yes, plain `route_top_k` and
6783 /// `route_for_layer` must return the same decision; and each of the
6784 /// three features on its own must make it say no.
6785 #[test]
6786 fn the_gpu_router_predicate_admits_only_routing_it_reproduces() {
6787 // `tiny_test_config` is GLM-shaped and so gates with sigmoid;
6788 // the GPU router implements softmax, so start from the case the
6789 // predicate is supposed to ADMIT.
6790 let mut base = tiny_test_config();
6791 base.moe.gating = frink_moe::GatingFunction::Softmax;
6792 let decoder = Decoder::new_random_small(base.clone(), 2, 8);
6793 let plain_layer = &decoder.layers[0];
6794 let n_experts = base.moe.n_experts;
6795 // Chosen so each feature really bites: the top two experts sit
6796 // in DIFFERENT groups of two (so grouped routing must reorder
6797 // them), and the runners-up are close enough behind that a
6798 // per-expert bias flips the order.
6799 assert_eq!(n_experts, 6, "the logits below are written for six experts");
6800 let logits: Vec<f32> = vec![0.90, 0.10, 0.20, 0.85, 0.30, 0.05];
6801
6802 let agrees = |layer: &LayerWeights, cfg: &ModelConfig| -> bool {
6803 let host = Decoder::route_for_layer(layer, &logits, cfg);
6804 let gpu = route_top_k(
6805 &logits,
6806 cfg.moe.n_experts_active,
6807 cfg.moe.gating,
6808 cfg.moe.norm_topk_prob,
6809 );
6810 host.expert_ids == gpu.expert_ids
6811 && host.weights.len() == gpu.weights.len()
6812 && host
6813 .weights
6814 .iter()
6815 .zip(gpu.weights.iter())
6816 .all(|(a, b)| a.to_bits() == b.to_bits())
6817 };
6818
6819 // The admitted case: the predicate says yes, and the two
6820 // routers really do agree.
6821 assert!(
6822 Decoder::gpu_router_matches_host_routing(plain_layer, &base),
6823 "a plain softmax MoE layer must stay eligible, or this test proves nothing"
6824 );
6825 assert!(agrees(plain_layer, &base));
6826
6827 // A per-expert router bias.
6828 let mut biased_decoder = Decoder::new_random_small(base.clone(), 2, 8);
6829 biased_decoder.layers[0].moe.exp_probs_bias =
6830 Some((0..n_experts).map(|e| 0.9 - 0.4 * e as f32).collect());
6831 let biased_layer = &biased_decoder.layers[0];
6832 assert!(
6833 !Decoder::gpu_router_matches_host_routing(biased_layer, &base),
6834 "exp_probs_bias must make the layer ineligible for the GPU router"
6835 );
6836 assert!(
6837 !agrees(biased_layer, &base),
6838 "the bias must actually change the routing, or the check above is vacuous"
6839 );
6840
6841 // `expert_weights_scale`.
6842 let mut scaled = base.clone();
6843 scaled.moe.expert_weights_scale = 2.5;
6844 assert!(
6845 !Decoder::gpu_router_matches_host_routing(plain_layer, &scaled),
6846 "expert_weights_scale must make the layer ineligible for the GPU router"
6847 );
6848 assert!(
6849 !agrees(plain_layer, &scaled),
6850 "the scale must actually change the routing, or the check above is vacuous"
6851 );
6852
6853 // Grouped routing.
6854 let mut grouped = base.clone();
6855 grouped.moe.expert_group_count = Some(3);
6856 grouped.moe.expert_group_used_count = Some(1);
6857 assert!(
6858 !Decoder::gpu_router_matches_host_routing(plain_layer, &grouped),
6859 "grouped routing must make the layer ineligible for the GPU router"
6860 );
6861 assert!(
6862 !agrees(plain_layer, &grouped),
6863 "the grouping must actually change the routing, or the check above is vacuous"
6864 );
6865
6866 // A non-softmax gate: the GPU kernel implements softmax only.
6867 let mut sigmoid = base.clone();
6868 sigmoid.moe.gating = frink_moe::GatingFunction::Sigmoid;
6869 assert!(
6870 !Decoder::gpu_router_matches_host_routing(plain_layer, &sigmoid),
6871 "a non-softmax gate must make the layer ineligible for the GPU router"
6872 );
6873
6874 // A router that reads the raw layer input (`smallthinker`):
6875 // every GPU router reads `normed2`, so the SAME logits routed
6876 // the same way are still the wrong experts. `agrees` cannot see
6877 // this one -- it compares two routers over one logit vector --
6878 // which is exactly why the predicate has to.
6879 let mut raw = base.clone();
6880 raw.router_input = crate::router_input::RouterInput::RawLayerInput;
6881 assert!(
6882 !Decoder::gpu_router_matches_host_routing(plain_layer, &raw),
6883 "a raw-layer-input router must make the layer ineligible for the GPU router"
6884 );
6885
6886 // A router (and experts) reading the normed layer input
6887 // (`arctic`): the same predicate, for the same reason, and the
6888 // experts would read the wrong tensor too.
6889 let mut branch = base;
6890 branch.router_input = crate::router_input::RouterInput::NormedLayerInput;
6891 assert!(
6892 !Decoder::gpu_router_matches_host_routing(plain_layer, &branch),
6893 "a normed-layer-input branch must make the layer ineligible for the GPU router"
6894 );
6895 }
6896
6897 /// OLMoE-style QK-norm (`attn_q_norm`/`attn_k_norm`, see `AttnWeights`'
6898 /// doc comment): with both set, `forward_batch` must still match
6899 /// sequential `forward_token` calls exactly -- the same consistency
6900 /// property `forward_batch_matches_sequential_forward_token_exactly`
6901 /// checks for the no-QK-norm path, now exercising the norm-applied
6902 /// per-row slicing (`q_batch.chunks_mut(q_width)`,
6903 /// `k_batch.chunks_mut(kv_width)`) instead of trusting it by
6904 /// inspection.
6905 #[test]
6906 fn forward_batch_matches_forward_token_with_qk_norm_present() {
6907 let cfg = tiny_test_config();
6908 let vocab = 8;
6909 let tokens = [1usize, 3, 5, 2, 7];
6910 let q_width = cfg.n_heads * cfg.head_dim;
6911 let kv_width = cfg.n_kv_heads * cfg.head_dim;
6912
6913 let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6914 for layer in &mut decoder_a.layers {
6915 layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
6916 layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
6917 }
6918 let mut caches_a: Vec<KvCache> = decoder_a.config.new_kv_caches();
6919 let sequential: Vec<Vec<f32>> = tokens
6920 .iter()
6921 .enumerate()
6922 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
6923 .collect();
6924
6925 let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6926 for layer in &mut decoder_b.layers {
6927 layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
6928 layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
6929 }
6930 let mut caches_b: Vec<KvCache> = decoder_b.config.new_kv_caches();
6931 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
6932
6933 assert_eq!(batched.len(), sequential.len());
6934 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
6935 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6936 assert!(
6937 (s - b).abs() < BATCH_VS_SEQUENTIAL_ULP,
6938 "position {pos}, logit {i}: sequential={s} batched={b}"
6939 );
6940 }
6941 }
6942 }
6943
6944 /// QK-norm being present must actually change the output -- otherwise
6945 /// the `Some(...)` branches in `forward_token`/`forward_batch` could
6946 /// silently be dead code and this feature would ship unverified. Must
6947 /// decode at least 2 positions: at position 0 with a fresh cache,
6948 /// causal softmax has exactly one candidate (the token attending to
6949 /// itself) and always evaluates to weight 1.0 regardless of the Q*K
6950 /// dot product -- so the attention output there is Q/K-invariant by
6951 /// construction, and a single-position version of this test would
6952 /// pass even with `q_norm`/`k_norm` silently never applied.
6953 #[test]
6954 fn qk_norm_present_changes_output_versus_absent() {
6955 let cfg = tiny_test_config();
6956 let vocab = 8;
6957 let q_width = cfg.n_heads * cfg.head_dim;
6958 let kv_width = cfg.n_kv_heads * cfg.head_dim;
6959 let tokens = [3usize, 5];
6960
6961 let without_norm = Decoder::new_random_small(cfg.clone(), 1, vocab);
6962 let mut with_norm = Decoder::new_random_small(cfg, 1, vocab);
6963 for layer in &mut with_norm.layers {
6964 layer.attn.q_norm = Some(vec![2.0; q_width]);
6965 layer.attn.k_norm = Some(vec![2.0; kv_width]);
6966 }
6967
6968 let mut caches_a: Vec<KvCache> = without_norm.config.new_kv_caches();
6969 let mut caches_b: Vec<KvCache> = with_norm.config.new_kv_caches();
6970
6971 let mut out_a = Vec::new();
6972 let mut out_b = Vec::new();
6973 for (pos, &t) in tokens.iter().enumerate() {
6974 out_a = without_norm.forward_token(t, pos, &mut caches_a);
6975 out_b = with_norm.forward_token(t, pos, &mut caches_b);
6976 }
6977
6978 let differs = out_a
6979 .iter()
6980 .zip(out_b.iter())
6981 .any(|(a, b)| (a - b).abs() > 1e-4);
6982 assert!(
6983 differs,
6984 "QK-norm weights changed nothing -- forward_token likely isn't applying q_norm/k_norm"
6985 );
6986 }
6987
6988 /// Qwen2/Qwen2-MoE-family QKV attention bias (`AttnWeights::q_bias`/
6989 /// `k_bias`/`v_bias`): a real, previously-unhandled gap found by
6990 /// running frink's generic GGUF loader against a real downloaded
6991 /// Qwen1.5-MoE-A2.7B-Chat checkpoint, which produced fluent-but-wrong
6992 /// output because these real `attn_{q,k,v}.bias` tensors were
6993 /// silently never added anywhere. Same two real properties checked
6994 /// as the QK-norm tests above: (1) `forward_batch` must match
6995 /// sequential `forward_token` exactly with bias present (batched
6996 /// per-row broadcast must be correct, not just the single-token
6997 /// path), and (2) bias must actually change the output at position
6998 /// 0 or later (not silently dead code) -- checked at position 1
6999 /// specifically, since position 0's causal softmax has exactly one
7000 /// candidate and is Q/K-invariant regardless of any additive bias
7001 /// shifting Q/K, for the same reason the QK-norm test above needs
7002 /// >=2 positions.
7003 #[test]
7004 fn forward_batch_matches_forward_token_with_qkv_bias_present() {
7005 let cfg = tiny_test_config();
7006 let vocab = 8;
7007 let tokens = [1usize, 3, 5, 2, 7];
7008 let q_width = cfg.n_heads * cfg.head_dim;
7009 let kv_width = cfg.n_kv_heads * cfg.head_dim;
7010
7011 let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
7012 for layer in &mut decoder_a.layers {
7013 layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
7014 layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
7015 layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
7016 }
7017 let mut caches_a: Vec<KvCache> = decoder_a.config.new_kv_caches();
7018 let sequential: Vec<Vec<f32>> = tokens
7019 .iter()
7020 .enumerate()
7021 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
7022 .collect();
7023
7024 let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
7025 for layer in &mut decoder_b.layers {
7026 layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
7027 layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
7028 layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
7029 }
7030 let mut caches_b: Vec<KvCache> = decoder_b.config.new_kv_caches();
7031 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
7032
7033 assert_eq!(batched.len(), sequential.len());
7034 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
7035 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
7036 assert!(
7037 (s - b).abs() < BATCH_VS_SEQUENTIAL_ULP,
7038 "position {pos}, logit {i}: sequential={s} batched={b}"
7039 );
7040 }
7041 }
7042 }
7043
7044 #[test]
7045 fn qkv_bias_present_changes_output_versus_absent() {
7046 let cfg = tiny_test_config();
7047 let vocab = 8;
7048 let q_width = cfg.n_heads * cfg.head_dim;
7049 let kv_width = cfg.n_kv_heads * cfg.head_dim;
7050 let tokens = [3usize, 5];
7051
7052 let without_bias = Decoder::new_random_small(cfg.clone(), 1, vocab);
7053 let mut with_bias = Decoder::new_random_small(cfg, 1, vocab);
7054 for layer in &mut with_bias.layers {
7055 layer.attn.q_bias = Some(vec![0.5; q_width]);
7056 layer.attn.k_bias = Some(vec![0.5; kv_width]);
7057 layer.attn.v_bias = Some(vec![0.5; kv_width]);
7058 }
7059
7060 let mut caches_a: Vec<KvCache> = without_bias.config.new_kv_caches();
7061 let mut caches_b: Vec<KvCache> = with_bias.config.new_kv_caches();
7062
7063 let mut out_a = Vec::new();
7064 let mut out_b = Vec::new();
7065 for (pos, &t) in tokens.iter().enumerate() {
7066 out_a = without_bias.forward_token(t, pos, &mut caches_a);
7067 out_b = with_bias.forward_token(t, pos, &mut caches_b);
7068 }
7069
7070 let differs = out_a
7071 .iter()
7072 .zip(out_b.iter())
7073 .any(|(a, b)| (a - b).abs() > 1e-4);
7074 assert!(
7075 differs,
7076 "QKV bias changed nothing -- forward_token likely isn't applying q_bias/k_bias/v_bias"
7077 );
7078 }
7079
7080 #[test]
7081 fn forward_batch_and_forward_token_leave_kv_caches_in_the_same_state() {
7082 let cfg = tiny_test_config();
7083 let tokens = [2usize, 4, 6];
7084
7085 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
7086 let mut caches_a: Vec<KvCache> = decoder_a.config.new_kv_caches();
7087 for (pos, &t) in tokens.iter().enumerate() {
7088 decoder_a.forward_token(t, pos, &mut caches_a);
7089 }
7090
7091 let decoder_b = Decoder::new_random_small(cfg, 2, 8);
7092 let mut caches_b: Vec<KvCache> = decoder_b.config.new_kv_caches();
7093 decoder_b.forward_batch(&tokens, 0, &mut caches_b);
7094
7095 for (ca, cb) in caches_a.iter().zip(caches_b.iter()) {
7096 assert_eq!(ca.positions(), cb.positions());
7097 assert_eq!(ca.k.len(), cb.k.len());
7098 for (a, b) in ca.k.iter().zip(cb.k.iter()) {
7099 assert!((a - b).abs() < 1e-4);
7100 }
7101 }
7102 }
7103
7104 /// Same architecture shape as `tiny_test_config` but genuinely
7105 /// dense (one expert, no shared experts) -- the shape every non-MoE
7106 /// model, and every DeepSeek-style leading dense layer, loads as.
7107 /// Exercises `Decoder::is_dense_layer`'s fast path.
7108 fn tiny_dense_test_config() -> ModelConfig {
7109 let mut cfg = tiny_test_config();
7110 cfg.moe.n_experts = 1;
7111 cfg.moe.n_experts_active = 1;
7112 cfg.moe.n_shared_experts = 0;
7113 cfg
7114 }
7115
7116 #[test]
7117 fn dense_layer_forward_pass_produces_finite_logits_of_correct_shape() {
7118 let vocab = 10;
7119 let decoder = Decoder::new_random_small(tiny_dense_test_config(), 2, vocab);
7120 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
7121
7122 let logits = decoder.forward_token(3, 0, &mut caches);
7123 assert_eq!(logits.len(), vocab);
7124 assert!(
7125 logits.iter().all(|v| v.is_finite()),
7126 "logits must not contain NaN/Inf"
7127 );
7128 }
7129
7130 #[test]
7131 fn dense_layer_forward_batch_matches_sequential_forward_token_to_one_ulp() {
7132 let cfg = tiny_dense_test_config();
7133 let vocab = 8;
7134 let tokens = [1usize, 3, 5, 2, 7];
7135
7136 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
7137 let mut caches_a: Vec<KvCache> = decoder_a.config.new_kv_caches();
7138 let sequential: Vec<Vec<f32>> = tokens
7139 .iter()
7140 .enumerate()
7141 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
7142 .collect();
7143
7144 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
7145 let mut caches_b: Vec<KvCache> = decoder_b.config.new_kv_caches();
7146 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
7147
7148 assert_eq!(batched.len(), sequential.len());
7149 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
7150 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
7151 assert!(
7152 (s - b).abs() < BATCH_VS_SEQUENTIAL_ULP,
7153 "position {pos}, logit {i}: sequential={s} batched={b}"
7154 );
7155 }
7156 }
7157 }
7158
7159 #[test]
7160 fn dense_layer_fast_path_still_records_expert_zero_activations() {
7161 // The dense fast path bypasses `route_top_k` entirely, but
7162 // must still record an activation for expert 0 every step --
7163 // `MoeWeights::placement_plan` and hotness-based GPU placement
7164 // depend on this being real for every model shape, not just
7165 // genuinely-MoE ones.
7166 let decoder = Decoder::new_random_small(tiny_dense_test_config(), 1, 8);
7167 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
7168
7169 decoder.forward_token(0, 0, &mut caches);
7170 decoder.forward_token(1, 1, &mut caches);
7171 decoder.forward_token(2, 2, &mut caches);
7172
7173 let count =
7174 decoder.layers[0].moe.activation_counts[0].load(std::sync::atomic::Ordering::Relaxed);
7175 assert_eq!(count, 3);
7176 }
7177
7178 #[test]
7179 fn forward_batch_with_empty_tokens_returns_empty() {
7180 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
7181 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
7182 let out = decoder.forward_batch(&[], 0, &mut caches);
7183 assert!(out.is_empty());
7184 }
7185
7186 #[test]
7187 fn forward_batch_continues_correctly_after_prior_forward_token_calls() {
7188 // Realistic usage pattern: some tokens processed one at a time
7189 // (e.g. the first generated token), then a batch verifying
7190 // several draft tokens at once, continuing from the same
7191 // cache. The batch's positions must be numbered starting from
7192 // wherever the cache left off, not from zero.
7193 let cfg = tiny_test_config();
7194
7195 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
7196 let mut caches_a: Vec<KvCache> = decoder_a.config.new_kv_caches();
7197 decoder_a.forward_token(1, 0, &mut caches_a);
7198 decoder_a.forward_token(3, 1, &mut caches_a);
7199 let seq_next = decoder_a.forward_token(5, 2, &mut caches_a);
7200
7201 let decoder_b = Decoder::new_random_small(cfg, 2, 8);
7202 let mut caches_b: Vec<KvCache> = decoder_b.config.new_kv_caches();
7203 decoder_b.forward_token(1, 0, &mut caches_b);
7204 let batch_next = decoder_b.forward_batch(&[3, 5], 1, &mut caches_b);
7205
7206 for (s, b) in seq_next.iter().zip(batch_next[1].iter()) {
7207 assert!(
7208 (s - b).abs() < BATCH_VS_SEQUENTIAL_ULP,
7209 "sequential={s} batched={b}"
7210 );
7211 }
7212 }
7213
7214 /// `PlacementPlan::from_budget` is
7215 /// real and tested in isolation, but only meaningful once it's fed
7216 /// genuinely observed per-expert activation counts rather than
7217 /// zeros. This proves the full loop: run real forward passes,
7218 /// confirm `MoeWeights::activation_counts` actually reflects what
7219 /// `route_top_k` selected, and confirm `placement_plan` prioritizes
7220 /// the expert that was genuinely hottest -- not just that the
7221 /// budget/size arithmetic works on synthetic inputs.
7222 #[test]
7223 fn placement_plan_reflects_real_observed_expert_activations() {
7224 let cfg = tiny_test_config(); // 6 experts, top-2 active/token
7225 let decoder = Decoder::new_random_small(cfg, 2, 16);
7226 let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
7227
7228 let n_calls = 20;
7229 for pos in 0..n_calls {
7230 decoder.forward_token(pos % 16, pos, &mut caches);
7231 }
7232
7233 let layer0 = &decoder.layers[0].moe;
7234 let counts: Vec<u64> = layer0
7235 .activation_counts
7236 .iter()
7237 .map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
7238 .collect();
7239 let total: u64 = counts.iter().sum();
7240 assert_eq!(
7241 total,
7242 (n_calls as u64) * (decoder.config.moe.n_experts_active as u64),
7243 "total recorded activations must equal calls * experts_active_per_call"
7244 );
7245
7246 // Ties are realistic at this small a sample size; break them the
7247 // same way `PlacementPlan::from_budget` does (lowest index
7248 // wins), so this assertion can't spuriously fail on a tie that
7249 // `from_budget` resolves differently than a naive `max_by_key`
7250 // (which returns the *last* max element) would.
7251 let hottest_count = *counts.iter().max().unwrap();
7252 let hottest_idx = counts.iter().position(|&c| c == hottest_count).unwrap();
7253 assert!(hottest_count > 0);
7254
7255 // A per-expert resident size big enough for exactly one expert.
7256 let per_expert_bytes = layer0.expert_bytes(0);
7257 let plan = layer0.placement_plan(per_expert_bytes as u64);
7258
7259 assert_eq!(
7260 plan.placement_for(hottest_idx),
7261 frink_moe::ExpertPlacement::GpuDevice(0),
7262 "the genuinely hottest expert (index {hottest_idx}, {hottest_count} activations) \
7263 must be the one the plan places on GPU when only one expert fits the budget"
7264 );
7265 }
7266}
7267
7268/// The Metal side of Phi-3/Phi-4's RoPE: partial rotary and LongRoPE's
7269/// `attn_factor` used to be a refusal in `layer_supports_metal_attn`
7270/// and are now two uniforms on [`frink_metal::attn::MetalRope`].
7271#[cfg(all(test, feature = "metal"))]
7272mod metal_rope_tests {
7273 use super::*;
7274
7275 fn phi_like_config() -> ModelConfig {
7276 let mut cfg = crate::config::test_dense_fixture();
7277 cfg.head_dim = 128;
7278 cfg.rope_layout = crate::config::RopeLayout::Neox;
7279 cfg.rope_dim = Some(96);
7280 cfg.rope_attn_factor = 1.1902381;
7281 cfg
7282 }
7283
7284 /// Both values must reach the kernels, and they must be the same two
7285 /// the CPU path reads — otherwise the backends compute different
7286 /// attention for the same weights, which is the whole reason the
7287 /// model was refused Metal in the first place.
7288 #[test]
7289 fn metal_rope_carries_partial_rotary_and_mscale() {
7290 let decoder = Decoder::new_random_small(phi_like_config(), 1, 32);
7291 let rope = decoder.metal_rope();
7292 assert_eq!(rope.layout, frink_metal::attn::MetalRopeLayout::Neox);
7293 assert_eq!(rope.rot_dim, Some(96));
7294 assert_eq!(rope.attn_factor, 1.1902381);
7295 }
7296
7297 /// `rope.dimension_count == head_dim` is "the whole head rotates",
7298 /// which must reach the kernel as `None` rather than as a width —
7299 /// same graph, one code path.
7300 #[test]
7301 fn rot_dim_equal_to_head_dim_becomes_none() {
7302 let mut cfg = phi_like_config();
7303 cfg.rope_dim = Some(cfg.head_dim);
7304 let decoder = Decoder::new_random_small(cfg, 1, 32);
7305 assert_eq!(decoder.metal_rope().rot_dim, None);
7306 }
7307
7308 /// A non-unit `attn_factor` is no longer a reason to refuse Metal;
7309 /// an odd `n_rot` still is, because ggml's `ggml_rope_impl` asserts
7310 /// an even width and the split-half pairing is otherwise undefined
7311 /// for the last channel.
7312 #[test]
7313 fn odd_rot_dim_is_still_refused_but_mscale_is_not() {
7314 let supported = |cfg: ModelConfig| {
7315 let d = Decoder::new_random_small(cfg, 1, 32);
7316 d.layer_supports_metal_attn(&d.layers[0])
7317 };
7318
7319 // The control: with no rope oddity the fixture is admitted, so
7320 // the two assertions below are about the rope config and not
7321 // about the fixture failing some other check.
7322 let mut plain = phi_like_config();
7323 plain.rope_dim = None;
7324 plain.rope_attn_factor = 1.0;
7325 assert!(supported(plain), "fixture must be Metal-eligible to start");
7326
7327 assert!(
7328 supported(phi_like_config()),
7329 "partial rotary + a non-unit attn_factor must no longer refuse Metal"
7330 );
7331
7332 let mut odd = phi_like_config();
7333 odd.rope_dim = Some(95);
7334 assert!(!supported(odd), "odd n_rot must keep the model off Metal");
7335 }
7336
7337 /// A `residual_scale` keeps every fused Metal path off the model,
7338 /// through ONE predicate the four eligibility checks share.
7339 ///
7340 /// Granite multiplies both branch outputs before every residual add.
7341 /// The fused launches -- dense decode, resident MoE decode, and both
7342 /// prefill stacks -- fold the residual add in on device with no
7343 /// uniform for a multiplier, so a Granite layer served by any of
7344 /// them would be scaled by the host bodies and not by the GPU: the
7345 /// same weights answering differently depending on which backend
7346 /// took the token. That is exactly what `attention_scale` is fenced
7347 /// off for next door, and this repo has already watched the GPU
7348 /// router's eligibility check drift four ways when it was four
7349 /// spellings instead of one.
7350 ///
7351 /// Only reachable in a `--features metal` build, which is where the
7352 /// fence exists at all.
7353 #[test]
7354 fn a_residual_scale_keeps_the_model_off_every_fused_metal_path() {
7355 let mut plain = phi_like_config();
7356 plain.rope_dim = None;
7357 plain.rope_attn_factor = 1.0;
7358 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7359 assert!(
7360 d.layer_supports_metal_attn(&d.layers[0]),
7361 "the fixture must be Metal-eligible to start, or this proves nothing"
7362 );
7363
7364 let mut scaled = plain;
7365 scaled.residual_scale = Some(0.22);
7366 let d = Decoder::new_random_small(scaled.clone(), 1, 32);
7367 assert!(
7368 !d.layer_supports_metal_attn(&d.layers[0]),
7369 "a residual multiplier no Metal kernel applies must refuse the fused attention"
7370 );
7371 assert!(
7372 !Decoder::metal_prefill_dense_layer_eligible(&d.layers[0], &scaled, false),
7373 "...and the prefill dense stack"
7374 );
7375 assert!(
7376 !Decoder::metal_can_serve_model(&scaled, false),
7377 "the shared predicate is what all four read"
7378 );
7379 }
7380
7381 /// A `clamp_kqv` keeps every fused Metal path off the model too,
7382 /// through the same predicate.
7383 ///
7384 /// The fused launches apply the QKV bias inside their kernels via
7385 /// `AttnExtras` and clamp nothing, while the host bodies clamp
7386 /// after the bias (`decoder/qkv_bias.rs`). A DBRX layer served by a
7387 /// fused launch would therefore run unclamped projections -- the
7388 /// disagreement the residual-scale fence exists for, on a different
7389 /// scalar. Only reachable in a `--features metal` build.
7390 #[test]
7391 fn a_qkv_clamp_keeps_the_model_off_every_fused_metal_path() {
7392 let mut plain = phi_like_config();
7393 plain.rope_dim = None;
7394 plain.rope_attn_factor = 1.0;
7395 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7396 assert!(
7397 d.layer_supports_metal_attn(&d.layers[0]),
7398 "the fixture must be Metal-eligible to start, or this proves nothing"
7399 );
7400
7401 let mut clamped = plain;
7402 clamped.clamp_kqv = Some(8.0);
7403 let d = Decoder::new_random_small(clamped.clone(), 1, 32);
7404 assert!(
7405 !d.layer_supports_metal_attn(&d.layers[0]),
7406 "a clamp no Metal kernel applies must refuse the fused attention"
7407 );
7408 assert!(
7409 !Decoder::metal_prefill_dense_layer_eligible(&d.layers[0], &clamped, false),
7410 "...and the prefill dense stack"
7411 );
7412 assert!(!Decoder::metal_can_serve_model(&clamped, false));
7413 }
7414
7415 /// Per-layer shapes keep every fused Metal path off the model,
7416 /// through the same predicate.
7417 ///
7418 /// The fused launches take ONE `n_heads` argument and one
7419 /// `MetalKvBuffers` geometry per run of layers, and the Metal KV
7420 /// plane is sized once from the scalars. A deci or openelm layer
7421 /// served by any of them would be projected and cached at another
7422 /// layer's width -- the disagreement the two fences above exist
7423 /// for, on a shape rather than a scalar. Only reachable in a
7424 /// `--features metal` build.
7425 #[test]
7426 fn per_layer_shapes_keep_the_model_off_every_fused_metal_path() {
7427 use crate::layer_shapes::{AttnShape, LayerShape, LayerShapes};
7428 let mut plain = phi_like_config();
7429 plain.rope_dim = None;
7430 plain.rope_attn_factor = 1.0;
7431 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7432 assert!(
7433 d.layer_supports_metal_attn(&d.layers[0]),
7434 "the fixture must be Metal-eligible to start, or this proves nothing"
7435 );
7436
7437 let mut shaped = plain;
7438 shaped.layer_shapes = LayerShapes::PerLayer(vec![LayerShape {
7439 attention: AttnShape::Gqa {
7440 n_heads: shaped.n_heads,
7441 n_kv_heads: shaped.n_kv_heads,
7442 },
7443 ffn_dim: shaped.moe.expert_ffn_dim,
7444 }]);
7445 let d = Decoder::new_random_small(shaped.clone(), 1, 32);
7446 assert!(
7447 !d.layer_supports_metal_attn(&d.layers[0]),
7448 "a per-layer table, even one whose single entry agrees with the scalars, must \
7449 refuse the fused attention: the stacks hold one geometry"
7450 );
7451 assert!(
7452 !Decoder::metal_prefill_dense_layer_eligible(&d.layers[0], &shaped, false),
7453 "...and the prefill dense stack"
7454 );
7455 assert!(!Decoder::metal_can_serve_model(&shaped, false));
7456 }
7457
7458 /// A per-position attention temperature keeps every fused Metal
7459 /// path off the model, through the same predicate.
7460 ///
7461 /// No fused launch takes a per-token Q scale: the host bodies
7462 /// multiply Q by `log(floor(pos / floor) + 1) * scale + 1` after
7463 /// RoPE (`crate::attn_temperature`), and a Ministral-3 layer served
7464 /// by a fused launch would attend at temperature 1 while its
7465 /// neighbours on the host stepped with position -- the same
7466 /// disagreement the three fences above exist for. Only reachable
7467 /// in a `--features metal` build.
7468 #[test]
7469 fn a_per_position_temperature_keeps_the_model_off_every_fused_metal_path() {
7470 use crate::attn_temperature::AttnTemperature;
7471 let mut plain = phi_like_config();
7472 plain.rope_dim = None;
7473 plain.rope_attn_factor = 1.0;
7474 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7475 assert!(
7476 d.layer_supports_metal_attn(&d.layers[0]),
7477 "the fixture must be Metal-eligible to start, or this proves nothing"
7478 );
7479
7480 let mut tempered = plain;
7481 tempered.attn_temperature = Some(AttnTemperature {
7482 scale: 0.5,
7483 floor_scale: std::num::NonZeroU32::new(2).unwrap(),
7484 offset: 0.0,
7485 unrotated_layers_only: false,
7486 });
7487 let d = Decoder::new_random_small(tempered.clone(), 1, 32);
7488 assert!(
7489 !d.layer_supports_metal_attn(&d.layers[0]),
7490 "a per-position Q scale no Metal kernel applies must refuse the fused attention"
7491 );
7492 assert!(
7493 !Decoder::metal_prefill_dense_layer_eligible(&d.layers[0], &tempered, false),
7494 "...and the prefill dense stack"
7495 );
7496 assert!(!Decoder::metal_can_serve_model(&tempered, false));
7497 }
7498
7499 /// BitNet's two inner norms keep the model off every fused Metal
7500 /// path, through the same predicate, and a layer that carries the
7501 /// attention one off the per-layer fused attention through the
7502 /// exhaustive destructure in `metal_attn_view`.
7503 ///
7504 /// No fused kernel norms between the V sum and `wo` or between the
7505 /// activation and `down`; a BitNet layer served by one would skip
7506 /// both norms at full speed (`crate::sub_norms`). Only reachable in
7507 /// a `--features metal` build.
7508 /// A parallel-residual model (`crate::parallel_residual`) stays off
7509 /// every fused Metal path: each of them bakes the pre-FFN norm over
7510 /// the POST-ATTENTION residual into its kernel, and a parallel layer
7511 /// norms the layer INPUT. The model-level flag is what the
7512 /// predicate reads, because the per-layer fact lives on
7513 /// `MoeWeights` and the two config-only callers cannot see it. Only
7514 /// reachable in a `--features metal` build.
7515 /// A learned-position model (`crate::position_embd`) stays off every
7516 /// fused Metal path: the GPU embedding gather has no add, and no
7517 /// stack sees `pos` for it. Only reachable in a `--features metal`
7518 /// build.
7519 /// An ALiBi model (`crate::alibi`) stays off every fused Metal path:
7520 /// no kernel adds a per-key bias to its scores. Only reachable in a
7521 /// `--features metal` build.
7522 #[test]
7523 fn an_alibi_bias_keeps_the_model_off_every_fused_metal_path() {
7524 let mut plain = phi_like_config();
7525 plain.rope_dim = None;
7526 plain.rope_attn_factor = 1.0;
7527 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7528 assert!(
7529 d.layer_supports_metal_attn(&d.layers[0]),
7530 "the fixture must be Metal-eligible to start, or this proves nothing"
7531 );
7532 let mut biased = plain;
7533 biased.alibi_max_bias = Some(8.0);
7534 let d = Decoder::new_random_small(biased.clone(), 1, 32);
7535 assert!(d.alibi_slopes.is_some(), "derived from the config");
7536 assert!(!d.layer_supports_metal_attn(&d.layers[0]));
7537 assert!(!Decoder::metal_can_serve_model(&biased, false));
7538 }
7539
7540 #[test]
7541 fn a_learned_position_table_keeps_the_model_off_every_fused_metal_path() {
7542 let mut plain = phi_like_config();
7543 plain.rope_dim = None;
7544 plain.rope_attn_factor = 1.0;
7545 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7546 assert!(
7547 d.layer_supports_metal_attn(&d.layers[0]),
7548 "the fixture must be Metal-eligible to start, or this proves nothing"
7549 );
7550 let mut positioned = plain;
7551 positioned.learned_positions = true;
7552 let d = Decoder::new_random_small(positioned.clone(), 1, 32);
7553 assert!(!d.layer_supports_metal_attn(&d.layers[0]));
7554 assert!(!Decoder::metal_can_serve_model(&positioned, false));
7555 }
7556
7557 #[test]
7558 fn a_parallel_residual_keeps_the_model_off_every_fused_metal_path() {
7559 let mut plain = phi_like_config();
7560 plain.rope_dim = None;
7561 plain.rope_attn_factor = 1.0;
7562 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7563 assert!(
7564 d.layer_supports_metal_attn(&d.layers[0]),
7565 "the fixture must be Metal-eligible to start, or this proves nothing"
7566 );
7567 let mut parallel = plain;
7568 parallel.parallel_residual = true;
7569 let d = Decoder::new_random_small(parallel.clone(), 1, 32);
7570 assert!(!d.layer_supports_metal_attn(&d.layers[0]));
7571 assert!(!Decoder::metal_prefill_dense_layer_eligible(
7572 &d.layers[0],
7573 ¶llel,
7574 false
7575 ));
7576 assert!(!Decoder::metal_can_serve_model(¶llel, false));
7577 }
7578
7579 /// Llama 4's two facts (`crate::chunked_swa`,
7580 /// `crate::weightless_qk_norm`) each keep the model off every fused
7581 /// launch: one window per layer, no weightless post-RoPE QK norm.
7582 #[test]
7583 fn a_chunked_window_and_the_weightless_qk_norm_each_keep_the_model_on_the_host() {
7584 let mut plain = phi_like_config();
7585 plain.rope_dim = None;
7586 plain.rope_attn_factor = 1.0;
7587 assert!(Decoder::metal_can_serve_model(&plain, false));
7588 let mut chunked = plain.clone();
7589 chunked.sliding_window = Some(8192);
7590 chunked.swa_chunked = true;
7591 assert!(!Decoder::metal_can_serve_model(&chunked, false));
7592 let mut normed = plain;
7593 normed.weightless_qk_norm = true;
7594 assert!(!Decoder::metal_can_serve_model(&normed, false));
7595 }
7596
7597 #[test]
7598 fn the_inner_norms_keep_the_model_off_every_fused_metal_path() {
7599 let mut plain = phi_like_config();
7600 plain.rope_dim = None;
7601 plain.rope_attn_factor = 1.0;
7602 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7603 assert!(
7604 d.layer_supports_metal_attn(&d.layers[0]),
7605 "the fixture must be Metal-eligible to start, or this proves nothing"
7606 );
7607
7608 let mut sub_normed = plain;
7609 sub_normed.block_sub_norms = true;
7610 let d = Decoder::new_random_small(sub_normed.clone(), 1, 32);
7611 assert!(
7612 !d.layer_supports_metal_attn(&d.layers[0]),
7613 "a norm between attention and `wo` that no Metal kernel applies must refuse the \
7614 fused attention"
7615 );
7616 assert!(
7617 !Decoder::metal_prefill_dense_layer_eligible(&d.layers[0], &sub_normed, false),
7618 "...and the prefill dense stack"
7619 );
7620 assert!(!Decoder::metal_can_serve_model(&sub_normed, false));
7621
7622 // Independently of the config: the tensor on the layer is enough
7623 // to lose the per-layer fused attention's view of it.
7624 let mut d = Decoder::new_random_small(plain_config_with_metal_view(), 1, 32);
7625 assert!(d.metal_attn_view(&d.layers[0]).is_some());
7626 d.layers[0].attn.attn_sub_norm = Some(vec![1.0; d.config.hidden_dim]);
7627 assert!(d.metal_attn_view(&d.layers[0]).is_none());
7628 }
7629
7630 /// A V head width that differs from K's, and a scale after `wo`,
7631 /// each keep the model off every fused Metal path through the same
7632 /// predicate: every fused launch takes ONE head width for its KV
7633 /// buffers, its attention tile and its `wo` fold, and none scales
7634 /// after the fold (`crate::kv_head_dims`, `crate::attn_value_scale`).
7635 /// Only reachable in a `--features metal` build.
7636 #[test]
7637 fn a_split_kv_head_width_or_a_value_scale_keeps_the_model_off_every_fused_metal_path() {
7638 let plain = plain_config_with_metal_view();
7639 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7640 assert!(d.layer_supports_metal_attn(&d.layers[0]), "the premise");
7641
7642 let mut split = plain.clone();
7643 split.v_head_dim = Some(plain.head_dim / 2);
7644 assert!(!Decoder::metal_can_serve_model(&split, false));
7645 assert!(!Decoder::metal_prefill_dense_layer_eligible(
7646 &d.layers[0],
7647 &split,
7648 false
7649 ));
7650
7651 let mut scaled = plain;
7652 scaled.attn_value_scale = Some(0.707);
7653 assert!(!Decoder::metal_can_serve_model(&scaled, false));
7654 assert!(!Decoder::metal_prefill_dense_layer_eligible(
7655 &d.layers[0],
7656 &scaled,
7657 false
7658 ));
7659 }
7660
7661 /// A looped model keeps off every fused Metal path: each launch
7662 /// indexes weights and KV buffers with one `l`, and a looped model's
7663 /// logical layers outnumber its weights (`crate::layer_loops`). Only
7664 /// reachable in a `--features metal` build.
7665 #[test]
7666 fn a_layer_loop_keeps_the_model_off_every_fused_metal_path() {
7667 let plain = plain_config_with_metal_view();
7668 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7669 assert!(d.layer_supports_metal_attn(&d.layers[0]), "the premise");
7670 let mut looped = plain;
7671 looped.layer_loops = Some(crate::layer_loops::LayerLoops::Repeat {
7672 n_phys: 1,
7673 n_loops: 2,
7674 skip_loop_final_norm: false,
7675 });
7676 assert!(!Decoder::metal_can_serve_model(&looped, false));
7677 assert!(!Decoder::metal_prefill_dense_layer_eligible(
7678 &d.layers[0],
7679 &looped,
7680 false
7681 ));
7682 }
7683
7684 /// Talkie's facts keep the model off every fused Metal path: the
7685 /// skip stream and the per-head scalar QK gain through the model
7686 /// predicate, and a projection gain on a layer through the
7687 /// exhaustive destructure and the dense-FFN predicate
7688 /// (`crate::skip_stream`, `crate::weight_scales`). Only reachable in
7689 /// a `--features metal` build.
7690 #[test]
7691 fn talkie_s_facts_keep_the_model_off_every_fused_metal_path() {
7692 let plain = plain_config_with_metal_view();
7693 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7694 assert!(d.layer_supports_metal_attn(&d.layers[0]), "the premise");
7695
7696 let mut skip = plain.clone();
7697 skip.skip_stream = true;
7698 assert!(!Decoder::metal_can_serve_model(&skip, false));
7699
7700 let mut scalar = plain.clone();
7701 scalar.qk_norm_style = crate::capability::QkNormStyle::PerHeadScalar;
7702 assert!(!Decoder::metal_can_serve_model(&scalar, false));
7703
7704 let mut d = Decoder::new_random_small(plain.clone(), 1, 32);
7705 assert!(d.metal_attn_view(&d.layers[0]).is_some());
7706 d.layers[0].attn.o_scale = Some(1.5);
7707 assert!(d.metal_attn_view(&d.layers[0]).is_none());
7708
7709 let mut d = Decoder::new_random_small(plain.clone(), 1, 32);
7710 assert!(Decoder::metal_prefill_dense_layer_eligible(
7711 &d.layers[0],
7712 &plain,
7713 false
7714 ));
7715 d.layers[0].moe.down_scale = Some(0.5);
7716 assert!(!Decoder::metal_prefill_dense_layer_eligible(
7717 &d.layers[0],
7718 &plain,
7719 false
7720 ));
7721 assert!(!Decoder::layer_supports_metal_dense_ffn(&d.layers[0]));
7722 }
7723
7724 fn plain_config_with_metal_view() -> ModelConfig {
7725 let mut plain = phi_like_config();
7726 plain.rope_dim = None;
7727 plain.rope_attn_factor = 1.0;
7728 plain
7729 }
7730
7731 /// An FFN activation no fused kernel spells keeps the model off
7732 /// every fused Metal path, through the same predicate.
7733 ///
7734 /// Six launch sites used to derive the kernels' `gelu: bool` as
7735 /// `!is_swiglu()`, which would have run the ungated ReLU-squared FFN
7736 /// (`arcee`) as GELU; `GluAct::fused_kernel_gelu_flag` is `None`
7737 /// there now, and this is the fence that keeps a layer from being
7738 /// half-served. Only reachable in a `--features metal` build.
7739 #[test]
7740 fn an_activation_no_kernel_spells_keeps_the_model_off_every_fused_metal_path() {
7741 let mut plain = phi_like_config();
7742 plain.rope_dim = None;
7743 plain.rope_attn_factor = 1.0;
7744 plain.ffn_activation = crate::config::FfnActivation::Swiglu;
7745 let d = Decoder::new_random_small(plain.clone(), 1, 32);
7746 assert!(d.layer_supports_metal_attn(&d.layers[0]));
7747
7748 let mut ungated = plain;
7749 ungated.ffn_activation = crate::config::FfnActivation::ReluSqr;
7750 let d = Decoder::new_random_small(ungated.clone(), 1, 32);
7751 assert!(!d.layer_supports_metal_attn(&d.layers[0]));
7752 assert!(!Decoder::metal_prefill_dense_layer_eligible(
7753 &d.layers[0],
7754 &ungated,
7755 false
7756 ));
7757 assert!(!Decoder::metal_can_serve_model(&ungated, false));
7758 assert_eq!(
7759 ungated
7760 .model_ffn_act()
7761 .and_then(GluAct::fused_kernel_gelu_flag),
7762 None
7763 );
7764
7765 // The two PARAMETERISED activations answer no whole-model
7766 // activation at all, and the two-width rotary is a fourth thing
7767 // the fused stacks cannot take; each keeps the model off alone.
7768 let mut xielu = ungated.clone();
7769 xielu.ffn_activation =
7770 crate::config::FfnActivation::Xielu(crate::act_layers::XieluLayers::new(vec![
7771 frink_moe::XieluParams::from_gguf(
7772 0.8, 0.8, 0.5, -1e-6
7773 );
7774 xielu.n_layers
7775 ]));
7776 assert_eq!(xielu.model_ffn_act(), None);
7777 assert!(!Decoder::metal_can_serve_model(&xielu, false));
7778 let mut clamped = ungated.clone();
7779 clamped.ffn_activation =
7780 crate::config::FfnActivation::SwigluClamped(crate::act_layers::SwigluClamps::new(
7781 vec![0.0; clamped.n_layers],
7782 vec![7.0; clamped.n_layers],
7783 frink_moe::ClampForm::AfterSilu,
7784 ));
7785 assert_eq!(clamped.model_ffn_act(), None);
7786 assert!(!Decoder::metal_can_serve_model(&clamped, false));
7787 let mut two_widths = ungated;
7788 two_widths.ffn_activation = crate::config::FfnActivation::Swiglu;
7789 assert!(Decoder::metal_can_serve_model(&two_widths, false));
7790 two_widths.sliding_window = Some(4);
7791 two_widths.swa_layers = crate::swa_layers::SwaLayers::period(2, false);
7792 two_widths.n_layers = 2;
7793 two_widths.rope_dim_swa = Some(two_widths.head_dim / 2);
7794 assert!(two_widths.rope_dim_varies_by_layer());
7795 assert!(!Decoder::metal_can_serve_model(&two_widths, false));
7796 }
7797
7798 /// An attention output gate or a set of attention sinks keeps THAT
7799 /// LAYER off every fused Metal attention launch, through the one
7800 /// exhaustive destructure in `metal_attn_view`.
7801 ///
7802 /// Both sit between the softmax and `wo`, which the fused kernels
7803 /// run with no host round-trip; a launch that ignored either would
7804 /// answer differently from the host bodies for the same weights.
7805 /// The gate is `afmoe` / `laguna` (`crate::attn_gate`); the sinks
7806 /// used to be refused by the gpt-oss NAME, and this pins that they
7807 /// are refused by the TENSOR now, on a model that is not gpt-oss.
7808 /// Only reachable in a `--features metal` build.
7809 #[test]
7810 fn an_output_gate_or_attention_sinks_keep_the_layer_off_every_fused_metal_path() {
7811 let mut plain = phi_like_config();
7812 plain.rope_dim = None;
7813 plain.rope_attn_factor = 1.0;
7814 let mut d = Decoder::new_random_small(plain.clone(), 2, 32);
7815 assert!(
7816 d.layer_supports_metal_attn(&d.layers[0]),
7817 "the fixture must be Metal-eligible to start, or this proves nothing"
7818 );
7819 assert!(d.metal_attn_view(&d.layers[1]).is_some());
7820
7821 // A gate on layer 0 only.
7822 let (n_heads, hidden) = (plain.n_heads, plain.hidden_dim);
7823 d.layers[0].attn.output_gate = Some(crate::attn_gate::AttnGate {
7824 proj: WeightMatrix::F32(Tensor::new(
7825 vec![0.1; n_heads * hidden],
7826 vec![n_heads, hidden],
7827 )),
7828 act: crate::attn_gate::GateAct::Sigmoid,
7829 width: crate::attn_gate::GateWidth::PerHead,
7830 });
7831 assert!(
7832 !d.layer_supports_metal_attn(&d.layers[0]),
7833 "a gate no Metal kernel applies must refuse the fused attention"
7834 );
7835 assert!(d.metal_attn_view(&d.layers[0]).is_none());
7836 assert!(
7837 d.layer_supports_metal_attn(&d.layers[1]),
7838 "the fence is per layer: the ungated layer is still served"
7839 );
7840
7841 // Sinks on layer 1, with `gpt_oss` still `None`.
7842 d.layers[1].attn.sinks = Some(vec![0.5; n_heads]);
7843 assert!(d.gpt_oss.is_none());
7844 assert!(
7845 !d.layer_supports_metal_attn(&d.layers[1]),
7846 "sinks are refused by the tensor, not by the gpt-oss name"
7847 );
7848 assert!(d.metal_attn_view(&d.layers[1]).is_none());
7849 }
7850
7851 /// A LoRA adapter keeps the WHOLE model off every fused Metal
7852 /// launch, through the shared predicate, and an adapted matrix has
7853 /// no raw-bytes Metal descriptor at all -- so a stack that somehow
7854 /// asked for one anyway could not build it. The delta lives inside
7855 /// `WeightMatrix::Adapted` and is served by that type's methods;
7856 /// the stacks read weight bytes past those methods.
7857 /// Only reachable in a `--features metal` build.
7858 #[test]
7859 fn a_lora_adapter_keeps_the_model_off_every_fused_metal_path() {
7860 let mut plain = phi_like_config();
7861 plain.rope_dim = None;
7862 plain.rope_attn_factor = 1.0;
7863 let mut d = Decoder::new_random_small(plain.clone(), 2, 32);
7864 assert!(Decoder::metal_can_serve_model(&plain, false));
7865 assert!(
7866 d.layer_supports_metal_attn(&d.layers[0]),
7867 "the fixture must be Metal-eligible to start, or this proves nothing"
7868 );
7869 assert!(Decoder::metal_matvec_launch(&d.layers[0].attn.q_proj).is_some());
7870
7871 // The fact the predicate reads.
7872 assert!(!Decoder::metal_can_serve_model(&plain, true));
7873 assert!(!Decoder::metal_prefill_dense_layer_eligible(
7874 &d.layers[0],
7875 &plain,
7876 true
7877 ));
7878
7879 // The variant, on one matrix: no descriptor, so no launch.
7880 let (rows, cols) = (
7881 d.layers[0].attn.q_proj.rows(),
7882 d.layers[0].attn.q_proj.cols(),
7883 );
7884 let scale = frink_core::weight_matrix::LoraScale::new(1.0);
7885 d.layers[0].attn.q_proj.attach_lora(
7886 frink_core::weight_matrix::LoraDelta::new(
7887 vec![0.01; 2 * cols],
7888 vec![0.01; rows * 2],
7889 2,
7890 rows,
7891 cols,
7892 0.0,
7893 scale.clone(),
7894 )
7895 .unwrap(),
7896 );
7897 assert!(Decoder::metal_matvec_launch(&d.layers[0].attn.q_proj).is_none());
7898 assert!(d.layers[0].attn.q_proj.mul_mm_sg_launch().is_none());
7899 assert!(
7900 !d.layer_supports_metal_attn(&d.layers[0]),
7901 "a layer whose projection has no Metal descriptor is not served"
7902 );
7903
7904 // The list, on the decoder: what every eligibility check asks.
7905 d.lora_adapters.push(crate::lora_attach::LoraAttached {
7906 path: "x.gguf".into(),
7907 alpha: 0.0,
7908 task_name: String::new(),
7909 prompt_prefix: String::new(),
7910 scale,
7911 n_tensors: 1,
7912 });
7913 assert!(d.lora_attached());
7914 assert!(
7915 !d.layer_supports_metal_attn(&d.layers[1]),
7916 "the fence is the whole model, not the adapted layer alone"
7917 );
7918 }
7919
7920 /// A Gemma-3-4B-shaped config: `rope_scaling {linear, factor 8}`
7921 /// folded into the full-attention layers' divisors, nothing on the
7922 /// sliding ones, `sliding_window_pattern = 6` last-dense.
7923 fn gemma3_4b_shaped_config() -> ModelConfig {
7924 let mut cfg = crate::config::test_dense_fixture();
7925 cfg.head_dim = 8;
7926 cfg.rope_layout = crate::config::RopeLayout::Norm;
7927 cfg.rope_theta = 1_000_000.0;
7928 cfg.rope_theta_swa = Some(10_000.0);
7929 cfg.sliding_window = Some(4);
7930 cfg.swa_layers = crate::swa_layers::SwaLayers::period(6, false);
7931 cfg.rope_freqs = Some(crate::config::RopeFreqs {
7932 full: vec![8.0; 4],
7933 swa: Some(vec![1.0; 4]),
7934 });
7935 // One full period, so the run holds five sliding layers and one
7936 // full-attention layer -- Gemma-3's ratio, and the smallest one
7937 // that makes `rope_freqs_vary_by_layer` true.
7938 cfg.n_layers = 6;
7939 cfg
7940 }
7941
7942 /// What the fused Metal stacks are handed per layer must be BOTH
7943 /// halves of `ModelConfig::layer_rope`, layer by layer.
7944 ///
7945 /// `Decoder::metal_stack_needs_per_layer_rope_freqs` used to refuse
7946 /// exactly this config off the fused prefill/decode stacks, because
7947 /// those took one `freq_factors` slice for a whole run beside a
7948 /// per-layer theta -- half the answer varying and half not, which is
7949 /// this repo's dominant bug shape. `LayerRope` carries the pair, and
7950 /// this pins that the decoder fills it from the pair rather than
7951 /// re-deriving either half on its own.
7952 #[test]
7953 fn the_metal_stacks_are_handed_each_layer_s_own_rope_pair() {
7954 let cfg = gemma3_4b_shaped_config();
7955 assert!(
7956 cfg.rope_freqs_vary_by_layer(),
7957 "fixture must be the shape that used to be refused"
7958 );
7959 let decoder = Decoder::new_random_small(cfg, 6, 32);
7960
7961 for il in 0..decoder.layers.len() {
7962 let rope = decoder
7963 .config
7964 .layer_rope(il)
7965 .expect("every layer rotates here");
7966 let sent = decoder
7967 .metal_layer_rope(il)
7968 .expect("so every layer is handed a rope");
7969 assert_eq!(sent.theta, rope.theta, "layer {il} base");
7970 assert_eq!(sent.freq_factors, rope.freq_factors, "layer {il} divisors");
7971 }
7972
7973 // Not vacuous: with `swa_pattern = 6` last-dense, layers 0..=4
7974 // slide and layer 5 does not, so the run really does hold two
7975 // different answers.
7976 let sliding = decoder.metal_layer_rope(0).unwrap();
7977 let full = decoder.metal_layer_rope(5).unwrap();
7978 assert_eq!(sliding.freq_factors, Some(&[1.0f32; 4][..]));
7979 assert_eq!(full.freq_factors, Some(&[8.0f32; 4][..]));
7980 assert_ne!(
7981 sliding, full,
7982 "a run of layers that all rope alike proves nothing here"
7983 );
7984 }
7985
7986 /// The THIRD half of the answer: a layer llama.cpp does not rotate
7987 /// reaches the Metal stacks as `None`, from the same accessor the
7988 /// CPU bodies read, and `layer_needs_metal_stack` sees it through
7989 /// the same `Option` -- so the per-layer launches, which always
7990 /// rope, can never be handed such a layer.
7991 #[test]
7992 fn an_unrotated_layer_is_handed_no_rope_and_routed_to_the_stack() {
7993 let mut cfg = gemma3_4b_shaped_config();
7994 // Gemma-3's own graph rotates everything; borrow its shape and
7995 // give it EXAONE-4 32B's rule so the full-attention layer 5 is
7996 // the one that does not rotate.
7997 cfg.rope_layers = crate::rope_layers::RopeLayers::SlidingOnly;
7998 let decoder = Decoder::new_random_small(cfg, 6, 32);
7999
8000 for il in 0..5 {
8001 assert!(
8002 decoder.metal_layer_rope(il).is_some(),
8003 "sliding layer {il} rotates"
8004 );
8005 }
8006 assert_eq!(
8007 decoder.metal_layer_rope(5),
8008 None,
8009 "the full-attention layer does not"
8010 );
8011 assert!(
8012 decoder.layer_needs_metal_stack(&decoder.layers[5], 5),
8013 "an unrotated layer must go to the fused stack, which implements `None`"
8014 );
8015 }
8016}