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}