spark_model/factory.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Model factory: builds the right model from config + weights.
4//!
5//! Weight loader selection is registry-driven — add new models by implementing
6//! [`ModelWeightLoader`] and registering in [`loader_for_config`]. No other
7//! code changes needed.
8
9use anyhow::{Result, bail};
10use atlas_core::config::ModelConfig;
11use spark_runtime::weights::WeightStore;
12
13use crate::mistral_loader::MistralWeightLoader;
14use crate::weight_loader::LongcatWeightLoader;
15use crate::weight_loader::Qwen4ExpWeightLoader;
16use crate::weight_loader::{
17 DeepSeekV4WeightLoader, DflashConfig, Gemma4WeightLoader, Glm5NextWeightLoader,
18 LagunaWeightLoader, MinimaxM2WeightLoader, ModelWeightLoader, NemotronHWeightLoader,
19 NllbWeightLoader, Qwen3VLWeightLoader, Qwen3WeightLoader, Qwen35DenseWeightLoader,
20 Qwen35WeightLoader, Step3p7WeightLoader,
21};
22
23/// DFlash speculative-decoding build arguments. `None` for non-DFlash runs;
24/// `Some(...)` carries the drafter's separate [`WeightStore`], parsed
25/// `config.json`, and CLI overrides for γ and the sliding-window size.
26///
27/// Construction order: the caller (`spark-server::main`) loads the drafter
28/// checkpoint into a fresh [`WeightStore`] via the same `WeightStore::load`
29/// path used for the target, then parses `config.json` via
30/// [`crate::weight_loader::dflash_loader::parse_dflash_config`]. Both inputs
31/// flow through to [`build_model`] which validates dimensions against the
32/// target before constructing [`crate::layers::BlockDiffusionDraftHead`].
33pub struct DflashBuildArgs<'a> {
34 pub drafter_store: &'a WeightStore,
35 pub drafter_config: DflashConfig,
36 pub gamma: Option<usize>,
37 pub window_size: Option<usize>,
38}
39
40/// LoRA adapter build arguments (`--lora-adapter NAME=PATH`). `None` for
41/// base-only runs; `Some(...)` carries the adapter's separate on-device
42/// [`WeightStore`] (loaded via
43/// `spark_runtime::weights::adapter::load_adapter_safetensors`), the parsed
44/// `adapter_config.json`, and the pool-shape CLI knobs.
45///
46/// Unlike DFlash (loaded post-construction), the LoRA pool is allocated at
47/// the TOP of `build_model` — before the buffer arena and the free-memory
48/// snapshot — so its bytes are automatically debited from the KV budget.
49pub struct LoraBuildArgs<'a> {
50 /// One or more adapters to pack (repeated `--lora-adapter NAME=PATH`),
51 /// each carrying its NAME, its on-device `WeightStore`, and its parsed
52 /// `adapter_config.json`. Slot k = `adapters[k]`. A single element is
53 /// byte-identical to the pre-multi-adapter path.
54 pub adapters: Vec<crate::lora::LoraAdapterInput<'a>>,
55 pub max_lora_rank: usize,
56 pub max_loras: usize,
57}
58
59// ── Loader registry ─────────────────────────────────────────────────────────
60// Adding a new model: implement ModelWeightLoader and add a match arm below.
61// Everything else (KV cache, buffers, TransformerModel) is model-agnostic.
62
63/// Select the weight loader for a given model config.
64///
65/// This is the ONLY place model_type strings are matched. All downstream
66/// code is model-agnostic.
67pub fn loader_for_config(config: &ModelConfig) -> Result<Box<dyn ModelWeightLoader>> {
68 let normalized = config.model_type.to_lowercase().replace(['-', '.'], "_");
69 match normalized.as_str() {
70 // Qwen3 family: sub-dispatch by config predicates
71 "qwen3_next" => Ok(Box::new(Qwen3WeightLoader)),
72 "qwen3_vl_moe" => Ok(Box::new(Qwen3VLWeightLoader)),
73 "qwen3_5_moe" | "qwen3_5" | "qwen35_moe" | "qwen35" => {
74 // Dense check has to come first. Qwen3.6-27B-FP8 is the dense text
75 // sibling of the Qwen3.6 VL family — its config declares the same
76 // `vision_config` block as the MoE-VL siblings (so `is_qwen3_vl()`
77 // returns true), but the checkpoint ships no vision tower and no
78 // MoE router, so the VL loader panics on a missing `mlp.gate`.
79 // `is_qwen35_dense()` requires `num_experts == 0`, which only the
80 // dense text models satisfy — VL-MoE always has experts.
81 if config.is_qwen35_dense() {
82 Ok(Box::new(Qwen35DenseWeightLoader))
83 } else if config.is_qwen3_vl() {
84 Ok(Box::new(Qwen3VLWeightLoader))
85 } else {
86 Ok(Box::new(Qwen35WeightLoader))
87 }
88 }
89 // Qwen3.6: identical architecture to Qwen3.5 MoE at the weight level
90 // (GDN + full-attention + MoE hybrid, same expert layout, same MTP).
91 // Only difference is MRoPE-interleaved layout + attn_output_gate on
92 // full-attention layers — both handled at forward-pass layer time,
93 // not during weight loading.
94 "qwen3_6_moe" | "holo3_1_moe" => Ok(Box::new(Qwen35WeightLoader)),
95 // Nemotron-H family (Mamba-2 + MoE + Attention), including Puzzle
96 // (heterogeneous per-block MoE intermediate / top-k).
97 "nemotron_h" | "nemotron_h_puzzle" => Ok(Box::new(NemotronHWeightLoader)),
98 // NLLB / M2M-100 encoder-decoder translation family.
99 "m2m_100" | "nllb" => Ok(Box::new(NllbWeightLoader)),
100 // Gemma-4 family (pure attention, GeGLU, sliding + full attention)
101 "gemma4" | "gemma_4" => Ok(Box::new(Gemma4WeightLoader)),
102 // Mistral family (MLA + MoE, GQA fallback for initial bring-up)
103 "mistral" => Ok(Box::new(MistralWeightLoader)),
104 // LongCat-Flash(-Lite): MLA dual-sublayer blocks + shortcut MoE with
105 // zero-computation experts (+ n-gram input embeddings).
106 "longcat_flash_ngram" | "longcat_flash" => Ok(Box::new(LongcatWeightLoader)),
107 // Qwen3.8-Flash-Next. `dispatch.rs` normalizes the older
108 // `qwen3_8_flash_next` naming onto `qwen4_exp`, so one arm covers both
109 // published quantizations.
110 "qwen4_exp" => Ok(Box::new(Qwen4ExpWeightLoader)),
111 // MiniMax M2 family (M2.1 / M2.7) — full attention + 256-expert
112 // sigmoid-routed MoE + 3-module MTP.
113 "minimax_m2" => Ok(Box::new(MinimaxM2WeightLoader)),
114 // Step 3.7 Flash — 288-expert sigmoid-routed MoE + shared expert +
115 // mixed full/sliding attention + attention gate + 3 MTP modules.
116 "step3p7" => Ok(Box::new(Step3p7WeightLoader)),
117 "laguna" => Ok(Box::new(LagunaWeightLoader)),
118 // DeepSeek-V4 family (Flash) — MLA + MoE + CSA/HCA hybrid attention + mHC.
119 "deepseek_v4" => Ok(Box::new(DeepSeekV4WeightLoader)),
120 // GLM-5.3-Flash — NoPE MLA behind a DSA kpool indexer + KDA linear attention +
121 // 288-expert sigmoid-routed MoE + mHC. `glm5_next_text` is the inner `model_type`;
122 // the parser canonicalises both onto `glm5_next`.
123 "glm5_next" | "glm5_next_text" => Ok(Box::new(Glm5NextWeightLoader)),
124 _ => bail!(
125 "Unsupported model type: '{}' (normalized: '{}'). \
126 Supported: qwen3_next, glm5_next, qwen3_5_moe, qwen3_5, qwen3_6_moe, holo3_1_moe, qwen3_vl_moe, nemotron_h, nemotron_h_puzzle, gemma4, mistral, minimax_m2, step3p7, laguna, deepseek_v4, qwen4_exp, m2m_100",
127 config.model_type,
128 normalized,
129 ),
130 }
131}
132
133mod build;
134mod lm_head_setup;
135mod m2_setup;
136
137pub use build::build_model;
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::layers::mtp_head::MtpQuantization;
143 use spark_runtime::kv_cache::KvCacheDtype;
144 use spark_runtime::prefix_cache::PrefixCache;
145
146 #[test]
147 fn test_unsupported_model_type() {
148 let mut config = ModelConfig::qwen3_next_80b_nvfp4();
149 config.model_type = "llama".to_string();
150
151 let gpu = spark_runtime::gpu::mock::MockGpuBackend::new();
152 let store = WeightStore::empty();
153
154 let prefix_cache: Box<dyn PrefixCache> =
155 Box::new(spark_runtime::prefix_cache::NoPrefixCaching);
156 let result = build_model(
157 config,
158 store,
159 Box::new(gpu),
160 1,
161 16,
162 4096,
163 8,
164 MtpQuantization::Nvfp4,
165 false,
166 prefix_cache,
167 0,
168 None,
169 false,
170 1,
171 KvCacheDtype::Fp8,
172 1024 * 1024 * 1024,
173 0.90,
174 0,
175 vec![],
176 0,
177 None,
178 None, // dflash_args
179 None, // lora_args
180 None, // nllb_lang
181 None, // nllb_lora_dir
182 );
183 match result {
184 Err(e) => assert!(e.to_string().contains("Unsupported model type: 'llama'")),
185 Ok(_) => panic!("Expected error for unsupported model type"),
186 }
187 }
188
189 #[test]
190 fn all_declared_model_type_spellings_are_accepted() {
191 let mut config = ModelConfig::qwen3_next_80b_nvfp4();
192 for model_type in [
193 "qwen3_next",
194 "qwen3_vl_moe",
195 "qwen3_5_moe",
196 "qwen3_5",
197 "qwen35_moe",
198 "qwen35",
199 "qwen3_6_moe",
200 "holo3_1_moe",
201 "nemotron_h",
202 "nemotron_h_puzzle",
203 "m2m_100",
204 "nllb",
205 "gemma4",
206 "gemma_4",
207 "mistral",
208 "minimax_m2",
209 "step3p7",
210 "laguna",
211 "deepseek_v4",
212 // Normalization accepts case, hyphens, and dots before dispatch.
213 "QWEN3-NEXT",
214 "nemotron.h.puzzle",
215 "M2M-100",
216 ] {
217 config.model_type = model_type.to_string();
218 assert!(
219 loader_for_config(&config).is_ok(),
220 "declared model type {model_type:?} was rejected"
221 );
222 }
223
224 config.model_type = "unsupported_model".to_string();
225 assert!(loader_for_config(&config).is_err());
226 }
227
228 // The generic `ModelWeightLoader` for NLLB must fail fast: real NLLB serving
229 // goes through the dedicated `NllbGpuModel` encoder-decoder runtime, which
230 // `build_model` selects before this loader is ever consulted. Reaching this
231 // loader is a routing bug, so every mandatory generic tensor-loading
232 // entry point bails. Optional hooks still report their normal absence.
233 #[test]
234 fn nllb_mandatory_generic_loads_fail_fast() {
235 let mut config = ModelConfig::qwen3_next_80b_nvfp4();
236 config.model_type = "nllb".to_string();
237 let loader = loader_for_config(&config).unwrap();
238 let store = WeightStore::empty();
239 let gpu = spark_runtime::gpu::mock::MockGpuBackend::new();
240
241 let errors = [
242 loader
243 .load_layers(&store, &config, &gpu, &[])
244 .err()
245 .expect("generic layer load unexpectedly succeeded"),
246 loader
247 .load_embedding(&store, &config, &gpu)
248 .expect_err("generic embedding load unexpectedly succeeded"),
249 loader
250 .load_final_norm(&store, &config, &gpu)
251 .expect_err("generic final-norm load unexpectedly succeeded"),
252 loader
253 .load_lm_head(&store, &config, &gpu)
254 .expect_err("generic LM-head load unexpectedly succeeded"),
255 ];
256 for err in errors {
257 assert!(
258 err.to_string()
259 .contains("dedicated GPU encoder-decoder runtime"),
260 "unexpected fail-fast diagnostic: {err}"
261 );
262 }
263 }
264}