Skip to main content

ferrum_cli/
gpu_mem_autosize.rs

1//! GPU memory auto-tuning for the KV pool.
2//!
3//! Reads model config + on-disk weight file sizes + nvidia-smi reported
4//! GPU total, then sets `FERRUM_KV_MAX_BLOCKS` so the KV pool fits inside
5//! `total_mem * gpu_memory_utilization` after weights and a scratch reserve.
6//! Mirrors vLLM's `gpu_memory_utilization` knob (default 0.9).
7//!
8//! Skipped when:
9//! - nvidia-smi missing (Mac / CPU-only): keep static defaults.
10//! - `config.json` not parseable: keep static defaults.
11//! - User explicitly set `FERRUM_KV_MAX_BLOCKS`: respect their override.
12
13use ferrum_types::{RuntimeConfigEntry, RuntimeConfigSnapshot, RuntimeConfigSource};
14use std::path::Path;
15
16/// Bytes reserved for everything that's NOT weights or KV pool: cuBLAS
17/// workspace, Marlin gather scratch, unified path scratch, embedding,
18/// lm_head logits buffer, runtime allocator overhead. 4 GB is the
19/// observed worst case at c=32 + chunked-prefill mixed batches.
20const SCRATCH_RESERVE_BYTES: u64 = 4 * 1024 * 1024 * 1024;
21
22/// PagedKvPool block_size — must match `PAGED_BLOCK_SIZE` in
23/// `llama_family.rs`.
24const PAGED_BLOCK_SIZE: u64 = 16;
25const DEFAULT_MAX_BATCHED_TOKENS: usize = 2048;
26// Tight recurrent-state models carry large non-KV decode state and can be
27// allocator-fragile near the end of the memory budget. Keep aggregate prefill
28// conservative by default; widening this to 1024 was shown to OOM the W3 c16
29// product-path diagnostic even though the KV block floor itself still fit.
30const TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS: usize = 192;
31const TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR: usize = 256;
32
33/// Bytes per element of the KV cache. ferrum currently always uses FP16
34/// for KV regardless of weight dtype (Marlin INT4 weights → FP16 KV).
35const KV_DTYPE_BYTES: u64 = 2;
36
37#[derive(Debug)]
38pub struct AutoSizeResult {
39    pub total_gpu_bytes: u64,
40    pub free_gpu_bytes: u64,
41    pub weight_bytes: u64,
42    pub budgeted_weight_bytes: u64,
43    pub weight_budget_shards: u64,
44    pub budgeted_layer_count: u64,
45    pub kv_block_bytes: u64,
46    pub kv_pool_copies: u64,
47    pub estimated_budget_blocks: usize,
48    pub requested_min_blocks: usize,
49    pub max_blocks: usize,
50    pub reserved_for_scratch: u64,
51}
52
53impl AutoSizeResult {
54    pub fn print_summary(&self) {
55        let gb = |b: u64| (b as f64) / 1024.0 / 1024.0 / 1024.0;
56        eprintln!(
57            "[auto-size] gpu={:.1} GB total / {:.1} GB free | weights={:.1} GB budget / {:.1} GB total | layers={} budget | scratch reserve={:.1} GB | KV pool budget {:.1} GB → max_blocks={}",
58            gb(self.total_gpu_bytes),
59            gb(self.free_gpu_bytes),
60            gb(self.budgeted_weight_bytes),
61            gb(self.weight_bytes),
62            self.budgeted_layer_count,
63            gb(self.reserved_for_scratch),
64            gb((self.max_blocks as u64) * self.kv_block_bytes * self.kv_pool_copies),
65            self.max_blocks,
66        );
67        if self.weight_budget_shards > 1 {
68            eprintln!(
69                "[auto-size] weight budget shards={} (distributed strategy)",
70                self.weight_budget_shards
71            );
72        }
73        if self.kv_pool_copies > 1 {
74            eprintln!(
75                "[auto-size] KV pool copies={} (FA-compatible attention path)",
76                self.kv_pool_copies
77            );
78        }
79        if self.requested_min_blocks > self.estimated_budget_blocks {
80            eprintln!(
81                "[auto-size] requested runtime token floor requires KV_MAX_BLOCKS={} above estimated budget {}; honoring explicit runtime limits",
82                self.requested_min_blocks, self.estimated_budget_blocks
83            );
84        }
85    }
86}
87
88/// Compute target `FERRUM_KV_MAX_BLOCKS` from `gpu_memory_utilization`.
89///
90/// Returns None when any input is unavailable — caller leaves the
91/// static default in place.
92pub fn auto_size_kv_blocks(model_dir: &Path, gpu_util: f32) -> Option<AutoSizeResult> {
93    auto_size_kv_blocks_with_pool_copies(model_dir, gpu_util, 1)
94}
95
96pub fn auto_size_kv_blocks_with_pool_copies(
97    model_dir: &Path,
98    gpu_util: f32,
99    kv_pool_copies: u64,
100) -> Option<AutoSizeResult> {
101    let current = RuntimeConfigSnapshot::capture_current();
102    auto_size_kv_blocks_with_pool_copies_for_snapshot(model_dir, gpu_util, kv_pool_copies, &current)
103}
104
105fn auto_size_kv_blocks_with_pool_copies_for_snapshot(
106    model_dir: &Path,
107    gpu_util: f32,
108    kv_pool_copies: u64,
109    runtime_config: &RuntimeConfigSnapshot,
110) -> Option<AutoSizeResult> {
111    let gpu_util = gpu_util.clamp(0.1, 1.0);
112    let kv_pool_copies = kv_pool_copies.max(1);
113
114    // 1. Query GPU total + free via nvidia-smi (most portable across
115    //    cudarc versions and works pre-cuda-driver-load).
116    let nvsmi = std::process::Command::new("nvidia-smi")
117        .args([
118            "--query-gpu=memory.total,memory.free",
119            "--format=csv,noheader,nounits",
120        ])
121        .output()
122        .ok()?;
123    if !nvsmi.status.success() {
124        return None;
125    }
126    let s = String::from_utf8(nvsmi.stdout).ok()?;
127    let line = s.lines().next()?.trim();
128    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
129    let total_mb: u64 = parts.first()?.parse().ok()?;
130    let free_mb: u64 = parts.get(1)?.parse().ok()?;
131    let total_bytes = total_mb * 1024 * 1024;
132    let free_bytes = free_mb * 1024 * 1024;
133
134    // 2. Parse config.json for model dims.
135    let config_path = model_dir.join("config.json");
136    let config: serde_json::Value =
137        serde_json::from_str(&std::fs::read_to_string(&config_path).ok()?).ok()?;
138    let num_layers = config_or_text_u64(&config, "num_hidden_layers")
139        .or_else(|| config_or_text_u64(&config, "num_layers"))?;
140    let hidden_size = config_or_text_u64(&config, "hidden_size")?;
141    let num_attn_heads = config_or_text_u64(&config, "num_attention_heads")?;
142    let num_kv_heads = config_or_text_u64(&config, "num_key_value_heads").unwrap_or(num_attn_heads);
143    let head_dim = config_or_text_u64(&config, "head_dim")
144        .unwrap_or_else(|| hidden_size / num_attn_heads.max(1));
145
146    // 3. Sum .safetensors / .bin file sizes for weight estimate.
147    let mut weight_bytes: u64 = 0;
148    if let Ok(entries) = std::fs::read_dir(model_dir) {
149        for entry in entries.flatten() {
150            let p = entry.path();
151            let is_weight = p
152                .extension()
153                .and_then(|s| s.to_str())
154                .map(|ext| ext == "safetensors" || ext == "bin")
155                .unwrap_or(false);
156            if is_weight {
157                // HuggingFace snapshot files are commonly symlinks into the
158                // blob cache. `DirEntry::metadata` reports the symlink itself;
159                // `std::fs::metadata` follows it and gives the real shard size.
160                if let Ok(meta) = std::fs::metadata(&p) {
161                    weight_bytes += meta.len();
162                }
163            }
164        }
165    }
166    if weight_bytes == 0 {
167        // Couldn't find weights — bail to static defaults.
168        return None;
169    }
170    let weight_budget_shards = weight_budget_shard_count(runtime_config);
171    let budgeted_weight_bytes = ceil_div_u64(weight_bytes, weight_budget_shards);
172    let budgeted_layer_count = layer_count_for_memory_budget(num_layers, runtime_config);
173
174    // 4. Compute KV budget. Reserve `(1 - util)` of total mem as host-
175    //    bookkeeping margin, plus a fixed scratch reserve covering all
176    //    transient buffers (cuBLAS workspace, Marlin scratch, unified
177    //    forward intermediates, embedding, lm_head, etc).
178    let target_used = (total_bytes as f64 * gpu_util as f64) as u64;
179    let avail_for_kv = target_used
180        .saturating_sub(budgeted_weight_bytes)
181        .saturating_sub(SCRATCH_RESERVE_BYTES);
182
183    // KV per block: num_layers × num_kv_heads × block_size × head_dim
184    //              × 2 (K and V) × dtype_bytes
185    let block_bytes =
186        budgeted_layer_count * num_kv_heads * PAGED_BLOCK_SIZE * head_dim * 2 * KV_DTYPE_BYTES;
187    if block_bytes == 0 {
188        return None;
189    }
190    let estimated_budget_blocks = (avail_for_kv / (block_bytes * kv_pool_copies)) as usize;
191    let requested_min_blocks = requested_min_kv_blocks_from_snapshot(runtime_config);
192    let max_blocks = estimated_budget_blocks.max(requested_min_blocks);
193
194    Some(AutoSizeResult {
195        total_gpu_bytes: total_bytes,
196        free_gpu_bytes: free_bytes,
197        weight_bytes,
198        budgeted_weight_bytes,
199        weight_budget_shards,
200        budgeted_layer_count,
201        kv_block_bytes: block_bytes,
202        kv_pool_copies,
203        estimated_budget_blocks,
204        requested_min_blocks,
205        max_blocks,
206        reserved_for_scratch: SCRATCH_RESERVE_BYTES,
207    })
208}
209
210/// CLI usage profile: which presets the autosizer should consider.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum AutoSizeProfile {
213    /// `ferrum serve` — many concurrent requests. Admission width and
214    /// per-request logical context are resolved independently over the shared
215    /// physical KV block pool. Bench scripts also use this profile.
216    Server,
217    /// `ferrum run` — single interactive user, multi-turn chat. Trade
218    /// max_seqs for KV_CAPACITY so a long conversation doesn't crash
219    /// after a few turns. With KV_CAPACITY=512 (server default), Qwen3
220    /// thinking-mode replies hit overflow at the 4th turn.
221    Chat,
222}
223
224#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
225enum ModelAutoSizeClass {
226    #[default]
227    Generic,
228    TightRecurrentState,
229}
230
231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
232struct ModelAutoSizeHints {
233    has_recurrent_linear_attention_state: bool,
234}
235
236#[derive(Clone, Copy, Debug)]
237struct ModelAutoSizeDefaults {
238    max_batched_tokens: usize,
239    max_sequences: usize,
240    max_sequence_tokens: usize,
241    kv_block_floor: usize,
242}
243
244/// Apply auto-sizing: read CLI flag, query nvidia-smi, set env vars.
245/// Sets `FERRUM_KV_MAX_BLOCKS` (global physical paged-KV block budget),
246/// `FERRUM_PAGED_MAX_SEQS` (scheduler/model concurrency shape), and
247/// `FERRUM_KV_CAPACITY` (per-sequence logical table stride). The model
248/// allocates the GPU KV pool from `KV_MAX_BLOCKS`; `PAGED_MAX_SEQS *
249/// KV_CAPACITY` no longer reserves physical KV blocks up front.
250///
251/// Idempotent — caller invokes once per CLI invocation before engine
252/// init. Respects user overrides (no clobber if env already set).
253///
254/// Defaults to `AutoSizeProfile::Server`. Use `apply_auto_size_with_profile`
255/// for chat (`ferrum run`) — it picks longer per-seq context.
256pub fn apply_auto_size(model_dir: &Path, gpu_util: f32) {
257    apply_auto_size_with_profile(model_dir, gpu_util, AutoSizeProfile::Server);
258}
259
260/// Apply auto-sizing with explicit usage profile. The chat profile
261/// flips priority — long context per seq beats wide batch — because
262/// the CLI REPL only ever has one active sequence and multi-turn
263/// dialogues blow past the default 512-token cap fast.
264pub fn apply_auto_size_with_profile(model_dir: &Path, gpu_util: f32, profile: AutoSizeProfile) {
265    let current = RuntimeConfigSnapshot::capture_current();
266    let entries = auto_size_runtime_entries(model_dir, gpu_util, profile, &current);
267    crate::runtime_env::materialize_runtime_env_defaults(&entries);
268}
269
270/// Legacy pool sizing against the caller's typed request. Product startup
271/// keeps these inferred entries separate from the original user environment.
272pub fn auto_size_runtime_entries(
273    model_dir: &Path,
274    gpu_util: f32,
275    profile: AutoSizeProfile,
276    current: &RuntimeConfigSnapshot,
277) -> Vec<RuntimeConfigEntry> {
278    let kv_overridden = snapshot_value(&current, "FERRUM_KV_MAX_BLOCKS").is_some();
279    let max_seqs_overridden = snapshot_value(&current, "FERRUM_PAGED_MAX_SEQS").is_some();
280    let max_batched_tokens_overridden =
281        snapshot_value(&current, "FERRUM_MAX_BATCHED_TOKENS").is_some();
282    let model_hints = model_auto_size_hints(model_dir);
283    let mut entries = Vec::new();
284    // ALL three knobs covered by the user — nothing to set.
285    if kv_overridden && max_seqs_overridden && max_batched_tokens_overridden {
286        return entries;
287    }
288    let kv_pool_copies = kv_pool_copies_from_snapshot(&current);
289    let preliminary_result = auto_size_kv_blocks_with_pool_copies_for_snapshot(
290        model_dir,
291        gpu_util,
292        kv_pool_copies,
293        &current,
294    );
295    let model_class =
296        model_auto_size_class_from_hints_and_budget(model_hints, preliminary_result.as_ref());
297    let defaults = model_auto_size_defaults(model_class, profile);
298    // Set MAX_BATCHED_TOKENS first so it lands even when the user overrode
299    // FERRUM_KV_MAX_BLOCKS + FERRUM_PAGED_MAX_SEQS (apples bench does both,
300    // which used to silently skip the Phase 3 scratch budget alongside).
301    if !max_batched_tokens_overridden {
302        // 2048 is the safe generic default across smaller dense and MoE GPTQ
303        // profiles. Tight recurrent-state models only use the smaller scratch
304        // profile when the measured weight/VRAM budget cannot cover that
305        // generic aggregate-prefill floor.
306        let mbt = defaults.max_batched_tokens;
307        entries.push(RuntimeConfigEntry::new(
308            "FERRUM_MAX_BATCHED_TOKENS",
309            mbt.to_string(),
310            RuntimeConfigSource::MemoryProfile,
311        ));
312        eprintln!(
313            "[auto-size] MAX_BATCHED_TOKENS={} (profile={:?} model={:?})",
314            mbt, profile, model_class
315        );
316    }
317    if kv_overridden && max_seqs_overridden {
318        return entries;
319    }
320    let mut budget_snapshot = current.clone();
321    for entry in &entries {
322        budget_snapshot.upsert_entry(entry.clone());
323    }
324    let result = if entries.is_empty() {
325        preliminary_result
326    } else {
327        auto_size_kv_blocks_with_pool_copies_for_snapshot(
328            model_dir,
329            gpu_util,
330            kv_pool_copies,
331            &budget_snapshot,
332        )
333    };
334    let Some(result) = result else {
335        return entries;
336    };
337    result.print_summary();
338    let max_blocks = result.max_blocks.max(defaults.kv_block_floor);
339
340    // `KV_MAX_BLOCKS` is the physical authority. Logical sequence capacity
341    // does not reserve that many blocks per admitted sequence; requests borrow
342    // blocks on demand and admission applies backpressure when the shared pool
343    // cannot satisfy the next step.
344    let (max_seqs_clamped, kv_capacity) = select_dynamic_paged_pool_shape(
345        defaults.max_sequences,
346        defaults.max_sequence_tokens,
347        max_blocks,
348    );
349
350    let kv_capacity_overridden = snapshot_value(&current, "FERRUM_KV_CAPACITY").is_some();
351    // MAX_BATCHED_TOKENS already set above (it's independent of the KV pool
352    // sizing logic, runs even when the user overrode KV_MAX_BLOCKS + SEQS).
353    // FERRUM_MOE_GRAPH is resolved as a typed CLI startup default and
354    // materialized outside the autosizer so it lands even when this function
355    // early-returns on full-override.
356    if !kv_overridden {
357        entries.push(RuntimeConfigEntry::new(
358            "FERRUM_KV_MAX_BLOCKS",
359            max_blocks.to_string(),
360            RuntimeConfigSource::MemoryProfile,
361        ));
362    }
363    if !max_seqs_overridden {
364        entries.push(RuntimeConfigEntry::new(
365            "FERRUM_PAGED_MAX_SEQS",
366            max_seqs_clamped.to_string(),
367            RuntimeConfigSource::MemoryProfile,
368        ));
369    }
370    if kv_capacity > 0 && !kv_capacity_overridden {
371        entries.push(RuntimeConfigEntry::new(
372            "FERRUM_KV_CAPACITY",
373            kv_capacity.to_string(),
374            RuntimeConfigSource::MemoryProfile,
375        ));
376    }
377    eprintln!(
378        "[auto-size] KV_MAX_BLOCKS={} PAGED_MAX_SEQS={} KV_CAPACITY={}",
379        if kv_overridden {
380            "<user>".to_string()
381        } else {
382            max_blocks.to_string()
383        },
384        if max_seqs_overridden {
385            "<user>".to_string()
386        } else {
387            max_seqs_clamped.to_string()
388        },
389        if kv_capacity_overridden {
390            "<user>".to_string()
391        } else if kv_capacity > 0 {
392            kv_capacity.to_string()
393        } else {
394            "<default>".to_string()
395        },
396    );
397    entries
398}
399
400const MAX_AUTOSIZED_SEQUENCE_TOKENS: usize = 16_384;
401const DEFAULT_SERVER_MAX_SEQUENCES: usize = 32;
402const TIGHT_RECURRENT_STATE_SERVER_MAX_SEQUENCES: usize = 16;
403const CHAT_MAX_SEQUENCES: usize = 2;
404
405fn model_auto_size_defaults(
406    model_class: ModelAutoSizeClass,
407    profile: AutoSizeProfile,
408) -> ModelAutoSizeDefaults {
409    match (model_class, profile) {
410        (ModelAutoSizeClass::TightRecurrentState, AutoSizeProfile::Server) => {
411            ModelAutoSizeDefaults {
412                max_batched_tokens: TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS,
413                max_sequences: TIGHT_RECURRENT_STATE_SERVER_MAX_SEQUENCES,
414                max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
415                kv_block_floor: TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR,
416            }
417        }
418        (ModelAutoSizeClass::TightRecurrentState, AutoSizeProfile::Chat) => ModelAutoSizeDefaults {
419            max_batched_tokens: TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS,
420            max_sequences: CHAT_MAX_SEQUENCES,
421            max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
422            kv_block_floor: TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR,
423        },
424        (ModelAutoSizeClass::Generic, AutoSizeProfile::Server) => ModelAutoSizeDefaults {
425            max_batched_tokens: DEFAULT_MAX_BATCHED_TOKENS,
426            max_sequences: DEFAULT_SERVER_MAX_SEQUENCES,
427            max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
428            kv_block_floor: 0,
429        },
430        (ModelAutoSizeClass::Generic, AutoSizeProfile::Chat) => ModelAutoSizeDefaults {
431            max_batched_tokens: DEFAULT_MAX_BATCHED_TOKENS,
432            max_sequences: CHAT_MAX_SEQUENCES,
433            max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
434            kv_block_floor: 0,
435        },
436    }
437}
438
439fn model_auto_size_hints(model_dir: &Path) -> ModelAutoSizeHints {
440    let Ok(config_text) = std::fs::read_to_string(model_dir.join("config.json")) else {
441        return ModelAutoSizeHints::default();
442    };
443    let Ok(config) = serde_json::from_str::<serde_json::Value>(&config_text) else {
444        return ModelAutoSizeHints::default();
445    };
446    model_auto_size_hints_from_config(&config)
447}
448
449fn model_auto_size_hints_from_config(config: &serde_json::Value) -> ModelAutoSizeHints {
450    ModelAutoSizeHints {
451        has_recurrent_linear_attention_state: has_recurrent_linear_attention_state(config),
452    }
453}
454
455fn model_auto_size_class_from_hints_and_budget(
456    hints: ModelAutoSizeHints,
457    budget: Option<&AutoSizeResult>,
458) -> ModelAutoSizeClass {
459    if !hints.has_recurrent_linear_attention_state {
460        return ModelAutoSizeClass::Generic;
461    }
462    let Some(budget) = budget else {
463        return ModelAutoSizeClass::Generic;
464    };
465    let generic_prefill_blocks =
466        ceil_div_usize(DEFAULT_MAX_BATCHED_TOKENS, PAGED_BLOCK_SIZE as usize);
467    if budget.estimated_budget_blocks < generic_prefill_blocks {
468        ModelAutoSizeClass::TightRecurrentState
469    } else {
470        ModelAutoSizeClass::Generic
471    }
472}
473
474fn has_recurrent_linear_attention_state(config: &serde_json::Value) -> bool {
475    let text = config.get("text_config").unwrap_or(config);
476    let has_linear_layers = text
477        .get("layer_types")
478        .and_then(|value| value.as_array())
479        .is_some_and(|layers| {
480            layers.iter().any(|layer| {
481                layer
482                    .as_str()
483                    .is_some_and(|name| name.eq_ignore_ascii_case("linear_attention"))
484            })
485        });
486    let has_linear_state_dims = [
487        "linear_conv_kernel_dim",
488        "linear_key_head_dim",
489        "linear_num_key_heads",
490        "linear_num_value_heads",
491        "linear_value_head_dim",
492    ]
493    .iter()
494    .all(|key| text.get(*key).and_then(|value| value.as_u64()).is_some());
495    has_linear_layers && has_linear_state_dims
496}
497
498fn config_or_text_u64(config: &serde_json::Value, key: &str) -> Option<u64> {
499    config
500        .get(key)
501        .and_then(|value| value.as_u64())
502        .or_else(|| {
503            config
504                .get("text_config")
505                .and_then(|text| text.get(key))
506                .and_then(|value| value.as_u64())
507        })
508}
509
510fn ceil_div_u64(value: u64, divisor: u64) -> u64 {
511    if divisor == 0 {
512        return value;
513    }
514    value.div_ceil(divisor)
515}
516
517fn ceil_div_usize(value: usize, divisor: usize) -> usize {
518    if divisor == 0 {
519        return value;
520    }
521    value.div_ceil(divisor)
522}
523
524fn select_dynamic_paged_pool_shape(
525    max_sequences: usize,
526    max_sequence_tokens: usize,
527    max_blocks: usize,
528) -> (usize, usize) {
529    let physical_blocks = max_blocks.max(1);
530    let sequences = max_sequences.max(1).min(physical_blocks);
531    let physical_tokens = physical_blocks.saturating_mul(PAGED_BLOCK_SIZE as usize);
532    let sequence_tokens = max_sequence_tokens
533        .max(PAGED_BLOCK_SIZE as usize)
534        .min(physical_tokens);
535    (sequences, sequence_tokens)
536}
537
538fn weight_budget_shard_count(snapshot: &RuntimeConfigSnapshot) -> u64 {
539    match snapshot_value(
540        snapshot,
541        crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
542    ) {
543        Some("layer_split") => selected_gpu_device_count(snapshot).max(1) as u64,
544        // Tensor-parallel support should extend this match with its own
545        // weight/KV placement rules instead of treating all multi-GPU
546        // strategies as identical.
547        _ => 1,
548    }
549}
550
551fn layer_count_for_memory_budget(num_layers: u64, snapshot: &RuntimeConfigSnapshot) -> u64 {
552    match snapshot_value(
553        snapshot,
554        crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
555    ) {
556        Some("layer_split") => {
557            let shards = selected_gpu_device_count(snapshot).max(1) as u64;
558            ceil_div_u64(num_layers, shards).max(1)
559        }
560        _ => num_layers,
561    }
562}
563
564fn selected_gpu_device_count(snapshot: &RuntimeConfigSnapshot) -> usize {
565    snapshot_value(snapshot, crate::gpu_devices::SELECTED_GPU_DEVICES_KEY)
566        .map(|value| {
567            value
568                .split(',')
569                .filter(|part| !part.trim().is_empty())
570                .count()
571        })
572        .unwrap_or(1)
573}
574
575fn requested_min_kv_blocks_from_snapshot(snapshot: &RuntimeConfigSnapshot) -> usize {
576    let max_model_len_blocks = snapshot_usize(snapshot, "FERRUM_MAX_MODEL_LEN")
577        .map(|value| ceil_div_usize(value, PAGED_BLOCK_SIZE as usize))
578        .unwrap_or(0);
579    let max_batched_token_blocks = snapshot_usize(snapshot, "FERRUM_MAX_BATCHED_TOKENS")
580        .map(|value| ceil_div_usize(value, PAGED_BLOCK_SIZE as usize))
581        .unwrap_or(0);
582
583    max_model_len_blocks.max(max_batched_token_blocks)
584}
585
586fn snapshot_value<'a>(snapshot: &'a RuntimeConfigSnapshot, key: &str) -> Option<&'a str> {
587    snapshot
588        .entries
589        .iter()
590        .find(|entry| entry.key == key)
591        .map(|entry| entry.effective_value.as_str())
592}
593
594fn snapshot_usize(snapshot: &RuntimeConfigSnapshot, key: &str) -> Option<usize> {
595    snapshot_value(snapshot, key).and_then(|value| value.parse::<usize>().ok())
596}
597
598fn snapshot_bool(snapshot: &RuntimeConfigSnapshot, key: &str) -> Option<bool> {
599    snapshot_value(snapshot, key).map(|value| matches!(value, "1" | "true" | "TRUE" | "on" | "ON"))
600}
601
602fn kv_pool_copies_from_snapshot(snapshot: &RuntimeConfigSnapshot) -> u64 {
603    let fa_layout = snapshot_bool(snapshot, "FERRUM_FA_LAYOUT_VARLEN").unwrap_or(false);
604    let fa2_source = snapshot_bool(snapshot, "FERRUM_FA2_SOURCE").unwrap_or(false);
605    let fa2_direct_ffi = snapshot_bool(snapshot, "FERRUM_FA2_DIRECT_FFI")
606        .unwrap_or_else(|| snapshot_value(snapshot, "FERRUM_FA2_DIRECT_FFI_SHIM").is_some());
607
608    if fa_layout || fa2_source || fa2_direct_ffi {
609        2
610    } else {
611        1
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    fn snapshot(vars: &[(&str, &str)]) -> RuntimeConfigSnapshot {
620        RuntimeConfigSnapshot::from_env_vars(vars.iter().copied())
621    }
622
623    fn budget_with_estimated_blocks(estimated_budget_blocks: usize) -> AutoSizeResult {
624        AutoSizeResult {
625            total_gpu_bytes: 24 * 1024 * 1024 * 1024,
626            free_gpu_bytes: 20 * 1024 * 1024 * 1024,
627            weight_bytes: 18 * 1024 * 1024 * 1024,
628            budgeted_weight_bytes: 18 * 1024 * 1024 * 1024,
629            weight_budget_shards: 1,
630            budgeted_layer_count: 40,
631            kv_block_bytes: 4 * 1024 * 1024,
632            kv_pool_copies: 1,
633            estimated_budget_blocks,
634            requested_min_blocks: 0,
635            max_blocks: estimated_budget_blocks,
636            reserved_for_scratch: SCRATCH_RESERVE_BYTES,
637        }
638    }
639
640    #[test]
641    fn fa_compatible_attention_paths_count_two_kv_pool_copies() {
642        assert_eq!(kv_pool_copies_from_snapshot(&snapshot(&[])), 1);
643        assert_eq!(
644            kv_pool_copies_from_snapshot(&snapshot(&[("FERRUM_FA_LAYOUT_VARLEN", "1")])),
645            2
646        );
647        assert_eq!(
648            kv_pool_copies_from_snapshot(&snapshot(&[("FERRUM_FA2_SOURCE", "1")])),
649            2
650        );
651        assert_eq!(
652            kv_pool_copies_from_snapshot(&snapshot(&[("FERRUM_FA2_DIRECT_FFI_SHIM", "/tmp/x.so")])),
653            2
654        );
655        assert_eq!(
656            kv_pool_copies_from_snapshot(&snapshot(&[
657                ("FERRUM_FA2_DIRECT_FFI", "0"),
658                ("FERRUM_FA2_DIRECT_FFI_SHIM", "/tmp/x.so"),
659            ])),
660            1
661        );
662    }
663
664    #[test]
665    fn layer_split_scopes_weight_and_layer_budget_to_selected_devices() {
666        let snapshot = snapshot(&[
667            (
668                crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
669                "layer_split",
670            ),
671            (crate::gpu_devices::SELECTED_GPU_DEVICES_KEY, "0,1"),
672        ]);
673
674        assert_eq!(weight_budget_shard_count(&snapshot), 2);
675        assert_eq!(layer_count_for_memory_budget(80, &snapshot), 40);
676        assert_eq!(ceil_div_u64(37, weight_budget_shard_count(&snapshot)), 19);
677    }
678
679    #[test]
680    fn unknown_multi_gpu_strategy_keeps_single_device_budget_until_wired() {
681        let snapshot = snapshot(&[
682            (
683                crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
684                "tensor_parallel",
685            ),
686            (crate::gpu_devices::SELECTED_GPU_DEVICES_KEY, "0,1"),
687        ]);
688
689        assert_eq!(weight_budget_shard_count(&snapshot), 1);
690        assert_eq!(layer_count_for_memory_budget(80, &snapshot), 80);
691    }
692
693    #[test]
694    fn requested_runtime_token_limits_define_kv_block_floor() {
695        let snapshot = snapshot(&[
696            ("FERRUM_MAX_MODEL_LEN", "8192"),
697            ("FERRUM_MAX_BATCHED_TOKENS", "1024"),
698            ("FERRUM_PAGED_MAX_SEQS", "8"),
699            ("FERRUM_KV_CAPACITY", "2048"),
700        ]);
701
702        assert_eq!(requested_min_kv_blocks_from_snapshot(&snapshot), 512);
703    }
704
705    #[test]
706    fn paged_pool_shape_decouples_admission_width_from_sequence_capacity() {
707        assert_eq!(
708            select_dynamic_paged_pool_shape(32, 16_384, 338),
709            (32, 5_408)
710        );
711        assert_eq!(
712            select_dynamic_paged_pool_shape(32, 16_384, 2_048),
713            (32, 16_384)
714        );
715        assert_eq!(select_dynamic_paged_pool_shape(32, 16_384, 8), (8, 128));
716    }
717
718    #[test]
719    fn recurrent_linear_attention_budget_pressure_selects_tight_memory_profile() {
720        let config = serde_json::json!({
721            "architectures": ["SyntheticRecurrentStateModel"],
722            "model_type": "synthetic_recurrent_state",
723            "text_config": {
724                "model_type": "synthetic_recurrent_state_text",
725                "layer_types": ["linear_attention", "full_attention"],
726                "linear_conv_kernel_dim": 4,
727                "mamba_ssm_dtype": "float32",
728                "linear_key_head_dim": 128,
729                "linear_num_key_heads": 16,
730                "linear_num_value_heads": 16,
731                "linear_value_head_dim": 128
732            }
733        });
734
735        let hints = model_auto_size_hints_from_config(&config);
736        assert!(hints.has_recurrent_linear_attention_state);
737        let class = model_auto_size_class_from_hints_and_budget(
738            hints,
739            Some(&budget_with_estimated_blocks(127)),
740        );
741        assert_eq!(class, ModelAutoSizeClass::TightRecurrentState);
742        let server = model_auto_size_defaults(class, AutoSizeProfile::Server);
743        assert_eq!(
744            server.max_batched_tokens,
745            TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS
746        );
747        assert_eq!(
748            server
749                .max_batched_tokens
750                .div_ceil(PAGED_BLOCK_SIZE as usize),
751            12
752        );
753        assert!(
754            server
755                .max_batched_tokens
756                .div_ceil(PAGED_BLOCK_SIZE as usize)
757                <= server.kv_block_floor
758        );
759        assert_eq!(server.kv_block_floor, TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR);
760        assert_eq!(
761            select_dynamic_paged_pool_shape(
762                server.max_sequences,
763                server.max_sequence_tokens,
764                server.kv_block_floor,
765            ),
766            (16, 4096)
767        );
768
769        let chat = model_auto_size_defaults(class, AutoSizeProfile::Chat);
770        assert_eq!(
771            chat.max_batched_tokens,
772            TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS
773        );
774        assert_eq!(chat.kv_block_floor, TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR);
775        assert_eq!(
776            select_dynamic_paged_pool_shape(
777                chat.max_sequences,
778                chat.max_sequence_tokens,
779                chat.kv_block_floor,
780            ),
781            (2, 4096)
782        );
783    }
784
785    #[test]
786    fn recurrent_linear_attention_memory_profile_requires_budget_pressure() {
787        let recurrent = serde_json::json!({
788            "model_type": "synthetic_recurrent_state",
789            "text_config": {
790                "layer_types": ["linear_attention", "full_attention"],
791                "linear_conv_kernel_dim": 4,
792                "mamba_ssm_dtype": "float32",
793                "linear_key_head_dim": 128,
794                "linear_num_key_heads": 16,
795                "linear_num_value_heads": 16,
796                "linear_value_head_dim": 128
797            }
798        });
799        let hints = model_auto_size_hints_from_config(&recurrent);
800        assert_eq!(
801            model_auto_size_class_from_hints_and_budget(
802                hints,
803                Some(&budget_with_estimated_blocks(128)),
804            ),
805            ModelAutoSizeClass::Generic
806        );
807        assert_eq!(
808            model_auto_size_class_from_hints_and_budget(hints, None),
809            ModelAutoSizeClass::Generic
810        );
811
812        let dense = serde_json::json!({
813            "model_type": "dense",
814            "text_config": {
815                "layer_types": ["full_attention", "full_attention"]
816            }
817        });
818        assert_eq!(
819            model_auto_size_class_from_hints_and_budget(
820                model_auto_size_hints_from_config(&dense),
821                Some(&budget_with_estimated_blocks(0)),
822            ),
823            ModelAutoSizeClass::Generic
824        );
825
826        let generic =
827            model_auto_size_defaults(ModelAutoSizeClass::Generic, AutoSizeProfile::Server);
828        assert_eq!(generic.max_batched_tokens, DEFAULT_MAX_BATCHED_TOKENS);
829        assert_eq!(generic.max_sequences, DEFAULT_SERVER_MAX_SEQUENCES);
830        assert_eq!(generic.max_sequence_tokens, MAX_AUTOSIZED_SEQUENCE_TOKENS);
831        assert_eq!(generic.kv_block_floor, 0);
832    }
833
834    #[test]
835    fn autosize_dimension_lookup_falls_back_to_text_config() {
836        let config = serde_json::json!({
837            "model_type": "synthetic_text_wrapped_model",
838            "text_config": {
839                "hidden_size": 2048,
840                "num_hidden_layers": 40,
841                "num_attention_heads": 16,
842                "num_key_value_heads": 2,
843                "head_dim": 256
844            }
845        });
846
847        assert_eq!(config_or_text_u64(&config, "hidden_size"), Some(2048));
848        assert_eq!(config_or_text_u64(&config, "num_hidden_layers"), Some(40));
849        assert_eq!(config_or_text_u64(&config, "num_key_value_heads"), Some(2));
850        assert_eq!(config_or_text_u64(&config, "head_dim"), Some(256));
851    }
852}