spark_model/
preflight.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3//! Pre-flight weight-store / config consistency checks.
4//!
5//! Runs **before** NCCL init and model construction so obvious checkpoint
6//! mismatches (wrong expert count, missing `lm_head`, MiniMax checkpoint
7//! shipped with MTP tensors that the loader can't consume, etc.) fail fast
8//! with a readable error instead of surfacing later as:
9//!
10//!   * an `ncclCommInitRank` hang (when only one rank bails before
11//!     reaching collective init),
12//!   * a cryptic `build_model` error ~10 minutes into startup,
13//!   * an opaque "Received NCCL unique ID from master" log trail with
14//!     no explanation — the failure mode Discord users have been posting.
15//!
16//! The checks are intentionally cheap: they only consult tensor NAMES
17//! already in the `WeightStore` (loaded lazily by the safetensors index
18//! pass), never touch GPU memory, and never issue collectives. Safe to
19//! call on every rank.
20
21use anyhow::{Result, bail};
22use atlas_core::config::ModelConfig;
23use spark_runtime::weights::WeightStore;
24
25mod deepseek_v4;
26
27/// Run all model-agnostic + model-type-specific pre-flight checks.
28///
29/// Called by `spark-server/src/main.rs` immediately after the
30/// `WeightStore` is populated and before `spark_comm::NcclBackend::new`
31/// runs, so a bad checkpoint aborts rank 0 before rank 1 even connects.
32///
33/// `use_speculative` is the final resolved flag (user `--speculative`
34/// OR model default). It gates MiniMax-specific MTP-presence diagnostics:
35/// if the user didn't ask for speculative decoding, MTP tensors in the
36/// checkpoint are harmless dead weight and do not warrant a bail.
37pub fn preflight(
38    store: &WeightStore,
39    config: &ModelConfig,
40    use_speculative: bool,
41    kv_cache_dtype: Option<&str>,
42) -> Result<()> {
43    // NLLB / M2M-100 is an encoder-decoder checkpoint: the tied embedding is
44    // `model.shared.weight`, layers span separate encoder + decoder stacks, and
45    // there is cross-attention — none of which fit these decoder-only checks.
46    // `NllbGpuModel::new` validates its own weights (presence + bf16 dtype).
47    if matches!(config.model_type.as_str(), "m2m_100" | "nllb") {
48        tracing::info!(
49            "Pre-flight: NLLB/M2M-100 encoder-decoder — generic checks skipped \
50             (weights validated in NllbGpuModel::new)"
51        );
52        return Ok(());
53    }
54    // Model-agnostic checks — driven purely by `store.names()` and
55    // `config` (which already carries the parsed `config.json` values).
56    check_quant_method(config)?;
57    deepseek_v4::check_native_dspark_checkpoint(store, config, use_speculative)?;
58    check_embedding_and_head(store)?;
59    let max_layer_idx = check_layer_count(store, config)?;
60    check_expert_count(store, config)?;
61    check_correction_bias_shape(store, config)?;
62    check_qsa_kv_dtype(store.names(), kv_cache_dtype)?;
63
64    // If the checkpoint has layers beyond `config.num_hidden_layers` AND
65    // the user asked for speculative decoding, warn/error about MTP
66    // consumability. The only model family where Atlas currently bails
67    // rather than ignoring extra MTP layers is MiniMax — but the check
68    // itself is discovery-based, not name-based.
69    if use_speculative && max_layer_idx + 1 > config.num_hidden_layers {
70        check_mtp_consumability(config)?;
71    }
72
73    tracing::info!("Pre-flight checks passed");
74    Ok(())
75}
76
77/// A QSA checkpoint served with a non-BF16 KV cache cannot decode.
78///
79/// The selection gather copies raw NHD rows out of the paged cache, so a
80/// quantized cache has nothing it can copy. The decode path already refuses —
81/// but it refuses on the FIRST REQUEST, which is after the weights have loaded
82/// (three minutes and a hundred-plus gigabytes for Qwen3.8-Flash-Next) and
83/// after the operator has sent something and waited for it. The fact is known
84/// as soon as the store is populated, so it is said here instead.
85///
86/// Presence of `self_attn.indexer.*` is what makes a checkpoint QSA — the same
87/// thing the loader keys on. Nothing in `config.json` records it.
88fn check_qsa_kv_dtype<'a>(
89    names: impl Iterator<Item = &'a str>,
90    kv_cache_dtype: Option<&str>,
91) -> Result<()> {
92    // Names, not the store: this needs nothing else, and taking the store would
93    // have meant a test-only way to put a name into one.
94    // 🪤 `.self_attn.indexer.` is a FAMILY name, not a QSA name. GLM-5.3's DSA
95    // indexer lands in the same namespace (`indexer.index_kpool_compress_ape`,
96    // `.wk`, `.wq_b`, `.weights_proj`, `.k_norm.*`) while being a different
97    // mechanism: a k-pooled top-k feeding a NoPE selected-index sparse MLA
98    // decode kernel that reads an FP8 KV cache by construction, not a
99    // selection gather copying raw NHD rows. Refusing fp8 for it would refuse
100    // its only served configuration.
101    //
102    // So the kpool shape is identified positively and exempted; every other
103    // indexer checkpoint stays under the rule exactly as before.
104    let mut has_indexer = false;
105    let mut has_kpool_indexer = false;
106    for n in names {
107        if n.contains(".self_attn.indexer.") {
108            has_indexer = true;
109            if n.contains(".indexer.index_kpool_") {
110                has_kpool_indexer = true;
111            }
112        }
113    }
114    if !has_indexer || has_kpool_indexer {
115        return Ok(());
116    }
117    // `None` means the caller genuinely has no KV cache to speak of, NOT "the
118    // default is fine": the engine default is fp8, and reading an absent flag as
119    // bf16 made this check inert on the bare `spark serve <model>` -- the exact
120    // invocation it exists to catch, and the one that pays a hundred-plus
121    // gigabytes of load before the decode path refuses on the first request.
122    // Callers resolve the default before calling; see `serve_load`.
123    let Some(dt) = kv_cache_dtype else {
124        return Ok(());
125    };
126    // Only `bf16` parses downstream (`KvCacheDtype::FromStr`); `bfloat16` and
127    // `auto` were accepted here but are rejected by CLI validation before they
128    // could ever arrive, so listing them only made the rule look laxer than it is.
129    let d = dt.trim().to_ascii_lowercase();
130    anyhow::ensure!(
131        d == "bf16",
132        "this checkpoint uses QSA sparse attention, whose selection gather \
133         copies raw NHD rows, so it needs a plain BF16 KV cache — but \
134         the KV cache resolves to `{dt}`.\n  \
135         Serve with `--kv-cache-dtype bf16`. Dropping the flag does NOT do \
136         that -- the default is fp8.\n  \
137         Said here rather than on your first request, which is where the \
138         decode path notices."
139    );
140    Ok(())
141}
142
143/// Fail fast when the checkpoint declares a `quant_method` Atlas doesn't
144/// understand. Discovery-based fallback at load time would then either
145/// silently mis-detect the format (the Discord 2026-04-17 bug) or die
146/// with a cryptic dtype error. A clear error here beats either.
147fn check_quant_method(config: &ModelConfig) -> Result<()> {
148    let Some(qc) = &config.quantization_config else {
149        return Ok(());
150    };
151    // Empty method strings are fine — the config-parser already skips
152    // fully-empty blocks; a non-empty ignore list with empty method
153    // just means "use heuristic detection on the tensor names".
154    if qc.quant_method.is_empty() {
155        return Ok(());
156    }
157    const KNOWN_METHODS: &[&str] = &["compressed-tensors", "modelopt", "fp8"];
158    if !KNOWN_METHODS.contains(&qc.quant_method.as_str()) {
159        bail!(
160            "Pre-flight: checkpoint declares quant_method={:?} which Atlas doesn't \
161             recognize. Supported schemes: {:?}. If this is a new NVIDIA/HF format, \
162             add an impl of `QuantFormat` in `crates/spark-model/src/quant_format/` \
163             and extend `detect_quant_format`. See `docs/EP2-TROUBLESHOOTING.md`.",
164            qc.quant_method,
165            KNOWN_METHODS,
166        );
167    }
168    Ok(())
169}
170
171fn check_embedding_and_head(store: &WeightStore) -> Result<()> {
172    // Three canonical embedding-tensor naming schemes across the
173    // families Atlas supports:
174    //   `*.embed_tokens.weight`  — HF standard (Qwen, Gemma, MiniMax)
175    //   `*.embeddings.weight`    — Nemotron-H backbone prefix
176    //   `tok_embeddings.weight`  — Mistral consolidated checkpoints
177    // Scan discovery-based so future families that adopt yet-another
178    // spelling only need to appear as a new suffix here — no enumerated
179    // prefix list to maintain.
180    const EMBED_SUFFIXES: &[&str] = &[".embed_tokens.weight", ".embeddings.weight"];
181    const EMBED_EXACTS: &[&str] = &[
182        "tok_embeddings.weight",
183        "embed_tokens.weight",
184        "embed.weight",
185    ];
186    let has_embed = store
187        .names()
188        .any(|n| EMBED_EXACTS.contains(&n) || EMBED_SUFFIXES.iter().any(|s| n.ends_with(s)));
189    if !has_embed {
190        let sample: Vec<_> = store.names().take(20).collect();
191        bail!(
192            "Pre-flight: no embedding tensor found (checked exact: {EMBED_EXACTS:?}, \
193             suffixes: {EMBED_SUFFIXES:?}). Is this a language-model checkpoint? \
194             Sample tensor names: {sample:?}\
195             \n\nHint: Some re-quant checkpoints (e.g. RedHatAI DeepSeek-V4-Flash-NVFP4-FP8) \
196             ship embedding weights in a separate file not listed in model.safetensors.index.json. \
197             Check if the checkpoint directory contains a separate model.safetensors or \
198             embed_tokens.safetensors file, and if model.safetensors.index.json maps \
199             'model.embed_tokens.weight' to a shard."
200        );
201    }
202    // LM head is optional (tied embeddings skip it). Scan suffixes:
203    //   `lm_head.weight`       — HF / Qwen / Gemma / MiniMax
204    //   `output.weight`        — Mistral consolidated
205    //   `head.weight`          — DeepSeek-V4 / RedHatAI re-quant
206    let has_head = store
207        .names()
208        .any(|n| n.ends_with("lm_head.weight") || n == "output.weight" || n == "head.weight");
209    if !has_head {
210        tracing::info!("Pre-flight: no dedicated LM head tensor; assuming tied embeddings.");
211    }
212    Ok(())
213}
214
215/// Detect the highest per-layer index present in the store by scanning
216/// any tensor matching `*.layers.{N}.*`. Returns the observed max index
217/// so the caller can decide whether extra MTP layers were shipped.
218///
219/// We intentionally do NOT key off a specific sub-path like
220/// `input_layernorm`: Nemotron-H's Mamba2 layers ship `norm.weight`
221/// instead, and an earlier anchor-specific check broke preflight for
222/// every Nemotron checkpoint (Discord 2026-04-19). Any tensor under
223/// `.layers.N.` counts as evidence that layer N exists.
224fn check_layer_count(store: &WeightStore, config: &ModelConfig) -> Result<usize> {
225    let mut observed: Vec<usize> = Vec::new();
226    for name in store.names() {
227        // Accept `*.layers.N.*` (HF-style with any prefix) AND
228        // `layers.N.*` (Mistral consolidated checkpoints have no
229        // leading `model.` / `backbone.` prefix).
230        let tail = if let Some(pos) = name.find(".layers.") {
231            &name[pos + ".layers.".len()..]
232        } else if let Some(rest) = name.strip_prefix("layers.") {
233            rest
234        } else {
235            continue;
236        };
237        let Some(end) = tail.find('.') else { continue };
238        let Ok(idx) = tail[..end].parse::<usize>() else {
239            continue;
240        };
241        observed.push(idx);
242    }
243    if observed.is_empty() {
244        bail!(
245            "Pre-flight: no `*.layers.N.*` tensors found. \
246             Checkpoint is empty or uses a naming convention Atlas \
247             doesn't recognize (expected {} layers).",
248            config.num_hidden_layers,
249        );
250    }
251    observed.sort_unstable();
252    observed.dedup();
253    let max_idx = *observed.last().unwrap();
254    // LongCat-Flash serves each CHECKPOINT layer as TWO engine sublayers
255    // (dual-sublayer "shortcut" blocks — the HF modeling file makes the same
256    // 2x expansion), so the checkpoint legitimately carries half as many
257    // `layers.N` indices as `num_hidden_layers`. Comparing against the engine
258    // count would reject every valid LongCat checkpoint.
259    let expected = if config.model_type.starts_with("longcat_flash") {
260        config.num_hidden_layers / 2
261    } else {
262        config.num_hidden_layers
263    };
264    if max_idx + 1 < expected {
265        bail!(
266            "Pre-flight: checkpoint has layers 0..{} but config.num_hidden_layers = {}. \
267             Wrong variant, or the index pass dropped tensors.",
268            max_idx + 1,
269            expected,
270        );
271    }
272    if max_idx + 1 > expected {
273        let extras = (max_idx + 1) - expected;
274        tracing::info!(
275            "Pre-flight: checkpoint has {extras} layer(s) beyond num_hidden_layers={expected} \
276             (max index {max_idx}). Treating extras as MTP/draft modules."
277        );
278    }
279    Ok(max_idx)
280}
281
282/// Detect the highest expert index across every layer and compare with
283/// `config.num_experts`. Catches the "community re-quant shipped a
284/// different base model" class of error cheaply.
285fn check_expert_count(store: &WeightStore, config: &ModelConfig) -> Result<()> {
286    if config.num_experts == 0 {
287        return Ok(());
288    }
289    let mut max_expert: Option<usize> = None;
290    for name in store.names() {
291        // Patterns seen across supported MoE models:
292        //   <prefix>.layers.{L}.block_sparse_moe.experts.{E}.w?.weight
293        //   <prefix>.layers.{L}.mlp.experts.{E}.{up|down|gate}_proj.weight
294        //   <prefix>.layers.{L}.feed_forward.experts.{E}.weight
295        let Some(idx) = extract_expert_idx(name) else {
296            continue;
297        };
298        max_expert = Some(max_expert.map_or(idx, |m| m.max(idx)));
299    }
300    let Some(max_idx) = max_expert else {
301        // Config says we're MoE but no expert tensors exist at all —
302        // EP=2 may have sharded them all onto another rank, which is
303        // legitimate. Warn rather than fail.
304        tracing::warn!(
305            "Pre-flight: config.num_experts={} but no expert tensors found locally. \
306             Normal under EP>1 when the local rank owns zero experts; otherwise check \
307             your checkpoint.",
308            config.num_experts,
309        );
310        return Ok(());
311    };
312    let expected = config.num_experts;
313    if max_idx + 1 > expected {
314        bail!(
315            "Pre-flight: checkpoint has experts 0..{} but config.num_experts = {}. \
316             Likely a different base-model variant (e.g. {}-expert re-quant shipped with \
317             a {}-expert config).",
318            max_idx + 1,
319            expected,
320            max_idx + 1,
321            expected,
322        );
323    }
324    Ok(())
325}
326
327/// Parse the expert index `E` out of a tensor key, handling the three
328/// common naming conventions (`block_sparse_moe.experts.{E}`,
329/// `mlp.experts.{E}`, `feed_forward.experts.{E}`).
330fn extract_expert_idx(name: &str) -> Option<usize> {
331    for marker in [
332        ".block_sparse_moe.experts.",
333        ".mlp.experts.",
334        ".feed_forward.experts.",
335        ".experts.",
336    ] {
337        if let Some(tail) = name.split(marker).nth(1)
338            && let Some(end) = tail.find('.')
339            && let Ok(idx) = tail[..end].parse::<usize>()
340        {
341            return Some(idx);
342        }
343    }
344    None
345}
346
347/// Check whether the loader for the declared `model_type` can actually
348/// consume the extra layers the checkpoint ships. Today only MiniMax
349/// ships per-module MTP layers that Atlas's loader doesn't handle yet
350/// (see `weight_loader/minimax.rs:load_mtp_weights_multi`). Every other
351/// family either embeds MTP differently (Qwen3.5 / Qwen3-Next ship
352/// a dedicated `mtp.safetensors` shard with its own prefix — not extra
353/// transformer layers) or doesn't ship it at all.
354///
355/// This function stays discovery-based: when new families grow MTP
356/// support, add an entry in `MTP_SUPPORTED_MODEL_TYPES` below and it
357/// works without further preflight surgery.
358fn check_mtp_consumability(config: &ModelConfig) -> Result<()> {
359    const MTP_SUPPORTED_MODEL_TYPES: &[&str] = &[
360        "qwen3_next",
361        "qwen3_5_moe",
362        "qwen3_6_moe",
363        "holo3_1_moe",
364        "qwen3_vl_moe",
365        "qwen3_coder_next",
366        // GLM-5.3: the MTP block is `layers.{num_hidden_layers}` (a DSA mixer + the routed MoE
367        // + `shared_head.norm`, no mHC), consumed by `load_glm5next_mtp_module` and driven by
368        // `Glm5NextMtpHead`. 🪤 It does NOT use `mtp.0.*`, so a `grep mtp` over the checkpoint
369        // finds nothing and this list is the only place that records that it is supported.
370        "glm5_next",
371    ];
372    if MTP_SUPPORTED_MODEL_TYPES.contains(&config.model_type.as_str()) {
373        return Ok(());
374    }
375    bail!(
376        "Pre-flight: `--speculative` requested, but the checkpoint for model_type='{}' \
377         ships MTP module layers that Atlas's loader doesn't consume yet. \
378         Either retry without `--speculative`, or pick a checkpoint variant that \
379         omits the MTP layers. Supported MTP model_types: {:?}.",
380        config.model_type,
381        MTP_SUPPORTED_MODEL_TYPES,
382    );
383}
384
385/// Generic MoE correction_bias shape check (DeepSeek V3 / MiniMax M2 /
386/// any future family that uses the loss-free-balancing bias). The
387/// bias tensor name is `<moe-prefix>.e_score_correction_bias` and its
388/// shape must match `config.num_experts`. Discovery-based: we scan
389/// `store.names()` for ANY tensor ending in that suffix and validate
390/// each one, so we don't need to know the MoE prefix
391/// (`block_sparse_moe` on MiniMax, `mlp` on DeepSeek V3, etc.).
392fn check_correction_bias_shape(store: &WeightStore, config: &ModelConfig) -> Result<()> {
393    if config.num_experts == 0 {
394        return Ok(());
395    }
396    for name in store.names() {
397        if !name.ends_with(".e_score_correction_bias") && !name.ends_with(".correction_bias") {
398            continue;
399        }
400        let t = store.get(name)?;
401        let n_elems = t.num_elements();
402        // The bias is per ROUTER LOGIT, and a zero-expert model (LongCat)
403        // scores `num_experts + zero_expert_num` of them — the identity
404        // experts are selectable but have no FFN weights.
405        let expected = config.num_experts + config.zero_expert_num;
406        if n_elems != expected {
407            bail!(
408                "Pre-flight: '{name}' has {n_elems} elements but config declares {expected} \
409                 router logits (num_experts = {} + zero_expert_num = {}). The checkpoint is \
410                 for a different expert count; EP sharding math would be wrong and the MoE \
411                 router would route to non-existent experts.",
412                config.num_experts,
413                config.zero_expert_num,
414            );
415        }
416    }
417    Ok(())
418}
419
420#[cfg(test)]
421mod qsa_kv_tests {
422    use super::check_qsa_kv_dtype;
423
424    /// Names from a checkpoint with no indexer: not QSA, so the flag is none of
425    /// this check's business.
426    const PLAIN: [&str; 2] = [
427        "model.layers.0.self_attn.q_proj.weight",
428        "model.embed_tokens.weight",
429    ];
430    /// Presence of an indexer tensor is what makes a checkpoint QSA — the same
431    /// thing the loader keys on.
432    const QSA: [&str; 2] = [
433        "model.layers.0.self_attn.q_proj.weight",
434        "model.layers.0.self_attn.indexer.index_qk_proj.weight",
435    ];
436    /// GLM-5.3's DSA k-pool indexer shares the `self_attn.indexer.` namespace
437    /// but is a different mechanism with an FP8-KV decode kernel. Names taken
438    /// verbatim from the NVFP4 checkpoint index.
439    const KPOOL_DSA: [&str; 3] = [
440        "model.language_model.layers.3.self_attn.indexer.index_kpool_compress_ape",
441        "model.language_model.layers.3.self_attn.indexer.wq_b.weight",
442        "model.language_model.layers.3.self_attn.indexer.k_norm.weight",
443    ];
444
445    /// A k-pool DSA checkpoint must keep its fp8 KV cache. This is the sealed
446    /// GLM-5.3 serving configuration; refusing it here refuses the only
447    /// configuration that model has ever served.
448    #[test]
449    fn a_kpool_dsa_checkpoint_is_not_qsa_and_keeps_fp8() {
450        for dt in [None, Some("fp8"), Some("bf16")] {
451            assert!(
452                check_qsa_kv_dtype(KPOOL_DSA.into_iter(), dt).is_ok(),
453                "kpool DSA must not be caught by the QSA rule: {dt:?}"
454            );
455        }
456    }
457
458    /// ...and narrowing it must NOT have made the original rule inert.
459    #[test]
460    fn narrowing_for_kpool_did_not_disarm_the_qsa_rule() {
461        check_qsa_kv_dtype(QSA.into_iter(), Some("fp8")).expect_err("QSA at fp8 must still refuse");
462    }
463
464    #[test]
465    fn a_non_qsa_checkpoint_accepts_any_kv_dtype() {
466        for dt in [None, Some("fp8"), Some("bf16"), Some("nonsense")] {
467            assert!(check_qsa_kv_dtype(PLAIN.into_iter(), dt).is_ok(), "{dt:?}");
468        }
469    }
470
471    /// The refusal has to name the flag AND the value, because the operator is
472    /// reading it three minutes into a load they will have to repeat.
473    #[test]
474    fn a_qsa_checkpoint_refuses_a_quantized_kv_cache_by_name() {
475        let e = check_qsa_kv_dtype(QSA.into_iter(), Some("fp8")).expect_err("must refuse");
476        let msg = format!("{e}");
477        assert!(msg.contains("--kv-cache-dtype bf16"), "{msg}");
478        assert!(msg.contains("fp8"), "must quote what was given: {msg}");
479        // The old text said "or drop the flag", which is the one thing that
480        // cannot work: dropping it resolves to fp8, which is how we got here.
481        assert!(
482            !msg.contains("drop the flag"),
483            "must not advise dropping the flag: {msg}"
484        );
485    }
486
487    /// bf16, in the spellings that actually parse downstream.
488    #[test]
489    fn a_qsa_checkpoint_accepts_bf16() {
490        for dt in [Some("bf16"), Some("BF16"), Some(" bf16 ")] {
491            assert!(
492                check_qsa_kv_dtype(QSA.into_iter(), dt).is_ok(),
493                "{dt:?} must pass"
494            );
495        }
496    }
497
498    /// The regression this check was written for and then could not catch: the
499    /// engine default is fp8, so an unresolved `None` read as "bf16, fine" made
500    /// the check inert on `spark serve <model>` with no flag — the invocation
501    /// most operators type. Callers resolve before calling; if one forgets, this
502    /// is the test that says so.
503    #[test]
504    fn the_engine_default_is_not_something_a_qsa_checkpoint_can_use() {
505        assert_eq!(spark_server_default_kv_dtype(), "fp8");
506        check_qsa_kv_dtype(QSA.into_iter(), Some(spark_server_default_kv_dtype()))
507            .expect_err("the resolved default must be refused, not waved through");
508    }
509
510    /// Spelled out rather than imported: `spark-server` depends on this crate,
511    /// so the constant cannot come the other way. If the server's default ever
512    /// changes, the assertion above fails here and names this comment.
513    fn spark_server_default_kv_dtype() -> &'static str {
514        "fp8"
515    }
516
517    /// Spellings the CLI rejects before preflight can see them. Accepting them
518    /// here only made the rule look laxer than it is.
519    #[test]
520    fn spellings_that_do_not_parse_downstream_are_not_accepted() {
521        for dt in [Some("bfloat16"), Some("auto")] {
522            check_qsa_kv_dtype(QSA.into_iter(), dt).expect_err("{dt:?} must not pass");
523        }
524    }
525}