atlas_core/config/
methods.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! [`ModelConfig`] inherent helper methods. Split out of `config.rs` for
4//! file-size budget. Pure derived getters + small predicates over the
5//! struct fields.
6
7#![allow(unused_imports)]
8
9use super::{LayerType, ModelConfig};
10
11impl ModelConfig {
12    /// Every configured stop-token id, primary first.
13    ///
14    /// Falls back to `vec![eos_token_id]` when `eos_token_ids` was never populated, so a
15    /// hand-built `ModelConfig` and a scalar-EOS checkpoint both behave exactly as before.
16    pub fn eos_ids(&self) -> Vec<u32> {
17        if self.eos_token_ids.is_empty() {
18            vec![self.eos_token_id]
19        } else {
20            self.eos_token_ids.clone()
21        }
22    }
23
24    /// Does this token id terminate generation?
25    pub fn is_eos(&self, id: u32) -> bool {
26        if self.eos_token_ids.is_empty() {
27            id == self.eos_token_id
28        } else {
29            self.eos_token_ids.contains(&id)
30        }
31    }
32
33    /// GQA ratio: number of Q heads per KV head.
34    pub fn gqa_ratio(&self) -> usize {
35        self.num_attention_heads
36            .checked_div(self.num_key_value_heads)
37            .unwrap_or(1)
38    }
39
40    /// Layer type for a given layer index.
41    /// Falls back to full_attention_interval if layer_types is empty.
42    /// Layer kind for ANY index in the checkpoint, including layers past the text stack.
43    ///
44    /// `layer_type` covers the text stack only. Indices `>= num_hidden_layers` are
45    /// MTP/NextN layers and resolve through `mtp_layer_types`; that is what lets GLM-5.3's
46    /// layer 45 be represented as the sparse-attention block it actually is, rather than
47    /// being appended to the text stack and silently swept into every text-layer loop.
48    pub fn layer_type_at(&self, layer_idx: usize) -> Option<LayerType> {
49        if layer_idx < self.num_hidden_layers {
50            return Some(self.layer_type(layer_idx));
51        }
52        self.mtp_layer_types
53            .get(layer_idx - self.num_hidden_layers)
54            .copied()
55    }
56
57    /// Layers (text stack only) whose mixer is `deepseek_sparse_attention`.
58    pub fn sparse_attention_layers(&self) -> Vec<usize> {
59        self.layer_types
60            .iter()
61            .enumerate()
62            .filter(|(_, t)| **t == LayerType::SparseAttention)
63            .map(|(i, _)| i)
64            .collect()
65    }
66
67    /// True when any layer — text stack **or** MTP — needs the sparse-attention
68    /// indexer. Scheduling and cache sizing both key off this, so it must not be
69    /// answered from `layer_types` alone.
70    pub fn has_sparse_attention(&self) -> bool {
71        self.layer_types.contains(&LayerType::SparseAttention)
72            || self.mtp_layer_types.contains(&LayerType::SparseAttention)
73    }
74
75    pub fn layer_type(&self, layer_idx: usize) -> LayerType {
76        if !self.layer_types.is_empty() {
77            self.layer_types
78                .get(layer_idx)
79                .cloned()
80                .unwrap_or(LayerType::FullAttention)
81        } else if self.full_attention_interval > 0
82            && (layer_idx + 1).is_multiple_of(self.full_attention_interval)
83        {
84            LayerType::FullAttention
85        } else {
86            LayerType::LinearAttention
87        }
88    }
89
90    /// Number of attention (KV-cache-consuming) layers: full, sliding, and
91    /// sparse. All three write to the paged KV cache — only *which* keys they
92    /// read differs (all / a window / a runtime-selected top-k) — so every
93    /// consumer sized from this count — KV pool `num_layers`,
94    /// `attn_layer_dtypes`, loader `layer_kv_dtypes` indexing — must see them
95    /// all. Step 3.7 is the only model emitting `SlidingAttention` layer types
96    /// (12 full + 33 sliding); counting full-only there undersized the dtype
97    /// vec and panicked the loader at layer 13.
98    ///
99    /// 🪤 The same omission recurred for `SparseAttention`: GLM-5.3-Flash is
100    /// 34 `linear_attention` + 11 `deepseek_sparse_attention`, so a full/sliding
101    /// filter returned **0** and the KV pool came out zero-sized ("KV cache block
102    /// size is zero", measured 2026-08-28). Delegating to
103    /// [`LayerType::is_attention`] is what keeps this honest: the predicate lives
104    /// next to the enum, so a new variant is answered in one place.
105    pub fn num_attention_layers(&self) -> usize {
106        if !self.layer_types.is_empty() {
107            self.layer_types.iter().filter(|t| t.is_attention()).count()
108        } else {
109            self.num_hidden_layers
110                .checked_div(self.full_attention_interval)
111                .unwrap_or(self.num_hidden_layers)
112        }
113    }
114
115    /// Number of SSM (linear attention) layers.
116    pub fn num_ssm_layers(&self) -> usize {
117        if !self.layer_types.is_empty() {
118            self.layer_types
119                .iter()
120                .filter(|t| **t == LayerType::LinearAttention)
121                .count()
122        } else {
123            self.num_hidden_layers - self.num_attention_layers()
124        }
125    }
126
127    /// Whether this model carries recurrent (SSM / linear-attention) state —
128    /// the honest capability signal for the SSM snapshot tiers. Derived from
129    /// [`Self::num_ssm_layers`] so the config-level predicate and the runtime
130    /// pool predicate (`ssm_pool.num_ssm_layers > 0`) agree by construction
131    /// (SSOT). A pure-attention model (dense or MoE) returns `false`:
132    /// requesting an SSM tier for it must fail fast, never silently no-op.
133    pub fn has_recurrent_state(&self) -> bool {
134        self.num_ssm_layers() > 0
135    }
136
137    /// Whether this model has MoE routed experts — the capability signal for
138    /// the expert-streaming tier. Keyed on config, never on observed expert
139    /// tensors (EP ranks legitimately own zero local expert tensors).
140    pub fn has_experts(&self) -> bool {
141        self.num_experts > 0
142    }
143
144    /// Rotary embedding dimension.
145    ///
146    /// Priority:
147    /// 1. Explicit `rotary_dim` field (MiniMax M2 — integer in config.json).
148    /// 2. `partial_rotary_factor * head_dim` (Qwen3/Gemma-4 convention — float).
149    pub fn rotary_dim(&self) -> usize {
150        if self.rotary_dim > 0 {
151            self.rotary_dim
152        } else {
153            (self.partial_rotary_factor * self.head_dim as f64) as usize
154        }
155    }
156
157    /// SSM projection output size: Q + K + V + Z concatenated.
158    pub fn ssm_qkvz_size(&self) -> usize {
159        let q = self.linear_num_key_heads * self.linear_key_head_dim;
160        let k = self.linear_num_key_heads * self.linear_key_head_dim;
161        let v = self.linear_num_value_heads * self.linear_value_head_dim;
162        let z = self.linear_num_value_heads * self.linear_value_head_dim;
163        q + k + v + z
164    }
165
166    /// SSM QKV projection output size (without Z): Q + K + V.
167    pub fn ssm_qkv_size(&self) -> usize {
168        let q = self.linear_num_key_heads * self.linear_key_head_dim;
169        let k = self.linear_num_key_heads * self.linear_key_head_dim;
170        let v = self.linear_num_value_heads * self.linear_value_head_dim;
171        q + k + v
172    }
173
174    /// SSM Z gate projection output size.
175    pub fn ssm_z_size(&self) -> usize {
176        self.linear_num_value_heads * self.linear_value_head_dim
177    }
178
179    /// SSM beta+alpha projection output size.
180    pub fn ssm_ba_size(&self) -> usize {
181        // beta: num_value_heads, alpha: num_value_heads
182        self.linear_num_value_heads * 2
183    }
184
185    /// Range of expert indices local to this EP rank.
186    /// Returns (start, end) where start is inclusive and end is exclusive.
187    pub fn local_expert_range(&self) -> (usize, usize) {
188        if self.ep_world_size <= 1 {
189            return (0, self.num_experts);
190        }
191        let per_rank = self.num_experts / self.ep_world_size;
192        let start = self.ep_rank * per_rank;
193        let end = if self.ep_rank == self.ep_world_size - 1 {
194            self.num_experts // last rank gets remainder
195        } else {
196            start + per_rank
197        };
198        (start, end)
199    }
200
201    /// Whether the given expert ID is local to this EP rank.
202    pub fn is_local_expert(&self, expert_id: usize) -> bool {
203        let (start, end) = self.local_expert_range();
204        expert_id >= start && expert_id < end
205    }
206
207    /// Range `[start, end)` of a `total`-sized dimension owned by this TP rank.
208    /// `total` must be divisible by `tp_world_size`. Returns `(0, total)` when
209    /// TP is disabled.
210    pub fn tp_shard_range(&self, total: usize) -> (usize, usize) {
211        if self.tp_world_size <= 1 {
212            return (0, total);
213        }
214        debug_assert!(
215            total.is_multiple_of(self.tp_world_size),
216            "tp_shard_range: total={} not divisible by tp_world_size={}",
217            total,
218            self.tp_world_size,
219        );
220        let per_rank = total / self.tp_world_size;
221        let start = self.tp_rank * per_rank;
222        (start, start + per_rank)
223    }
224
225    /// Per-rank shard size for a `total`-sized dimension under TP.
226    pub fn tp_shard_dim(&self, total: usize) -> usize {
227        if self.tp_world_size <= 1 {
228            return total;
229        }
230        total / self.tp_world_size
231    }
232
233    /// Weight key prefix for layer-level weights.
234    /// Returns `"model.layers"` for flat models (qwen3_next),
235    /// or `"model.language_model.layers"` for conditional generation models (qwen3_5_moe).
236    pub fn layer_prefix(&self, layer_idx: usize) -> String {
237        if self.weight_prefix.is_empty() {
238            format!("model.layers.{layer_idx}")
239        } else {
240            format!("{}.layers.{layer_idx}", self.weight_prefix)
241        }
242    }
243
244    /// Derive model-agnostic capabilities from this config.
245    pub fn capabilities(&self) -> crate::capabilities::ModelCapabilities {
246        crate::capabilities::ModelCapabilities::from_config(self)
247    }
248
249    // ── Factory sub-dispatch predicates ──
250    // Used only by loader_for_config() to select the right weight loader
251    // within the qwen3_5_moe model_type family. Not for general use —
252    // prefer config fields (attn_gated, nested_config) or capabilities.
253
254    /// Factory use only. Prefer `config.attn_gated` or `config.capabilities()`.
255    pub fn is_qwen35(&self) -> bool {
256        self.model_type == "qwen3_5_moe"
257    }
258
259    /// Factory use only.
260    pub fn is_qwen35_dense(&self) -> bool {
261        self.model_type == "qwen3_5" && self.num_experts == 0
262    }
263
264    /// Factory use only.
265    ///
266    /// Recognises the upstream `qwen3_vl_moe` model_type (Qwen3-VL MoE)
267    /// and Qwen3.5-VL — which ships with `model_type = "qwen3_5"` plus
268    /// `architectures = ["Qwen3_5ForConditionalGeneration"]` and a
269    /// populated `vision_config` block. The vision_config presence is
270    /// the durable signal: the trunk model_type stays `qwen3_5` whether
271    /// the checkpoint is text-only or VL, but VL ships an extra
272    /// vision encoder which the parser exposes as `config.vision`.
273    pub fn is_qwen3_vl(&self) -> bool {
274        if self.model_type == "qwen3_vl_moe" {
275            return true;
276        }
277        // Qwen3.5-VL: trunk model_type is `qwen3_5`; the vision tower
278        // is detected by the parsed `vision_config` block.
279        if self.model_type == "qwen3_5" && self.vision.is_some() {
280            return true;
281        }
282        false
283    }
284
285    /// Whether to skip NVFP4 quantization of the LM head.
286    /// MLA models (kv_lora_rank > 0) lose logit precision under NVFP4.
287    /// Gemma-4 dense (31B): the LM head ties to BF16 embed_tokens whose
288    /// rows have heavy outliers (final_norm.weight max=510, several
289    /// embedding rows in similar range). The runtime BF16→NVFP4 path
290    /// uses a single per-tensor absmax for `scale2`, which forces a
291    /// coarse scale that loses ~7 bits in normal-magnitude rows. For a
292    /// 262 144-row vocab matrix that compounds into the 0.14-margin
293    /// argmax flip on creative prompts (verified 2026-05-01 via FP32
294    /// lm_head bisection: NVFP4 output had top1=` a` 21.85 vs FP32 BF16
295    /// view top1=` a` 21.85 — quantization noise was visible in the
296    /// SAME logit channel that flipped the tiebreak). Skipping the
297    /// runtime quantization keeps the LM head as plain BF16 dense; the
298    /// FP32 lm_head path (gated by `ATLAS_GEMMA4_FP32_LMHEAD=1`) can
299    /// then act on full-precision weights without the NVFP4 floor.
300    pub fn skip_lm_head_quantization(&self) -> bool {
301        // CLI override (`--lm-head-dtype`, set into `lm_head_bf16_override` at serve
302        // time) wins. `bf16` keeps the LM head in BF16 instead of runtime-quantizing it
303        // to NVFP4 — the 4-bit floor on the final vocab projection is a prime suspect for
304        // argmax flips in long structured generation; vLLM keeps lm_head at checkpoint
305        // precision. (Replaces the former ATLAS_LMHEAD_BF16 env var; PCND: explicit arg.)
306        if let Some(force_bf16) = self.lm_head_bf16_override {
307            return force_bf16;
308        }
309        if self.kv_lora_rank > 0 {
310            return true;
311        }
312        if self.model_type == "laguna" {
313            return true;
314        }
315        if self.model_type == "gemma4" && self.num_experts == 0 {
316            // Allow rollback via env for A/B testing.
317            return std::env::var("ATLAS_GEMMA4_LMHEAD_NVFP4").ok().as_deref() != Some("1");
318        }
319        false
320    }
321
322    /// Mamba-2 d_inner = mamba_num_heads * mamba_head_dim.
323    pub fn mamba2_d_inner(&self) -> usize {
324        self.mamba_num_heads * self.mamba_head_dim
325    }
326
327    /// Mamba-2 d_xBC = d_inner + 2 * n_groups * ssm_state_size.
328    /// This is the dimension that goes through conv1d (x + B + C concatenated).
329    pub fn mamba2_d_xbc(&self) -> usize {
330        self.mamba2_d_inner() + 2 * self.n_groups * self.ssm_state_size
331    }
332
333    /// Mamba-2 in_proj output size = z + xBC + dt.
334    pub fn mamba2_in_proj_size(&self) -> usize {
335        self.mamba2_d_inner() + self.mamba2_d_xbc() + self.mamba_num_heads
336    }
337
338    /// Per-layer SSM hidden state size in bytes (FP32).
339    /// Dispatches on SSM architecture: Mamba-2 vs GDN, using config fields.
340    pub fn ssm_h_state_bytes(&self) -> usize {
341        if self.mamba_num_heads > 0 && self.mamba_head_dim > 0 {
342            // Mamba-2: h[num_heads, head_dim, state_size] FP32
343            self.mamba_num_heads * self.mamba_head_dim * self.ssm_state_size * 4
344        } else {
345            // GDN: h[nv, vd, kd] FP32
346            self.linear_num_value_heads * self.linear_value_head_dim * self.linear_key_head_dim * 4
347        }
348    }
349
350    /// Per-layer SSM conv state size in bytes (FP32).
351    pub fn ssm_conv_state_bytes(&self) -> usize {
352        let d_conv = self.linear_conv_kernel_dim;
353        if self.mamba_num_heads > 0 && self.mamba_head_dim > 0 {
354            // Mamba-2: conv: [d_xBC, d_conv] FP32
355            self.mamba2_d_xbc() * d_conv * 4
356        } else {
357            // GDN: conv: [conv_dim, d_conv] FP32
358            let conv_dim = self.linear_num_key_heads * self.linear_key_head_dim * 2
359                + self.linear_num_value_heads * self.linear_value_head_dim;
360            conv_dim * d_conv * 4
361        }
362    }
363
364    /// SSM state normalization dimensions: (num_heads, k_dim, v_dim).
365    /// Used by the state normalization kernel to prevent drift.
366    pub fn ssm_state_norm_dims(&self) -> (usize, usize, usize) {
367        if self.mamba_num_heads > 0 && self.mamba_head_dim > 0 {
368            (
369                self.mamba_num_heads,
370                self.mamba_head_dim,
371                self.ssm_state_size,
372            )
373        } else {
374            (
375                self.linear_num_value_heads,
376                self.linear_key_head_dim,
377                self.linear_value_head_dim,
378            )
379        }
380    }
381
382    /// MoE expert input dimension: latent size if LatentMoE, else hidden_size.
383    pub fn moe_input_size(&self) -> usize {
384        if self.moe_latent_size > 0 {
385            self.moe_latent_size
386        } else {
387            self.hidden_size
388        }
389    }
390
391    /// Routed expert intermediate size for layer `i`.
392    ///
393    /// Puzzle checkpoints prune channels non-uniformly across MoE layers;
394    /// look up `moe_intermediate_sizes[i]` when populated, else the scalar.
395    pub fn moe_intermediate_size_for(&self, layer: usize) -> usize {
396        self.moe_intermediate_sizes
397            .get(layer)
398            .copied()
399            .filter(|&s| s > 0)
400            .unwrap_or(self.moe_intermediate_size)
401    }
402
403    /// Top-K experts per token for layer `i` (Puzzle per-block schedule).
404    pub fn num_experts_per_tok_for(&self, layer: usize) -> usize {
405        self.num_experts_per_toks
406            .get(layer)
407            .copied()
408            .filter(|&k| k > 0)
409            .unwrap_or(self.num_experts_per_tok)
410    }
411
412    /// Max routed intermediate across all layers (buffer / scratch sizing).
413    pub fn max_moe_intermediate_size(&self) -> usize {
414        self.moe_intermediate_sizes
415            .iter()
416            .copied()
417            .max()
418            .unwrap_or(0)
419            .max(self.moe_intermediate_size)
420    }
421
422    /// Number of MoE-only layers (Nemotron-H).
423    pub fn num_moe_layers(&self) -> usize {
424        self.layer_types
425            .iter()
426            .filter(|t| **t == LayerType::Moe)
427            .count()
428    }
429
430    /// Whether every byte of a sequence's per-layer state is represented by
431    /// its KV blocks.
432    ///
433    /// False for models whose PREFILL builds per-sequence state that KV pages
434    /// do not carry: GLM-5.3's DSA indexer rows (`Glm5NextDsaState`) and
435    /// compressed DeepSeek V4's compressor pool/ring. Every KV-only mechanism
436    /// — radix prefix reuse and the `--swap-space-gb` spill image alike — is
437    /// unsafe for those models, and this is the single fact both gates below
438    /// are asking about.
439    fn per_sequence_state_is_kv_complete(&self) -> bool {
440        match self.model_type.as_str() {
441            "glm5_next" | "glm5_next_text" => false,
442            "deepseek_v4" => self.compress_ratios.iter().all(|&ratio| ratio == 0),
443            _ => true,
444        }
445    }
446
447    /// Whether the radix prefix cache captures every state needed to resume
448    /// this model exactly. Preflight SSOT for `build_prefix_cache`.
449    pub fn kv_only_prefix_cache_is_safe(&self) -> bool {
450        self.per_sequence_state_is_kv_complete()
451    }
452
453    /// Whether a sequence may be swapped out to the `--swap-space-gb` pool and
454    /// restored from it. Preflight SSOT for `resolve_swap_space_gb`.
455    ///
456    /// `save_sequence_state_dispatch` writes KV blocks plus the `SsmLayerState`
457    /// of each `LayerType::LinearAttention` layer, and nothing else; the
458    /// swap-out then calls `free_sequence`, which hands every remaining
459    /// per-layer state to #821's `release_state`. A model that is not
460    /// KV-complete therefore resumes with a freshly ZEROED pool behind a KV
461    /// image that assumes a populated one — a silently wrong answer, not a
462    /// crash. Distinct from the prefix-cache predicate because they are
463    /// distinct guarantees; they happen to have the same answer today.
464    pub fn kv_only_swap_out_is_safe(&self) -> bool {
465        self.per_sequence_state_is_kv_complete()
466    }
467}