1use anyhow::{Context, Result, bail};
6use std::collections::HashMap;
7use std::path::Path;
8
9use super::{SafetensorsLoader, WeightLoader, WeightStore};
10use crate::gpu::GpuBackend;
11
12impl WeightLoader for SafetensorsLoader {
13 fn load(
14 &self,
15 model_dir: &Path,
16 gpu: &dyn GpuBackend,
17 oom_reserve_bytes: usize,
18 ) -> Result<WeightStore> {
19 let skip_fn = |name: &str| self.should_skip_tensor(name);
20
21 let index_path = model_dir.join("model.safetensors.index.json");
25 let consolidated_index = model_dir.join("consolidated.safetensors.index.json");
26 let shard_files: Vec<std::path::PathBuf>;
27 let use_index;
28 let actual_index_path;
29
30 if index_path.exists() {
31 use_index = true;
32 actual_index_path = index_path;
33 shard_files = vec![];
34 } else if consolidated_index.exists() {
35 use_index = true;
36 actual_index_path = consolidated_index;
37 shard_files = vec![];
38 } else {
39 use_index = false;
40 actual_index_path = index_path; let single = model_dir.join("model.safetensors");
42 if single.exists() {
43 shard_files = vec![single];
44 } else {
45 let mut shards: Vec<_> = std::fs::read_dir(model_dir)?
47 .filter_map(|e| e.ok())
48 .map(|e| e.path())
49 .filter(|p| {
50 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
51 (n.starts_with("model.safetensors-") || n.starts_with("consolidated-"))
52 && n.ends_with(".safetensors")
53 })
54 })
55 .collect();
56 shards.sort();
57 if shards.is_empty() {
58 bail!(
59 "No safetensor files found in {}. Expected model.safetensors*, \
60 consolidated.safetensors*, or consolidated-*-of-*.safetensors",
61 model_dir.display()
62 );
63 }
64 shard_files = shards;
65 }
66 }
67
68 let preflight_skip = |name: &str| skip_fn(name) || super::is_ngram_table(name);
83 {
84 let estimated = estimate_load_bytes(&shard_files, &preflight_skip)?;
85 let has_fp8 = estimate_has_fp8(&shard_files, &preflight_skip)?;
86 let overhead_multiplier: f64 =
87 self.peak_memory_multiplier
88 .unwrap_or(if has_fp8 { 1.5 } else { 1.3 });
89 let peak_estimated = (estimated as f64 * overhead_multiplier) as usize;
90 let free = gpu.free_memory()?;
91 let free_gb = free as f64 / (1024.0 * 1024.0 * 1024.0);
92 let est_gb = estimated as f64 / (1024.0 * 1024.0 * 1024.0);
93 let peak_gb = peak_estimated as f64 / (1024.0 * 1024.0 * 1024.0);
94 let reserve_gb = oom_reserve_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
95 tracing::info!(
96 "Pre-flight estimate: {:.2} GB on-disk weights, {:.1}x overhead = {:.2} GB peak, \
97 {:.2} GB free, {:.1} GB reserve (FP8: {})",
98 est_gb,
99 overhead_multiplier,
100 peak_gb,
101 free_gb,
102 reserve_gb,
103 has_fp8,
104 );
105 if peak_estimated + oom_reserve_bytes > free {
106 bail!(
107 "OOM pre-flight: model peak memory ({:.2} GB = {:.2} GB weights × {:.1}x \
108 model-building overhead) + {:.1} GB reserve = {:.2} GB, \
109 but only {:.2} GB GPU memory is available. \
110 This model is too large. Use a smaller quantization (NVFP4 instead of FP8) \
111 or add more GPUs for expert parallelism.",
112 peak_gb,
113 est_gb,
114 overhead_multiplier,
115 reserve_gb,
116 peak_gb + reserve_gb,
117 free_gb,
118 );
119 }
120 }
121
122 let mut deferred: HashMap<String, crate::weights::DeferredTensor> = HashMap::new();
124 let mut weight_map = if use_index {
125 load_sharded(
126 model_dir,
127 &actual_index_path,
128 gpu,
129 oom_reserve_bytes,
130 &skip_fn,
131 self.peak_memory_multiplier,
132 &mut deferred,
133 )?
134 } else if shard_files.len() == 1 {
135 load_single(&shard_files[0], gpu, oom_reserve_bytes, &skip_fn)?
136 } else {
137 tracing::info!("Loading {} unindexed safetensor shards", shard_files.len());
138 let initial_free = gpu.free_memory()?;
139 let mut combined = HashMap::new();
140 for (i, shard) in shard_files.iter().enumerate() {
141 let map = load_single(shard, gpu, oom_reserve_bytes, &skip_fn)?;
142 let free_now = gpu.free_memory().unwrap_or(0);
143 let used = initial_free.saturating_sub(free_now);
144 tracing::info!(
145 " Shard {}/{} done — GPU memory: {:.2} GB used, {:.2} GB free",
146 i + 1,
147 shard_files.len(),
148 used as f64 / (1024.0 * 1024.0 * 1024.0),
149 free_now as f64 / (1024.0 * 1024.0 * 1024.0),
150 );
151 check_oom_guard(
152 gpu,
153 oom_reserve_bytes,
154 &format!("weight loading (shard {}/{})", i + 1, shard_files.len()),
155 )?;
156 combined.extend(map);
157 }
158 combined
159 };
160
161 let no_skip = |_: &str| false;
164 let extra = model_dir.join("extra_weights.safetensors");
165 if extra.exists() {
166 let extra_weights = load_single(&extra, gpu, oom_reserve_bytes, &no_skip)?;
167 tracing::info!(
168 "Loaded {} extra weight tensors from extra_weights.safetensors",
169 extra_weights.len()
170 );
171 weight_map.extend(extra_weights);
172 }
173
174 let mut store = WeightStore::from_map(weight_map);
175 for (name, d) in deferred {
176 store.defer(name, d);
177 }
178 Ok(store)
179 }
180}
181
182#[derive(serde::Deserialize)]
184struct SafetensorsIndex {
185 weight_map: HashMap<String, String>,
186}
187
188pub(crate) fn read_safetensor_header(
192 path: &Path,
193) -> Result<Vec<(String, Vec<usize>, safetensors::Dtype)>> {
194 use std::io::Read;
195 let mut file = std::fs::File::open(path)
196 .with_context(|| format!("Pre-flight: failed to open {}", path.display()))?;
197
198 let mut size_buf = [0u8; 8];
200 file.read_exact(&mut size_buf)?;
201 let header_size = u64::from_le_bytes(size_buf) as usize;
202
203 if header_size > 64 * 1024 * 1024 {
205 bail!(
206 "Safetensor header too large ({} bytes) in {}",
207 header_size,
208 path.display()
209 );
210 }
211
212 let mut header_buf = vec![0u8; header_size];
213 file.read_exact(&mut header_buf)?;
214
215 let header: serde_json::Value = serde_json::from_slice(&header_buf)?;
217 let obj = header.as_object().context("Invalid safetensor header")?;
218
219 let mut tensors = Vec::new();
220 for (name, info) in obj {
221 if name == "__metadata__" {
222 continue;
223 }
224 let dtype_str = info["dtype"].as_str().unwrap_or("BF16");
225 let dtype = match dtype_str {
226 "F32" => safetensors::Dtype::F32,
227 "F16" => safetensors::Dtype::F16,
228 "BF16" => safetensors::Dtype::BF16,
229 "I32" => safetensors::Dtype::I32,
230 "I16" => safetensors::Dtype::I16,
231 "I8" => safetensors::Dtype::I8,
232 "U8" => safetensors::Dtype::U8,
233 "F8_E4M3" => safetensors::Dtype::F8_E4M3,
234 "F8_E5M2" => safetensors::Dtype::F8_E5M2,
235 _ => safetensors::Dtype::BF16,
236 };
237 let shape: Vec<usize> = info["shape"]
238 .as_array()
239 .map(|a| {
240 a.iter()
241 .filter_map(|v| v.as_u64().map(|n| n as usize))
242 .collect()
243 })
244 .unwrap_or_default();
245 tensors.push((name.clone(), shape, dtype));
246 }
247 Ok(tensors)
248}
249
250pub(crate) fn estimate_load_bytes(
254 files: &[std::path::PathBuf],
255 skip_fn: &dyn Fn(&str) -> bool,
256) -> Result<usize> {
257 let mut total = 0usize;
258 for path in files {
259 for (name, shape, dtype) in read_safetensor_header(path)? {
260 if skip_fn(&name) {
261 continue;
262 }
263 let numel: usize = shape.iter().product();
264 let elem_bytes = match dtype {
265 safetensors::Dtype::F32 | safetensors::Dtype::I32 | safetensors::Dtype::U32 => 4,
266 safetensors::Dtype::F16
267 | safetensors::Dtype::BF16
268 | safetensors::Dtype::I16
269 | safetensors::Dtype::U16 => 2,
270 safetensors::Dtype::I8
271 | safetensors::Dtype::U8
272 | safetensors::Dtype::F8_E4M3
273 | safetensors::Dtype::F8_E5M2 => 1,
274 _ => 2,
275 };
276 total += numel * elem_bytes;
277 }
278 }
279 Ok(total)
280}
281
282pub(crate) fn estimate_has_fp8(
286 files: &[std::path::PathBuf],
287 skip_fn: &dyn Fn(&str) -> bool,
288) -> Result<bool> {
289 let mut fp8_bytes = 0usize;
290 let mut total_bytes = 0usize;
291 for path in files {
292 for (name, shape, dtype) in read_safetensor_header(path)? {
293 if skip_fn(&name) {
294 continue;
295 }
296 let numel: usize = shape.iter().product();
297 let elem_bytes = match dtype {
298 safetensors::Dtype::F32 | safetensors::Dtype::I32 | safetensors::Dtype::U32 => 4,
299 safetensors::Dtype::F16
300 | safetensors::Dtype::BF16
301 | safetensors::Dtype::I16
302 | safetensors::Dtype::U16 => 2,
303 safetensors::Dtype::I8
304 | safetensors::Dtype::U8
305 | safetensors::Dtype::F8_E4M3
306 | safetensors::Dtype::F8_E5M2 => 1,
307 _ => 2,
308 };
309 let bytes = numel * elem_bytes;
310 total_bytes += bytes;
311 if matches!(
312 dtype,
313 safetensors::Dtype::F8_E4M3 | safetensors::Dtype::F8_E5M2
314 ) {
315 fp8_bytes += bytes;
316 }
317 }
318 }
319 let fp8_frac = if total_bytes > 0 {
320 fp8_bytes as f64 / total_bytes as f64
321 } else {
322 0.0
323 };
324 tracing::debug!(
325 "FP8 fraction: {:.1}% ({} / {} bytes)",
326 fp8_frac * 100.0,
327 fp8_bytes,
328 total_bytes
329 );
330 Ok(fp8_frac > 0.5)
331}
332
333pub(crate) fn check_oom_guard(
334 gpu: &dyn GpuBackend,
335 reserve_bytes: usize,
336 phase: &str,
337) -> Result<()> {
338 let free = gpu.free_memory()?;
339 if free < reserve_bytes {
340 let free_gb = free as f64 / (1024.0 * 1024.0 * 1024.0);
341 let reserve_gb = reserve_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
342 bail!(
343 "OOM guard: aborting during {phase}. \
344 Free GPU memory ({free_gb:.2} GB) is below the {reserve_gb:.1} GB safety reserve. \
345 This model is too large for available GPU memory. \
346 Reduce --max-seq-len, increase --oom-guard-mb, or use a smaller model."
347 );
348 }
349 Ok(())
350}
351mod load_fns;
352use load_fns::{load_sharded, load_single};