spark_runtime/fast_weights/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Fast safetensors loader (InstantTensor-style) — pure Rust.
4//!
5//! Two wins over the mmap-based loader in [`crate::weights`]:
6//!
7//! 1. **`O_DIRECT`** reads. Bypasses the OS page cache, so the bytes never
8//!    compete with GPU allocations on GB10 unified memory. The mmap path
9//!    already works around this with `POSIX_FADV_DONTNEED` post-load; here
10//!    we avoid the pollution in the first place.
11//! 2. **Pipelined read/copy**. One background reader thread fetches the
12//!    next tensor while the main thread does `copy_h2d` for the current
13//!    one. Overlaps disk I/O with the host→device memcpy.
14//!
15//! Behavioural parity with [`crate::weights::SafetensorsLoader`] is
16//! preserved — same EP filtering, same OOM pre-flight, same UVM fallback
17//! on GPU allocation failure, same extra-weights handling.
18
19use crate::gpu::GpuBackend;
20use crate::weights::{
21    WeightLoader, WeightStore, WeightTensor, check_oom_guard, estimate_has_fp8,
22    estimate_load_bytes, evict_page_cache, f16_to_bf16_bytes,
23};
24use anyhow::{Context, Result, bail};
25use std::collections::HashMap;
26use std::fs::File;
27use std::path::{Path, PathBuf};
28use std::sync::mpsc::sync_channel;
29
30mod direct_io;
31mod header;
32
33use header::{parse_header, resolve_shards};
34
35/// Pure-Rust InstantTensor-style loader. Same public shape as
36/// [`crate::weights::SafetensorsLoader`].
37pub struct FastSafetensorsLoader {
38    pub ep_rank: usize,
39    pub ep_world_size: usize,
40    pub num_experts: usize,
41    pub peak_memory_multiplier: Option<f64>,
42    /// Skip the W4A4 `*.input_scale` activation scales at load.
43    ///
44    /// ModelOpt NVFP4 checkpoints ship one 0-dim F32 scalar per quantized
45    /// projection. On a 512-expert model that is ~74k four-byte allocations,
46    /// each taking a full allocation granule — GBs of padding for values
47    /// Atlas never reads, because it serves w4a16 (BF16 activations) and the
48    /// NVFP4 loader already treats the key as optional.
49    ///
50    /// OPT-IN: `step3p7` reads this key on its own path, so it must stay off
51    /// unless the model's loader is known not to need it.
52    pub skip_activation_scales: bool,
53    /// Skip `mtp.*` tensors at load.
54    ///
55    /// For models whose loader deliberately does not build an MTP head,
56    /// uploading its weights is pure waste — on Qwen3.8-Flash-Next that is a
57    /// 1.49 GB expert shard plus the MTP backbone, held resident while the KV
58    /// cache goes without.
59    ///
60    /// OPT-IN: a model that DOES build an MTP head must keep them, so this is
61    /// set only where `load_mtp_weights` is known to return `None`.
62    pub skip_mtp: bool,
63    /// When true (default), attempt `O_DIRECT`; fall back to buffered reads if
64    /// the filesystem rejects it (tmpfs, overlayfs, some FUSE backends).
65    pub try_direct_io: bool,
66    /// Per-shard heuristic cap: if a shard's tensor count exceeds this,
67    /// we skip `O_DIRECT` for that shard and fall back to buffered +
68    /// pipelined reads even when [`Self::try_direct_io`] is `true`.
69    ///
70    /// Motivation: `O_DIRECT`'s 4 KiB-aligned per-tensor `pread` has a
71    /// fixed syscall + copy overhead that kernel readahead amortises for
72    /// free on the buffered path. Benchmarks on GB10 showed buffered wins
73    /// above ~5k tensors/shard; O_DIRECT wins below. Set to [`usize::MAX`]
74    /// to disable.
75    pub direct_io_tensor_cap: usize,
76    /// When true, advise the kernel to read a whole buffered shard
77    /// sequentially before the per-tensor copy loop starts. This helps NFS
78    /// mounts where many small tensor reads defeat normal readahead.
79    pub prefetch_shards: bool,
80    /// Skip a multimodal checkpoint's vision tower.
81    ///
82    /// Set by the caller from `ModelWeightLoader::binds_vision_encoder()`:
83    /// false by default, true only when the model's loader is a text-only
84    /// port that will never bind the tower. Reading it anyway costs the full
85    /// tower in unified memory (1.05 GiB/rank on GLM-5.3's checkpoint) from
86    /// load time until `build_model` frees it — which is after the inference
87    /// -buffer preflight has already refused the serve.
88    pub skip_vision: bool,
89}
90
91/// Is this tensor part of a multimodal checkpoint's vision tower?
92///
93/// Same three spellings `build_model`'s unbound-tower reclaim matches, kept
94/// here so the load-time skip and the post-bind free can never disagree.
95pub fn is_vision_tensor(name: &str) -> bool {
96    name.starts_with("model.visual.")
97        || name.starts_with("model.vision")
98        || name.starts_with("visual.")
99}
100
101/// Default tensor-count cap for per-shard `O_DIRECT`. Above this, the fast
102/// loader uses buffered reads even when `try_direct_io = true`. See the
103/// field doc on [`FastSafetensorsLoader::direct_io_tensor_cap`].
104pub const DEFAULT_DIRECT_IO_TENSOR_CAP: usize = 5000;
105
106impl Default for FastSafetensorsLoader {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112#[path = "skip.rs"]
113mod skip;
114
115impl FastSafetensorsLoader {
116    pub fn new() -> Self {
117        Self {
118            ep_rank: 0,
119            ep_world_size: 1,
120            num_experts: 0,
121            peak_memory_multiplier: None,
122            skip_activation_scales: false,
123            skip_mtp: false,
124            try_direct_io: true,
125            direct_io_tensor_cap: DEFAULT_DIRECT_IO_TENSOR_CAP,
126            prefetch_shards: false,
127            skip_vision: false,
128        }
129    }
130
131    pub fn with_ep(ep_rank: usize, ep_world_size: usize, num_experts: usize) -> Self {
132        Self {
133            ep_rank,
134            ep_world_size,
135            num_experts,
136            peak_memory_multiplier: None,
137            skip_activation_scales: false,
138            skip_mtp: false,
139            try_direct_io: true,
140            direct_io_tensor_cap: DEFAULT_DIRECT_IO_TENSOR_CAP,
141            prefetch_shards: false,
142            skip_vision: false,
143        }
144    }
145}
146
147impl WeightLoader for FastSafetensorsLoader {
148    fn load(
149        &self,
150        model_dir: &Path,
151        gpu: &dyn GpuBackend,
152        oom_reserve_bytes: usize,
153    ) -> Result<WeightStore> {
154        let skip_fn = |name: &str| self.should_skip_tensor(name);
155
156        // Resolve shard list (sharded index, single file, or unindexed shards).
157        let (shard_files, tensor_to_shard): (Vec<PathBuf>, Option<HashMap<String, String>>) =
158            resolve_shards(model_dir)?;
159
160        // Pre-flight OOM estimate (identical to SafetensorsLoader).
161        //
162        // The n-gram tables are DEFERRED further down — they are never
163        // uploaded, so counting them here refuses a model that fits. On
164        // LongCat-Flash-Lite they are 62.8 of the checkpoint's 138 GB, which
165        // is the difference between a 167 GB "peak" and a 98 GB one.
166        let preflight_skip = |name: &str| skip_fn(name) || crate::weights::is_ngram_table(name);
167        {
168            let estimated = estimate_load_bytes(&shard_files, &preflight_skip)?;
169            let has_fp8 = estimate_has_fp8(&shard_files, &preflight_skip)?;
170            let mult = self
171                .peak_memory_multiplier
172                .unwrap_or(if has_fp8 { 1.5 } else { 1.3 });
173            let peak = (estimated as f64 * mult) as usize;
174            let free = gpu.free_memory()?;
175            let gib = |b: usize| b as f64 / (1024.0 * 1024.0 * 1024.0);
176            tracing::info!(
177                "Fast-load pre-flight: {:.2} GB on-disk, {:.1}x overhead = {:.2} GB peak, \
178                 {:.2} GB free, {:.1} GB reserve (FP8: {})",
179                gib(estimated),
180                mult,
181                gib(peak),
182                gib(free),
183                gib(oom_reserve_bytes),
184                has_fp8,
185            );
186            crate::progress::preflight(gib(estimated), gib(free));
187            if peak + oom_reserve_bytes > free {
188                bail!(
189                    "OOM pre-flight: peak {:.2} GB + {:.2} GB reserve exceeds {:.2} GB free. \
190                     Use a smaller quantization or add more GPUs for EP.",
191                    gib(peak),
192                    gib(oom_reserve_bytes),
193                    gib(free),
194                );
195            }
196        }
197
198        // Load each shard. Loaded tensors filtered by EP rules upstream.
199        let mut weights: HashMap<String, WeightTensor> = HashMap::new();
200        // Locations of tensors deliberately NOT uploaded (the n-gram tables).
201        let mut deferred: HashMap<String, crate::weights::DeferredTensor> = HashMap::new();
202        let total_shards = shard_files.len();
203        let initial_free = gpu.free_memory()?;
204        let mut offload_logged = false;
205
206        for (i, shard_path) in shard_files.iter().enumerate() {
207            // When an index is present, only load the tensors it routes here;
208            // otherwise load everything in the shard. `None` means "load all".
209            let shard_name = shard_path
210                .file_name()
211                .and_then(|n| n.to_str())
212                .unwrap_or_default();
213            let tensor_filter: Option<Vec<String>> = tensor_to_shard.as_ref().map(|map| {
214                map.iter()
215                    .filter(|(_, s)| *s == shard_name)
216                    .map(|(t, _)| t.clone())
217                    .collect()
218            });
219
220            tracing::info!(
221                "Fast-loading shard {}/{}: {}{}",
222                i + 1,
223                total_shards,
224                shard_name,
225                tensor_filter
226                    .as_ref()
227                    .map(|v| format!(" ({} tensors)", v.len()))
228                    .unwrap_or_default(),
229            );
230            crate::progress::shard_start(i + 1, total_shards, shard_name);
231
232            load_shard_fast(
233                shard_path,
234                tensor_filter.as_deref(),
235                gpu,
236                &skip_fn,
237                self.try_direct_io,
238                self.direct_io_tensor_cap,
239                self.prefetch_shards,
240                &mut weights,
241                &mut deferred,
242                &mut offload_logged,
243            )?;
244
245            let free_now = gpu.free_memory().unwrap_or(0);
246            let used = initial_free.saturating_sub(free_now);
247            tracing::info!(
248                "  Shard {}/{} done — GPU memory: {:.2} GB used, {:.2} GB free",
249                i + 1,
250                total_shards,
251                used as f64 / (1024.0 * 1024.0 * 1024.0),
252                free_now as f64 / (1024.0 * 1024.0 * 1024.0),
253            );
254            crate::progress::shard_done(
255                i + 1,
256                total_shards,
257                used as f64 / (1024.0 * 1024.0 * 1024.0),
258                free_now as f64 / (1024.0 * 1024.0 * 1024.0),
259            );
260            if !offload_logged {
261                check_oom_guard(
262                    gpu,
263                    oom_reserve_bytes,
264                    &format!("fast weight loading (shard {}/{})", i + 1, total_shards),
265                )?;
266            }
267        }
268
269        // Extra weights (e.g. MTP grafted from another quantization).
270        let no_skip = |_: &str| false;
271        let extra = model_dir.join("extra_weights.safetensors");
272        if extra.exists() {
273            tracing::info!("Fast-loading extra_weights.safetensors");
274            let mut extra_offload = false;
275            load_shard_fast(
276                &extra,
277                None,
278                gpu,
279                &no_skip,
280                self.try_direct_io,
281                self.direct_io_tensor_cap,
282                self.prefetch_shards,
283                &mut weights,
284                &mut deferred,
285                &mut extra_offload,
286            )?;
287        }
288
289        tracing::info!("Fast-loaded {} weight tensors", weights.len());
290        let mut store = WeightStore::from_map(weights);
291        for (name, d) in deferred {
292            store.defer(name, d);
293        }
294        Ok(store)
295    }
296}
297
298/// Load a single shard with O_DIRECT + pipelined read/copy.
299///
300/// Pipeline:
301///   reader thread: pread tensor N into aligned buffer → sync_channel ──▶
302///   main thread:   recv → copy_h2d → store tensor
303///
304/// The channel has capacity 1, so at any time the reader is ≤1 tensor
305/// ahead of the copier. Memory overhead per shard: 2 × max_tensor_bytes
306/// (rounded up to O_DIRECT alignment).
307#[allow(clippy::too_many_arguments)]
308fn load_shard_fast(
309    shard_path: &Path,
310    tensor_filter: Option<&[String]>,
311    gpu: &dyn GpuBackend,
312    skip_fn: &dyn Fn(&str) -> bool,
313    try_direct_io: bool,
314    direct_io_tensor_cap: usize,
315    prefetch_shards: bool,
316    out: &mut HashMap<String, WeightTensor>,
317    deferred_out: &mut HashMap<String, crate::weights::DeferredTensor>,
318    offload_logged: &mut bool,
319) -> Result<()> {
320    // Header parsing uses a buffered fd — header is a few KB, cache pollution
321    // is negligible and buffered I/O handles short reads cleanly.
322    let mut meta_file = File::open(shard_path)
323        .with_context(|| format!("Failed to open {}", shard_path.display()))?;
324    let mut tensors = parse_header(&mut meta_file)?;
325    let file_size = meta_file.metadata()?.len();
326
327    // Filter down to tensors we actually want (index filter + EP filter).
328    if let Some(allow) = tensor_filter {
329        let allow_set: std::collections::HashSet<&str> = allow.iter().map(|s| s.as_str()).collect();
330        tensors.retain(|t| allow_set.contains(t.name.as_str()));
331    }
332    // The n-gram embedding TABLES are never uploaded with the checkpoint —
333    // 63 GB (LongCat-Lite) to ~102 GB (Flash-Next) of BF16 would exhaust a
334    // 121 GB unified box before any quantization could run, and the fallback
335    // on GB10 is managed memory, i.e. Linux swap, i.e. a kernel freeze. They
336    // are recorded with their on-disk location and served either by streaming
337    // per-table quantize-on-load or straight off NVMe by the row cache.
338    let mut deferred_here: Vec<(String, crate::weights::DeferredTensor)> = Vec::new();
339    #[allow(clippy::items_after_statements)]
340    tensors.retain(|t| {
341        if crate::weights::is_ngram_table(&t.name) {
342            deferred_here.push((
343                t.name.clone(),
344                crate::weights::DeferredTensor {
345                    path: shard_path.to_path_buf(),
346                    offset: t.abs_offset,
347                    shape: t.shape.clone(),
348                    dtype: t.dtype,
349                },
350            ));
351            return false;
352        }
353        !skip_fn(&t.name)
354    });
355    if !deferred_here.is_empty() {
356        tracing::info!(
357            "Deferred {} n-gram table(s) in {} — served from disk, not uploaded",
358            deferred_here.len(),
359            shard_path.display()
360        );
361        deferred_out.extend(deferred_here);
362    }
363
364    // Per-shard heuristic: above `direct_io_tensor_cap` tensors, O_DIRECT's
365    // per-tensor syscall + 4 KiB alignment overhead costs more than kernel
366    // readahead on the buffered path saves. Skip the direct-open attempt
367    // entirely in that case — keeps the log clean and avoids a wasted fd.
368    let wants_direct = try_direct_io && tensors.len() <= direct_io_tensor_cap;
369    if try_direct_io && !wants_direct {
370        tracing::info!(
371            "  Shard has {} tensors (> {} cap) — using buffered+pipelined path",
372            tensors.len(),
373            direct_io_tensor_cap
374        );
375    }
376
377    // File for data reads. Try O_DIRECT; if it fails, fall through to buffered.
378    let (direct_file, using_direct) = match wants_direct
379        .then(|| direct_io::open_direct(shard_path))
380        .transpose()
381    {
382        Ok(Some(f)) => (Some(f), true),
383        Ok(None) => (None, false),
384        Err(e) => {
385            tracing::warn!(
386                "O_DIRECT open failed for {} ({e}); falling back to buffered reads",
387                shard_path.display()
388            );
389            (None, false)
390        }
391    };
392    let buffered_file = File::open(shard_path)?;
393    let data_fd = direct_file.as_ref().unwrap_or(&buffered_file);
394    if prefetch_shards && !using_direct {
395        advise_prefetch_shard(&buffered_file, shard_path, file_size);
396    }
397
398    // Pipelined reader: sends (tensor_index, aligned_buffer, slice_start) to main.
399    type ReadMsg = (usize, direct_io::AlignedBuffer, usize);
400    let (tx, rx) = sync_channel::<Result<ReadMsg>>(1);
401    let tensors_for_reader: Vec<(u64, usize)> =
402        tensors.iter().map(|t| (t.abs_offset, t.len)).collect();
403    let raw_fd = {
404        use std::os::unix::io::AsRawFd;
405        data_fd.as_raw_fd()
406    };
407
408    let _ = file_size; // retained for future use (tail-fragment buffered read)
409    let reader_handle = std::thread::spawn(move || {
410        for (idx, (abs_offset, len)) in tensors_for_reader.iter().enumerate() {
411            let msg = direct_io::read_tensor_aligned(raw_fd, *abs_offset, *len, using_direct)
412                .map(|(buf, slice_start)| (idx, buf, slice_start));
413            if tx.send(msg).is_err() {
414                break; // receiver dropped
415            }
416        }
417    });
418
419    // Copier: drains the channel, does gpu alloc + copy_h2d, inserts into the map.
420    for result in rx {
421        let (idx, buf, slice_start) = result?;
422        let meta = &tensors[idx];
423        let raw = &buf.as_slice()[slice_start..slice_start + meta.len];
424        // F16 shards: convert bytes to BF16 before upload (same length,
425        // different bit layout — meta.dtype is already staged as BF16).
426        let converted: Vec<u8>;
427        let src: &[u8] = if meta.from_f16 {
428            converted = f16_to_bf16_bytes(raw);
429            &converted
430        } else {
431            raw
432        };
433
434        let ptr = match gpu.alloc(meta.len) {
435            Ok(p) => {
436                gpu.copy_h2d(src, p)?;
437                p
438            }
439            Err(_) => {
440                if !*offload_logged {
441                    tracing::warn!(
442                        "GPU alloc failed for {} ({} bytes) — switching to managed (UVM) memory",
443                        meta.name,
444                        meta.len
445                    );
446                    *offload_logged = true;
447                }
448                let p = gpu.alloc_managed(meta.len)?;
449                unsafe {
450                    std::ptr::copy_nonoverlapping(src.as_ptr(), p.0 as *mut u8, meta.len);
451                }
452                p
453            }
454        };
455
456        out.insert(
457            meta.name.clone(),
458            WeightTensor {
459                ptr,
460                shape: meta.shape.clone(),
461                dtype: meta.dtype,
462            },
463        );
464    }
465
466    reader_handle
467        .join()
468        .map_err(|_| anyhow::anyhow!("reader thread panicked"))?;
469
470    // Release file handles, then advise the kernel to drop any pages we did
471    // end up caching on the buffered fallback path. O_DIRECT reads never hit
472    // the page cache, so the posix_fadvise is a no-op there but cheap.
473    drop(direct_file);
474    evict_page_cache(&buffered_file);
475    drop(buffered_file);
476    Ok(())
477}
478
479#[cfg(target_os = "linux")]
480fn advise_prefetch_shard(file: &File, shard_path: &Path, file_size: u64) {
481    use std::os::unix::io::AsRawFd;
482
483    let fd = file.as_raw_fd();
484    let seq_rc = unsafe { libc::posix_fadvise(fd, 0, 0, libc::POSIX_FADV_SEQUENTIAL) };
485    let willneed_rc = unsafe { libc::posix_fadvise(fd, 0, 0, libc::POSIX_FADV_WILLNEED) };
486    if seq_rc == 0 && willneed_rc == 0 {
487        tracing::info!(
488            "  NFS/shard prefetch requested for {} ({:.2} GB)",
489            shard_path.display(),
490            file_size as f64 / (1024.0 * 1024.0 * 1024.0)
491        );
492    } else {
493        tracing::warn!(
494            "  NFS/shard prefetch hint failed for {}: sequential_rc={}, willneed_rc={}",
495            shard_path.display(),
496            seq_rc,
497            willneed_rc
498        );
499    }
500}
501
502#[cfg(not(target_os = "linux"))]
503fn advise_prefetch_shard(_file: &File, _shard_path: &Path, _file_size: u64) {}
504
505#[cfg(test)]
506mod skip_vision_tests {
507    use super::{FastSafetensorsLoader, is_vision_tensor};
508
509    fn loader(skip_vision: bool, ep: usize) -> FastSafetensorsLoader {
510        let mut l = FastSafetensorsLoader::with_ep(0, ep, 288);
511        l.skip_vision = skip_vision;
512        l
513    }
514
515    #[test]
516    fn vision_names_are_recognised() {
517        assert!(is_vision_tensor("model.visual.blocks.0.attn.proj.weight"));
518        assert!(is_vision_tensor(
519            "model.vision_tower.encoder.layer.0.weight"
520        ));
521        assert!(is_vision_tensor("visual.merger.proj.weight"));
522        assert!(!is_vision_tensor(
523            "model.language_model.layers.45.eh_proj.weight"
524        ));
525        // The trap: a text tensor whose name merely CONTAINS "vision".
526        assert!(!is_vision_tensor(
527            "model.language_model.layers.3.mlp.revision.weight"
528        ));
529    }
530
531    #[test]
532    fn skip_vision_drops_only_the_tower() {
533        let l = loader(true, 2);
534        assert!(l.should_skip_tensor("model.visual.blocks.0.attn.proj.weight"));
535        assert!(!l.should_skip_tensor("model.language_model.layers.45.eh_proj.weight"));
536        assert!(!l.should_skip_tensor("lm_head.weight"));
537    }
538
539    #[test]
540    fn skip_vision_applies_without_ep() {
541        // The EP short-circuit must not swallow the vision rule at ep=1.
542        let l = loader(true, 1);
543        assert!(l.should_skip_tensor("model.visual.blocks.0.attn.proj.weight"));
544        assert!(!l.should_skip_tensor("model.layers.0.self_attn.q_proj.weight"));
545    }
546
547    #[test]
548    fn default_loader_keeps_the_tower() {
549        let l = loader(false, 2);
550        assert!(!l.should_skip_tensor("model.visual.blocks.0.attn.proj.weight"));
551        assert!(!FastSafetensorsLoader::new().skip_vision);
552    }
553
554    #[test]
555    fn ep_expert_filtering_is_unchanged_by_the_vision_rule() {
556        let l = loader(true, 2); // ep_rank 0 of 2, 288 experts -> keeps 0..143
557        assert!(
558            !l.should_skip_tensor("model.language_model.layers.4.mlp.experts.7.up_proj.weight")
559        );
560        assert!(
561            l.should_skip_tensor("model.language_model.layers.4.mlp.experts.200.up_proj.weight")
562        );
563    }
564}