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}