spark_model/weight_loader/
glm5_next_load.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `Glm5NextWeightLoader` — assembles the 45-layer GLM-5.3 text stack from a `WeightStore`.
4//!
5//! Everything it calls already existed and was gated: `bind_kda_weights`, `build_dsa_weights`,
6//! `glm5next_mlp::build`. This is the wiring, plus the one thing wiring must do that the pieces
7//! cannot — decide, per layer, WHICH pieces.
8//!
9//! # Where the classification comes from
10//!
11//! Not from tensor names, and not from modular arithmetic. `Glm5NextTextSkeleton::from_config`
12//! derives the mixer and MLP kind of all 45 layers from the checkpoint's own
13//! `linear_attn_config` index lists and `first_k_dense_replace`, cross-checked against the
14//! textual arrays, and refuses anything it was not taught. This loader iterates that.
15//!
16//! # TP, on every half
17//!
18//! DSA shards through `DsaTpPlan`, the MLP through `Glm5NextMlpConfig`, and KDA through
19//! [`KdaShardedSource`] — an adapter that slices the host bytes **before** the proven
20//! `bind_kda_weights` sees them, so TP=1 and TP=2 take the identical binder code path.
21//!
22//! ðŸŠĪ Both mixers end in a **row-parallel** `o_proj`, so the attention output is a partial sum
23//! at TP>1 and `Glm5NextLayer::mixer_all_reduce` reduces it before the mHC highway sees it.
24//! Half-applying the sharding — the state before this was wired — meant every rank computed a
25//! WHOLE KDA block and the all-reduce double-counted it: no crash, no shape error.
26
27use anyhow::{Context, Result, bail};
28use avarok_core::config::ModelConfig;
29use spark_runtime::gpu::{DevicePtr, GpuBackend};
30use spark_runtime::kv_cache::KvCacheDtype;
31use spark_runtime::weights::{WeightDtype, WeightStore, WeightTensor};
32
33use super::ModelWeightLoader;
34use crate::layer::TransformerLayer;
35use crate::layers::glm5next_dsa::build::build_dsa_weights;
36use crate::layers::glm5next_dsa::layer::{Glm5NextDsaLayer, Glm5NextDsaLayerKernels};
37use crate::layers::glm5next_dsa::{Glm5NextDsaConfig, Glm5NextDsaKernels};
38use crate::layers::glm5next_kda::binding::{
39    KdaDtype, KdaTensorSource, RawTensor, bind_kda_weights,
40};
41use crate::layers::glm5next_kda::tp::KdaTpPlan;
42use crate::layers::glm5next_kda::tp_bind::KdaShardedSource;
43use crate::layers::glm5next_kda::{Glm5NextKdaConfig, Glm5NextKdaKernels, Glm5NextKdaLayer};
44use crate::layers::glm5next_layer::{Glm5NextLayer, Glm5NextMhc, Glm5NextMixer, Glm5NextMlpSite};
45use crate::layers::glm5next_mlp::weights::{Glm5NextExpertWeights, Nvfp4Proj};
46use crate::layers::glm5next_mlp::{Glm5NextMlpConfig, Glm5NextMlpKernels, build as mlp_build};
47use crate::layers::glm5next_skeleton::{Glm5NextTextSkeleton, Mixer, Mlp};
48use crate::layers::ops::{Glm5NextMhcKernels, Glm5NextMhcSiteWeights, mhc_mix_max_tokens, mix_hc};
49use crate::weight_map::DenseWeight;
50
51#[cfg(test)]
52mod defer_hook_tests;
53#[cfg(test)]
54mod export_layout_tests;
55mod nvfp4_dequant;
56mod nvfp4_quant;
57#[cfg(test)]
58mod plan_cast_tests;
59mod plan_dtype;
60
61pub struct Glm5NextWeightLoader;
62
63/// A `[layer]`-relative tensor name, fully qualified for this checkpoint.
64///
65/// ðŸŠĪ GLM-5.3 nests the text stack under `model.language_model.`, not `model.`. And it does NOT
66/// use `mtp.0.*` — the MTP block is `layers.45`.
67fn qualify(layer: usize, leaf: &str) -> String {
68    format!("model.language_model.layers.{layer}.{leaf}")
69}
70
71/// Is this store tensor one the binders have already re-uploaded a copy of?
72///
73/// `load_layers` pulls every non-expert layer tensor to the host
74/// (`LayerSource::collect`) and the binders upload fresh device buffers — a TP
75/// shard for KDA/DSA, a dtype-converted copy for mHC. The store's originals are
76/// dead from that moment, and on GB10 they are 15.7 GB of unified memory the KV
77/// cache never gets. Measured 2026-08-28: the first 2-node bring-up died with
78/// "No memory left for KV cache" at 112.8 GB resident against a 99.64 GB load.
79///
80/// ðŸŠĪ Two things must NOT match:
81/// * `mlp.experts.*` — bound **zero-copy** from these very pointers
82///   (`bind_expert`). Freeing them is a use-after-free with no diagnostic.
83/// * `layers.{num_layers}` — GLM's MTP/draft block sits one past the skeleton
84///   (`layers.45` at `num_hidden_layers = 45`) and is read by
85///   `load_mtp_weights_multi`, not by `load_layers`.
86fn is_reuploaded(name: &str, num_layers: usize) -> bool {
87    let Some(rest) = name.strip_prefix("model.language_model.layers.") else {
88        return false;
89    };
90    let Some((idx, rel)) = rest.split_once('.') else {
91        return false;
92    };
93    let Ok(idx) = idx.parse::<usize>() else {
94        return false;
95    };
96    idx < num_layers && !rel.starts_with("mlp.experts.")
97}
98
99/// Is this store tensor a routed-expert projection [`bind_expert`] QUANTISED
100/// instead of binding zero-copy?
101///
102/// The discriminator is the on-disk dtype and nothing else. A packed U8 expert
103/// IS the kernel's operand and must never be freed — that is the
104/// use-after-free [`is_reuploaded`] exists to avoid. A BF16 one cannot be: no
105/// GLM routed-expert kernel reads BF16, so the only thing that ever touched it
106/// was the quantiser, and the NVFP4 result it produced lives in the layer.
107///
108/// ðŸŠĪ This can only be acted on AFTER `load_glm5next_mtp_module` has bound
109/// `layers.{num_hidden_layers}` — which `factory::build` guarantees by calling
110/// `prune_after_load` last, and which is the same ordering `is_reuploaded`'s
111/// "the MTP block is kept" rule already depends on.
112///
113/// On both disk loaders this now matches NOTHING, because
114/// [`Glm5NextWeightLoader::defer_predicate`] keeps those tensors off the device
115/// in the first place. It stays for the paths that have no defer hook — the
116/// RDMA weight peer, and any future loader that fills a store directly — where
117/// a BF16 expert still arrives resident and still has to be freed.
118fn is_quantized_expert_weight(name: &str, dtype: WeightDtype) -> bool {
119    dtype == WeightDtype::BF16
120        && name.starts_with("model.language_model.layers.")
121        && name.contains(".mlp.experts.")
122        && name.ends_with("_proj.weight")
123}
124
125/// Is this a routed-expert projection of the MTP block that the export left at
126/// full width — the family [`Glm5NextWeightLoader::defer_predicate`] keeps off
127/// the device entirely?
128///
129/// Both halves of the key matter:
130///
131/// * **the LAYER** is `config.num_hidden_layers`, never a literal. GLM puts the
132///   MTP block one past the text stack, and the text stack's own experts are
133///   packed U8 in both exports — deferring one of those would withhold the
134///   kernel's actual operand.
135/// * **the DTYPE** is BF16. `LibertAIDAI/GLM-5.3-Flash-NVFP4` quantises this
136///   block like every other, so on that checkpoint this is false for every
137///   tensor and the loader defers nothing at all.
138///
139/// ðŸŠĪ `mlp.experts.` and not `experts.`: the SHARED expert
140/// (`mlp.shared_experts.{gate,up,down}_proj.weight`) is a plain float tensor in
141/// both exports, is read through [`LayerSource`], and must stay resident.
142fn is_full_width_mtp_expert(name: &str, dtype: WeightDtype, num_layers: usize) -> bool {
143    if dtype != WeightDtype::BF16 || !name.ends_with("_proj.weight") {
144        return false;
145    }
146    let Some(rest) = name.strip_prefix("model.language_model.layers.") else {
147        return false;
148    };
149    let Some((idx, rel)) = rest.split_once('.') else {
150        return false;
151    };
152    idx.parse::<usize>().ok() == Some(num_layers) && rel.starts_with("mlp.experts.")
153}
154
155/// Read a device tensor back as host bytes.
156fn host_bytes(gpu: &dyn GpuBackend, t: &WeightTensor) -> Result<Vec<u8>> {
157    let mut b = vec![0u8; t.byte_size()];
158    gpu.copy_d2h(t.ptr, &mut b)?;
159    Ok(b)
160}
161
162/// Read a device tensor back as host `f32`, whatever width it is stored at.
163///
164/// ðŸŠĪ The dtype is read off the tensor, never assumed. `hc_*_fn` is BF16 on disk while the kernel
165/// wants F32, and `weight_scale_2` is F32 — this is the #341/#347 dtype-mismatch class, and
166/// the shapes never say so.
167fn host_f32(gpu: &dyn GpuBackend, t: &WeightTensor, what: &str) -> Result<Vec<f32>> {
168    let b = host_bytes(gpu, t)?;
169    match t.dtype {
170        WeightDtype::BF16 => Ok(b
171            .chunks_exact(2)
172            .map(|c| half::bf16::from_bits(u16::from_le_bytes([c[0], c[1]])).to_f32())
173            .collect()),
174        WeightDtype::FP32 => Ok(b
175            .chunks_exact(4)
176            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
177            .collect()),
178        other => bail!(
179            "{what}: dtype {other:?} cannot be read as f32 without a conversion this loader refuses to guess"
180        ),
181    }
182}
183
184fn upload_f32(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
185    let b: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
186    let p = gpu.alloc(b.len().max(1))?;
187    gpu.copy_h2d(&b, p)?;
188    Ok(p)
189}
190
191/// One layer's slice of the store, presented to the KDA binder as layer-relative names.
192pub(super) struct LayerSource {
193    names: Vec<String>,
194    tensors: std::collections::BTreeMap<String, (WeightDtype, Vec<usize>, Vec<u8>)>,
195    /// Plan-dtype copies of the tensors whose on-disk width is not the width the
196    /// attention binders bind at — see [`plan_dtype`]. Consulted by the RAW path
197    /// ([`KdaTensorSource::get`]) and by nothing else: `f32` keeps reading the
198    /// checkpoint's own bytes, so the DSA absorb math and the F32 `ape` upload
199    /// still see every bit the export carries.
200    ///
201    /// **Empty for `LibertAIDAI/GLM-5.3-Flash-NVFP4`**, whose attention stack is
202    /// BF16 throughout — that checkpoint allocates nothing here and takes the
203    /// same code path it always did.
204    plan_cast: std::collections::BTreeMap<String, (WeightDtype, Vec<u8>)>,
205}
206
207impl LayerSource {
208    pub(super) fn collect(gpu: &dyn GpuBackend, store: &WeightStore, layer: usize) -> Result<Self> {
209        let prefix = format!("model.language_model.layers.{layer}.");
210        let mut names = Vec::new();
211        let mut tensors = std::collections::BTreeMap::new();
212        let mut plan_cast = std::collections::BTreeMap::new();
213        let rels: Vec<String> = store
214            .names()
215            .filter_map(|n| n.strip_prefix(&prefix).map(|r| r.to_string()))
216            .collect();
217        for rel in rels {
218            let rel = rel.as_str();
219            // The routed experts are the bulk of a layer and are bound zero-copy from their
220            // device pointers; pulling them to the host here would move gigabytes for nothing.
221            if rel.starts_with("mlp.experts.") {
222                names.push(rel.to_string());
223                continue;
224            }
225            names.push(rel.to_string());
226            let t = store.get(&format!("{prefix}{rel}"))?;
227            let bytes = host_bytes(gpu, t)?;
228            // ðŸŠĪ Storage width is an EXPORT choice, not a model change. NVIDIA's
229            // `nvidia/GLM-5.3-Flash-NVFP4` writes the non-quantised attention
230            // tensors at F32 where `LibertAIDAI/GLM-5.3-Flash-NVFP4` writes
231            // BF16 — same numbers, twice the bytes. Materialise the plan-dtype
232            // copy the RAW path needs, and only for the tensors that need it.
233            if let Some(cast) = plan_dtype::cast_to_plan_dtype(rel, t.dtype, &bytes)? {
234                plan_cast.insert(rel.to_string(), cast);
235            }
236            tensors.insert(rel.to_string(), (t.dtype, t.shape.clone(), bytes));
237        }
238        Ok(Self {
239            names,
240            tensors,
241            plan_cast,
242        })
243    }
244
245    pub(super) fn f32(&self, name: &str) -> Result<Vec<f32>> {
246        let (dtype, shape, bytes) = self
247            .tensors
248            .get(name)
249            .with_context(|| format!("missing tensor {name}"))?;
250        match dtype {
251            WeightDtype::BF16 => Ok(bytes
252                .chunks_exact(2)
253                .map(|c| half::bf16::from_bits(u16::from_le_bytes([c[0], c[1]])).to_f32())
254                .collect()),
255            WeightDtype::FP32 => Ok(bytes
256                .chunks_exact(4)
257                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
258                .collect()),
259            // ðŸŠĪ QUANTISATION is an EXPORT choice too, not a model change.
260            // NVIDIA's `nvidia/GLM-5.3-Flash-NVFP4` quantises the three dense
261            // MLP layers `LibertAIDAI/GLM-5.3-Flash-NVFP4` leaves BF16. The
262            // dense MLP builder and its kernels are BF16-only by contract, so
263            // the packed codes are unpacked HERE and nothing downstream moves.
264            WeightDtype::UInt8 => self.dequant_packed_nvfp4(name, shape, bytes),
265            other => bail!("{name}: dtype {other:?} is not a plain float tensor"),
266        }
267    }
268
269    /// One packed-NVFP4 tensor of this layer as `f32`, read with its own
270    /// `.weight_scale` / `.weight_scale_2` siblings.
271    ///
272    /// Reached only from the `UInt8` arm of [`Self::f32`], so a checkpoint
273    /// whose dense MLP is BF16 never enters it.
274    ///
275    /// ðŸŠĪ The siblings are looked up by NAME, not assumed: a `.weight` without
276    /// them is a format this loader has not been taught (compressed-tensors
277    /// spells them `weight_packed` / `weight_global_scale` and stores the
278    /// RECIPROCAL global scale), and guessing would apply the wrong
279    /// convention with no error.
280    fn dequant_packed_nvfp4(&self, name: &str, shape: &[usize], bytes: &[u8]) -> Result<Vec<f32>> {
281        let base = name.strip_suffix(".weight").with_context(|| {
282            format!("{name}: packed NVFP4 must be a `.weight`, with scale siblings beside it")
283        })?;
284        let (scale_dtype, _, scale_bytes) = self
285            .tensors
286            .get(&format!("{base}.weight_scale"))
287            .with_context(|| format!("{name} is packed NVFP4 but {base}.weight_scale is absent"))?;
288        if *scale_dtype != WeightDtype::FP8E4M3 {
289            bail!("{base}.weight_scale is {scale_dtype:?}, expected F8_E4M3 block scales");
290        }
291        let s2 = self.f32(&format!("{base}.weight_scale_2"))?;
292        let [scale_2] = s2[..] else {
293            bail!("{base}.weight_scale_2 is not a scalar");
294        };
295        nvfp4_dequant::dequant_nvfp4_to_f32(name, bytes, shape, scale_bytes, scale_2)
296    }
297}
298
299impl KdaTensorSource for LayerSource {
300    fn get(&self, name: &str) -> Option<RawTensor<'_>> {
301        let (dtype, shape, bytes) = match self.plan_cast.get(name) {
302            // The export stored this one at a width the plan does not bind at;
303            // hand over the plan-dtype copy. Absent for every checkpoint whose
304            // attention stack is already at the plan's widths.
305            Some((dtype, bytes)) => (dtype, &self.tensors.get(name)?.1, bytes),
306            None => {
307                let (dtype, shape, bytes) = self.tensors.get(name)?;
308                (dtype, shape, bytes)
309            }
310        };
311        // ðŸŠĪ A KDA block is entirely BF16 except `A_log`/`dt_bias`, which are F32. Anything
312        // else here is not a KDA tensor, and the binder must see the absence rather than a
313        // coerced dtype — it refuses on a dtype mismatch precisely because a cast would
314        // change the numerics.
315        let dtype = match dtype {
316            WeightDtype::BF16 => KdaDtype::Bf16,
317            WeightDtype::FP32 => KdaDtype::F32,
318            _ => return None,
319        };
320        Some(RawTensor {
321            dtype,
322            shape: shape.clone(),
323            bytes,
324        })
325    }
326    fn names(&self) -> Vec<String> {
327        self.names.clone()
328    }
329}
330
331/// Bind the two mHC sites of one layer.
332///
333/// ðŸŠĪ `hc_*_fn` is **BF16 on disk and F32 at the kernel**; `base`/`scale` are already F32. All
334/// three are uploaded as F32 here. Passing the on-disk BF16 straight through is exactly the
335/// defect class that produced #341 and #347.
336fn bind_mhc_site(
337    gpu: &dyn GpuBackend,
338    src: &LayerSource,
339    site: &str,
340    hc_mult: usize,
341    hidden: usize,
342) -> Result<Glm5NextMhcSiteWeights> {
343    let f = src.f32(&format!("hc_{site}_fn"))?;
344    let want = mix_hc(hc_mult) * hc_mult * hidden;
345    if f.len() != want {
346        bail!(
347            "hc_{site}_fn has {} elements, expected mix_hc({hc_mult}) * {hc_mult} * {hidden} = {want}",
348            f.len()
349        );
350    }
351    let base = src.f32(&format!("hc_{site}_base"))?;
352    if base.len() != mix_hc(hc_mult) {
353        bail!(
354            "hc_{site}_base has {} entries, expected mix_hc({hc_mult}) = {}",
355            base.len(),
356            mix_hc(hc_mult)
357        );
358    }
359    let scale = src.f32(&format!("hc_{site}_scale"))?;
360    if scale.len() != 3 {
361        bail!(
362            "hc_{site}_scale has {} entries, expected 3 (pre, post, comb)",
363            scale.len()
364        );
365    }
366    Ok(Glm5NextMhcSiteWeights {
367        // ðŸ”ī BF16, because that is what the checkpoint stores (`[24, 16384]`, BF16 in the
368        // safetensors header). Uploading it as F32 doubled `hc_mix`'s traffic — 1.57 MB
369        // instead of 0.79 MB per site, 90 sites per token — for values that were already
370        // exactly BF16. `glm5next_hc_mix_bf16` reads it at that width and is bit-identical.
371        hc_fn: upload_f32_as_bf16(gpu, &f)?,
372        hc_fn_bf16: true,
373        hc_scale: upload_f32(gpu, &scale)?,
374        hc_base: upload_f32(gpu, &base)?,
375        // `hc_mix` -> `hc_finish` handoff. Per site so the layer's two sites cannot alias.
376        mix: gpu.alloc(mhc_mix_max_tokens() * mix_hc(hc_mult) * 4)?,
377    })
378}
379
380/// One routed expert, bound straight off the checkpoint's device pointers —
381/// or quantised to the same operand triple when the export left it full width.
382///
383/// ðŸŠĪ The arm is chosen by the `.weight` tensor's ON-DISK dtype, never by a
384/// flag. `LibertAIDAI/GLM-5.3-Flash-NVFP4`'s experts are U8 everywhere, so that
385/// checkpoint takes the zero-copy arm for every expert of every layer exactly
386/// as it did before the BF16 arm existed.
387///
388/// Three arms, in the order they are tried:
389///
390/// 1. **deferred** — the weight loader honoured
391///    [`Glm5NextWeightLoader::defer_predicate`] and left it on disk. Read the
392///    host bytes, quantise, upload the NVFP4. Nothing full-width ever touches
393///    the device. This is the arm the official export takes.
394/// 2. **U8 resident** — the checkpoint already carries the triple; bind it
395///    where it lies, zero copy. The community export takes this arm.
396/// 3. **BF16 resident** — a store filled by something with no defer hook (the
397///    RDMA weight peer). Read it back off the device and quantise;
398///    `prune_after_load` frees the source.
399pub(super) fn bind_expert(
400    gpu: &dyn GpuBackend,
401    store: &WeightStore,
402    layer: usize,
403    id: usize,
404) -> Result<Glm5NextExpertWeights> {
405    let proj = |p: &str| -> Result<Nvfp4Proj> {
406        let base = format!("mlp.experts.{id}.{p}");
407        let wname = qualify(layer, &format!("{base}.weight"));
408        // Deferred: never uploaded, at this loader's own request. Checked
409        // FIRST, because the store has no tensor under this name at all.
410        if let Some(d) = store.deferred(&wname) {
411            return quantize_deferred_expert_proj(gpu, store, d, &base);
412        }
413        let w = store.get(&wname)?;
414        match w.dtype {
415            // The checkpoint already carries the triple: bind it where it lies.
416            WeightDtype::UInt8 => {
417                let scale = store.get(&qualify(layer, &format!("{base}.weight_scale")))?;
418                let s2 = store.get(&qualify(layer, &format!("{base}.weight_scale_2")))?;
419                let s2 = host_f32(gpu, s2, &format!("{base}.weight_scale_2"))?;
420                let [s2] = s2[..] else {
421                    bail!("{base}.weight_scale_2 is not a scalar");
422                };
423                Ok(Nvfp4Proj {
424                    packed: w.ptr,
425                    scale: scale.ptr,
426                    scale_2: s2,
427                })
428            }
429            // ðŸŠĪ NVIDIA's official export leaves the MTP block's experts BF16
430            // with no scales at all. There is no BF16 routed-expert forward to
431            // fall back to, so the triple is MADE here — see [`nvfp4_quant`].
432            WeightDtype::BF16 => quantize_expert_proj(gpu, store, w, &base),
433            other => bail!("{base}.weight is {other:?}, expected packed U8 NVFP4 or BF16"),
434        }
435    };
436    Ok(Glm5NextExpertWeights {
437        gate_proj: proj("gate_proj")?,
438        up_proj: proj("up_proj")?,
439        down_proj: proj("down_proj")?,
440    })
441}
442
443/// Quantise one full-width expert projection the loader left ON DISK.
444///
445/// This is the arm that makes the official export fit. The BF16 bytes are read
446/// from the shard into host memory, quantised, and only the ~3.6x smaller
447/// NVFP4 triple is uploaded — so at no point in the 45-layer build is a
448/// full-width expert resident. Measured 2026-09-21, before this arm existed:
449/// the fast loader swept the 432 BF16 tensors of one EP=2 rank (~7.25 GB) to
450/// the device and they stayed there until `prune_after_load`, which killed
451/// rank 0 at layer 38 on a 32K boot. See `spark_runtime::weights::deferred`.
452///
453/// Only the experts this rank owns are read: `build_moe` calls
454/// [`bind_expert`] over `local_expert_range()` alone, and the loader's own EP
455/// rule never deferred a remote expert to begin with.
456///
457/// ðŸŠĪ One host buffer at a time. `read_host_bytes` + the `f32` expansion are
458/// ~40 MB together for a `[2048, 4096]` BF16 projection, and both are dropped
459/// before the next projection is read — on a unified-memory box the host
460/// working set IS the device working set.
461fn quantize_deferred_expert_proj(
462    gpu: &dyn GpuBackend,
463    store: &WeightStore,
464    d: &spark_runtime::weights::DeferredTensor,
465    base: &str,
466) -> Result<Nvfp4Proj> {
467    let [rows, cols] = d.shape[..] else {
468        bail!(
469            "{base}.weight must be 2-D to quantise, got shape {:?}",
470            d.shape
471        );
472    };
473    if d.dtype != WeightDtype::BF16 {
474        bail!(
475            "{base}.weight was deferred as {:?}; only a full-width BF16 expert is \
476             quantised at load",
477            d.dtype
478        );
479    }
480    let bytes = d
481        .read_host_bytes()
482        .with_context(|| format!("{base}.weight: reading the deferred expert from its shard"))?;
483    let values: Vec<f32> = bytes
484        .chunks_exact(2)
485        .map(|c| half::bf16::from_bits(u16::from_le_bytes([c[0], c[1]])).to_f32())
486        .collect();
487    drop(bytes);
488    quantize_and_upload(gpu, store, base, &values, rows, cols)
489}
490
491/// Quantise one full-width BF16 expert projection that IS resident.
492///
493/// Reached only by a store no defer hook filled (the RDMA weight peer). The
494/// BF16 source is dead afterwards and is released by
495/// [`Glm5NextWeightLoader::prune_after_load`], which is the only place that
496/// can, because every binder takes `&WeightStore`.
497fn quantize_expert_proj(
498    gpu: &dyn GpuBackend,
499    store: &WeightStore,
500    w: &WeightTensor,
501    base: &str,
502) -> Result<Nvfp4Proj> {
503    let [rows, cols] = w.shape[..] else {
504        bail!(
505            "{base}.weight must be 2-D to quantise, got shape {:?}",
506            w.shape
507        );
508    };
509    let values = host_f32(gpu, w, &format!("{base}.weight"))?;
510    quantize_and_upload(gpu, store, base, &values, rows, cols)
511}
512
513/// The tail both quantise arms share: encode, upload, adopt.
514///
515/// ðŸŠĪ Both buffers are ADOPTED by the store's derived-weight ledger. A
516/// `gpu.alloc` that lives in a layer struct has no owner otherwise: teardown's
517/// `sweep_unreleased` reclaims it, but only after the backend is gone, so the
518/// bytes are unreclaimable for the life of the model (see
519/// `spark_runtime::weights::derived`).
520fn quantize_and_upload(
521    gpu: &dyn GpuBackend,
522    store: &WeightStore,
523    base: &str,
524    values: &[f32],
525    rows: usize,
526    cols: usize,
527) -> Result<Nvfp4Proj> {
528    let blob = nvfp4_quant::quantize_to_nvfp4(base, values, rows, cols)?;
529    let packed = upload_bytes(gpu, store, &blob.packed)?;
530    let scale = upload_bytes(gpu, store, &blob.scales)?;
531    Ok(Nvfp4Proj {
532        packed,
533        scale,
534        scale_2: blob.scale_2,
535    })
536}
537
538/// Label for every buffer [`quantize_expert_proj`] adopts, so the residency
539/// report can attribute them as one line rather than per expert.
540const QUANTIZED_EXPERT_LABEL: &str = "glm5_next routed expert, NVFP4 at load";
541
542fn upload_bytes(gpu: &dyn GpuBackend, store: &WeightStore, b: &[u8]) -> Result<DevicePtr> {
543    let p = gpu.alloc(b.len().max(1))?;
544    gpu.copy_h2d(b, p)?;
545    store.derived().adopt(QUANTIZED_EXPERT_LABEL, p, b.len());
546    Ok(p)
547}
548
549fn dense(store: &WeightStore, name: &str) -> Result<DenseWeight> {
550    Ok(DenseWeight {
551        weight: store.get(name)?.ptr,
552    })
553}
554
555impl ModelWeightLoader for Glm5NextWeightLoader {
556    /// This loader binds the tower when — and only when — the operator asked
557    /// for it with `AVAROK_GLM_VISION=1`.
558    ///
559    /// It used to answer a flat `false`, which kept 1.05 GiB/rank of
560    /// `model.visual.*` off the GPU entirely. That was not free: measured
561    /// 2026-08-29, K=3 at 32 K needs 13.58 GiB against 12.07 GiB free, so the
562    /// tower is exactly the difference between `--speculative --num-drafts 2`
563    /// and a serve that fits. Binding it is a real memory decision, not a
564    /// correctness cleanup, and a GLM serve that wants the old headroom back
565    /// has to be given it deliberately.
566    ///
567    /// ðŸŠĪ This method takes no `ModelConfig`, so it cannot answer per
568    /// checkpoint — `binds_vision(config)` in the server resolves the loader
569    /// from the config and then asks the loader alone. That is why the gate is
570    /// an ENV read rather than a config field: it is the one input both this
571    /// method and `parse_glm5_next` can see, so the withhold decision and the
572    /// `config.vision` decision cannot disagree. Threading the config through
573    /// the trait would let the gate become a config field; it is a wider
574    /// change than this one.
575    ///
576    /// The `true` arm is a real bind (`glm5_next_vision.rs`), not a
577    /// load-then-free: `factory::build`'s reclaim is keyed off whether a tower
578    /// came back, so it stops firing for this model on its own.
579    fn binds_vision_encoder(&self) -> bool {
580        avarok_core::config::glm_vision_enabled()
581    }
582
583    /// Bind the 347-tensor `model.visual.*` tower. See
584    /// [`crate::weight_loader::glm5_next_vision`].
585    fn load_vision_encoder(
586        &self,
587        store: &WeightStore,
588        config: &ModelConfig,
589        gpu: &dyn GpuBackend,
590    ) -> Result<Option<crate::layers::VisionTower>> {
591        crate::weight_loader::glm5_next_vision::load_glm5_next_vision(store, config, gpu)
592    }
593
594    /// Keep the MTP block's full-width routed experts off the device.
595    ///
596    /// `nvidia/GLM-5.3-Flash-NVFP4` ships `layers.{num_hidden_layers}.mlp.
597    /// experts.E.{gate,up,down}_proj.weight` as BF16 `[2048, 4096]` against a
598    /// w4a16-only forward. `bind_expert` reads each one ONCE and replaces it
599    /// with an NVFP4 triple ~3.6x smaller, so uploading the originals is pure
600    /// transient — and on GB10's unified memory the transient is the problem:
601    /// measured 2026-09-21 at EP=2, ~7.25 GB per rank resident from the fast
602    /// loader's sweep until `prune_after_load`, i.e. across the whole 45-layer
603    /// build. That survived a 2048-token boot (host floor 1.74 GB) and killed
604    /// rank 0 at layer 38 on the 32K qualification shape (935 MB against a
605    /// 1200 MB floor), BEFORE the MTP bind could free anything.
606    ///
607    /// Deferring them makes the transient structurally impossible: the BF16
608    /// stays in the page cache and `bind_expert` reads it from there.
609    ///
610    /// ðŸŠĪ `None` would be wrong ONLY as a silent default — the predicate is
611    /// dtype-keyed, so returning it on `LibertAIDAI/GLM-5.3-Flash-NVFP4`
612    /// (packed U8 experts everywhere) defers nothing and that checkpoint loads
613    /// byte-identically. The layer index comes from the config, never a
614    /// literal: `layers.45` is `num_hidden_layers`, not a property of GLM.
615    fn defer_predicate(&self, config: &ModelConfig) -> Option<spark_runtime::weights::DeferHook> {
616        let num_layers = config.num_hidden_layers;
617        Some(std::sync::Arc::new(
618            move |name: &str, dtype: WeightDtype| is_full_width_mtp_expert(name, dtype, num_layers),
619        ))
620    }
621
622    /// All three halves shard: DSA by head, KDA by head/channel, the MLP by width (TP) and by
623    /// expert set (EP).
624    fn supports_tp(&self) -> bool {
625        true
626    }
627
628    fn load_layers(
629        &self,
630        store: &WeightStore,
631        config: &ModelConfig,
632        gpu: &dyn GpuBackend,
633        _layer_kv_dtypes: &[KvCacheDtype],
634    ) -> Result<Vec<Box<dyn TransformerLayer>>> {
635        let skeleton = Glm5NextTextSkeleton::from_config(config)?;
636        // ðŸŠĪ `l2_eps` and `chunk` are NOT config keys — `l2_eps` is FLA's `1/sqrt(sum + eps)`
637        // convention and `chunk` is a prefill tiling width whose results are identical over
638        // 2..32. Everything else comes off the checkpoint, including `gate_lower_bound`, which
639        // the parser now refuses to default.
640        let kda_cfg = Glm5NextKdaConfig {
641            hidden: config.hidden_size,
642            heads: config.linear_num_value_heads,
643            head_dim: config.linear_value_head_dim,
644            conv_kernel: config.linear_conv_kernel_dim,
645            gate_lower_bound: config.linear_gate_lower_bound,
646            rms_norm_eps: config.rms_norm_eps as f32,
647            l2_eps: 1e-6,
648            chunk: 32,
649        };
650        kda_cfg.validate()?;
651        // ðŸŠĪ `gate_rank` is not a config key — it is `f_a_proj`'s row count, read off the
652        // checkpoint (128 on GLM-5.3). Reading it from layer 0 rather than assuming it means a
653        // checkpoint revision that changes the gate bottleneck fails loudly at load.
654        let gate_rank = {
655            let n = qualify(0, "self_attn.f_a_proj.weight");
656            let t = store.get(&n).with_context(|| {
657                format!("glm5_next: {n} is needed to size the KDA gate bottleneck")
658            })?;
659            *t.shape.first().context("f_a_proj has no rows")?
660        };
661        let kda_plan = KdaTpPlan::from_config(config, gate_rank)?;
662        let dsa_cfg = Glm5NextDsaConfig::from_config(config)?;
663        let mlp_cfg = Glm5NextMlpConfig::from_config(config)?;
664
665        let kda_kernels = Glm5NextKdaKernels::resolve(gpu)?;
666        let dsa_kernels = Glm5NextDsaKernels::resolve(gpu)?;
667        let dsa_layer_kernels = Glm5NextDsaLayerKernels::resolve(gpu)?;
668        let mlp_kernels = Glm5NextMlpKernels::resolve(gpu)?;
669        let mhc_kernels_probe = Glm5NextMhcKernels::resolve(gpu)?;
670        let rms_norm_k = gpu.kernel("rms_norm_vanilla", "rms_norm_vanilla")?;
671        // Only the MTP layer's plain residual path uses this; a text layer's residual lives in
672        // the mHC highway. `try_kernel` so a target without it still serves the text stack.
673        let add_k = crate::layers::try_kernel(gpu, "bf16_add", "bf16_add_inplace");
674
675        // ðŸŠĪ All 34 KDA blocks have identical geometry, so ONE workspace serves them all.
676        //
677        // Sized for the widest speculative verify rather than one token. Prefill still runs
678        // token-by-token through `Glm5NextLayer::prefill` (the mHC highway forces per-token
679        // anyway), but `Glm5NextLayer::forward_k` sweeps the KDA weights ONCE for all K rows of
680        // a verify and needs `[K, ...]` scratch to do it. The cap is the batched GEMV kernel's
681        // own `MAX_M`: past it `ops::dense_mm_bf16` falls back to the tile GEMM, which is not
682        // bit-identical to the serial decode a verify must reproduce.
683        //
684        // Cost is a few MB for the whole model: the FP32 buffers are already sized to
685        // `t_pad = ceil(t / chunk) * chunk = 32` at t = 1, so only the BF16 `[t, *]` scratch
686        // grows.
687        // ðŸ”ī The workspaces must also hold a batched PREFILL sub-chunk, which is wider than any
688        // verify (ANOMALIES A65 — `Glm5NextLayer::prefill` hands `forward_k` `PREFILL_ROWS`
689        // rows). Sizing to the verify width alone made `forward_k` bail the moment prefill
690        // used it. Cost is per-layer scratch that scales with rows, not with context.
691        // ðŸŠĪ `prefill_rows()` too, not just the constant: `AVAROK_GLM_PREFILL_ROWS` can widen the
692        // sub-chunk at launch, and a workspace built for the default would make `forward_k` bail
693        // the first time the A/B lever was actually used.
694        let verify_k = (crate::layers::ops::DENSE_GEMV_BATCHM_MAX_M as usize)
695            .max(crate::layers::glm5next_layer::PREFILL_ROWS)
696            .max(crate::layers::glm5next_layer::prefill_rows());
697        let kda_ws = std::sync::Arc::new(crate::layers::glm5next_kda::Glm5NextKdaWorkspace::new(
698            gpu, &kda_cfg, verify_k,
699        )?);
700
701        // ðŸ”ī ONE MLP scratch for the whole stack, allocated HERE — at load, before the layer
702        // loop and long before the KV pool is sized (A59: a pool allocated after KV sizing is a
703        // pool the KV sizing did not know about). The KDA workspace above has been shared since
704        // it was written; the MLP one was private per layer, and at a wide prefill sub-chunk
705        // that is what dominated the LOAD-time host-memory dip in ANOMALIES A124/A127 —
706        // `mlp_ws_per_layer_bytes * 45`, ≈2.1 GB at 256 rows and ≈9.5 GB at 1024.
707        // Correctness: the scratch never carries state between calls (see the field doc on
708        // `Glm5NextLayer::mlp_ws`), and the whole stack runs on one stream.
709        let mlp_ws_bytes =
710            crate::layers::glm5next_mlp::forward::mlp_ws_total_bytes(&mlp_cfg, verify_k);
711        let shared_mlp_ws = if crate::layers::glm5next_mlp::forward::mlp_ws_shared() {
712            tracing::info!(
713                "GLM MLP workspace: SHARED, 1 x {:.1} MB for {} layers at {verify_k} rows \
714                 (per-layer would be {:.1} MB)",
715                mlp_ws_bytes as f64 / 1e6,
716                skeleton.layers.len(),
717                (mlp_ws_bytes * skeleton.layers.len()) as f64 / 1e6,
718            );
719            Some(std::sync::Arc::new(
720                crate::layers::glm5next_mlp::forward::Glm5NextMlpWorkspace::new(
721                    gpu, &mlp_cfg, verify_k,
722                )?,
723            ))
724        } else {
725            tracing::warn!(
726                "GLM MLP workspace: PER-LAYER, {} x {:.1} MB at {verify_k} rows",
727                skeleton.layers.len(),
728                mlp_ws_bytes as f64 / 1e6,
729            );
730            None
731        };
732
733        let dsa_plan = crate::layers::glm5next_dsa::tp::DsaTpPlan::new(
734            config.tp_rank,
735            config.tp_world_size.max(1),
736            &dsa_cfg,
737        )?;
738        let last = skeleton.layers.len() - 1;
739        let mut out: Vec<Box<dyn TransformerLayer>> = Vec::with_capacity(skeleton.layers.len());
740
741        // ðŸŠĪ The KV pool is sized to `num_attention_layers()` (11 on GLM-5.3 — the
742        // sparse blocks), so a DSA layer must address it by its ordinal among
743        // KV-consuming layers, NOT by its index in the 45-layer model stack. The
744        // 34 KDA blocks carry recurrent state and take no pool slot.
745        let mut attn_layer_idx = 0usize;
746
747        for sl in &skeleton.layers {
748            let idx = sl.index;
749            let t_layer = std::time::Instant::now();
750            let src = LayerSource::collect(gpu, store, idx)
751                .with_context(|| format!("glm5_next: collecting layer {idx}"))?;
752            let t_collect = t_layer.elapsed();
753
754            let mixer = match sl.mixer {
755                Mixer::Kda => {
756                    // The adapter yields THIS RANK's slice with local shapes; the binder
757                    // validates against the (already local) config exactly as at TP=1.
758                    let sharded = KdaShardedSource::new(&src, &kda_plan)?;
759                    let (w, _report) = bind_kda_weights(gpu, &kda_cfg, idx, &sharded)?;
760                    Glm5NextMixer::Kda {
761                        layer: Box::new(Glm5NextKdaLayer::new(idx, kda_cfg, w, kda_kernels)?),
762                        ws: kda_ws.clone(),
763                        cfg: kda_cfg,
764                    }
765                }
766                Mixer::Dsa => {
767                    let load = |n: &str| src.f32(n);
768                    let w = build_dsa_weights(gpu, &dsa_cfg, &dsa_plan, &load)?;
769                    Glm5NextMixer::Dsa(Box::new(Glm5NextDsaLayer {
770                        // ON by default since A55 was closed (the `weights_proj` overrun
771                        // fix). Kill switch `AVAROK_GLM_DSA_ALLOC_PER_STEP=1` restores the
772                        // per-step `gpu.alloc` + `gpu.free`.
773                        persist_bt: std::env::var("AVAROK_GLM_DSA_ALLOC_PER_STEP").as_deref()
774                            != Ok("1"),
775                        cfg: dsa_cfg,
776                        weights: w,
777                        kernels: dsa_layer_kernels,
778                        select_kernels: dsa_kernels,
779                        decode_kernel:
780                            crate::layers::glm5next_dsa::attend::Glm5NextDsaDecodeKernel::resolve(
781                                gpu,
782                            )?,
783                        workspace: crate::layers::glm5next_dsa::layer::Glm5NextDsaWorkspace::new(
784                            gpu, &dsa_cfg, verify_k,
785                        )?,
786                        layer_idx: idx,
787                        attn_layer_idx: {
788                            let a = attn_layer_idx;
789                            attn_layer_idx += 1;
790                            a
791                        },
792                        rms_eps: config.rms_norm_eps as f32,
793                        kv_scale: 1.0,
794                    }))
795                }
796            };
797
798            let t_mixer = t_layer.elapsed();
799
800            let load = |n: &str| src.f32(n);
801            let mlp = match sl.mlp {
802                Mlp::Dense => Glm5NextMlpSite::Dense(mlp_build::build_dense_mlp(
803                    gpu,
804                    &mlp_cfg,
805                    config.tp_rank,
806                    config.intermediate_size,
807                    "mlp",
808                    &load,
809                )?),
810                Mlp::RoutedMoe => {
811                    let expert = |id: usize| bind_expert(gpu, store, idx, id);
812                    Glm5NextMlpSite::Moe(Box::new(mlp_build::build_moe(
813                        gpu,
814                        &mlp_cfg,
815                        config.tp_rank,
816                        config.shared_expert_intermediate_size,
817                        &load,
818                        &expert,
819                    )?))
820                }
821            };
822
823            let t_mlp = t_layer.elapsed();
824            // Splits the unattributed post-load window by ARM. A dense layer has
825            // neither a DSA mixer nor a MoE arm; a KDA+MoE layer has one; a
826            // DSA+MoE layer has both — three shapes, so the byte-proportional
827            // host round trip and the non-proportional per-arm work (absorb_q,
828            // the expert sync storm) stop being collinear and can be separated.
829            tracing::info!(
830                "glm5_next layer {idx} built: collect {:.2}s mixer {:.2}s mlp {:.2}s \
831                 (mixer={:?} mlp={:?})",
832                t_collect.as_secs_f64(),
833                (t_mixer - t_collect).as_secs_f64(),
834                (t_mlp - t_mixer).as_secs_f64(),
835                sl.mixer,
836                sl.mlp,
837            );
838
839            let mhc = if sl.hyper_connection {
840                Some(Glm5NextMhc {
841                    kernels: mhc_kernels_probe,
842                    attn: bind_mhc_site(gpu, &src, "attn", config.hc_mult, config.hidden_size)?,
843                    ffn: bind_mhc_site(gpu, &src, "ffn", config.hc_mult, config.hidden_size)?,
844                    hc_mult: config.hc_mult,
845                    sinkhorn_iters: config.hc_sinkhorn_iters,
846                    hc_eps: config.hc_eps,
847                })
848            } else {
849                None
850            };
851
852            out.push(Box::new(Glm5NextLayer {
853                layer_idx: idx,
854                mixer,
855                mlp,
856                mlp_cfg,
857                mlp_kernels,
858                mlp_ws: match &shared_mlp_ws {
859                    Some(ws) => ws.clone(),
860                    None => std::sync::Arc::new(
861                        crate::layers::glm5next_mlp::forward::Glm5NextMlpWorkspace::new(
862                            gpu, &mlp_cfg, verify_k,
863                        )?,
864                    ),
865                },
866                mhc,
867                input_norm: upload_f32_as_bf16(gpu, &src.f32("input_layernorm.weight")?)?,
868                post_attn_norm: upload_f32_as_bf16(
869                    gpu,
870                    &src.f32("post_attention_layernorm.weight")?,
871                )?,
872                rms_norm_k,
873                add_k,
874                rms_eps: config.rms_norm_eps as f32,
875                hidden: config.hidden_size,
876                mixer_all_reduce: match sl.mixer {
877                    Mixer::Kda => kda_plan.needs_output_all_reduce(),
878                    Mixer::Dsa => dsa_plan.needs_output_all_reduce(),
879                },
880                is_first: idx == 0,
881                is_last: idx == last,
882            }));
883        }
884        Ok(out)
885    }
886
887    fn load_embedding(
888        &self,
889        store: &WeightStore,
890        _config: &ModelConfig,
891        _gpu: &dyn GpuBackend,
892    ) -> Result<DenseWeight> {
893        dense(store, "model.language_model.embed_tokens.weight")
894    }
895
896    fn load_final_norm(
897        &self,
898        store: &WeightStore,
899        _config: &ModelConfig,
900        _gpu: &dyn GpuBackend,
901    ) -> Result<DenseWeight> {
902        dense(store, "model.language_model.norm.weight")
903    }
904
905    fn load_lm_head(
906        &self,
907        store: &WeightStore,
908        _config: &ModelConfig,
909        _gpu: &dyn GpuBackend,
910    ) -> Result<DenseWeight> {
911        dense(store, "lm_head.weight")
912    }
913
914    /// MTP is deliberately out of scope for this slice. `None` = "no speculative head", which
915    /// the scheduler already handles; it is not a silent skip of something wired.
916    fn load_mtp_weights(
917        &self,
918        _store: &WeightStore,
919        _config: &ModelConfig,
920        _gpu: &dyn GpuBackend,
921    ) -> Result<Option<crate::weight_loader::MtpWeights>> {
922        Ok(None)
923    }
924
925    /// Drop the store's copy of everything `load_layers` re-uploaded.
926    fn prune_after_load(
927        &self,
928        store: &mut WeightStore,
929        config: &ModelConfig,
930        gpu: &dyn GpuBackend,
931    ) -> Result<()> {
932        let n = config.num_hidden_layers;
933        let (count, bytes) = store.free_matching(gpu, |name| is_reuploaded(name, n))?;
934        tracing::info!(
935            "glm5_next: released {count} store tensors ({:.2} GB) already re-uploaded by the \
936             binders; routed experts and the MTP block kept",
937            bytes as f64 / 1e9,
938        );
939        // Full-width BF16 routed experts (NVIDIA's official export leaves the
940        // MTP block's that way) were re-encoded by `bind_expert`, so they are
941        // dead the same way every other re-uploaded tensor is.
942        //
943        // Empty on both disk loaders: `defer_predicate` keeps that family off
944        // the device entirely, which is the whole point — freeing 7 GB here is
945        // 45 layers too late. This sweep remains for a store filled by
946        // something with no defer hook (the RDMA weight peer).
947        let quantized: std::collections::BTreeSet<String> = store
948            .names()
949            .filter(|n| {
950                store
951                    .get(n)
952                    .is_ok_and(|t| is_quantized_expert_weight(n, t.dtype))
953            })
954            .map(str::to_string)
955            .collect();
956        if !quantized.is_empty() {
957            let (qcount, qbytes) = store.free_matching(gpu, |name| quantized.contains(name))?;
958            tracing::info!(
959                "glm5_next: released {qcount} full-width BF16 routed-expert tensors \
960                 ({:.2} GB) quantised to NVFP4 at bind time",
961                qbytes as f64 / 1e9,
962            );
963        }
964        Ok(())
965    }
966}
967
968pub(super) fn upload_f32_as_bf16(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
969    let b: Vec<u8> = v
970        .iter()
971        .flat_map(|x| half::bf16::from_f32(*x).to_le_bytes())
972        .collect();
973    let p = gpu.alloc(b.len().max(1))?;
974    gpu.copy_h2d(&b, p)?;
975    Ok(p)
976}
977
978#[cfg(test)]
979mod prune_tests {
980    use super::is_reuploaded;
981
982    #[test]
983    fn prunes_only_the_reuploaded_layer_tensors() {
984        let n = 45;
985        // Re-uploaded by the binders -> free.
986        assert!(is_reuploaded(
987            "model.language_model.layers.0.self_attn.q_proj.weight",
988            n
989        ));
990        assert!(is_reuploaded(
991            "model.language_model.layers.44.mlp.gate.weight",
992            n
993        ));
994        assert!(is_reuploaded(
995            "model.language_model.layers.3.hc_attn_fn.weight",
996            n
997        ));
998        // Bound zero-copy from the store -> use-after-free if freed.
999        assert!(!is_reuploaded(
1000            "model.language_model.layers.7.mlp.experts.12.down_proj.weight",
1001            n
1002        ));
1003        // MTP block, one past the skeleton -> read by load_mtp_weights_multi.
1004        assert!(!is_reuploaded(
1005            "model.language_model.layers.45.self_attn.q_proj.weight",
1006            n
1007        ));
1008        // Not a layer tensor at all.
1009        assert!(!is_reuploaded(
1010            "model.language_model.embed_tokens.weight",
1011            n
1012        ));
1013        assert!(!is_reuploaded("lm_head.weight", n));
1014        // Malformed / non-numeric index is never a match.
1015        assert!(!is_reuploaded("model.language_model.layers.x.foo", n));
1016    }
1017}
1018
1019/// [`LayerSource::collect`], named for the MTP loader's call site.
1020pub(super) fn layer_source(
1021    gpu: &dyn GpuBackend,
1022    store: &WeightStore,
1023    layer: usize,
1024) -> Result<LayerSource> {
1025    LayerSource::collect(gpu, store, layer)
1026}
1027
1028/// [`bind_expert`], named for the MTP loader's call site.
1029pub(super) fn bind_expert_at(
1030    gpu: &dyn GpuBackend,
1031    store: &WeightStore,
1032    layer: usize,
1033    id: usize,
1034) -> Result<Glm5NextExpertWeights> {
1035    bind_expert(gpu, store, layer, id)
1036}
1037
1038/// [`upload_f32_as_bf16`], named for the MTP loader's call site.
1039pub(super) fn upload_bf16(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
1040    upload_f32_as_bf16(gpu, v)
1041}
1042
1043#[cfg(test)]
1044mod vision_capability_tests {
1045    use super::Glm5NextWeightLoader;
1046    use crate::weight_loader::ModelWeightLoader;
1047
1048    /// The default is the pre-port behaviour: the tower is withheld, so a
1049    /// certified text serve keeps the footprint it was certified with.
1050    ///
1051    /// This asserts against the UNSET environment, which is what CI and every
1052    /// text serve run with. `glm_vision_enabled_from` carries the both-states
1053    /// coverage, because setting the variable here would race the rest of the
1054    /// binary.
1055    #[test]
1056    fn the_vision_tower_is_off_unless_the_operator_asks() {
1057        assert_eq!(
1058            Glm5NextWeightLoader.binds_vision_encoder(),
1059            avarok_core::config::glm_vision_enabled(),
1060            "the loader's withhold decision must be the SAME gate the config \
1061             parser reads, or the two disagree and the loader asks for tensors \
1062             that were never uploaded"
1063        );
1064        assert!(
1065            !avarok_core::config::glm_vision_enabled_from(None),
1066            "unset must mean off: binding the tower costs 1.05 GiB/rank"
1067        );
1068        assert!(avarok_core::config::glm_vision_enabled_from(Some("1")));
1069    }
1070
1071    #[test]
1072    fn a_multimodal_loader_still_declares_true_by_default() {
1073        // The trait default must stay "load everything" — a loader that never
1074        // overrides this must never lose weights.
1075        assert!(crate::weight_loader::qwen35::Qwen35WeightLoader.binds_vision_encoder());
1076    }
1077}