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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ jobs:
- run: python -m unittest discover -s tests -v
- run: python -m compileall -q src tests

rust-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --manifest-path control/Cargo.toml -p exvram-control-core

145 changes: 145 additions & 0 deletions control/core/src/host.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! Host RAM and Windows commit headroom. No process is created or killed here.

const PROTECT_RAM_BYTES: u64 = 1536 * 1024 * 1024;
const PROTECT_COMMIT_BYTES: u64 = 2 * 1024 * 1024 * 1024;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HostSample {
pub ram_available_bytes: u64,
pub commit_free_bytes: Option<u64>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostDecision {
pub allowed: bool,
pub reason: String,
pub kills_processes: bool,
pub changes_system_settings: bool,
}

pub fn host_pressure(sample: HostSample, protect_pc: bool) -> HostDecision {
if !protect_pc {
return HostDecision {
allowed: true,
reason: "Protect My PC выключен. Проверка RAM и commit пропущена.".into(),
kills_processes: false,
changes_system_settings: false,
};
}
if sample.ram_available_bytes < PROTECT_RAM_BYTES {
return refused(
"Свободной RAM меньше 1.5 ГиБ. Запуск отменён, чтобы не увести систему в pagefile.",
);
}
if sample
.commit_free_bytes
.is_some_and(|free| free < PROTECT_COMMIT_BYTES)
{
return refused(
"Свободный commit меньше 2 ГиБ. Запуск отменён, pagefile не меняется и чужие процессы не закрываются.",
);
}
HostDecision {
allowed: true,
reason: "Запас RAM и commit достаточен.".into(),
kills_processes: false,
changes_system_settings: false,
}
}

fn refused(reason: &str) -> HostDecision {
HostDecision {
allowed: false,
reason: reason.into(),
kills_processes: false,
changes_system_settings: false,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn protect_mode_keeps_the_one_and_a_half_gib_ram_floor() {
let decision = host_pressure(
HostSample {
ram_available_bytes: PROTECT_RAM_BYTES - 1,
commit_free_bytes: Some(8 * 1024 * 1024 * 1024),
},
true,
);
assert!(!decision.allowed);
assert!(decision.reason.contains("1.5"));
assert!(!decision.kills_processes);
assert!(!decision.changes_system_settings);
}

#[test]
fn low_commit_headroom_defers_without_touching_other_processes() {
let decision = host_pressure(
HostSample {
ram_available_bytes: 8 * 1024 * 1024 * 1024,
commit_free_bytes: Some(PROTECT_COMMIT_BYTES - 1),
},
true,
);
assert!(!decision.allowed);
assert!(decision.reason.contains("commit"));
assert!(!decision.kills_processes);
}

#[test]
fn missing_commit_reading_does_not_invent_pressure() {
let decision = host_pressure(
HostSample {
ram_available_bytes: 8 * 1024 * 1024 * 1024,
commit_free_bytes: None,
},
true,
);
assert!(decision.allowed);
}

#[test]
fn protection_off_does_not_add_a_new_refusal() {
let decision = host_pressure(
HostSample {
ram_available_bytes: 0,
commit_free_bytes: Some(0),
},
false,
);
assert!(decision.allowed);
assert!(!decision.kills_processes);
}

#[test]
fn one_hundred_thousand_decisions_stay_under_a_millisecond_each() {
let sample = HostSample {
ram_available_bytes: 5 * 1024 * 1024 * 1024,
commit_free_bytes: Some(12 * 1024 * 1024 * 1024),
};
let started = std::time::Instant::now();
for _ in 0..100_000 {
assert!(host_pressure(sample, true).allowed);
}
let elapsed = started.elapsed();
assert!(
elapsed.as_millis() < 500,
"100000 decisions took {elapsed:?}"
);
}

#[test]
fn a_comfortable_host_is_allowed() {
let decision = host_pressure(
HostSample {
ram_available_bytes: 5 * 1024 * 1024 * 1024,
commit_free_bytes: Some(12 * 1024 * 1024 * 1024),
},
true,
);
assert!(decision.allowed);
}
}
2 changes: 2 additions & 0 deletions control/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod estimate;
pub mod gpu;
pub mod host;
pub mod launch;
pub mod recipe;
pub mod recommend;
Expand All @@ -11,6 +12,7 @@ pub mod storage;

pub use estimate::{estimate, launch_gate, KvFormat, MemoryEstimate, CONTEXT_PRESETS};
pub use gpu::{parse_nvidia_smi_line, GpuSample};
pub use host::{host_pressure, HostDecision, HostSample};
pub use launch::{server_arguments, thread_count, LaunchRequest};
pub use recipe::parse_recipe_yaml;
pub use recommend::recommend;
Expand Down
49 changes: 44 additions & 5 deletions control/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::time::Duration;

use exvram_control_core::estimate::{estimate, launch_gate, preset_allowed, KvFormat, CONTEXT_PRESETS};
use exvram_control_core::gpu::parse_nvidia_smi_line;
use exvram_control_core::host::{host_pressure, HostSample};
use exvram_control_core::launch::{server_arguments, LaunchRequest};
use exvram_control_core::recipe::{identity_matches, parse_recipe_yaml, PublishedRecipe};
use exvram_control_core::recommend::recommend;
Expand Down Expand Up @@ -234,6 +235,39 @@ fn logical_cpus(system: &System) -> u32 {
system.cpus().len().max(1) as u32
}

fn commit_free_bytes() -> Option<u64> {
#[cfg(windows)]
{
#[repr(C)]
struct MemoryStatusEx {
length: u32,
memory_load: u32,
total_phys: u64,
avail_phys: u64,
total_page_file: u64,
avail_page_file: u64,
total_virtual: u64,
avail_virtual: u64,
avail_extended_virtual: u64,
}
extern "system" {
fn GlobalMemoryStatusEx(status: *mut MemoryStatusEx) -> i32;
}
unsafe {
let mut status = std::mem::zeroed::<MemoryStatusEx>();
status.length = std::mem::size_of::<MemoryStatusEx>() as u32;
if GlobalMemoryStatusEx(&mut status) == 0 {
return None;
}
Some(status.avail_page_file)
}
}
#[cfg(not(windows))]
{
None
}
}

fn sample_gpu() -> Result<exvram_control_core::GpuSample, String> {
let output = Command::new("nvidia-smi")
.args([
Expand Down Expand Up @@ -1059,11 +1093,16 @@ fn start(app: AppHandle, state: State<'_, AppState>) -> Result<Panel, String> {
notify(&app, reason);
return Ok(build_panel(&mut inner));
}
let available = inner.system.available_memory();
if settings.protect_pc && available < 1536 * 1024 * 1024 {
let reason = "Свободной RAM меньше 1.5 ГиБ. Запуск отменён, чтобы не увести систему в pagefile.";
inner.notice = reason.into();
notify(&app, reason);
let host = host_pressure(
HostSample {
ram_available_bytes: inner.system.available_memory(),
commit_free_bytes: commit_free_bytes(),
},
settings.protect_pc,
);
if !host.allowed {
inner.notice = host.reason.clone();
notify(&app, &host.reason);
return Ok(build_panel(&mut inner));
}
let port = free_port()?;
Expand Down
6 changes: 4 additions & 2 deletions docs/CONTROL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ q4 recipe when it fits. Quality does not pick a heavier KV.
On by default. The panel uses 4 threads, below-normal priority, and direct
I/O. It refuses to start when another program already holds more than 2048 MiB
of VRAM, when the estimate does not leave 256 MiB on the card, when the GGUF
is larger than physical RAM, or when less than 1.5 GiB of RAM is free. It does
not close other programs and does not change the pagefile.
is larger than physical RAM, when less than 1.5 GiB of RAM is free, or when
Windows commit headroom is under 2 GiB. It does not close other programs and
does not change the pagefile. Turning Protect My PC off skips the RAM and
commit floors; the file-larger-than-RAM check stays.

Context and KV changes apply on the next start. The running server is left
alone until Stop.
Expand Down
63 changes: 63 additions & 0 deletions docs/LANGUAGE_MIGRATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Language migration plan

Status: **audit complete on 2026-10-12**. The user-facing runtime is already
the Rust core in `control/core` plus the Tauri shell. This pass did not
start a second runtime and did not rewrite the Python lab.

Decision labels:

- `MIGRATE` — a move is justified and, if marked done, landed in this branch.
- `KEEP` — the current language is the right one.
- `OPTIONAL` — only after a measurement shows a real gap.
- `DO_NOT_TOUCH` — a port would add code without a product gain.

## Audit

| Component | Current language | Recommended language | Reason | Expected benefit | Migration risk | Decision |
|---|---|---|---|---|---|---|
| Control window, tray, static page | HTML / JS | HTML / JS | The panel is one page. A framework port does not change inference | None measured | UI regression | KEEP |
| Tauri shell: process spawn, stop, health poll | Rust | Rust | Already supervises only the llama-server it started, at below-normal priority, bound to 127.0.0.1 | Already the product path | Rewriting it duplicates a working supervisor | KEEP |
| Core: recipe, KV choice, VRAM gate, launch arguments, GPU text parse | Rust | Rust | Decisions are pure and tested. They read the published 8k recipe instead of searching | Already no Python dependency for the panel | A second optimizer would drift from the recipe | KEEP |
| Host RAM and commit gate | Was an inline RAM check in the shell | Rust | Commit headroom was not checked, so a launch could still push Windows into the pagefile | Reliability, not speed. The check refuses under 1.5 GiB free RAM or 2 GiB free commit when Protect My PC is on | A machine with a small pagefile can now be refused even when RAM looks fine | MIGRATE, done |
| `src/exvram/hardware.py` | Python | Python | One `nvidia-smi` call dominates. Measured at 121 ms, of which `nvidia-smi` itself was 80 ms | A Rust port would not move that wait | Two detectors | KEEP |
| `src/exvram/memory.py` | Python | Python | Research estimates for the lab planner, not the live panel | None for the desktop app | Divergent budgets | KEEP |
| `src/exvram/optimizer/` | Python | Python | Search over unmeasured configs belongs to the lab. The panel already calls `recommend` and stays on measured q4_0 | None until a search result is published as a recipe | Silent quality changes | KEEP |
| Adapters (llama.cpp, ExLlamaV3, HQQ, GemLite, BitNet, Ollama) | Python | Python | They describe external tools. They do not run the desktop session | None | Breaking the lab CLI | KEEP |
| `benchmark/`, P9, quantization, imatrix, calibration | Python | Python | Evaluation and quantization stay in the lab | None | Losing the active P9 path | DO_NOT_TOUCH |
| llama.cpp and ExLlamaV3 | C++ / CUDA, external | unchanged | No measured gap that those projects do not already cover | A private engine would be a larger, slower codebase | Full inference rewrite | DO_NOT_TOUCH |
| First-party CUDA | none | none until a named gap | The roadmap already refuses a kernel without a measured miss | None today | Unmaintained kernels on SM120 | DO_NOT_TOUCH |
| Recipes and JSONL results | YAML / JSONL | same files | Both sides already share `recipes/rtx5060_8gb_qwen38_27b_8k.yaml` | One recipe, no translated copy | A second schema | KEEP |

C++ was not added. No new crate was added for the host check. The shell
calls `GlobalMemoryStatusEx` and passes the numbers into `host_pressure`.

## What this branch changed

`host_pressure` lives in `control/core`. The Start command uses it instead
of the inline 1.5 GiB comparison. Protect My PC off still skips that floor.
The file-larger-than-RAM check is unchanged. The function never kills a
process and never changes the pagefile.

A release build of the core ran 100,000 decisions in **3.08 ms** (about
31 ns each). The uncommitted lab sampler's decision loop, which also builds
a list of reasons, took **161 ms** for the same count. Neither number is
the launch time. `nvidia-smi` on this machine was **80 ms**, and
`detect_hardware()` was **121 ms**. Decode and prefill were not re-run.
The panel's working set was not re-measured; the earlier debug figure in
[CONTROL.md](CONTROL.md) still stands at about 170 MiB with the window
open and no model loaded.

## What stays on Python

The lab CLI, adapters, benchmarks, P9, quantization, calibration, and the
research optimizer. NumPy and PyTorch stay where the work is already inside
those libraries. Opening the panel does not start Python.

## What not to move next

Do not port the benchmark runners, the quality scorer, or the quantization
scripts. Do not wrap llama.cpp. Do not add a CUDA kernel on the hope that
Rust or C++ is faster. The next useful check, if one is needed, is a
release-build measurement of the panel's working set and of llama-server
startup with the new commit refusal in the path. That is a measurement,
not another port.
Loading