spark_model/weight_loader/qwen4_exp.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `Qwen3.8-Flash-Next` (`qwen4_exp`) weight loader. Port tracked in Avarok
4//! #753.
5//!
6//! **The mHC highway runs; PLE does not.** The low-rank multi-hyperconnection
7//! residual is wired on all 48 layers and validated against the reference
8//! (`ops/hyper_connection_lowrank_tests.rs`, PLAN.md phases A-C). What is
9//! still missing:
10//!
11//! * **PLE n-gram injection** — refused at LOAD unless
12//! `ATLAS_QWEN4EXP_NO_PLE=1`, because skipping it does not crash and does
13//! not look wrong. It produces fluent text from a model missing an input.
14//! * **The QSA indexer** — provably inert at or below `indexer_budget`, which
15//! is the context this fits today; required above it, and refused there.
16//! See PLAN.md §1.5.
17//! * **Batched / multi-sequence decode** — refused by name; v1 is C=1.
18//!
19//! WHY THIS IS MOSTLY qwen35's LOADER. Qwen3.8-Flash-Next and Qwen3.6-35B-A3B
20//! share far more than the version numbers suggest: 3:1 GDN/full-attention
21//! interleave, MoE with a shared expert, gated attention, mRoPE, a ViT tower,
22//! vocab 248320, rope_theta 1e7, head_dim 256, partial rotary 0.25, and the
23//! same GDN key geometry. Critically, `load_ssm_qwen35` already reads
24//! `in_proj_qkv` and `in_proj_z` as SEPARATE tensors and concatenates them —
25//! which is exactly this model's layout, not a coincidence to be re-derived.
26//! So the GDN and full-attention arms are called directly, with
27//! `config.weight_prefix = "model.language_model"` making
28//! `config.layer_prefix(i)` yield the real keys.
29//!
30//! WHAT IS GENUINELY DIFFERENT, and why each needs care:
31//!
32//! 1. **There are no per-layer norms.** No `input_layernorm`, no
33//! `post_attention_layernorm`, no final `model.norm`. Normalization lives
34//! inside the hyper-connection blocks as `hc_norm [hc_mult*hidden]`, and
35//! the model-level `hyper_connection_mixer` — which collapses the streams
36//! back to one before `lm_head` — carries the final norm. A loader that
37//! "helpfully" defaults these would be inventing weights.
38//! 2. **mHC is 4 residual streams**, mixed low-rank (rank 320). Atlas's mHC
39//! plumbing is DeepSeek-V4's, whose mixer is Sinkhorn-normalized — same
40//! stream layout, different math.
41//! 3. **A QSA indexer** on the 12 full-attention layers.
42//! 4. **PLE n-gram injection** at one layer, off a ~320M-row table served
43//! from NVMe rather than resident.
44
45use anyhow::{Context, Result};
46use atlas_core::config::{LayerType, ModelConfig};
47use spark_runtime::gpu::GpuBackend;
48use spark_runtime::kv_cache::KvCacheDtype;
49use spark_runtime::weights::WeightStore;
50
51use crate::layer::TransformerLayer;
52use crate::weight_loader::ModelWeightLoader;
53use crate::weight_map::{DenseWeight, MtpWeights, dense};
54
55// `aux_sites`, NOT `aux`: bare `aux` is a RESERVED filename on Windows
56// (CON/PRN/AUX/NUL...) — git checkout of `aux.rs` fails with "invalid
57// path" on every Windows runner, which killed the release-matrix builds.
58#[path = "qwen4_exp/aux_sites.rs"]
59mod aux;
60mod ffn;
61mod hc;
62mod ple;
63mod probe;
64
65pub use probe::audit_namespace;
66
67/// The PLE table's shard layout, read straight from a checkpoint's
68/// safetensors header.
69///
70/// Exists so a test can rebuild the segmented row cache WITHOUT loading a
71/// 75 GB model — the gather is the one part of PLE whose failure is invisible
72/// downstream, so it needs a cheap isolated arm.
73#[cfg(test)]
74pub fn ple_shard_layout(snapshot: &str) -> Result<(Vec<(std::path::PathBuf, u64)>, u64)> {
75 use std::io::{Read, Seek, SeekFrom};
76 let idx: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(
77 std::path::Path::new(snapshot).join("model.safetensors.index.json"),
78 )?)?;
79 let map = idx["weight_map"].as_object().context("weight_map")?;
80 let mut names: Vec<(usize, &String)> = map
81 .keys()
82 .filter(|k| k.contains(".ngram_embedding.shard_"))
83 .map(|k| {
84 let n = k
85 .rsplit("shard_")
86 .next()
87 .and_then(|r| r.split('.').next())
88 .and_then(|r| r.parse().ok())
89 .unwrap_or(usize::MAX);
90 (n, k)
91 })
92 .collect();
93 names.sort();
94 anyhow::ensure!(!names.is_empty(), "no PLE shards in {snapshot}");
95
96 // Header per FILE, read once and reused. The released NVFP4 checkpoint
97 // spreads these 128 shards across ten `model-plefp8-*.safetensors`, so an
98 // offset is only meaningful against its own file's `data_start` — computing
99 // every one against shard 0's file put each row in the wrong place, when it
100 // did not simply refuse to load.
101 let mut headers: std::collections::HashMap<String, (serde_json::Value, u64)> =
102 std::collections::HashMap::new();
103 let mut shards = Vec::with_capacity(names.len());
104 let mut rows_per = 0u64;
105 for (i, name) in &names {
106 let file = map[name.as_str()].as_str().context("shard file")?;
107 if !headers.contains_key(file) {
108 let path = std::path::Path::new(snapshot).join(file);
109 let mut fh = std::fs::File::open(&path)?;
110 let mut len = [0u8; 8];
111 fh.read_exact(&mut len)?;
112 let hlen = u64::from_le_bytes(len);
113 let mut hdr = vec![0u8; hlen as usize];
114 fh.seek(SeekFrom::Start(8))?;
115 fh.read_exact(&mut hdr)?;
116 headers.insert(file.to_owned(), (serde_json::from_slice(&hdr)?, 8 + hlen));
117 }
118 let (hdr, data_start) = &headers[file];
119 let e = &hdr[name.as_str()];
120 let off = e["data_offsets"][0].as_u64().context("data_offsets")?;
121 let rows = e["shape"][0].as_u64().context("shape")?;
122 if *i == 0 {
123 rows_per = rows;
124 }
125 anyhow::ensure!(
126 rows == rows_per,
127 "shard {i} has {rows} rows, not {rows_per}"
128 );
129 shards.push((std::path::Path::new(snapshot).join(file), data_start + off));
130 }
131 Ok((shards, rows_per))
132}
133
134pub struct Qwen4ExpWeightLoader;
135
136impl ModelWeightLoader for Qwen4ExpWeightLoader {
137 fn supports_tp(&self) -> bool {
138 // Not attempted. mHC would need the stream buffer sharded alongside
139 // every projection, and the PLE row cache is a single-device arena.
140 false
141 }
142
143 fn load_layers(
144 &self,
145 store: &WeightStore,
146 config: &ModelConfig,
147 gpu: &dyn GpuBackend,
148 layer_kv_dtypes: &[KvCacheDtype],
149 ) -> Result<Vec<Box<dyn TransformerLayer>>> {
150 let report = audit_namespace(store, config);
151 report.log();
152 report.ensure_loadable()?;
153
154 let h = config.hidden_size;
155 let variant = crate::weight_map::detect_nvfp4_variant(store, config);
156 let absmax_k = gpu.kernel("quantize_nvfp4", "nvfp4_global_absmax")?;
157 let quantize_k = gpu.kernel("quantize_nvfp4", "quantize_bf16_to_nvfp4")?;
158 let stream = gpu.default_stream();
159
160 tracing::info!(
161 "Qwen3.8-Flash-Next: {} layers ({} GDN + {} full attention), \
162 {} experts top-{}, hc {} streams x rank {}, indexer budget {}, \
163 PLE at {:?}; NVFP4 variant {:?}",
164 config.num_hidden_layers,
165 config
166 .layer_types
167 .iter()
168 .filter(|t| **t == LayerType::LinearAttention)
169 .count(),
170 config
171 .layer_types
172 .iter()
173 .filter(|t| **t == LayerType::FullAttention)
174 .count(),
175 config.num_experts,
176 config.num_experts_per_tok,
177 config.hc_mult,
178 config.hc_lowrank,
179 config.index_topk,
180 config.ple_layer_ids,
181 variant,
182 );
183
184 // The model-level mixer collapses the streams before `lm_head` and
185 // carries the FINAL NORM (this checkpoint has no `model.norm.weight`).
186 // Replicated onto every layer; only the last one consumes it.
187 let hc_head = if config.hc_mult > 0 {
188 Some(hc::load_head(store, config)?)
189 } else {
190 None
191 };
192
193 // PLE scratch is sized once, for the largest prefill CHUNK a pass can
194 // present — not the model's context.
195 //
196 // Deliberately NOT `config.max_position_embeddings`: `--max-seq-len`
197 // is never written back into it, so on this model that field is the
198 // architectural 262144 and any clamp of it over-allocates. The six
199 // buffers total `tokens * 10240 * 14` bytes, which at 8192 is 1.26 GB
200 // — enough to push a 94.6 GB resident model past the util pledge on a
201 // box with 2.7 GB of headroom, which is exactly what it did.
202 //
203 // 2048 covers the chunk sizes this model runs at; a larger chunk gets
204 // the layer's refusal, which names this variable, rather than a
205 // silent overrun.
206 let max_ple_tokens: usize = std::env::var("ATLAS_PLE_MAX_TOKENS")
207 .ok()
208 .and_then(|v| v.parse().ok())
209 .unwrap_or(2048);
210 // With PLE disabled for bisection, skip the 21 MB arena and the
211 // 128-shard open entirely rather than building what we will not run.
212 let ple_off = std::env::var("ATLAS_QWEN4EXP_NO_PLE").as_deref() == Ok("1");
213 // GDN projections stay BF16 by DEFAULT on this checkpoint. Measured,
214 // both arms, same prompt, util 0.85 / 16K / bf16 KV:
215 //
216 // requantized NVFP4 BF16 (default)
217 // layer construction 7.43 GB / 154.8 MB/L 1.39 GB / 28.9 MB/L
218 // attn/GDN arms 7.34 GB 1.32 GB
219 // pre-KV 95.8 GB 90.0 GB
220 // KV budget 3.9 GB / 172144 tok 9.7 GB / 424464 tok
221 // decode 2.207 tok/s 2.188 tok/s
222 //
223 // 6.04 GB back for ~1% of decode. GDN weight bandwidth is simply not
224 // what bounds decode here at C=1 — something else dominates — so the
225 // usual w4a16-is-faster argument does not apply yet. Revisit if that
226 // changes.
227 //
228 // And it is not only a memory lever: ONLY the routed experts are
229 // quantized in this checkpoint. The GDN projections ship BF16, so
230 // requantizing them was a lossy round trip we chose, on 36 of 48
231 // layers. `=0` opts back into it for A/B.
232 let bf16_gdn = std::env::var("ATLAS_QWEN4EXP_BF16_GDN").as_deref() != Ok("0");
233 tracing::info!(
234 "GDN projections: {} on the {} linear-attention layers",
235 if bf16_gdn {
236 "BF16 as shipped (no runtime NVFP4 requantization)"
237 } else {
238 "requantized to NVFP4 (ATLAS_QWEN4EXP_BF16_GDN=0)"
239 },
240 config
241 .layer_types
242 .iter()
243 .filter(|t| **t == LayerType::LinearAttention)
244 .count(),
245 );
246
247 let mut layers: Vec<Box<dyn TransformerLayer>> =
248 Vec::with_capacity(config.num_hidden_layers);
249 let mut attn_idx = 0usize;
250
251 // Per-arm memory attribution. Layer construction costs 7.41 GB on this
252 // model (154.5 MB/layer, measured) on top of the 85.2 GB of uploaded
253 // shards, and nothing said which arm spent it. Summed here and logged
254 // once, so the answer is read rather than guessed.
255 let (mut moe_bytes, mut arm_bytes, mut hc_bytes) = (0u64, 0u64, 0u64);
256 let free_now = |g: &dyn GpuBackend| g.free_memory().unwrap_or(0) as u64;
257
258 for i in 0..config.num_hidden_layers {
259 let lp = config.layer_prefix(i);
260 let f0 = free_now(gpu);
261 let ffn = ffn::build_moe(store, &lp, config, gpu, variant)?;
262 let f1 = free_now(gpu);
263 moe_bytes += f0.saturating_sub(f1);
264
265 // Norm placeholders — see module docs. This model keeps its
266 // normalization inside the hyper-connection blocks, so there is
267 // no per-layer norm tensor to load. Ones-filled buffers keep the
268 // shared arms' shape contract without inventing a scale, and they
269 // are unreachable at runtime because the mHC forward refuses
270 // before any layer executes.
271 let input_norm = ones_norm(h, gpu)?;
272 let post_attn_norm = ones_norm(h, gpu)?;
273
274 let layer = match config.layer_types[i] {
275 LayerType::LinearAttention if bf16_gdn => {
276 // Keep the GDN projections BF16 instead of requantizing
277 // them to NVFP4 at load.
278 //
279 // Two reasons, and the second is the interesting one.
280 // (1) MEMORY: the requantization is where this model's
281 // build spends its 7.34 GB (152.8 MB/layer, measured —
282 // the MoE costs zero because its experts ship NVFP4 and
283 // upload straight through).
284 // (2) PRECISION: these tensors ship as BF16 in this
285 // checkpoint. Only the routed experts are quantized. So
286 // BF16 -> NVFP4 here is a lossy round trip we chose, not
287 // one the checkpoint forced, and it lands on the GDN
288 // projections of 36 of 48 layers.
289 crate::weight_loader::qwen35::load_layers::linear_attn_arms::build_linear_attention_dense_bf16(
290 i, store, &lp, gpu, variant, config, h,
291 input_norm, post_attn_norm, ffn,
292 )
293 .with_context(|| format!("qwen4_exp: GDN layer {i} (BF16)"))?
294 }
295 LayerType::LinearAttention => {
296 crate::weight_loader::qwen35::load_layers::linear_attn_arms::build_linear_attention_nvfp4(
297 store, &lp, gpu, variant, config, h, absmax_k, quantize_k, stream,
298 input_norm, post_attn_norm, ffn,
299 )
300 .with_context(|| format!("qwen4_exp: GDN layer {i}"))?
301 }
302 LayerType::FullAttention => {
303 let kv_dtype = layer_kv_dtypes
304 .get(attn_idx)
305 .copied()
306 .unwrap_or(KvCacheDtype::Bf16);
307 let l = crate::weight_loader::qwen35::load_layers::attention_arms::build_full_attention_nvfp4(
308 i, store, &lp, gpu, variant, config, h, absmax_k, quantize_k, stream,
309 kv_dtype, attn_idx, input_norm, post_attn_norm, ffn,
310 )
311 .with_context(|| format!("qwen4_exp: full-attention layer {i}"))?;
312 attn_idx += 1;
313 l
314 }
315 other => anyhow::bail!(
316 "qwen4_exp layer {i} has type {other:?}; this architecture is \
317 only linear_attention / full_attention"
318 ),
319 };
320 let f2 = free_now(gpu);
321 arm_bytes += f1.saturating_sub(f2);
322
323 // mHC: two sites per layer wrapping attention and the MoE. The
324 // residual this model carries is `hc_mult * hidden` wide, so
325 // without these the layer would run on a stream it never mixed.
326 let mut layer = layer;
327 if config.hc_mult > 0 {
328 let (attn, ffn) = hc::load_layer_sites(store, &lp, config)?;
329 aux::attach_hc(&mut layer, i, attn, ffn, hc_head.clone(), config)?;
330 }
331 aux::attach_qsa(&mut layer, i, &lp, store, config, gpu)?;
332 // PLE lands on exactly one layer, which on this checkpoint is a
333 // GDN one. `load` returns None for every other layer.
334 let ple_layer = if ple_off {
335 None
336 } else {
337 ple::load(store, config, i, max_ple_tokens, gpu)?
338 };
339 if let Some(p) = ple_layer {
340 aux::attach_ple(&mut layer, i, p)?;
341 }
342 layers.push(layer);
343 hc_bytes += f2.saturating_sub(free_now(gpu));
344 }
345 tracing::info!(
346 "qwen4_exp layer construction: MoE {:.2} GB ({:.1} MB/layer), \
347 attn/GDN arms {:.2} GB ({:.1} MB/layer), mHC+PLE {:.2} GB",
348 moe_bytes as f64 / 1e9,
349 moe_bytes as f64 / 1e6 / config.num_hidden_layers as f64,
350 arm_bytes as f64 / 1e9,
351 arm_bytes as f64 / 1e6 / config.num_hidden_layers as f64,
352 hc_bytes as f64 / 1e9,
353 );
354
355 // PLE is wired (PLAN.md phase D) and validated against the reference
356 // in `ops/ple_tests.rs`. The escape hatch stays, inverted: it now
357 // DISABLES a mechanism that is present, for bisecting, and says so.
358 if !config.ple_layer_ids.is_empty()
359 && std::env::var("ATLAS_QWEN4EXP_NO_PLE").as_deref() == Ok("1")
360 {
361 tracing::warn!(
362 "ATLAS_QWEN4EXP_NO_PLE=1: PLE n-gram injection at model layer {} \
363 is DISABLED. Output is wrong by construction — this arm exists \
364 to bisect the mHC spine, nothing else.",
365 config.ple_layer_ids[0].saturating_sub(1),
366 );
367 }
368 tracing::info!(
369 "Qwen3.8-Flash-Next loaded {} layers with the mHC highway live on \
370 all of them ({} GDN + {} full-attention).",
371 layers.len(),
372 layers.len()
373 - config
374 .layer_types
375 .iter()
376 .filter(|t| **t == LayerType::FullAttention)
377 .count(),
378 config
379 .layer_types
380 .iter()
381 .filter(|t| **t == LayerType::FullAttention)
382 .count(),
383 );
384 Ok(layers)
385 }
386
387 fn load_embedding(
388 &self,
389 store: &WeightStore,
390 config: &ModelConfig,
391 _gpu: &dyn GpuBackend,
392 ) -> Result<DenseWeight> {
393 let pfx = embed_prefix(config);
394 dense(store, &format!("{pfx}.embed_tokens.weight")).context("qwen4_exp: embedding")
395 }
396
397 /// **This model has no final norm tensor.**
398 ///
399 /// There is no `model.norm.weight` anywhere in the checkpoint. The
400 /// model-level `hyper_connection_mixer` — which collapses the `hc_mult`
401 /// residual streams back to a single hidden state before `lm_head` —
402 /// carries `hc_norm [hc_mult*hidden]`, and that IS the final
403 /// normalization. It is the wrong width to stand in here (10240 against
404 /// 2560), and applying it as though it were a plain final norm would be
405 /// inventing math.
406 ///
407 /// A ones-filled buffer keeps the shape contract so the footprint can be
408 /// measured at load. It is unreachable at inference because the mHC
409 /// forward refuses first; if that ever stops being true, this is the
410 /// first thing to fix.
411 fn load_final_norm(
412 &self,
413 store: &WeightStore,
414 config: &ModelConfig,
415 gpu: &dyn GpuBackend,
416 ) -> Result<DenseWeight> {
417 aux::final_norm_placeholder(store, config, gpu)
418 }
419
420 fn load_lm_head(
421 &self,
422 store: &WeightStore,
423 config: &ModelConfig,
424 _gpu: &dyn GpuBackend,
425 ) -> Result<DenseWeight> {
426 if store.contains("lm_head.weight") {
427 return dense(store, "lm_head.weight");
428 }
429 anyhow::ensure!(
430 config.tie_word_embeddings,
431 "qwen4_exp: no lm_head.weight and tie_word_embeddings is false"
432 );
433 let pfx = embed_prefix(config);
434 dense(store, &format!("{pfx}.embed_tokens.weight")).context("qwen4_exp: tied lm_head")
435 }
436
437 fn load_vision_encoder(
438 &self,
439 store: &WeightStore,
440 config: &ModelConfig,
441 gpu: &dyn GpuBackend,
442 ) -> Result<Option<crate::layers::VisionEncoder>> {
443 // The ViT tower IS the Qwen3-VL family shape the qwen35 loader
444 // already reads: 27 blocks under `model.visual.*`, patch 16,
445 // spatial-merge 2, plain BF16 weights (no quant tensors under
446 // `visual` in this checkpoint), empty deepstack list. The
447 // qwen3.8-flash-next kernel target ships its own vision_encoder.cu
448 // shadow, so kernels resolve per-target as usual.
449 crate::weight_loader::qwen35::Qwen35WeightLoader.load_vision_encoder(store, config, gpu)
450 }
451
452 fn load_mtp_weights(
453 &self,
454 _store: &WeightStore,
455 _config: &ModelConfig,
456 _gpu: &dyn GpuBackend,
457 ) -> Result<Option<MtpWeights>> {
458 // Dropped for v1 (#753 item I). The MTP block is effectively a second
459 // model: its own 512-expert MoE, its own hyper-connection mixer, its
460 // own QSA indexer, and `fc_embedding`/`fc_hidden` where Atlas's
461 // `MtpWeights` wants a fused `eh_proj`. Wiring it before the main
462 // forward path works would be building on sand.
463 Ok(None)
464 }
465}
466
467/// A ones-filled `[n]` BF16 norm scale.
468///
469/// BF16 1.0 is `0x3F80`, so the buffer cannot be produced with `memset`.
470fn ones_norm(n: usize, gpu: &dyn GpuBackend) -> Result<DenseWeight> {
471 let host: Vec<u8> = std::iter::repeat_n([0x80u8, 0x3Fu8], n).flatten().collect();
472 let ptr = gpu.alloc(host.len())?;
473 gpu.copy_h2d(&host, ptr)?;
474 Ok(DenseWeight { weight: ptr })
475}
476
477/// `model.language_model` for the multimodal layout, `model` otherwise.
478fn embed_prefix(config: &ModelConfig) -> String {
479 if config.weight_prefix.is_empty() {
480 "model".to_string()
481 } else {
482 config.weight_prefix.clone()
483 }
484}
485
486/// The model-level hyper-connection mixer that collapses the residual streams.
487fn mixer_prefix(config: &ModelConfig) -> String {
488 format!("{}.hyper_connection_mixer", embed_prefix(config))
489}