Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ against the A3B MoE DFlash perfmaxx line.
| `HIPFIRE_PROMPT_HEAT_LIMIT` | Max rows in heat dump | 64 |
| `HIPFIRE_KV_MODE` | Override kv_cache config | (config) |
| `HIPFIRE_ATTN_FLASH` | Override flash_mode config | (config) |
| `HIPFIRE_OOM_GUARD` | Memory preflight OOM guard (`kv_slots::preflight_alloc`, SlotPool arena, bench-sweep headroom check). `auto`: on for unified-memory APUs (Strix Halo — overshoot is a global OOM), off for discrete GPUs, swap-decided for GPU-less processes | `auto` (`memory.oom_guard`) |
|`HIPFIRE_DFLASH_DRAFT`|Force a specific draft path. Empty string = explicit opt-out|(filename auto-match alongside target)|
|`HIPFIRE_DFLASH_CTX_CAP`|Max rows for draft context-indexed structures (target_hidden, draft K/V caches, hidden ring). Bounds draft-side VRAM on large-`max_seq` serve loads; over-cap requests fall back to AR (identical output, slower). `0` = uncapped legacy.|8192|
|`HIPFIRE_DFLASH_WINDOW`|Windowed draft context (NInfer pattern): SWA over the last W rows on draft layers 0..n-2 + full-attention last layer reaching min(physical_cap, 4W). Draft VRAM pins at W regardless of `max_seq`; past-W requests degrade τ instead of falling back to AR. Refused with CASK eviction. `0`/unset = Legacy (cap + AR fallback).|0 (off)|
Expand Down
4 changes: 2 additions & 2 deletions crates/hipfire-cli/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside
| File | Lines | Public items | Tests |
|---|---:|---:|---:|
| [`src/bench_concurrency.rs`](src/bench_concurrency.rs) | 720 | 21 | 9 |
| [`src/main.rs`](src/main.rs) | 9,692 | 0 | 65 |
| [`src/main.rs`](src/main.rs) | 9,702 | 0 | 65 |
| [`src/serve/complete.rs`](src/serve/complete.rs) | 6,754 | 0 | 89 |
| [`src/serve/http.rs`](src/serve/http.rs) | 1,089 | 0 | 6 |
| [`src/serve/metrics.rs`](src/serve/metrics.rs) | 328 | 0 | 5 |
Expand Down Expand Up @@ -53,6 +53,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside

### Totals

- 7 modules · 22,228 lines · 23 public items · 206 tests · 0 examples
- 7 modules · 22,238 lines · 23 public items · 206 tests · 0 examples

