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 kv_overridden = snapshot_value(&current, "FERRUM_KV_MAX_BLOCKS").is_some();
267    let max_seqs_overridden = snapshot_value(&current, "FERRUM_PAGED_MAX_SEQS").is_some();
268    let max_batched_tokens_overridden =
269        snapshot_value(&current, "FERRUM_MAX_BATCHED_TOKENS").is_some();
270    let model_hints = model_auto_size_hints(model_dir);
271    let mut entries = Vec::new();
272    // ALL three knobs covered by the user — nothing to set.
273    if kv_overridden && max_seqs_overridden && max_batched_tokens_overridden {
274        return;
275    }
276    let kv_pool_copies = kv_pool_copies_from_snapshot(&current);
277    let preliminary_result = auto_size_kv_blocks_with_pool_copies_for_snapshot(
278        model_dir,
279        gpu_util,
280        kv_pool_copies,
281        &current,
282    );
283    let model_class =
284        model_auto_size_class_from_hints_and_budget(model_hints, preliminary_result.as_ref());
285    let defaults = model_auto_size_defaults(model_class, profile);
286    // Set MAX_BATCHED_TOKENS first so it lands even when the user overrode
287    // FERRUM_KV_MAX_BLOCKS + FERRUM_PAGED_MAX_SEQS (apples bench does both,
288    // which used to silently skip the Phase 3 scratch budget alongside).
289    if !max_batched_tokens_overridden {
290        // 2048 is the safe generic default across smaller dense and MoE GPTQ
291        // profiles. Tight recurrent-state models only use the smaller scratch
292        // profile when the measured weight/VRAM budget cannot cover that
293        // generic aggregate-prefill floor.
294        let mbt = defaults.max_batched_tokens;
295        entries.push(RuntimeConfigEntry::new(
296            "FERRUM_MAX_BATCHED_TOKENS",
297            mbt.to_string(),
298            RuntimeConfigSource::MemoryProfile,
299        ));
300        eprintln!(
301            "[auto-size] MAX_BATCHED_TOKENS={} (profile={:?} model={:?})",
302            mbt, profile, model_class
303        );
304    }
305    if kv_overridden && max_seqs_overridden {
306        crate::runtime_env::materialize_runtime_env_defaults(&entries);
307        return;
308    }
309    let mut budget_snapshot = current.clone();
310    for entry in &entries {
311        budget_snapshot.upsert_entry(entry.clone());
312    }
313    let result = if entries.is_empty() {
314        preliminary_result
315    } else {
316        auto_size_kv_blocks_with_pool_copies_for_snapshot(
317            model_dir,
318            gpu_util,
319            kv_pool_copies,
320            &budget_snapshot,
321        )
322    };
323    let Some(result) = result else {
324        crate::runtime_env::materialize_runtime_env_defaults(&entries);
325        return;
326    };
327    result.print_summary();
328    let max_blocks = result.max_blocks.max(defaults.kv_block_floor);
329
330    // `KV_MAX_BLOCKS` is the physical authority. Logical sequence capacity
331    // does not reserve that many blocks per admitted sequence; requests borrow
332    // blocks on demand and admission applies backpressure when the shared pool
333    // cannot satisfy the next step.
334    let (max_seqs_clamped, kv_capacity) = select_dynamic_paged_pool_shape(
335        defaults.max_sequences,
336        defaults.max_sequence_tokens,
337        max_blocks,
338    );
339
340    let kv_capacity_overridden = snapshot_value(&current, "FERRUM_KV_CAPACITY").is_some();
341    // MAX_BATCHED_TOKENS already set above (it's independent of the KV pool
342    // sizing logic, runs even when the user overrode KV_MAX_BLOCKS + SEQS).
343    // FERRUM_MOE_GRAPH is resolved as a typed CLI startup default and
344    // materialized outside the autosizer so it lands even when this function
345    // early-returns on full-override.
346    if !kv_overridden {
347        entries.push(RuntimeConfigEntry::new(
348            "FERRUM_KV_MAX_BLOCKS",
349            max_blocks.to_string(),
350            RuntimeConfigSource::MemoryProfile,
351        ));
352    }
353    if !max_seqs_overridden {
354        entries.push(RuntimeConfigEntry::new(
355            "FERRUM_PAGED_MAX_SEQS",
356            max_seqs_clamped.to_string(),
357            RuntimeConfigSource::MemoryProfile,
358        ));
359    }
360    if kv_capacity > 0 && !kv_capacity_overridden {
361        entries.push(RuntimeConfigEntry::new(
362            "FERRUM_KV_CAPACITY",
363            kv_capacity.to_string(),
364            RuntimeConfigSource::MemoryProfile,
365        ));
366    }
367    crate::runtime_env::materialize_runtime_env_defaults(&entries);
368    eprintln!(
369        "[auto-size] KV_MAX_BLOCKS={} PAGED_MAX_SEQS={} KV_CAPACITY={}",
370        if kv_overridden {
371            "<user>".to_string()
372        } else {
373            max_blocks.to_string()
374        },
375        if max_seqs_overridden {
376            "<user>".to_string()
377        } else {
378            max_seqs_clamped.to_string()
379        },
380        if kv_capacity_overridden {
381            "<user>".to_string()
382        } else if kv_capacity > 0 {
383            kv_capacity.to_string()
384        } else {
385            "<default>".to_string()
386        },
387    );
388}
389
390const MAX_AUTOSIZED_SEQUENCE_TOKENS: usize = 16_384;
391const DEFAULT_SERVER_MAX_SEQUENCES: usize = 32;
392const TIGHT_RECURRENT_STATE_SERVER_MAX_SEQUENCES: usize = 16;
393const CHAT_MAX_SEQUENCES: usize = 2;
394
395fn model_auto_size_defaults(
396    model_class: ModelAutoSizeClass,
397    profile: AutoSizeProfile,
398) -> ModelAutoSizeDefaults {
399    match (model_class, profile) {
400        (ModelAutoSizeClass::TightRecurrentState, AutoSizeProfile::Server) => {
401            ModelAutoSizeDefaults {
402                max_batched_tokens: TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS,
403                max_sequences: TIGHT_RECURRENT_STATE_SERVER_MAX_SEQUENCES,
404                max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
405                kv_block_floor: TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR,
406            }
407        }
408        (ModelAutoSizeClass::TightRecurrentState, AutoSizeProfile::Chat) => ModelAutoSizeDefaults {
409            max_batched_tokens: TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS,
410            max_sequences: CHAT_MAX_SEQUENCES,
411            max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
412            kv_block_floor: TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR,
413        },
414        (ModelAutoSizeClass::Generic, AutoSizeProfile::Server) => ModelAutoSizeDefaults {
415            max_batched_tokens: DEFAULT_MAX_BATCHED_TOKENS,
416            max_sequences: DEFAULT_SERVER_MAX_SEQUENCES,
417            max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
418            kv_block_floor: 0,
419        },
420        (ModelAutoSizeClass::Generic, AutoSizeProfile::Chat) => ModelAutoSizeDefaults {
421            max_batched_tokens: DEFAULT_MAX_BATCHED_TOKENS,
422            max_sequences: CHAT_MAX_SEQUENCES,
423            max_sequence_tokens: MAX_AUTOSIZED_SEQUENCE_TOKENS,
424            kv_block_floor: 0,
425        },
426    }
427}
428
429fn model_auto_size_hints(model_dir: &Path) -> ModelAutoSizeHints {
430    let Ok(config_text) = std::fs::read_to_string(model_dir.join("config.json")) else {
431        return ModelAutoSizeHints::default();
432    };
433    let Ok(config) = serde_json::from_str::<serde_json::Value>(&config_text) else {
434        return ModelAutoSizeHints::default();
435    };
436    model_auto_size_hints_from_config(&config)
437}
438
439fn model_auto_size_hints_from_config(config: &serde_json::Value) -> ModelAutoSizeHints {
440    ModelAutoSizeHints {
441        has_recurrent_linear_attention_state: has_recurrent_linear_attention_state(config),
442    }
443}
444
445fn model_auto_size_class_from_hints_and_budget(
446    hints: ModelAutoSizeHints,
447    budget: Option<&AutoSizeResult>,
448) -> ModelAutoSizeClass {
449    if !hints.has_recurrent_linear_attention_state {
450        return ModelAutoSizeClass::Generic;
451    }
452    let Some(budget) = budget else {
453        return ModelAutoSizeClass::Generic;
454    };
455    let generic_prefill_blocks =
456        ceil_div_usize(DEFAULT_MAX_BATCHED_TOKENS, PAGED_BLOCK_SIZE as usize);
457    if budget.estimated_budget_blocks < generic_prefill_blocks {
458        ModelAutoSizeClass::TightRecurrentState
459    } else {
460        ModelAutoSizeClass::Generic
461    }
462}
463
464fn has_recurrent_linear_attention_state(config: &serde_json::Value) -> bool {
465    let text = config.get("text_config").unwrap_or(config);
466    let has_linear_layers = text
467        .get("layer_types")
468        .and_then(|value| value.as_array())
469        .is_some_and(|layers| {
470            layers.iter().any(|layer| {
471                layer
472                    .as_str()
473                    .is_some_and(|name| name.eq_ignore_ascii_case("linear_attention"))
474            })
475        });
476    let has_linear_state_dims = [
477        "linear_conv_kernel_dim",
478        "linear_key_head_dim",
479        "linear_num_key_heads",
480        "linear_num_value_heads",
481        "linear_value_head_dim",
482    ]
483    .iter()
484    .all(|key| text.get(*key).and_then(|value| value.as_u64()).is_some());
485    has_linear_layers && has_linear_state_dims
486}
487
488fn config_or_text_u64(config: &serde_json::Value, key: &str) -> Option<u64> {
489    config
490        .get(key)
491        .and_then(|value| value.as_u64())
492        .or_else(|| {
493            config
494                .get("text_config")
495                .and_then(|text| text.get(key))
496                .and_then(|value| value.as_u64())
497        })
498}
499
500fn ceil_div_u64(value: u64, divisor: u64) -> u64 {
501    if divisor == 0 {
502        return value;
503    }
504    value.div_ceil(divisor)
505}
506
507fn ceil_div_usize(value: usize, divisor: usize) -> usize {
508    if divisor == 0 {
509        return value;
510    }
511    value.div_ceil(divisor)
512}
513
514fn select_dynamic_paged_pool_shape(
515    max_sequences: usize,
516    max_sequence_tokens: usize,
517    max_blocks: usize,
518) -> (usize, usize) {
519    let physical_blocks = max_blocks.max(1);
520    let sequences = max_sequences.max(1).min(physical_blocks);
521    let physical_tokens = physical_blocks.saturating_mul(PAGED_BLOCK_SIZE as usize);
522    let sequence_tokens = max_sequence_tokens
523        .max(PAGED_BLOCK_SIZE as usize)
524        .min(physical_tokens);
525    (sequences, sequence_tokens)
526}
527
528fn weight_budget_shard_count(snapshot: &RuntimeConfigSnapshot) -> u64 {
529    match snapshot_value(
530        snapshot,
531        crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
532    ) {
533        Some("layer_split") => selected_gpu_device_count(snapshot).max(1) as u64,
534        // Tensor-parallel support should extend this match with its own
535        // weight/KV placement rules instead of treating all multi-GPU
536        // strategies as identical.
537        _ => 1,
538    }
539}
540
541fn layer_count_for_memory_budget(num_layers: u64, snapshot: &RuntimeConfigSnapshot) -> u64 {
542    match snapshot_value(
543        snapshot,
544        crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
545    ) {
546        Some("layer_split") => {
547            let shards = selected_gpu_device_count(snapshot).max(1) as u64;
548            ceil_div_u64(num_layers, shards).max(1)
549        }
550        _ => num_layers,
551    }
552}
553
554fn selected_gpu_device_count(snapshot: &RuntimeConfigSnapshot) -> usize {
555    snapshot_value(snapshot, crate::gpu_devices::SELECTED_GPU_DEVICES_KEY)
556        .map(|value| {
557            value
558                .split(',')
559                .filter(|part| !part.trim().is_empty())
560                .count()
561        })
562        .unwrap_or(1)
563}
564
565fn requested_min_kv_blocks_from_snapshot(snapshot: &RuntimeConfigSnapshot) -> usize {
566    let max_model_len_blocks = snapshot_usize(snapshot, "FERRUM_MAX_MODEL_LEN")
567        .map(|value| ceil_div_usize(value, PAGED_BLOCK_SIZE as usize))
568        .unwrap_or(0);
569    let max_batched_token_blocks = snapshot_usize(snapshot, "FERRUM_MAX_BATCHED_TOKENS")
570        .map(|value| ceil_div_usize(value, PAGED_BLOCK_SIZE as usize))
571        .unwrap_or(0);
572
573    max_model_len_blocks.max(max_batched_token_blocks)
574}
575
576fn snapshot_value<'a>(snapshot: &'a RuntimeConfigSnapshot, key: &str) -> Option<&'a str> {
577    snapshot
578        .entries
579        .iter()
580        .find(|entry| entry.key == key)
581        .map(|entry| entry.effective_value.as_str())
582}
583
584fn snapshot_usize(snapshot: &RuntimeConfigSnapshot, key: &str) -> Option<usize> {
585    snapshot_value(snapshot, key).and_then(|value| value.parse::<usize>().ok())
586}
587
588fn snapshot_bool(snapshot: &RuntimeConfigSnapshot, key: &str) -> Option<bool> {
589    snapshot_value(snapshot, key).map(|value| matches!(value, "1" | "true" | "TRUE" | "on" | "ON"))
590}
591
592fn kv_pool_copies_from_snapshot(snapshot: &RuntimeConfigSnapshot) -> u64 {
593    let fa_layout = snapshot_bool(snapshot, "FERRUM_FA_LAYOUT_VARLEN").unwrap_or(false);
594    let fa2_source = snapshot_bool(snapshot, "FERRUM_FA2_SOURCE").unwrap_or(false);
595    let fa2_direct_ffi = snapshot_bool(snapshot, "FERRUM_FA2_DIRECT_FFI")
596        .unwrap_or_else(|| snapshot_value(snapshot, "FERRUM_FA2_DIRECT_FFI_SHIM").is_some());
597
598    if fa_layout || fa2_source || fa2_direct_ffi {
599        2
600    } else {
601        1
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn snapshot(vars: &[(&str, &str)]) -> RuntimeConfigSnapshot {
610        RuntimeConfigSnapshot::from_env_vars(vars.iter().copied())
611    }
612
613    fn budget_with_estimated_blocks(estimated_budget_blocks: usize) -> AutoSizeResult {
614        AutoSizeResult {
615            total_gpu_bytes: 24 * 1024 * 1024 * 1024,
616            free_gpu_bytes: 20 * 1024 * 1024 * 1024,
617            weight_bytes: 18 * 1024 * 1024 * 1024,
618            budgeted_weight_bytes: 18 * 1024 * 1024 * 1024,
619            weight_budget_shards: 1,
620            budgeted_layer_count: 40,
621            kv_block_bytes: 4 * 1024 * 1024,
622            kv_pool_copies: 1,
623            estimated_budget_blocks,
624            requested_min_blocks: 0,
625            max_blocks: estimated_budget_blocks,
626            reserved_for_scratch: SCRATCH_RESERVE_BYTES,
627        }
628    }
629
630    #[test]
631    fn fa_compatible_attention_paths_count_two_kv_pool_copies() {
632        assert_eq!(kv_pool_copies_from_snapshot(&snapshot(&[])), 1);
633        assert_eq!(
634            kv_pool_copies_from_snapshot(&snapshot(&[("FERRUM_FA_LAYOUT_VARLEN", "1")])),
635            2
636        );
637        assert_eq!(
638            kv_pool_copies_from_snapshot(&snapshot(&[("FERRUM_FA2_SOURCE", "1")])),
639            2
640        );
641        assert_eq!(
642            kv_pool_copies_from_snapshot(&snapshot(&[("FERRUM_FA2_DIRECT_FFI_SHIM", "/tmp/x.so")])),
643            2
644        );
645        assert_eq!(
646            kv_pool_copies_from_snapshot(&snapshot(&[
647                ("FERRUM_FA2_DIRECT_FFI", "0"),
648                ("FERRUM_FA2_DIRECT_FFI_SHIM", "/tmp/x.so"),
649            ])),
650            1
651        );
652    }
653
654    #[test]
655    fn layer_split_scopes_weight_and_layer_budget_to_selected_devices() {
656        let snapshot = snapshot(&[
657            (
658                crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
659                "layer_split",
660            ),
661            (crate::gpu_devices::SELECTED_GPU_DEVICES_KEY, "0,1"),
662        ]);
663
664        assert_eq!(weight_budget_shard_count(&snapshot), 2);
665        assert_eq!(layer_count_for_memory_budget(80, &snapshot), 40);
666        assert_eq!(ceil_div_u64(37, weight_budget_shard_count(&snapshot)), 19);
667    }
668
669    #[test]
670    fn unknown_multi_gpu_strategy_keeps_single_device_budget_until_wired() {
671        let snapshot = snapshot(&[
672            (
673                crate::gpu_devices::SELECTED_DISTRIBUTED_STRATEGY_KEY,
674                "tensor_parallel",
675            ),
676            (crate::gpu_devices::SELECTED_GPU_DEVICES_KEY, "0,1"),
677        ]);
678
679        assert_eq!(weight_budget_shard_count(&snapshot), 1);
680        assert_eq!(layer_count_for_memory_budget(80, &snapshot), 80);
681    }
682
683    #[test]
684    fn requested_runtime_token_limits_define_kv_block_floor() {
685        let snapshot = snapshot(&[
686            ("FERRUM_MAX_MODEL_LEN", "8192"),
687            ("FERRUM_MAX_BATCHED_TOKENS", "1024"),
688            ("FERRUM_PAGED_MAX_SEQS", "8"),
689            ("FERRUM_KV_CAPACITY", "2048"),
690        ]);
691
692        assert_eq!(requested_min_kv_blocks_from_snapshot(&snapshot), 512);
693    }
694
695    #[test]
696    fn paged_pool_shape_decouples_admission_width_from_sequence_capacity() {
697        assert_eq!(
698            select_dynamic_paged_pool_shape(32, 16_384, 338),
699            (32, 5_408)
700        );
701        assert_eq!(
702            select_dynamic_paged_pool_shape(32, 16_384, 2_048),
703            (32, 16_384)
704        );
705        assert_eq!(select_dynamic_paged_pool_shape(32, 16_384, 8), (8, 128));
706    }
707
708    #[test]
709    fn recurrent_linear_attention_budget_pressure_selects_tight_memory_profile() {
710        let config = serde_json::json!({
711            "architectures": ["SyntheticRecurrentStateModel"],
712            "model_type": "synthetic_recurrent_state",
713            "text_config": {
714                "model_type": "synthetic_recurrent_state_text",
715                "layer_types": ["linear_attention", "full_attention"],
716                "linear_conv_kernel_dim": 4,
717                "mamba_ssm_dtype": "float32",
718                "linear_key_head_dim": 128,
719                "linear_num_key_heads": 16,
720                "linear_num_value_heads": 16,
721                "linear_value_head_dim": 128
722            }
723        });
724
725        let hints = model_auto_size_hints_from_config(&config);
726        assert!(hints.has_recurrent_linear_attention_state);
727        let class = model_auto_size_class_from_hints_and_budget(
728            hints,
729            Some(&budget_with_estimated_blocks(127)),
730        );
731        assert_eq!(class, ModelAutoSizeClass::TightRecurrentState);
732        let server = model_auto_size_defaults(class, AutoSizeProfile::Server);
733        assert_eq!(
734            server.max_batched_tokens,
735            TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS
736        );
737        assert_eq!(
738            server
739                .max_batched_tokens
740                .div_ceil(PAGED_BLOCK_SIZE as usize),
741            12
742        );
743        assert!(
744            server
745                .max_batched_tokens
746                .div_ceil(PAGED_BLOCK_SIZE as usize)
747                <= server.kv_block_floor
748        );
749        assert_eq!(server.kv_block_floor, TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR);
750        assert_eq!(
751            select_dynamic_paged_pool_shape(
752                server.max_sequences,
753                server.max_sequence_tokens,
754                server.kv_block_floor,
755            ),
756            (16, 4096)
757        );
758
759        let chat = model_auto_size_defaults(class, AutoSizeProfile::Chat);
760        assert_eq!(
761            chat.max_batched_tokens,
762            TIGHT_RECURRENT_STATE_MAX_BATCHED_TOKENS
763        );
764        assert_eq!(chat.kv_block_floor, TIGHT_RECURRENT_STATE_KV_BLOCK_FLOOR);
765        assert_eq!(
766            select_dynamic_paged_pool_shape(
767                chat.max_sequences,
768                chat.max_sequence_tokens,
769                chat.kv_block_floor,
770            ),
771            (2, 4096)
772        );
773    }
774
775    #[test]
776    fn recurrent_linear_attention_memory_profile_requires_budget_pressure() {
777        let recurrent = serde_json::json!({
778            "model_type": "synthetic_recurrent_state",
779            "text_config": {
780                "layer_types": ["linear_attention", "full_attention"],
781                "linear_conv_kernel_dim": 4,
782                "mamba_ssm_dtype": "float32",
783                "linear_key_head_dim": 128,
784                "linear_num_key_heads": 16,
785                "linear_num_value_heads": 16,
786                "linear_value_head_dim": 128
787            }
788        });
789        let hints = model_auto_size_hints_from_config(&recurrent);
790        assert_eq!(
791            model_auto_size_class_from_hints_and_budget(
792                hints,
793                Some(&budget_with_estimated_blocks(128)),
794            ),
795            ModelAutoSizeClass::Generic
796        );
797        assert_eq!(
798            model_auto_size_class_from_hints_and_budget(hints, None),
799            ModelAutoSizeClass::Generic
800        );
801
802        let dense = serde_json::json!({
803            "model_type": "dense",
804            "text_config": {
805                "layer_types": ["full_attention", "full_attention"]
806            }
807        });
808        assert_eq!(
809            model_auto_size_class_from_hints_and_budget(
810                model_auto_size_hints_from_config(&dense),
811                Some(&budget_with_estimated_blocks(0)),
812            ),
813            ModelAutoSizeClass::Generic
814        );
815
816        let generic =
817            model_auto_size_defaults(ModelAutoSizeClass::Generic, AutoSizeProfile::Server);
818        assert_eq!(generic.max_batched_tokens, DEFAULT_MAX_BATCHED_TOKENS);
819        assert_eq!(generic.max_sequences, DEFAULT_SERVER_MAX_SEQUENCES);
820        assert_eq!(generic.max_sequence_tokens, MAX_AUTOSIZED_SEQUENCE_TOKENS);
821        assert_eq!(generic.kv_block_floor, 0);
822    }
823
824    #[test]
825    fn autosize_dimension_lookup_falls_back_to_text_config() {
826        let config = serde_json::json!({
827            "model_type": "synthetic_text_wrapped_model",
828            "text_config": {
829                "hidden_size": 2048,
830                "num_hidden_layers": 40,
831                "num_attention_heads": 16,
832                "num_key_value_heads": 2,
833                "head_dim": 256
834            }
835        });
836
837        assert_eq!(config_or_text_u64(&config, "hidden_size"), Some(2048));
838        assert_eq!(config_or_text_u64(&config, "num_hidden_layers"), Some(40));
839        assert_eq!(config_or_text_u64(&config, "num_key_value_heads"), Some(2));
840        assert_eq!(config_or_text_u64(&config, "head_dim"), Some(256));
841    }
842}