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}