spark_runtime/
weights.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Weight loading from safetensors files (SBIO IORouter for filesystem I/O).
4
5use crate::gpu::{DevicePtr, GpuBackend};
6use anyhow::{Result, bail};
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Advise the OS to evict a file's pages from the page cache.
11///
12/// On GB10 (unified memory), mmap'd safetensors share the GPU memory pool.
13/// After copying tensors to GPU, the mmap pages linger in the page cache,
14/// consuming memory that should be available for KV cache and inference buffers.
15/// This function tells the kernel those pages are no longer needed.
16#[cfg(target_os = "linux")]
17pub(crate) fn evict_page_cache(file: &std::fs::File) {
18    use std::os::unix::io::AsRawFd;
19    // POSIX_FADV_DONTNEED = 4 on Linux (POSIX standard).
20    // macOS lacks posix_fadvise — see the non-linux branch below.
21    const POSIX_FADV_DONTNEED: libc::c_int = 4;
22    unsafe {
23        libc::posix_fadvise(file.as_raw_fd(), 0, 0, POSIX_FADV_DONTNEED);
24    }
25}
26
27#[cfg(not(target_os = "linux"))]
28pub(crate) fn evict_page_cache(_file: &std::fs::File) {
29    // No-op: macOS/BSD have no posix_fadvise. Apple Silicon UMA already
30    // shares page cache with the GPU pool, so eviction is unnecessary.
31}
32
33/// Data type of a weight tensor.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WeightDtype {
36    BF16,
37    FP32,
38    FP8E4M3,
39    FP8E8M0,
40    UInt8,
41    Int64,
42    /// Keep-packed PrismML ternary Q2_0 (ggml id 42): raw on-disk blocks stay
43    /// 2-bit in VRAM (fp16 scale + 2-bit codes per group of `group` elements),
44    /// dequantized in-kernel by the native `q2_0_gemv` decode path. Only
45    /// produced by the GGUF loader under `ATLAS_GGUF_NATIVE_Q2=1`. Its byte
46    /// footprint is NOT a per-element size (2-bit codes + an inline scale per
47    /// group), so [`WeightDtype::byte_size`] returns 0 for this variant and the
48    /// real size is computed in [`WeightTensor::byte_size`] (shape + group).
49    PackedQ2_0 {
50        group: u16,
51    },
52}
53
54impl WeightDtype {
55    /// Bytes per element for the fixed-width dtypes. Returns 0 for the
56    /// block-based [`WeightDtype::PackedQ2_0`] — [`WeightTensor::byte_size`]
57    /// handles that variant directly, and no caller multiplies its numel by this.
58    pub fn byte_size(self) -> usize {
59        match self {
60            Self::BF16 => 2,
61            Self::FP32 => 4,
62            Self::FP8E4M3 => 1,
63            Self::FP8E8M0 => 1,
64            Self::UInt8 => 1,
65            Self::Int64 => 8,
66            Self::PackedQ2_0 { .. } => 0,
67        }
68    }
69
70    fn from_safetensors(dtype: safetensors::Dtype) -> Result<Self> {
71        match dtype {
72            safetensors::Dtype::BF16 => Ok(Self::BF16),
73            safetensors::Dtype::F32 => Ok(Self::FP32),
74            safetensors::Dtype::U8 => Ok(Self::UInt8),
75            // I8: raw 1-byte container for 4-bit-packed NVFP4 (DeepSeek-V4 MTP
76            // experts). Treat as UInt8 — signedness is irrelevant for packed FP4.
77            safetensors::Dtype::I8 => Ok(Self::UInt8),
78            safetensors::Dtype::F8_E4M3 => Ok(Self::FP8E4M3),
79            safetensors::Dtype::F8_E8M0 => Ok(Self::FP8E8M0),
80            safetensors::Dtype::I64 => Ok(Self::Int64),
81            other => bail!("Unsupported safetensors dtype: {other:?}"),
82        }
83    }
84
85    /// Map a raw safetensors header dtype STRING (as it appears in the JSON
86    /// header, e.g. `"BF16"`, `"F8_E4M3"`) to a [`WeightDtype`], factored out
87    /// so the RDMA weight loader (which receives dtype as a wire string in the
88    /// peer manifest, not a `safetensors::Dtype`) resolves it identically to
89    /// the disk loaders — byte-identity depends on the two ends agreeing.
90    pub fn from_safetensors_str(s: &str) -> Result<Self> {
91        Ok(match s {
92            "F32" => Self::FP32,
93            "BF16" => Self::BF16,
94            "U8" => Self::UInt8,
95            // I8 is a 1-byte raw container (packed NVFP4); signedness is
96            // irrelevant, treat as raw bytes exactly like the disk path.
97            "I8" => Self::UInt8,
98            "F8_E4M3" => Self::FP8E4M3,
99            "F8_E8M0" => Self::FP8E8M0,
100            "I64" => Self::Int64,
101            other => bail!("Unsupported safetensors dtype '{other}'"),
102        })
103    }
104}
105
106/// Convert a little-endian IEEE-754 half-precision (F16) tensor byte buffer
107/// to BF16 bytes. F16 and BF16 are both 2 bytes/element but have different
108/// bit layouts (5-bit vs 8-bit exponent), so the bytes cannot be
109/// reinterpreted — each value goes f16 → f32 (exact) → bf16
110/// (round-to-nearest-even). Shared by both disk loaders so F16 checkpoints
111/// (e.g. centml modelopt W4A4 exports, which ship all unquantized tensors as
112/// F16) land in the store as BF16; [`WeightDtype`] itself stays closed to
113/// store-legal dtypes and F16 can never appear on the RDMA wire.
114pub(crate) fn f16_to_bf16_bytes(src: &[u8]) -> Vec<u8> {
115    use half::{bf16, f16};
116    debug_assert_eq!(src.len() % 2, 0, "F16 tensor byte length must be even");
117    let mut out = Vec::with_capacity(src.len());
118    for pair in src.chunks_exact(2) {
119        let h = f16::from_le_bytes([pair[0], pair[1]]);
120        out.extend_from_slice(&bf16::from_f32(h.to_f32()).to_le_bytes());
121    }
122    out
123}
124
125/// A weight tensor on the GPU.
126pub struct WeightTensor {
127    pub ptr: DevicePtr,
128    pub shape: Vec<usize>,
129    pub dtype: WeightDtype,
130}
131
132impl WeightTensor {
133    pub fn num_elements(&self) -> usize {
134        self.shape.iter().product()
135    }
136
137    pub fn byte_size(&self) -> usize {
138        match self.dtype {
139            // Packed Q2_0: `n_blocks = numel / group` blocks of
140            // `2 + group/4` bytes (34 @ g128, 18 @ g64) — the on-disk footprint.
141            WeightDtype::PackedQ2_0 { group } => {
142                let g = group as usize;
143                debug_assert!(g == 128 || g == 64, "unexpected Q2_0 group {g}");
144                let n_blocks = self.num_elements() / g.max(1);
145                n_blocks * (2 + g / 4)
146            }
147            d => self.num_elements() * d.byte_size(),
148        }
149    }
150
151    /// The Q2_0 group size if this tensor is keep-packed ternary, else `None`.
152    pub fn q2_group(&self) -> Option<u16> {
153        match self.dtype {
154            WeightDtype::PackedQ2_0 { group } => Some(group),
155            _ => None,
156        }
157    }
158
159    /// True if this tensor holds keep-packed ternary Q2_0 blocks (id 42).
160    pub fn is_packed_q2(&self) -> bool {
161        matches!(self.dtype, WeightDtype::PackedQ2_0 { .. })
162    }
163}
164
165/// All model weights loaded onto the GPU, keyed by HuggingFace name.
166pub struct WeightStore {
167    weights: HashMap<String, WeightTensor>,
168    /// Buffers a loader derived from these tensors — fused concats, transposed
169    /// twins, requants. Owned here so teardown RELEASES them instead of the
170    /// backend sweep reclaiming them unowned (#736, #915); see `derived.rs`.
171    derived: DerivedStore,
172    /// Tensors deliberately NOT uploaded, with where they live on disk.
173    ///
174    /// The n-gram embedding tables of the LongCat / Qwen3.8-Flash-Next family
175    /// are 63 GB (LongCat-Lite) to ~102 GB (Flash-Next) of BF16. Uploading
176    /// them through the generic path would exhaust a 121 GB unified box
177    /// before any quantization could run — and on GB10 the fallback is
178    /// `alloc_managed`, i.e. Linux swap, i.e. the documented kernel freeze.
179    /// They are skipped at load and served either by streaming per-table
180    /// quantize-on-load or straight off NVMe by `NgramRowCache`, both of
181    /// which need only this (path, offset) locator.
182    deferred: HashMap<String, DeferredTensor>,
183}
184
185/// Where a skipped tensor lives, so a consumer can read it in place.
186#[derive(Clone, Debug)]
187pub struct DeferredTensor {
188    /// Shard file containing the tensor.
189    pub path: std::path::PathBuf,
190    /// ABSOLUTE byte offset of the tensor's first element in that file
191    /// (safetensors header length + the tensor's `data_offsets[0]`).
192    pub offset: u64,
193    pub shape: Vec<usize>,
194    pub dtype: WeightDtype,
195}
196
197impl WeightStore {
198    /// Create an empty weight store (for testing).
199    pub fn empty() -> Self {
200        Self {
201            weights: HashMap::new(),
202            deferred: HashMap::new(),
203            derived: DerivedStore::default(),
204        }
205    }
206
207    /// Record a tensor that was skipped at load, with its on-disk location.
208    pub fn defer(&mut self, name: String, t: DeferredTensor) {
209        self.deferred.insert(name, t);
210    }
211
212    /// Look up a deferred (not-uploaded) tensor's on-disk location.
213    pub fn deferred(&self, name: &str) -> Option<&DeferredTensor> {
214        self.deferred.get(name)
215    }
216
217    /// Every deferred tensor, name-sorted (NUMERIC on a trailing index, so
218    /// `embedders.10` sorts after `embedders.2` — a lexicographic sort here
219    /// silently mis-maps the n-gram tables, which cost a real debugging
220    /// session the first time).
221    pub fn deferred_sorted(&self) -> Vec<(&String, &DeferredTensor)> {
222        let mut v: Vec<_> = self.deferred.iter().collect();
223        v.sort_by_key(|(n, _)| split_trailing_index(n));
224        v
225    }
226
227    /// Wrap a pre-built map. Used by alternate loaders (e.g.
228    /// `fast_weights::FastSafetensorsLoader`, and the RDMA weight loader in
229    /// `spark-storage`, which lives in a different crate and so needs this pub).
230    pub fn from_map(weights: HashMap<String, WeightTensor>) -> Self {
231        Self {
232            weights,
233            deferred: HashMap::new(),
234            derived: DerivedStore::default(),
235        }
236    }
237
238    /// Get a weight tensor by name. Fails fast if not found.
239    pub fn get(&self, name: &str) -> Result<&WeightTensor> {
240        self.weights
241            .get(name)
242            .ok_or_else(|| anyhow::anyhow!("Weight '{name}' not found in store"))
243    }
244
245    /// Check if a weight exists.
246    pub fn contains(&self, name: &str) -> bool {
247        self.weights.contains_key(name)
248    }
249
250    /// Number of loaded weights.
251    pub fn len(&self) -> usize {
252        self.weights.len()
253    }
254
255    /// True if no weights are loaded.
256    pub fn is_empty(&self) -> bool {
257        self.weights.is_empty()
258    }
259
260    /// Device bytes the store still holds. Not the on-disk load estimate:
261    /// this shrinks as `free_matching` drops tensors the binders replaced.
262    pub fn resident_bytes(&self) -> usize {
263        self.weights.values().map(|t| t.byte_size()).sum()
264    }
265
266    /// Iterator over all weight names.
267    pub fn names(&self) -> impl Iterator<Item = &str> {
268        self.weights.keys().map(|s| s.as_str())
269    }
270
271    /// Free and forget every tensor whose name matches `pred`. Returns
272    /// `(tensors freed, bytes freed)`.
273    ///
274    /// For loaders that do NOT bind zero-copy from the store's device pointers:
275    /// they upload their own copy, so the original is dead weight the moment the
276    /// binder returns, and on a unified-memory GB10 that duplicate is the
277    /// difference between fitting a KV cache and not.
278    ///
279    /// 🪤 The caller owns the "is it dead?" question. A tensor bound zero-copy
280    /// (every routed expert, and the fused per-expert views in
281    /// `weight_loader/step3p7.rs`) is still live in a layer struct — freeing it
282    /// here is a use-after-free with no diagnostic. Match narrowly.
283    ///
284    /// Per-entry free is sound for the same reason `release` gives below: the
285    /// loaders allocate one `gpu.alloc` per tensor, and no loader inserts an
286    /// `.offset()` view of a shared block into this map.
287    pub fn free_matching(
288        &mut self,
289        gpu: &dyn GpuBackend,
290        pred: impl Fn(&str) -> bool,
291    ) -> Result<(usize, usize)> {
292        let doomed: Vec<String> = self.weights.keys().filter(|n| pred(n)).cloned().collect();
293        let (mut count, mut bytes) = (0usize, 0usize);
294        for name in doomed {
295            // `remove` before `free`: the map must never hold a pointer to
296            // memory that is gone, even if the free below fails.
297            let Some(t) = self.weights.remove(&name) else {
298                continue;
299            };
300            bytes += t.byte_size();
301            gpu.free(t.ptr)
302                .map_err(|e| e.context(format!("freeing weight {name}")))?;
303            count += 1;
304        }
305        Ok((count, bytes))
306    }
307
308    /// The owner for buffers a loader derives from these tensors.
309    ///
310    /// `&self` because `ModelWeightLoader::load_layers` takes `&WeightStore`;
311    /// the interior `Mutex` is the whole reason `DerivedStore` exists as a
312    /// type rather than a `Vec` field. See `weights/derived.rs`.
313    pub fn derived(&self) -> &DerivedStore {
314        &self.derived
315    }
316
317    /// Total bytes across all weight tensors on the GPU.
318    pub fn total_bytes(&self) -> usize {
319        self.weights.values().map(|w| w.byte_size()).sum()
320    }
321
322    /// Check if any tensor has FP8 dtype.
323    pub fn has_fp8_weights(&self) -> bool {
324        self.weights
325            .values()
326            .any(|w| matches!(w.dtype, WeightDtype::FP8E4M3))
327    }
328
329    /// Number of per-layer FP8 KV-cache scale tensors (`*.k_scale`) the
330    /// checkpoint ships. `>0` means the model carries calibrated KV scales, so
331    /// FP8 KV needs no online calibration; `0` means the scales default to 1.0
332    /// (which clips BF16 into E4M3 range), so online calibration or a non-FP8 KV
333    /// dtype is required. Used to log the right guidance at serve time.
334    pub fn fp8_kv_scale_count(&self) -> usize {
335        self.names().filter(|n| n.ends_with(".k_scale")).count()
336    }
337}
338
339/// SBIO IORouter trait for weight loading.
340pub trait WeightLoader {
341    fn load(
342        &self,
343        model_dir: &Path,
344        gpu: &dyn GpuBackend,
345        oom_reserve_bytes: usize,
346    ) -> Result<WeightStore>;
347}
348
349/// Loads weights from safetensors files using mmap.
350pub struct SafetensorsLoader {
351    /// EP rank (0-based). Only used when ep_world_size > 1.
352    pub ep_rank: usize,
353    /// EP world size. When > 1, remote expert tensors are skipped.
354    pub ep_world_size: usize,
355    /// Total number of MoE experts in the model (for EP partitioning).
356    pub num_experts: usize,
357    /// Override for the peak memory multiplier in the pre-flight OOM check.
358    /// Set from QuantFormat::peak_memory_multiplier() in the caller.
359    /// When None, the pre-flight uses its own heuristic (1.3x NVFP4 / 1.5x FP8).
360    pub peak_memory_multiplier: Option<f64>,
361    /// Skip the W4A4 `*.input_scale` activation scales at load.
362    ///
363    /// ModelOpt NVFP4 checkpoints ship one 0-dim F32 scalar per quantized
364    /// projection. On a 512-expert model that is ~74k four-byte allocations,
365    /// each taking a full allocation granule — GBs of padding for values
366    /// Atlas never reads, because it serves w4a16 (BF16 activations) and the
367    /// NVFP4 loader already treats the key as optional.
368    ///
369    /// OPT-IN: `step3p7` reads this key on its own path, so it must stay off
370    /// unless the model's loader is known not to need it.
371    pub skip_activation_scales: bool,
372    /// Skip `mtp.*` tensors at load.
373    ///
374    /// For models whose loader deliberately does not build an MTP head,
375    /// uploading its weights is pure waste — on Qwen3.8-Flash-Next that is a
376    /// 1.49 GB expert shard plus the MTP backbone, held resident while the KV
377    /// cache goes without.
378    ///
379    /// OPT-IN: a model that DOES build an MTP head must keep them, so this is
380    /// set only where `load_mtp_weights` is known to return `None`.
381    pub skip_mtp: bool,
382}
383
384impl Default for SafetensorsLoader {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390impl SafetensorsLoader {
391    /// Create a loader with no expert parallelism (loads all tensors).
392    pub fn new() -> Self {
393        Self {
394            ep_rank: 0,
395            ep_world_size: 1,
396            num_experts: 0,
397            peak_memory_multiplier: None,
398            skip_activation_scales: false,
399            skip_mtp: false,
400        }
401    }
402
403    /// Create a loader with EP-aware filtering.
404    pub fn with_ep(ep_rank: usize, ep_world_size: usize, num_experts: usize) -> Self {
405        Self {
406            ep_rank,
407            ep_world_size,
408            num_experts,
409            peak_memory_multiplier: None,
410            skip_activation_scales: false,
411            skip_mtp: false,
412        }
413    }
414
415    /// Check if a tensor should be skipped under EP.
416    /// Skips `*.experts.{E}.*` tensors where E is not in local range.
417    /// MTP head experts are never skipped (small, fully replicated).
418    ///
419    /// 🪤 The MTP exemption keys on a leading `mtp.` — a DeepSeek-style name.
420    /// GLM-5.3 puts its MTP head at `model.language_model.layers.45.*` with no
421    /// `mtp.` prefix, so that layer's routed experts ARE sharded on GLM. Fine
422    /// while the MTP head is out of scope; revisit before enabling it.
423    ///
424    /// `pub` so residency can be PROVEN against a real checkpoint index
425    /// without collectives (see `spark-model/tests/glm53_ep_residency.rs`).
426    pub fn should_skip_tensor(&self, name: &str) -> bool {
427        // MTP head weights for a model whose loader does not build one.
428        if self.skip_mtp && name.starts_with("mtp.") {
429            return true;
430        }
431        // W4A4 activation scales: never read on the w4a16 path (the NVFP4
432        // loader falls back to `DevicePtr::NULL`), and 4-byte allocations are
433        // almost pure granule padding at expert scale.
434        if self.skip_activation_scales && name.ends_with(".input_scale") {
435            return true;
436        }
437        if self.ep_world_size <= 1 {
438            return false;
439        }
440        // MTP head experts are small — always replicate, never shard.
441        if name.starts_with("mtp.") {
442            return false;
443        }
444        // Parse expert index from patterns like "*.experts.42.gate_proj*"
445        if let Some(idx) = parse_expert_index(name) {
446            let per_rank = self.num_experts / self.ep_world_size;
447            let local_start = self.ep_rank * per_rank;
448            let local_end = if self.ep_rank == self.ep_world_size - 1 {
449                self.num_experts
450            } else {
451                local_start + per_rank
452            };
453            idx < local_start || idx >= local_end
454        } else {
455            false // Non-expert tensors are always loaded (replicated)
456        }
457    }
458}
459
460/// Split a tensor name into (everything but its last numeric path segment,
461/// that segment as a number) so names sort NUMERICALLY on the index.
462/// `embedders.2` must precede `embedders.10`; a plain lexicographic sort puts
463/// `10` first and silently mis-maps every table after the ninth.
464pub mod adapter;
465mod derived;
466pub use derived::DerivedStore;
467mod gguf;
468mod loader;
469pub mod mlx_int8;
470pub use gguf::{GgufLoader, config_from_gguf_dir, find_gguf};
471pub(crate) use loader::estimate_load_bytes;
472// Platform-independent: consumed by the unix-only fast-weights (O_DIRECT) path
473// AND by the GGUF loader, which builds everywhere. Gating this on `unix` broke
474// the Windows CUDA build the moment `gguf.rs` started using it.
475pub(crate) use loader::check_oom_guard;
476// Consumed by the unix-only fast-weights (O_DIRECT) loader path.
477#[cfg(unix)]
478pub(crate) use loader::estimate_has_fp8;
479
480mod name_utils;
481pub(crate) use name_utils::split_trailing_index;
482pub use name_utils::{is_ngram_table, parse_expert_index};
483
484#[cfg(test)]
485mod packed_q2_tests;
486mod prefix_detect;
487pub use prefix_detect::auto_detect_weight_prefix;
488
489/// Release every weight tensor.
490///
491/// Safe to free per-entry because the loaders allocate per-tensor: the fast
492/// path calls `gpu.alloc(meta.len)` once per tensor before inserting it
493/// (`fast_weights/mod.rs:360-388`), and no loader inserts an `.offset()` view of
494/// a shared block into this map. (Fused per-expert views DO exist — see
495/// `weight_loader/step3p7.rs:93` — but they live in the layer structs that own
496/// the fused allocation, not here, so this cannot double-free them.)
497impl atlas_core::scope::ModelResource<dyn GpuBackend> for WeightStore {
498    fn label(&self) -> &'static str {
499        "weight store"
500    }
501
502    fn release(&mut self, gpu: &dyn GpuBackend) -> anyhow::Result<()> {
503        // Derived buffers FIRST: they are re-encodings of the tensors below and
504        // nothing reads one after the other is gone, but freeing the source a
505        // derivation was built from while the derivation is still listed would
506        // make a later failure here impossible to attribute.
507        let mut first_error = self.derived.release(gpu).err();
508        // `drain` rather than iterate: the map must not be left holding
509        // pointers to memory that is gone, and it makes this idempotent.
510        for (name, tensor) in self.weights.drain() {
511            if let Err(e) = gpu.free(tensor.ptr)
512                && first_error.is_none()
513            {
514                first_error = Some(e.context(format!("freeing weight {name}")));
515            }
516        }
517        match first_error {
518            Some(e) => Err(e),
519            None => Ok(()),
520        }
521    }
522}
523
524#[cfg(test)]
525mod teardown_tests;