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}