<!-- crate-map:generated:end -->
10 changes: 10 additions & 0 deletions crates/hipfire-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3877,7 +3877,17 @@ fn bench_concurrency_command(paths: &Paths, args: &BenchArgs, spec: &str) -> Res
/// a leaked first model would show up: if the slots engine did not actually
/// release its weights, `MemAvailable` is still depressed here and this stops
/// the sweep instead of taking the box down.
///
/// `memory.oom_guard` (default `auto`) opts out or forces the check on: this
/// process never initializes a GPU, so `auto` falls back to host swap state —
/// with swap an overcommit degrades rather than kills and the check stands
/// down; without swap it stays up. A discrete-GPU box that wants the check
/// anyway pins `memory.oom_guard=true`.
fn preflight_headroom_for_model(paths: &Paths, model: &str) -> Result<()> {
if !hipfire_config::oom_guard_effective(None) {
eprintln!("memory headroom guard inactive (memory.oom_guard); continuing sweep");
return Ok(());
}
let registry = load_registry(&paths.registry).registry;
let Some(path) = find_model_path(paths, &registry, model) else {
return Ok(());
Expand Down
6 changes: 3 additions & 3 deletions crates/hipfire-config/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside
| File | Lines | Public items | Tests |
|---|---:|---:|---:|
| [`src/bin/hipfire-rocm-resolve.rs`](src/bin/hipfire-rocm-resolve.rs) | 105 | 0 | 0 |
| [`src/lib.rs`](src/lib.rs) | 5,159 | 79 | 28 |
| [`src/lib.rs`](src/lib.rs) | 5,402 | 85 | 33 |
| [`src/rocm.rs`](src/rocm.rs) | 2,460 | 39 | 37 |

### Public API surface

- [`src/bin/hipfire-rocm-resolve.rs`](src/bin/hipfire-rocm-resolve.rs): —
- [`src/lib.rs`](src/lib.rs): `rocm`, `CONFIG_SCHEMA_VERSION`, `ConfigError`, `Result`, `ConfigValue`, `DeviceSelector`, `Deepseek4ComputePlacement`, `Deepseek4CompressorCache`, `kind`, `ConfigCategory`, `ConfigScope`, `DefaultValue`, +67 more
- [`src/lib.rs`](src/lib.rs): `rocm`, `CONFIG_SCHEMA_VERSION`, `ConfigError`, `Result`, `ConfigValue`, `DeviceSelector`, `Deepseek4ComputePlacement`, `Deepseek4CompressorCache`, `kind`, `ConfigCategory`, `ConfigScope`, `DefaultValue`, +73 more
- [`src/rocm.rs`](src/rocm.rs): `DEVICE_COMPILERS`, `configured_root`, `has_configured_root`, `configured_compiler`, `has_configured_compiler`, `configured_compiler_from`, `is_strict_rocm`, `strict_from`, `strict_from_str`, `CompilerSource`, `ResolvedToolchain`, `version_for_root`, +27 more

### Dependencies (from `Cargo.toml`)
Expand All @@ -45,6 +45,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside

### Totals

- 3 modules · 7,724 lines · 118 public items · 65 tests · 0 examples
- 3 modules · 7,967 lines · 124 public items · 70 tests · 0 examples

<!-- crate-map:generated:end -->
243 changes: 243 additions & 0 deletions crates/hipfire-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,17 @@ pub static FIELDS: &[ConfigField] = &[
Some("HIPFIRE_KV_ADAPTIVE"),
"Runtime VRAM-fit KV precision policy."
),
// Process-scoped: the preflight guards snapshot this once at startup, and
// a mid-serve flip would make the refusal policy depend on which load ran
// last — dishonest for a long-lived daemon.
process_auto_bool_field!(
"memory.oom_guard",
"oom_guard",
Memory,
false,
"HIPFIRE_OOM_GUARD",
"Memory preflight OOM guard. Default auto: on for unified-memory APU architectures (GPU allocations come out of system RAM, so an overshoot can globally OOM the desktop), off for discrete GPUs, and for GPU-less processes decided by host swap state. Set true to force on, false to force off (HIPFIRE_OOM_GUARD)."
),
field!(
"model.deepseek4_experts_per_token",
"deepseek4_experts_per_token",
Expand Down Expand Up @@ -3226,6 +3237,146 @@ pub fn process_value(name: &str) -> Option<String> {
active_or_local_process_config().legacy_value(name)
}

/// Resolve the memory preflight OOM guard (`memory.oom_guard`, compat
/// `HIPFIRE_OOM_GUARD`). The guard exists because on unified-memory APUs
/// (Strix Halo) GPU allocations come out of system RAM with no swap, so a
/// bad admission takes the desktop down with a global OOM rather than
/// failing one request; on a discrete GPU an overshoot is a plain failed
/// `hipMalloc`. Default `auto` resolves per deployment class — see
/// [`oom_guard_effective`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OomGuardMode {
/// Decide by deployment class (unified-memory APU vs discrete GPU).
Auto,
/// Always refuse oversized allocations before they are made.
On,
/// Never refuse (the operator's informed trade).
Off,
}

/// Read the configured mode: `auto` (also unset or unparseable — validated
/// layers should not produce anything else), or an on/off spelling.
fn oom_guard_mode_for(value: Option<&str>) -> OomGuardMode {
match value.map(|v| v.trim().to_ascii_lowercase()) {
Some(v) if v == "0" || v == "false" || v == "off" || v == "no" => OomGuardMode::Off,
Some(v) if v == "1" || v == "true" || v == "on" || v == "yes" => OomGuardMode::On,
_ => OomGuardMode::Auto,
}
}

/// The configured mode of the memory preflight OOM guard.
pub fn oom_guard_mode() -> OomGuardMode {
oom_guard_mode_for(process_value("HIPFIRE_OOM_GUARD").as_deref())
}

/// GPU architectures whose allocations land in system RAM: the GPU has no
/// private VRAM (or only a small carve-out), so model weights and KV eat the
/// same physical memory as the desktop. An overshoot here is a global OOM,
/// not a failed hipMalloc.
pub const UNIFIED_MEMORY_ARCHS: &[&str] = &[
"gfx1035", "gfx1036", // RDNA2 APU (Van Gogh / Steam Deck class)
"gfx1103", // RDNA3 APU (Phoenix orphan)
"gfx1150", "gfx1151", "gfx1152", // RDNA3.5 APU (Strix Point / Strix Halo)
];

/// GPU architectures with private VRAM: allocations that exceed it fail
/// that one allocation instead of the machine.
pub const DISCRETE_MEMORY_ARCHS: &[&str] = &[
"gfx906", "gfx908", "gfx940", "gfx941", "gfx942", // CDNA (HBM)
"gfx1010", "gfx1011", "gfx1012", // RDNA1
"gfx1030", "gfx1031", "gfx1032", // RDNA2 dGPU
"gfx1100", "gfx1101", "gfx1102", // RDNA3 dGPU
"gfx1200", "gfx1201", // RDNA4
];

/// Whether `arch` is a unified-memory APU (GPU memory is system RAM).
pub fn is_unified_memory_arch(arch: &str) -> bool {
UNIFIED_MEMORY_ARCHS
.iter()
.any(|known| arch.eq_ignore_ascii_case(known))
}

/// Whether `arch` is a recognized discrete-VRAM GPU.
fn is_discrete_memory_arch(arch: &str) -> bool {
DISCRETE_MEMORY_ARCHS
.iter()
.any(|known| arch.eq_ignore_ascii_case(known))
}

/// `SwapTotal` (kB) from a /proc/meminfo body; `None` when absent/unreadable.
fn swap_total_kb_from_meminfo(meminfo: &str) -> Option<u64> {
for line in meminfo.lines() {
if let Some(rest) = line.strip_prefix("SwapTotal:") {
return rest.split_whitespace().next()?.parse().ok();
}
}
None
}

/// Host swap size in kB; `None` when /proc/meminfo cannot be read.
fn host_has_swap() -> Option<bool> {
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
Some(swap_total_kb_from_meminfo(&meminfo)? > 0)
}

/// Pure auto decision, testable without pinning host state. With a known GPU
/// arch the deployment class decides (unified-memory APU → on, discrete →
/// off, unrecognized → on, failing safe). Without one (no GPU has been
/// initialized in this process) the host's own lethality decides: with swap
/// an overcommit degrades instead of killing, so the guard stands down;
/// without (or with unreadable) swap, it stays up.
fn oom_guard_auto_for(arch: Option<&str>, has_swap: Option<bool>) -> bool {
match arch {
Some(arch) if is_unified_memory_arch(arch) => true,
Some(arch) if is_discrete_memory_arch(arch) => false,
Some(_) => true,
None => !matches!(has_swap, Some(true)),
}
}

/// Resolve whether the memory preflight guard should refuse allocations in
/// this process.
///
/// `arch` is the GPU arch this process initialized (see
/// `rdna_compute::arch_caps::process_gpu_arch`), or `None` when no GPU is
/// (yet) known — e.g. a CLI process that only supervises the daemon. The
/// `auto` decision is logged once to stderr with its reason so a refusal (or
/// a skipped refusal) in a daemon log explains itself.
pub fn oom_guard_effective(arch: Option<&str>) -> bool {
match oom_guard_mode() {
OomGuardMode::On => true,
OomGuardMode::Off => false,
OomGuardMode::Auto => {
static DECISION_NOTE: std::sync::Once = std::sync::Once::new();
let has_swap = host_has_swap();
let enabled = oom_guard_auto_for(arch, has_swap);
DECISION_NOTE.call_once(|| {
let why = match (arch, has_swap) {
(Some(a), _) if is_unified_memory_arch(a) => {
format!("{a}: unified-memory APU; GPU allocations come from system RAM")
}
(Some(a), _) if is_discrete_memory_arch(a) => {
format!("{a}: discrete GPU; an overshoot is a failed hipMalloc, not an OOM")
}
(Some(a), _) => format!("{a}: unrecognized arch; failing safe"),
(None, Some(true)) => {
"no GPU arch known; host has swap, so an overcommit degrades rather than kills"
.to_string()
}
(None, _) => {
"no GPU arch known; host has no readable swap; failing safe".to_string()
}
};
eprintln!(
"[oom_guard] auto: {why} → guard {}",
if enabled { "on" } else { "off" }
);
});
enabled
}
}
}

/// Compatibility-shaped access for experimental code while its public policy
/// is being consolidated. Values come exclusively from the process snapshot.
pub fn developer_var(name: &str) -> std::result::Result<String, std::env::VarError> {
Expand Down Expand Up @@ -4445,6 +4596,98 @@ mod tests {
env::temp_dir().join(format!("hipfire-config-{name}-{}", std::process::id()))
}

#[test]
fn oom_guard_mode_parses_auto_on_off() {
// Unset, "auto", and unparseable values all land on Auto — a garbage
// value must not silently disable a safety guard, nor force it past
// the deployment-class decision.
assert_eq!(oom_guard_mode_for(None), OomGuardMode::Auto);
assert_eq!(oom_guard_mode_for(Some("auto")), OomGuardMode::Auto);
assert_eq!(oom_guard_mode_for(Some("AUTO")), OomGuardMode::Auto);
assert_eq!(oom_guard_mode_for(Some("banana")), OomGuardMode::Auto);
assert_eq!(oom_guard_mode_for(Some("1")), OomGuardMode::On);
assert_eq!(oom_guard_mode_for(Some("true")), OomGuardMode::On);
assert_eq!(oom_guard_mode_for(Some("ON")), OomGuardMode::On);
// The typed bool renders "0"; raw compat spellings also count.
assert_eq!(oom_guard_mode_for(Some("0")), OomGuardMode::Off);
assert_eq!(oom_guard_mode_for(Some("false")), OomGuardMode::Off);
assert_eq!(oom_guard_mode_for(Some("OFF")), OomGuardMode::Off);
assert_eq!(oom_guard_mode_for(Some("no")), OomGuardMode::Off);
}

#[test]
fn unified_and_discrete_arch_classes_are_disjoint_and_complete() {
// Every APU arch must resolve to unified, every dGPU/CDNA arch to
// not-unified, and the two tables must never overlap.
for arch in UNIFIED_MEMORY_ARCHS {
assert!(is_unified_memory_arch(arch));
assert!(
!DISCRETE_MEMORY_ARCHS.contains(arch),
"{arch} in both tables"
);
// Case-insensitive: arch strings arrive from the HIP runtime.
assert!(is_unified_memory_arch(&arch.to_uppercase()));
}
for arch in DISCRETE_MEMORY_ARCHS {
assert!(!is_unified_memory_arch(arch));
assert!(is_discrete_memory_arch(arch));
}
assert!(is_unified_memory_arch("gfx1151"));
assert!(!is_unified_memory_arch("gfx1100"));
}

#[test]
fn oom_guard_auto_decision_matrix() {
// Known unified-memory APU: guard on regardless of host swap — GPU
// allocations land in RAM either way.
assert!(oom_guard_auto_for(Some("gfx1151"), Some(true)));
assert!(oom_guard_auto_for(Some("gfx1151"), Some(false)));
assert!(oom_guard_auto_for(Some("gfx1103"), None));
// Known discrete GPU: overshoot is a failed hipMalloc; stand down.
assert!(!oom_guard_auto_for(Some("gfx1100"), Some(true)));
assert!(!oom_guard_auto_for(Some("gfx942"), None));
assert!(!oom_guard_auto_for(Some("gfx1201"), Some(false)));
// Unrecognized arch: fail safe.
assert!(oom_guard_auto_for(Some("gfx9999"), Some(true)));
// No GPU arch in this process: the host's own lethality decides.
assert!(!oom_guard_auto_for(None, Some(true)));
assert!(oom_guard_auto_for(None, Some(false)));
// Unreadable /proc/meminfo: fail safe.
assert!(oom_guard_auto_for(None, None));
}

#[test]
fn swap_total_parses_from_meminfo() {
let with_swap = "MemTotal: 130000000 kB\nSwapTotal: 2000000 kB\nSwapFree: 2000000 kB\n";
assert_eq!(swap_total_kb_from_meminfo(with_swap), Some(2_000_000));
let no_swap = "MemTotal: 130000000 kB\nSwapTotal: 0 kB\n";
assert_eq!(swap_total_kb_from_meminfo(no_swap), Some(0));
assert_eq!(swap_total_kb_from_meminfo("MemTotal: 100 kB\n"), None);
}

#[test]
fn oom_guard_schema_field_is_process_scoped_with_env_compat() {
let field = field("memory.oom_guard").expect("oom_guard schema field");
assert_eq!(field.env_compat, Some("HIPFIRE_OOM_GUARD"));
// Default is the string "auto": the deployment-class decision, not a
// blanket on/off.
assert!(matches!(
field.default.to_value(),
ConfigValue::String(v) if v == "auto"
));
assert!(matches!(field.rule, ValueRule::AutoBool));
assert!(!field.include_builtin_in_process_config);
// The AutoBool rule must accept all three spellings end to end.
assert!(field.validate(&ConfigValue::Bool(false)).is_ok());
assert!(field.validate(&ConfigValue::Bool(true)).is_ok());
assert!(field
.validate(&ConfigValue::String("auto".to_string()))
.is_ok());
assert!(field
.validate(&ConfigValue::String("sometimes".to_string()))
.is_err());
}

#[test]
fn schema_has_unique_keys_and_legacy_keys() {
let mut canonical = std::collections::BTreeSet::new();
Expand Down
Loading
Loading