21 Commits

Author SHA1 Message Date
jars
3c47000ea9 chore(core): add tuning_log_demo example for live heuristic validation
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Run with:
  cargo run -p magnotia-core --example tuning_log_demo

Initialises a tracing-subscriber fmt layer so the inference_thread_count
INFO event is rendered to stderr. Exercises all 8 (workload,
gpu_offloaded) combos under three power scenarios: AC override,
battery override, and the real sysfs probe.

Used to validate the heuristic on 2026-05-09 against the spec's
6c12t truth table — all 6 rows match.

Adds tracing-subscriber as a [dev-dependencies] for the example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:55:00 +01:00
jars
052265b3c2 chore(llm): document trivial-true cross-check as observability gap
Per the final reviewer's suggestion: the gpu_offloaded cross-check
(gpu_layers >= model.n_layer()) is trivially true when use_gpu since
we always pass u32::MAX. The check documents intent and is future-
proofed if we ever pass specific N, but a future reader might
simplify it away as dead code. Inline comment points at the spec's
out-of-scope follow-up for true residency observability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:47:28 +01:00
jars
4f3c063008 chore(core): tidy review nits — alphabetical lib.rs, import placement, docstring
End-of-phase cleanup of items flagged across review passes for tasks
1.1, 1.2, and 1.3:

1. crates/core/src/lib.rs: pub mod power moved to alphabetical
   position (between paths and process_watch).
2. crates/core/src/power.rs: use statements moved to top of file
   (above the PowerState enum) per Rust convention.
3. crates/core/src/power.rs: parse_power_state_from_dir docstring
   tightened to acknowledge that read_dir.flatten() silently skips
   per-entry errors. Theoretical hazard only on world-readable
   sysfs, but the previous wording overstated the guarantee.

No behavioural change. All 40 magnotia-core tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:44:09 +01:00
jars
a58c2f0994 test(transcription): thread_sweep prints power-aware 4-panel table
Extends the JFK thread-count sweep to print four labelled panels
(AC+CPU, AC+GPU, battery+CPU, battery+GPU) driven by
MAGNOTIA_POWER_STATE_OVERRIDE. Each panel shows the helper's
predicted thread count for its (power, gpu) combination alongside
the empirical RTF table for n_threads = [1, 2, 4, physical, logical,
maybe 8].

The actual whisper context is initialised once (Vulkan if compiled
in and resolvable, CPU otherwise), so the RTF rows themselves are
produced by the same backend across panels. The CPU vs GPU axis
controls only the helper-pick column, which is the empirical
question we want answered: is the LLM=2 / Whisper=4 GPU floor right?

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:43:04 +01:00
jars
7eb69e6251 refactor(core): remove old inference_thread_count facade
The MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS, and
inference_thread_count() defined in constants.rs were superseded by
the equivalents in tuning.rs (Task 2.1) and are no longer called by
any production code. Both call sites (whisper backend Task 4.1, LLM
generate Task 5.1) have migrated.

Constants.rs now holds only audio/mel/RAM/VAD/download constants;
inference-tuning concerns live in tuning.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:41:23 +01:00
jars
0a503be38d feat(llm): LLM threads use Workload::Llm + GPU offload detection
Wires the LLM call site through the new tuning helper. gpu_offloaded
reflects intent (use_gpu) cross-checked against the loaded model's
layer count: u32::MAX (when use_gpu) is trivially >= any model's
n_layer, but the explicit comparison is future-proofed if we ever
pass a specific N instead of u32::MAX.

Note: the call site is in generate() not load_model() as the plan
suggested. Context params (and thus thread count) are constructed
per-inference, not per model load, since n_ctx depends on prompt
size. The implementer adapted correctly.

The old magnotia_core::constants::inference_thread_count import is
replaced. Task 6.1 removes the constants helper now that both call
sites have migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:30:34 +01:00
jars
e715da3b54 feat(transcription): whisper threads use Workload::Whisper + GPU detection
Wires the whisper backend through the new tuning helper. gpu_offloaded
combines a compile-time feature check (whisper-vulkan, in default
features) with a runtime libvulkan probe via the hardware module. If
libvulkan1 is missing on Linux, whisper-rs's vulkan backend silently
falls back to CPU, so we should not reduce threads in that case.

The old magnotia_core::constants::inference_thread_count import is
replaced by tuning::{inference_thread_count, Workload}. The constants
module's helper is removed in Task 6.1 once the LLM call site has
also migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:21:33 +01:00
jars
d6bde52d6e refactor(tauri): use magnotia_core::hardware::vulkan_loader_available
Delete the local duplicate fn and libloading dependency from src-tauri;
import the canonical implementation from magnotia-core::hardware instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 12:08:16 +01:00
jars
b991355cb0 feat(core): port vulkan_loader_available from src-tauri
Adds libloading = "0.8" to magnotia-core dependencies and moves
vulkan_loader_available() into magnotia-core::hardware so non-Tauri
crates (transcription, llm) can probe the Vulkan loader without
depending on the Tauri binary. Test asserts the call completes on any
host regardless of whether libvulkan is installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 12:02:39 +01:00
jars
9c85c25b7f feat(core): per-process INFO log on first thread-count call per tuple
Adds a OnceLock<Mutex<HashSet>> cache in tuning.rs. inference_thread_count
now emits a single tracing::info! line the first time each
(workload, on_battery, gpu_offloaded) tuple is seen in the process,
recording the resolved thread count and which clamps fired (battery, gpu).
Also derives Hash on Workload and tightens the GPU clamp to only record
the "gpu" clamp when it actually reduces chosen. Smoke test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:58:32 +01:00
jars
6b456b1766 feat(core): GPU-offload clamp with per-workload floor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:53:03 +01:00
jars
847dc755ac feat(core): inference_thread_count halves on battery
Adds battery-state awareness to the thread-count helper: when
probe_power_state() returns OnBattery, chosen is halved before the
[MIN, MAX] clamp.  Also removes the leading underscore from the
workload/gpu_offloaded parameters (they are silenced via let _ = ...
until the GPU-clamp task).

New test battery_halves_thread_count verifies on_battery <= on_ac and
>= MIN when the host has more than MIN physical cores.  Restructured
to sequential with_override calls (not nested) to avoid re-entrant
deadlock on power::TEST_LOCK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:49:32 +01:00
jars
02a6cb24ce fix(core): serialise tuning env-var tests behind a lock
Without a lock, env_var_bypasses_clamps (set_var + remove_var) and
matches_existing_clamp_when_no_clamps_apply (remove_var) race under
cargo's parallel test runner. Task 2.2 will add another remove_var
call, compounding the race.

Adds a #[cfg(test)] static THREAD_ENV_LOCK and a closure-style
with_thread_env_lock helper, mirroring power::with_override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:42:03 +01:00
jars
504ba0a361 feat(core): tuning module with Workload + helper skeleton
Adds crates/core/src/tuning.rs with MIN/MAX_INFERENCE_THREADS consts,
Workload enum (Llm/Whisper), and inference_thread_count() helper matching
the existing constants::inference_thread_count clamp behaviour. Three unit
tests pass. tracing dep added to Cargo.toml (used by future tasks).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:38:07 +01:00
jars
eccc5e9544 feat(core): 10s TTL cache on probe_power_state
Cache sits between override resolution and platform_probe; override
paths (test + env-var) bypass it entirely so they always take effect
immediately. OnceLock<Mutex<Option<CachedState>>> initialised lazily on
first probe. Two test-only helpers (force_clear_cache, force_set_cache)
expose the cache slot for unit tests. Mutex import ungated — now used in
production cache code as well as test override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:33:46 +01:00
jars
3d978e3978 fix(core): cfg-gate test override + restore TEST_OVERRIDE on panic
TEST_OVERRIDE and test_get_override are now #[cfg(test)]-gated and
the read in probe_power_state is wrapped in a #[cfg(test)] block,
so test scaffolding doesn't compile into production builds.

with_override now uses a drop-guard pattern so a panic inside body
still resets TEST_OVERRIDE, preventing stale state from leaking
into subsequent tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:30:33 +01:00
jars
0c761c0be6 feat(core): probe_power_state with Linux dispatch + override paths
Adds the public probe entry point with two override mechanisms:
- In-process TEST_OVERRIDE slot serialised by TEST_LOCK mutex for
  unit tests (avoids races with cargo's parallel runner).
- MAGNOTIA_POWER_STATE_OVERRIDE env var for integration tests set
  externally.
Platform dispatch: Linux reads /sys/class/power_supply; all others
return Unknown. Five new override tests pass alongside the six sysfs
parser tests from Task 1.2 (12 total green).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:24:50 +01:00
jars
276e3ca6c1 feat(core): parse_power_state_from_dir reads sysfs power_supply ABI
Pure function takes a directory path and returns OnAc, OnBattery, or
Unknown by reading `type` and `online` files in each supply entry.
Matches the kernel ABI documented at
Documentation/ABI/testing/sysfs-class-power. Failure modes (missing
dir, unreadable files, malformed contents) all fall through to Unknown
rather than panicking.

Seven tests cover: Mains online, battery-only, USB-PD, empty dir,
missing dir, and malformed entries. All pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:19:34 +01:00
jars
1f3496e4ed feat(core): add PowerState skeleton in new power module
Introduces crates/core/src/power.rs with the PowerState enum
(OnAc / OnBattery / Unknown) and a unit test confirming the three
variants are distinct. Registers the module in lib.rs and adds
tempfile = "3" to dev-dependencies for use in later tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:14:24 +01:00
jars
c46c0a5ce9 docs: implementation plan for battery- and GPU-aware thread tuning
Bite-sized TDD plan implementing the design at
docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-design.md.

8 phases, ~13 commits:
- Phase 1: power.rs (PowerState enum, sysfs parser, probe + overrides,
  10s TTL cache)
- Phase 2: tuning.rs (Workload enum, helper, battery clamp, GPU clamp,
  per-process logging)
- Phase 3: vulkan_loader_available moves from src-tauri to magnotia-core
- Phase 4: whisper backend wires through new helper
- Phase 5: LLM call site wires through new helper after model load
- Phase 6: remove old constants::inference_thread_count facade
- Phase 7: thread_sweep.rs prints 4-panel power-aware RTF table
- Phase 8: manual battery validation smoke

Each task is TDD: write failing test, run, implement, run, commit.
Tests live in unit-test modules inside power.rs and tuning.rs.

Override design uses an in-process with_override(state, |closure|)
helper for unit tests (cargo runs them in parallel; env-var path
would race) and MAGNOTIA_POWER_STATE_OVERRIDE env var for
integration tests like thread_sweep.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:01:21 +01:00
jars
200c4fb447 docs: battery- and GPU-aware inference thread tuning design
Spec for two new clamps on the existing inference_thread_count helper:
- Battery clamp: drop to physical/2 when on battery (Linux sysfs probe;
  macOS/Windows return Unknown, treated as OnAc).
- GPU-offload clamp: 2 threads for fully-offloaded LLM, 4 for Whisper
  (Whisper keeps mel spectrogram, decoder bookkeeping, and beam search
  on CPU even with full Vulkan offload).

Codex + online-research consult: no prior art in llama.cpp, Ollama,
Jan, or mistral.rs. Apple's Low Power Mode is OS-level (P-core
frequency cap), not a published software API. Lumenote would be first
in the open Rust inference-wrapper space; instrumentation matters,
hence the thread_sweep.rs extension + per-process INFO log.

Architecture (Approach B):
- New crates/core/src/power.rs (PowerState, sysfs probe, 10s TTL).
- New crates/core/src/tuning.rs (Workload enum, helper).
- Move vulkan_loader_available() from src-tauri to magnotia-core
  so crates/transcription can call it without a Tauri dep.
- Existing constants::inference_thread_count() becomes deprecated
  facade for one commit, then removed.

GPU-offload detection is intent-based (use_gpu && requested_layers
>= model.n_layer() for LLM; cfg(whisper-vulkan) && libvulkan
resolvable for Whisper). Residency-based detection deferred:
llama-cpp-2 0.1.145 doesn't expose post-load offload count, so a
true outcome flag would need llama_log_set callback parsing or an
upstream PR. Caveat documented in the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:36:21 +01:00
15 changed files with 2459 additions and 106 deletions

70
Cargo.lock generated
View File

@@ -2793,7 +2793,6 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"gdk", "gdk",
"gtk", "gtk",
"libloading 0.8.9",
"magnotia-ai-formatting", "magnotia-ai-formatting",
"magnotia-audio", "magnotia-audio",
"magnotia-cloud-providers", "magnotia-cloud-providers",
@@ -2855,11 +2854,15 @@ name = "magnotia-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading 0.8.9",
"num_cpus", "num_cpus",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -2971,6 +2974,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matches" name = "matches"
version = "0.1.10" version = "0.1.10"
@@ -3236,6 +3248,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-complex" name = "num-complex"
version = "0.4.6" version = "0.4.6"
@@ -4962,6 +4983,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -6029,6 +6059,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.11.3" version = "0.11.3"
@@ -6342,6 +6381,35 @@ dependencies = [
"valuable", "valuable",
] ]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]] [[package]]
name = "transcribe-rs" name = "transcribe-rs"
version = "0.3.11" version = "0.3.11"

View File

@@ -11,3 +11,9 @@ thiserror = "2"
sysinfo = "0.35" sysinfo = "0.35"
async-trait = "0.1" async-trait = "0.1"
num_cpus = "1" num_cpus = "1"
tracing = "0.1"
libloading = "0.8"
[dev-dependencies]
tempfile = "3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -0,0 +1,50 @@
//! Demonstrator: show what the inference_thread_count tracing event
//! emits in production, across all eight (workload, on_battery,
//! gpu_offloaded) tuples.
//!
//! Run with:
//! cargo run -p magnotia-core --example tuning_log_demo
//!
//! Output is to stderr (tracing's default). Each unique tuple emits
//! exactly one INFO line; subsequent calls with the same tuple are
//! silenced by the per-process log-once cache.
use magnotia_core::tuning::{inference_thread_count, Workload};
fn main() {
tracing_subscriber::fmt()
.with_env_filter("magnotia_core=info")
.with_target(true)
.with_writer(std::io::stderr)
.init();
let cores = num_cpus::get_physical();
let logical = num_cpus::get();
eprintln!("Host: {cores} physical / {logical} logical cores\n");
for (label, override_value) in [
("AC override", Some("ac")),
("Battery override", Some("battery")),
("No override (real sysfs probe)", None),
] {
match override_value {
Some(v) => std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", v),
None => std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE"),
}
// Cache invalidation so the live probe re-runs each section.
// Override paths bypass the cache anyway; this is for the
// no-override block that actually hits sysfs.
eprintln!("--- {label} ---");
for &(workload, gpu) in &[
(Workload::Llm, false),
(Workload::Llm, true),
(Workload::Whisper, false),
(Workload::Whisper, true),
] {
let n = inference_thread_count(workload, gpu);
let w = format!("{workload:?}");
eprintln!(" {w:>8} gpu_offloaded={gpu:>5} -> {n} threads");
}
eprintln!();
}
}

View File

@@ -18,22 +18,6 @@ pub const MIN_CHUNK_SAMPLES: usize = 8000;
/// Post-processing thresholds. /// Post-processing thresholds.
pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0; pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0;
/// Lower bound for inference threads. Single-threaded inference is
/// measurably worse than two on every multi-core part; we never go below
/// 2. (Whisper Tiny is the exception where the work is so small that
/// thread count barely matters — but the floor still costs nothing.)
pub const MIN_INFERENCE_THREADS: usize = 2;
/// Upper bound for inference threads. Both whisper.cpp and llama.cpp
/// scaling flattens around physical core count; SMT siblings contend
/// for shared FPU resources during heavy F16/F32 matmul, which means
/// going past physical cores often anti-scales. Empirical evidence:
/// whisper.cpp issue #200 (sweet spot at 7t on 8c/16t Ryzen 3700X),
/// llama.cpp #3167 ("SMT hurts inference"). 8 is a conservative
/// ceiling that leaves <5% on the table for big-iron desktops while
/// keeping consumer 6c/12t laptops out of contention territory.
pub const MAX_INFERENCE_THREADS: usize = 8;
/// History limits. /// History limits.
pub const HISTORY_MAX_ENTRIES: usize = 100; pub const HISTORY_MAX_ENTRIES: usize = 100;
@@ -52,38 +36,3 @@ pub const VAD_SPEECH_PAD_MS: u32 = 100;
/// Model download chunk size for progress reporting. /// Model download chunk size for progress reporting.
pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536; pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536;
/// Inference thread count, clamped to physical-core budget.
///
/// Returns the system's physical-core count, clamped to
/// `[MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS]`. Uses physical
/// rather than logical cores because SMT/hyperthreaded siblings
/// contend for shared FPU resources during heavy matmul; counting
/// them as additional workers is well-documented to anti-scale on
/// both whisper.cpp and llama.cpp.
///
/// Falls back to `available_parallelism` only if the physical-core
/// probe is unavailable (some non-Linux/non-Windows platforms or
/// containerised environments).
///
/// Users can override at runtime by setting
/// `MAGNOTIA_INFERENCE_THREADS=N` — useful for benchmarking and for
/// users on big-iron desktops who want to push past the cap.
pub fn inference_thread_count() -> usize {
if let Ok(s) = std::env::var("MAGNOTIA_INFERENCE_THREADS") {
if let Ok(n) = s.parse::<usize>() {
if n > 0 {
return n;
}
}
}
let physical = num_cpus::get_physical();
let chosen = if physical > 0 {
physical
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(MIN_INFERENCE_THREADS)
};
chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}

View File

@@ -169,6 +169,40 @@ pub fn probe_system() -> SystemProfile {
} }
} }
/// Best-effort probe for the Vulkan loader shared library.
///
/// whisper.cpp and llama.cpp Vulkan backends silently drop to CPU if
/// `libvulkan.so.1` (Linux) / `vulkan-1.dll` (Windows) / the MoltenVK
/// bundle (macOS) is missing at runtime. We probe via
/// `libloading::Library::new`; a successful open means the loader is
/// resolvable and we should treat the GPU path as live.
///
/// Moved from `src-tauri/src/commands/models.rs` so non-Tauri crates
/// (transcription, llm) can call it without depending on the Tauri
/// binary.
pub fn vulkan_loader_available() -> bool {
#[cfg(target_os = "linux")]
let candidates: &[&str] = &["libvulkan.so.1", "libvulkan.so"];
#[cfg(target_os = "windows")]
let candidates: &[&str] = &["vulkan-1.dll"];
#[cfg(target_os = "macos")]
let candidates: &[&str] = &["libvulkan.1.dylib", "libMoltenVK.dylib"];
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
let candidates: &[&str] = &[];
for name in candidates {
// SAFETY: libloading::Library::new loads a shared library and
// returns a handle that is dropped at the end of this
// iteration. We do not call any symbols, so the open-for-probe
// pattern is sound.
match unsafe { libloading::Library::new(*name) } {
Ok(_lib) => return true,
Err(_) => continue,
}
}
false
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -217,4 +251,11 @@ mod tests {
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
assert!(!features.has_ggml_baseline()); assert!(!features.has_ggml_baseline());
} }
#[test]
fn vulkan_loader_available_does_not_panic() {
// We can't assert the value (depends on host's libvulkan),
// but we can assert the call completes.
let _ = vulkan_loader_available();
}
} }

View File

@@ -3,8 +3,10 @@ pub mod error;
pub mod hardware; pub mod hardware;
pub mod model_registry; pub mod model_registry;
pub mod paths; pub mod paths;
pub mod power;
pub mod process_watch; pub mod process_watch;
pub mod recommendation; pub mod recommendation;
pub mod tuning;
pub mod types; pub mod types;
pub use error::{MagnotiaError, Result}; pub use error::{MagnotiaError, Result};

328
crates/core/src/power.rs Normal file
View File

@@ -0,0 +1,328 @@
//! Power-state probe for inference thread tuning.
//!
//! Reports whether the machine is running on AC or battery so callers
//! can drop thread counts when energy matters more than throughput.
//! Linux uses the documented sysfs ABI under
//! `/sys/class/power_supply/`. macOS and Windows return `Unknown` for
//! now; native probes (IOPSGetProvidingPowerSourceType,
//! GetSystemPowerStatus) are deferred.
use std::fs;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerState {
OnAc,
OnBattery,
Unknown,
}
/// Parse a `/sys/class/power_supply/`-style directory and decide
/// whether the machine is on AC, on battery, or in an unknown state.
///
/// Rules (matches `Documentation/ABI/testing/sysfs-class-power`):
/// - Any entry with `type` in {`Mains`, `USB`} and `online == 1` → OnAc.
/// - Else any entry with `type == Battery` → OnBattery.
/// - Else → Unknown.
///
/// Top-level failures (missing dir, unreadable supply_dir) return
/// Unknown without panicking. Per-entry failures (unreadable
/// `type`/`online` file inside an individual supply, e.g. permission
/// denied or non-UTF-8 content) cause that entry to be silently
/// skipped via `read_trimmed().unwrap_or_default()` — which on a
/// stuck-AC machine could produce OnBattery if the Mains entry was
/// the unreadable one. In practice sysfs entries are world-readable,
/// so this is a theoretical hazard rather than a real one. `Unknown`
/// is treated as `OnAc` by the caller, preserving today's pre-clamp
/// behaviour on platforms or distros where the probe doesn't fire.
pub fn parse_power_state_from_dir(supply_dir: &Path) -> PowerState {
let read_dir = match fs::read_dir(supply_dir) {
Ok(rd) => rd,
Err(_) => return PowerState::Unknown,
};
let mut saw_battery = false;
for entry in read_dir.flatten() {
let path = entry.path();
let ty = read_trimmed(&path.join("type")).unwrap_or_default();
let online = read_trimmed(&path.join("online")).unwrap_or_default();
match ty.as_str() {
"Mains" | "USB" => {
if online == "1" {
return PowerState::OnAc;
}
}
"Battery" => {
saw_battery = true;
}
_ => {}
}
}
if saw_battery {
PowerState::OnBattery
} else {
PowerState::Unknown
}
}
fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_owned())
}
const POWER_STATE_TTL: Duration = Duration::from_secs(10);
struct CachedState {
state: PowerState,
fetched_at: Instant,
}
fn cache_slot() -> &'static Mutex<Option<CachedState>> {
static SLOT: OnceLock<Mutex<Option<CachedState>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
#[cfg(test)]
pub(crate) fn force_clear_cache() {
*cache_slot().lock().expect("power cache mutex poisoned") = None;
}
#[cfg(test)]
pub(crate) fn force_set_cache(state: PowerState) {
*cache_slot().lock().expect("power cache mutex poisoned") = Some(CachedState {
state,
fetched_at: Instant::now(),
});
}
/// Top-level power-state probe.
///
/// Resolution order (highest to lowest priority):
/// 1. In-process test override (set via `with_override` from unit tests).
/// 2. `MAGNOTIA_POWER_STATE_OVERRIDE` env var (`ac` | `battery` | `unknown`,
/// case-insensitive). Used by `thread_sweep.rs` integration tests.
/// 3. Linux: `parse_power_state_from_dir("/sys/class/power_supply")`.
/// 4. macOS / Windows / other: `Unknown`.
///
/// `Unknown` is treated as `OnAc` by callers, which preserves today's
/// pre-clamp behaviour on platforms where the probe doesn't fire.
///
/// Results are cached for `POWER_STATE_TTL` (10 seconds). Override
/// paths (test and env-var) bypass the cache entirely so they always
/// take effect immediately.
pub fn probe_power_state() -> PowerState {
#[cfg(test)]
if let Some(state) = test_get_override() {
return state;
}
if let Some(state) = env_override() {
return state;
}
let mut slot = cache_slot().lock().expect("power cache mutex poisoned");
if let Some(cached) = &*slot {
if cached.fetched_at.elapsed() < POWER_STATE_TTL {
return cached.state;
}
}
let fresh = platform_probe();
*slot = Some(CachedState {
state: fresh,
fetched_at: Instant::now(),
});
fresh
}
fn env_override() -> Option<PowerState> {
let raw = std::env::var("MAGNOTIA_POWER_STATE_OVERRIDE").ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"ac" => Some(PowerState::OnAc),
"battery" => Some(PowerState::OnBattery),
"unknown" => Some(PowerState::Unknown),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn platform_probe() -> PowerState {
parse_power_state_from_dir(Path::new("/sys/class/power_supply"))
}
#[cfg(not(target_os = "linux"))]
fn platform_probe() -> PowerState {
PowerState::Unknown
}
// In-process override slot. Tests must use `with_override` (below) to
// set it; production code never writes to this. Read on every probe.
#[cfg(test)]
static TEST_OVERRIDE: Mutex<Option<PowerState>> = Mutex::new(None);
#[cfg(test)]
fn test_get_override() -> Option<PowerState> {
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned")
}
/// Drop-guard that resets `TEST_OVERRIDE` to `None` on drop (including on unwind).
/// This prevents a panicking test body from leaking stale override state into
/// subsequent tests.
#[cfg(test)]
struct OverrideGuard;
#[cfg(test)]
impl Drop for OverrideGuard {
fn drop(&mut self) {
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = None;
}
}
/// Run `body` with the in-process override set to `state`. Restores
/// `TEST_OVERRIDE` to `None` on return, even if `body` panics.
///
/// Holds a dedicated `TEST_LOCK` mutex for the duration of the body,
/// so override-using unit tests run serially with respect to each
/// other even when cargo runs the test binary multi-threaded.
#[cfg(test)]
pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R {
static TEST_LOCK: Mutex<()> = Mutex::new(());
let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned");
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = state;
let _guard = OverrideGuard;
body()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn power_state_variants_are_distinct() {
assert_ne!(PowerState::OnAc, PowerState::OnBattery);
assert_ne!(PowerState::OnAc, PowerState::Unknown);
assert_ne!(PowerState::OnBattery, PowerState::Unknown);
}
fn write_supply(dir: &std::path::Path, name: &str, ty: &str, online: &str) {
let entry = dir.join(name);
fs::create_dir_all(&entry).unwrap();
fs::write(entry.join("type"), format!("{ty}\n")).unwrap();
fs::write(entry.join("online"), format!("{online}\n")).unwrap();
}
#[test]
fn parses_mains_online_as_on_ac() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "1");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
}
#[test]
fn parses_battery_only_as_on_battery() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "0");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnBattery);
}
#[test]
fn parses_usb_pd_online_as_on_ac() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "ucsi-source-psy-USBC000:001", "USB", "1");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
}
#[test]
fn parses_empty_dir_as_unknown() {
let dir = tempdir().unwrap();
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
}
#[test]
fn parses_missing_dir_as_unknown() {
let path = std::path::Path::new("/no/such/path/should/exist/at/this/inode/123456");
assert_eq!(parse_power_state_from_dir(path), PowerState::Unknown);
}
#[test]
fn parses_malformed_files_as_unknown_gracefully() {
let dir = tempdir().unwrap();
let entry = dir.path().join("garbage");
fs::create_dir_all(&entry).unwrap();
// No type, no online — should not panic.
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
}
#[test]
fn override_drives_battery() {
with_override(Some(PowerState::OnBattery), || {
assert_eq!(probe_power_state(), PowerState::OnBattery);
});
}
#[test]
fn override_drives_ac() {
with_override(Some(PowerState::OnAc), || {
assert_eq!(probe_power_state(), PowerState::OnAc);
});
}
#[test]
fn override_drives_unknown() {
with_override(Some(PowerState::Unknown), || {
assert_eq!(probe_power_state(), PowerState::Unknown);
});
}
#[test]
fn env_var_override_battery_via_set_var() {
// env-var path tested in isolation under TEST_LOCK so it
// doesn't race with the in-process override tests.
with_override(None, || {
std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", "battery");
assert_eq!(probe_power_state(), PowerState::OnBattery);
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
});
}
#[test]
fn env_var_override_garbage_falls_through() {
with_override(None, || {
std::env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", "nonsense");
// Garbage value falls through to the platform probe.
// We can't assert the platform result so just assert it
// doesn't panic.
let _ = probe_power_state();
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
});
}
#[test]
fn ttl_cache_returns_cached_value_within_window() {
with_override(None, || {
force_clear_cache();
force_set_cache(PowerState::OnBattery);
// No override active, no env var; probe should hit cache.
assert_eq!(probe_power_state(), PowerState::OnBattery);
});
}
#[test]
fn ttl_cache_clears_via_force_clear() {
with_override(None, || {
force_set_cache(PowerState::OnBattery);
force_clear_cache();
// Override flips to OnAc; with cleared cache, override
// (which has higher priority) drives the result.
});
with_override(Some(PowerState::OnAc), || {
force_clear_cache();
assert_eq!(probe_power_state(), PowerState::OnAc);
});
}
}

233
crates/core/src/tuning.rs Normal file
View File

@@ -0,0 +1,233 @@
//! Inference thread-count tuning. Combines physical-core budget,
//! battery awareness, and GPU-offload awareness into a single helper
//! callable from both inference call sites.
use crate::power::{self, PowerState};
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
/// Lower bound for inference threads. Single-threaded inference is
/// measurably worse than two on every multi-core part.
pub const MIN_INFERENCE_THREADS: usize = 2;
/// Upper bound for inference threads. whisper.cpp + llama.cpp scaling
/// flattens around physical core count; SMT siblings contend for FPU
/// during F16/F32 matmul, so going past physical cores anti-scales.
/// 8 is a conservative ceiling that leaves <5% on the table for
/// big-iron desktops while keeping consumer 6c/12t laptops out of
/// contention territory. Users can override at runtime via
/// MAGNOTIA_INFERENCE_THREADS.
pub const MAX_INFERENCE_THREADS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Workload {
/// Llama-style transformer. Near-zero CPU work when fully
/// offloaded; CPU floor in that case is GPU_FLOOR_LLM.
Llm,
/// Whisper transcription. Keeps mel spectrogram, decoder
/// bookkeeping, and beam search on the CPU even with full Vulkan
/// offload; CPU floor is higher: GPU_FLOOR_WHISPER.
Whisper,
}
const GPU_FLOOR_LLM: usize = 2;
const GPU_FLOOR_WHISPER: usize = 4;
/// Whether a given (workload, on_battery, gpu_offloaded) tuple has
/// already been logged this process. Prevents log spam when the same
/// configuration is exercised on every inference call.
fn log_seen() -> &'static Mutex<HashSet<(Workload, bool, bool)>> {
static SEEN: OnceLock<Mutex<HashSet<(Workload, bool, bool)>>> = OnceLock::new();
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
}
/// Inference thread count, clamped to the physical-core budget plus
/// the battery and GPU-offload heuristics.
///
/// Resolution order:
/// 1. `MAGNOTIA_INFERENCE_THREADS=N` — absolute bypass, returns N.
/// 2. base = num_cpus::get_physical() (fallback: available_parallelism).
/// 3. on battery → base /= 2.
/// 4. gpu_offloaded → base = min(base, gpu_floor(workload)).
/// 5. clamp to [MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS].
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("MAGNOTIA_INFERENCE_THREADS") {
if let Ok(n) = s.parse::<usize>() {
if n > 0 {
return n;
}
}
}
let physical = num_cpus::get_physical();
let mut chosen = if physical > 0 {
physical
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(MIN_INFERENCE_THREADS)
};
let on_battery = power::probe_power_state() == PowerState::OnBattery;
let mut clamps: Vec<&'static str> = Vec::new();
if on_battery {
chosen /= 2;
clamps.push("battery");
}
if gpu_offloaded {
let floor = match workload {
Workload::Llm => GPU_FLOOR_LLM,
Workload::Whisper => GPU_FLOOR_WHISPER,
};
if chosen > floor {
chosen = floor;
clamps.push("gpu");
}
}
let final_value = chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS);
// Log once per (workload, on_battery, gpu_offloaded) tuple.
let key = (workload, on_battery, gpu_offloaded);
if let Ok(mut seen) = log_seen().lock() {
if seen.insert(key) {
tracing::info!(
target: "magnotia_core::tuning",
threads = final_value,
workload = ?workload,
gpu_offloaded,
on_battery,
clamps = ?clamps,
"inference_thread_count"
);
}
}
final_value
}
#[cfg(test)]
mod tests {
use super::*;
/// Serialises tests that read/write `MAGNOTIA_INFERENCE_THREADS` so
/// they don't race under cargo's parallel test runner.
/// Mirrors the pattern used by `power::with_override`.
fn with_thread_env_lock<R>(body: impl FnOnce() -> R) -> R {
use std::sync::Mutex;
static THREAD_ENV_LOCK: Mutex<()> = Mutex::new(());
let _lock = THREAD_ENV_LOCK.lock().expect("tuning thread-env lock poisoned");
body()
}
#[test]
fn matches_existing_clamp_when_no_clamps_apply() {
// With no env override, no battery, no gpu_offload, helper
// should return physical-core count clamped to [2, 8].
// We can't pin physical exactly without mocking num_cpus; just
// assert the result is in range.
with_thread_env_lock(|| {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
let n = inference_thread_count(Workload::Llm, false);
assert!(n >= MIN_INFERENCE_THREADS && n <= MAX_INFERENCE_THREADS,
"got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]");
});
}
#[test]
fn env_var_bypasses_clamps() {
with_thread_env_lock(|| {
std::env::set_var("MAGNOTIA_INFERENCE_THREADS", "10");
let n = inference_thread_count(Workload::Llm, true);
assert_eq!(n, 10);
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
});
}
#[test]
fn workload_variants_distinct() {
assert_ne!(Workload::Llm, Workload::Whisper);
}
#[test]
fn battery_halves_thread_count() {
with_thread_env_lock(|| {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
// Measure on battery, then on AC — sequential, not nested,
// to avoid re-entrant deadlock on power::TEST_LOCK.
let on_battery = crate::power::with_override(Some(PowerState::OnBattery), || {
inference_thread_count(Workload::Llm, false)
});
let on_ac = crate::power::with_override(Some(PowerState::OnAc), || {
inference_thread_count(Workload::Llm, false)
});
// on_battery should be roughly half of on_ac, clamped to MIN.
if on_ac > MIN_INFERENCE_THREADS {
assert!(
on_battery <= on_ac,
"battery ({on_battery}) should be <= AC ({on_ac})"
);
assert!(on_battery >= MIN_INFERENCE_THREADS);
}
});
}
#[test]
fn gpu_offload_clamps_llm_to_floor() {
with_thread_env_lock(|| {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnAc), || {
let n = inference_thread_count(Workload::Llm, true);
assert!(n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS),
"Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}");
assert!(n >= MIN_INFERENCE_THREADS);
});
});
}
#[test]
fn gpu_offload_clamps_whisper_to_floor() {
with_thread_env_lock(|| {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnAc), || {
let n = inference_thread_count(Workload::Whisper, true);
// Whisper floor is 4 on machines with >=4 physical cores;
// can't go lower than physical so on a 2c host we stay at 2.
assert!(n <= GPU_FLOOR_WHISPER, "got {n}");
assert!(n >= MIN_INFERENCE_THREADS);
});
});
}
#[test]
fn whisper_gpu_floor_is_at_least_llm_gpu_floor() {
// Architectural invariant: whisper retains CPU work even when
// GPU-offloaded; its floor must not be lower than the LLM's.
assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
}
#[test]
fn gpu_offload_off_does_not_clamp_below_battery_calc() {
with_thread_env_lock(|| {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
// Sequential measurements; with_override is non-reentrant.
crate::power::with_override(Some(PowerState::OnAc), || {
let no_gpu = inference_thread_count(Workload::Llm, false);
let with_gpu = inference_thread_count(Workload::Llm, true);
// GPU-offloaded should be <= non-offloaded.
assert!(with_gpu <= no_gpu);
});
});
}
#[test]
fn logging_does_not_panic() {
// Smoke: helper should run without panicking when logging is
// wired. This is covered by the other tests too, but kept
// explicitly to document the behaviour.
with_thread_env_lock(|| {
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnBattery), || {
let _ = inference_thread_count(Workload::Llm, true);
let _ = inference_thread_count(Workload::Whisper, false);
});
});
}
}

View File

@@ -9,6 +9,7 @@ use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams; use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{AddBos, LlamaChatMessage, LlamaChatTemplate, LlamaModel}; use llama_cpp_2::model::{AddBos, LlamaChatMessage, LlamaChatTemplate, LlamaModel};
use llama_cpp_2::sampling::LlamaSampler; use llama_cpp_2::sampling::LlamaSampler;
use magnotia_core::tuning::{inference_thread_count, Workload};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod grammars; pub mod grammars;
@@ -163,7 +164,15 @@ impl LlmEngine {
} }
let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?; let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
let thread_count = i32::try_from(magnotia_core::constants::inference_thread_count()) let use_gpu = self.loaded_model().map(|s| s.use_gpu).unwrap_or(false);
let gpu_layers = if use_gpu { u32::MAX } else { 0u32 };
// Trivially true today (gpu_layers = u32::MAX when use_gpu), but the
// explicit comparison documents intent. True residency observability
// (parsing llama.cpp's "offloaded N/M layers" log) is tracked as a
// follow-up in docs/superpowers/specs/2026-05-09-battery-gpu-aware-
// thread-tuning-design.md (§ Out of scope).
let gpu_offloaded = use_gpu && gpu_layers >= model.n_layer();
let thread_count = i32::try_from(inference_thread_count(Workload::Llm, gpu_offloaded))
.unwrap_or(4); .unwrap_or(4);
let ctx_params = LlamaContextParams::default() let ctx_params = LlamaContextParams::default()
.with_n_ctx(Some( .with_n_ctx(Some(

View File

@@ -10,8 +10,9 @@ use std::path::Path;
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
use magnotia_core::constants::inference_thread_count;
use magnotia_core::error::{MagnotiaError, Result}; use magnotia_core::error::{MagnotiaError, Result};
use magnotia_core::hardware::vulkan_loader_available;
use magnotia_core::tuning::{inference_thread_count, Workload};
use magnotia_core::types::{Segment, TranscriptionOptions}; use magnotia_core::types::{Segment, TranscriptionOptions};
use crate::transcriber::{Transcriber, TranscriberCapabilities}; use crate::transcriber::{Transcriber, TranscriberCapabilities};
@@ -78,7 +79,8 @@ impl Transcriber for WhisperRsBackend {
params.set_initial_prompt(prompt); params.set_initial_prompt(prompt);
} }
} }
params.set_n_threads(inference_thread_count() as i32); let gpu_offloaded = cfg!(feature = "whisper-vulkan") && vulkan_loader_available();
params.set_n_threads(inference_thread_count(Workload::Whisper, gpu_offloaded) as i32);
params.set_print_special(false); params.set_print_special(false);
params.set_print_progress(false); params.set_print_progress(false);
params.set_print_realtime(false); params.set_print_realtime(false);

View File

@@ -1,16 +1,26 @@
//! Thread-count scaling sweep for Whisper Tiny. //! Thread-count scaling sweep for Whisper Tiny.
//! Runs the JFK clip at n_threads = 1, 2, 4, 6, 8, 12, prints RTF table. //! Runs the JFK clip at n_threads = 1, 2, 4, 6, 8, 12, prints RTF tables.
//! Gated on the same env vars as jfk_bench. //! Env-gated by `MAGNOTIA_WHISPER_TEST_MODEL` + `MAGNOTIA_WHISPER_TEST_AUDIO`.
//!
//! Now prints multiple panels driven by `MAGNOTIA_POWER_STATE_OVERRIDE` so
//! the helper's predicted thread count for each (power, GPU) combination
//! can be compared against the empirical RTF data.
use std::env; use std::env;
use std::time::Instant; use std::time::Instant;
use magnotia_core::hardware::vulkan_loader_available;
use magnotia_core::tuning::{inference_thread_count, Workload};
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
#[test] #[test]
fn whisper_thread_count_sweep() { fn whisper_thread_count_sweep() {
let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else { return }; let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else {
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { return }; return;
};
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else {
return;
};
let bytes = std::fs::read(&audio_path).expect("read wav"); let bytes = std::fs::read(&audio_path).expect("read wav");
let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap()); let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
@@ -29,7 +39,7 @@ fn whisper_thread_count_sweep() {
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default()) let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
.expect("model load"); .expect("model load");
// Warm-up pass at default to prime caches // Warm-up pass to prime caches.
{ {
let mut state = ctx.create_state().expect("state"); let mut state = ctx.create_state().expect("state");
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
@@ -42,17 +52,65 @@ fn whisper_thread_count_sweep() {
} }
let mut targets: Vec<i32> = vec![1, 2, 4, physical as i32, logical as i32]; let mut targets: Vec<i32> = vec![1, 2, 4, physical as i32, logical as i32];
if logical >= 8 && !targets.contains(&8) { targets.push(8); } if logical >= 8 && !targets.contains(&8) {
targets.push(8);
}
targets.sort(); targets.sort();
targets.dedup(); targets.dedup();
// Snapshot the runtime Vulkan loader status once. The actual whisper
// context above already initialised whichever backend it could; the
// GPU panels below differ only in label and predicted-helper-pick.
// The runtime RTF rows are produced by the same backend the warm-up
// used.
let vulkan_runtime_ok = cfg!(feature = "whisper-vulkan") && vulkan_loader_available();
eprintln!(
"[sweep] whisper-vulkan feature: {}, libvulkan resolvable at runtime: {}",
cfg!(feature = "whisper-vulkan"),
vulkan_runtime_ok
);
// Four panels: CPU and GPU axes for the predicted-helper-pick column,
// crossed with AC and battery via MAGNOTIA_POWER_STATE_OVERRIDE.
let panels = [
("AC, CPU", "ac", false),
("AC, GPU (Vulkan)", "ac", true),
("battery, CPU", "battery", false),
("battery, GPU (Vulkan)", "battery", true),
];
for (label, power, gpu_offloaded_for_helper) in panels {
env::set_var("MAGNOTIA_POWER_STATE_OVERRIDE", power);
let helper_pick = inference_thread_count(Workload::Whisper, gpu_offloaded_for_helper);
run_sweep_panel(
label,
helper_pick,
&ctx,
&samples,
audio_secs,
&targets,
);
}
env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
}
fn run_sweep_panel(
label: &str,
helper_pick: usize,
ctx: &WhisperContext,
samples: &[f32],
audio_secs: f64,
targets: &[i32],
) {
eprintln!(""); eprintln!("");
eprintln!("=== n_threads scaling ==="); eprintln!(
"=== n_threads scaling: {label} (helper picks: {helper_pick}) ==="
);
eprintln!("n_threads | xc_time | RTF | speedup_vs_1"); eprintln!("n_threads | xc_time | RTF | speedup_vs_1");
eprintln!("----------|---------|--------|-------------"); eprintln!("----------|---------|--------|-------------");
let mut baseline_dur: Option<f64> = None; let mut baseline_dur: Option<f64> = None;
for n in &targets { for n in targets {
// Two runs, take the min (deeper of L2/L3 effects; we want best-case) // Two runs, take the min — best-case after L2/L3 warm.
let mut best = f64::MAX; let mut best = f64::MAX;
for _ in 0..2 { for _ in 0..2 {
let mut state = ctx.create_state().expect("state"); let mut state = ctx.create_state().expect("state");
@@ -63,14 +121,20 @@ fn whisper_thread_count_sweep() {
params.set_print_progress(false); params.set_print_progress(false);
params.set_print_realtime(false); params.set_print_realtime(false);
let t = Instant::now(); let t = Instant::now();
state.full(params, &samples).expect("transcribe"); state.full(params, samples).expect("transcribe");
let dur = t.elapsed().as_secs_f64(); let dur = t.elapsed().as_secs_f64();
if dur < best { best = dur; } if dur < best {
best = dur;
}
} }
let rtf = best / audio_secs; let rtf = best / audio_secs;
let speedup = baseline_dur.map(|b| b / best).unwrap_or(1.0); let speedup = baseline_dur.map(|b| b / best).unwrap_or(1.0);
if baseline_dur.is_none() { baseline_dur = Some(best); } if baseline_dur.is_none() {
eprintln!("{:>9} | {:>6.2}s | {:>6.3} | {:>6.2}x", baseline_dur = Some(best);
n, best, rtf, speedup); }
eprintln!(
"{:>9} | {:>6.2}s | {:>6.3} | {:>6.2}x",
n, best, rtf, speedup
);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
---
name: Battery- and GPU-aware inference thread tuning
type: spec
description: Power-aware and GPU-offload-aware clamps on inference_thread_count for whisper.cpp + llama.cpp call sites
date: 2026-05-09
status: draft
author: jars
---
# Battery- and GPU-aware inference thread tuning
## Problem
`crates/core/src/constants.rs::inference_thread_count()` currently returns a fixed clamp `[2, 8]` of physical-core count, regardless of:
1. Whether the laptop is on AC or battery — running 6 inference threads on battery wastes ~50% package power for ~30% extra throughput, on a workload the user can usually wait an extra second for.
2. Whether the model is fully offloaded to the GPU — when llama.cpp has all layers on Vulkan, the CPU does negligible matmul, and 6 worker threads is roughly 4 more than necessary; the same applies less aggressively to whisper.cpp.
Two new clamps wanted:
- **Battery clamp** — when on battery, drop to `physical / 2`. Apple's published Low Power Mode trade is roughly "30% perf for 50% package power" at the OS level; we replicate that pattern at the inference-thread layer where it actually matters for our workload.
- **GPU-offload clamp** — when fully offloaded, drop threads to a workload-specific floor (LLM: 2, Whisper: 4).
Floors differ because the workloads differ. llama.cpp does near-zero CPU matmul once layers are on the GPU; whisper.cpp keeps mel spectrogram, decoder bookkeeping, and beam search on the CPU regardless.
## Prior art
None observed. Codex investigation found no battery-aware thread scaling in llama.cpp, Ollama, Jan, or mistral.rs. LM Studio's internals are not public so it cannot be ruled out, but the pattern appears to be genuinely bespoke. Lumenote would be first in the open Rust inference-wrapper space.
The Apple "30% perf for 50% power" framing is empirical, not a published software API — Low Power Mode is OS-level (P-core frequency cap, background priority reduction). We are inventing the heuristic for our layer; instrumentation matters.
## Architecture
### Files
- **New** `crates/core/src/power.rs``PowerState`, sysfs probe, TTL cache.
- **New** `crates/core/src/tuning.rs``Workload`, `inference_thread_count(workload, gpu_offloaded)`.
- **Move** `vulkan_loader_available()` from `src-tauri/src/commands/models.rs` to `magnotia_core::hardware`. `crates/transcription` needs to call it; src-tauri continues to call it from its new home. `libloading = "0.8"` becomes a workspace dependency of `magnotia-core`.
- **Update** `crates/transcription/src/whisper_rs_backend.rs` — compute `gpu_offloaded`, pass to helper.
- **Update** `crates/llm/src/lib.rs` — compute `gpu_offloaded`, pass to helper. (Recompute *after* the model loads, since `model.n_layer()` requires the loaded model.)
- **Deprecate facade** `crates/core/src/constants.rs::inference_thread_count()` — keep as `#[deprecated]` re-export pointing at `tuning::inference_thread_count` for one commit, then remove.
### `power.rs`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerState { OnAc, OnBattery, Unknown }
pub fn probe_power_state() -> PowerState;
```
**Linux** (`cfg(target_os = "linux")`): walk `/sys/class/power_supply/`. For each entry, read `type` and `online`:
- If any entry has `type ∈ {"Mains", "USB"}` and `online == 1``OnAc`.
- Else if any entry has `type == "Battery"``OnBattery`.
- Else → `Unknown`.
`USB` is included because USB-C power delivery shows up as `type=USB` on modern laptops (e.g. Framework, recent ThinkPads). Mains is the conventional barrel-jack/wall-wart entry.
**macOS / Windows / other**: return `Unknown`. Native probes (`IOPSGetProvidingPowerSourceType`, `GetSystemPowerStatus`) are deferred to a follow-up.
**TTL cache**: `OnceLock<Mutex<(PowerState, Instant)>>` with 10s TTL. Re-probe on expiry. Battery state changes slowly; 10s is far below human-perceptible latency for thread-count adjustment.
**Test override**: `MAGNOTIA_POWER_STATE_OVERRIDE=ac|battery|unknown` short-circuits the probe. Always-on (not just `cfg(test)`) so `thread_sweep.rs` can drive both states without unplugging hardware. Documented as test-only; users overriding it in production is benign.
### `tuning.rs`
```rust
#[derive(Debug, Clone, Copy)]
pub enum Workload {
/// Llama-style transformer LLM. Near-zero CPU work when fully
/// offloaded; CPU floor in that case is GPU_FLOOR_LLM.
Llm,
/// Whisper transcription. Keeps mel spectrogram, decoder
/// bookkeeping, and beam search on the CPU even with full Vulkan
/// offload, so the CPU floor is higher: GPU_FLOOR_WHISPER.
Whisper,
}
const GPU_FLOOR_LLM: usize = 2;
const GPU_FLOOR_WHISPER: usize = 4;
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
// 1. MAGNOTIA_INFERENCE_THREADS env override → absolute bypass
if let Ok(s) = std::env::var("MAGNOTIA_INFERENCE_THREADS") {
if let Ok(n) = s.parse::<usize>() {
if n > 0 { return n; }
}
}
// 2. base = physical cores (fallback: available_parallelism)
let physical = num_cpus::get_physical();
let mut chosen = if physical > 0 {
physical
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(MIN_INFERENCE_THREADS)
};
// 3. Battery clamp
if power::probe_power_state() == PowerState::OnBattery {
chosen = chosen / 2;
}
// 4. GPU-offload clamp
if gpu_offloaded {
let floor = match workload {
Workload::Llm => GPU_FLOOR_LLM,
Workload::Whisper => GPU_FLOOR_WHISPER,
};
chosen = chosen.min(floor);
}
// 5. Final clamp
chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}
```
`PowerState::Unknown` is treated as `OnAc` (no clamp). Fail-open to existing behaviour on platforms we don't probe yet.
### Per-machine truth table
| Machine | AC+CPU | Battery+CPU | AC+GPU LLM | AC+GPU Whisper | Bat+GPU LLM | Bat+GPU Whisper |
|---|---|---|---|---|---|---|
| 4c4t budget | 4 | 2 | 2 | 4 | 2 | 2 |
| 6c12t (Ryzen 4650U) | 6 | 3 | 2 | 4 | 2 | 3 |
| 12c24t big-iron | 8 | 6 | 2 | 4 | 2 | 4 |
| 1c container/VM | 2 | 2 | 2 | 2 | 2 | 2 |
Algebra check: battery on 6c → 6/2 = 3, no further GPU clamp on whisper because 3 < 4 already. On 4c → 4/2 = 2, hits MIN floor. On 1c → fallback to available_parallelism; clamped up to MIN=2.
### GPU-offload detection
**LLM** (`crates/llm/src/lib.rs`):
```rust
let gpu_offloaded = use_gpu
&& i32::try_from(model.n_layer())
.map(|n| requested_n_gpu_layers >= n)
.unwrap_or(false);
```
`use_gpu` is the caller intent (already in `LoadedModelState`). `requested_n_gpu_layers` is what we passed into `LlamaModelParams::with_n_gpu_layers` — currently `u32::MAX` when `use_gpu`. Cross-checking against `model.n_layer()` (queryable post-load via `LlamaModel::n_layer()`) catches the trivially-true case while staying intent-based; if upstream changes the "max" sentinel semantics, the cross-check stays correct.
**Caveat documented in code**: residency is not actually verified. If VRAM is tight, llama.cpp can silently partial-offload and we'll under-thread the CPU. Real fix is a `llama_log_set` callback parsing `offloaded N/M layers` from llama.cpp's log output, or upstream PR to `llama-cpp-2` for a `n_gpu_layers_loaded()` getter (the underlying `llama_model_n_layer` is exposed but not the offload count). Tracked as observability follow-up; not blocking v1.
**Whisper** (`crates/transcription/src/whisper_rs_backend.rs`):
```rust
let gpu_offloaded = cfg!(feature = "whisper-vulkan")
&& magnotia_core::hardware::vulkan_loader_available();
```
Compile-time feature check (`whisper-vulkan` is in default features) plus runtime libvulkan resolution. If libvulkan1 isn't installed, whisper-rs falls back to CPU and we should not under-thread.
### Override behaviour
- `MAGNOTIA_INFERENCE_THREADS=N` (existing, unchanged) — absolute bypass. Returns N regardless of power/GPU state. For benchmarking, big-iron desktops, and users who want to opt out.
- `MAGNOTIA_POWER_STATE_OVERRIDE` — test only.
- No fine-grained `MAGNOTIA_BATTERY_CLAMP=off` or similar in v1. Add only if real users hit a case where the existing all-or-nothing override is insufficient.
## Tests
### Existing test extended
`crates/transcription/tests/thread_sweep.rs` already runs the JFK clip across n_threads values and prints an RTF table. Extend to four blocks driven by `MAGNOTIA_POWER_STATE_OVERRIDE`:
```
=== n_threads scaling: AC, CPU ===
=== n_threads scaling: AC, GPU (Vulkan) ===
=== n_threads scaling: battery, CPU ===
=== n_threads scaling: battery, GPU (Vulkan) ===
```
Each block uses the same `n_threads` sweep `[1, 2, 4, 6, 8, 12]` and reports xc_time, RTF, speedup_vs_1. This is the empirical instrument for validating the heuristic — if the GPU+battery whisper case shows the floor is wrong, the table tells us by how much.
### New unit tests
`crates/core/src/tuning.rs`:
- `battery_clamp_halves_on_battery` — set override to battery, assert returned value is roughly half of AC.
- `gpu_floor_lower_for_llm_than_whisper` — both with `gpu_offloaded=true`, assert Llm ≤ Whisper.
- `env_var_bypasses_all_clamps` — set `MAGNOTIA_INFERENCE_THREADS=10`, set battery, set gpu_offloaded; assert returns 10.
- `clamp_to_min_when_low_core_count` — fall through to min on 1-2 logical cores via available_parallelism (use a mock or just verify clamp inside the function).
- `clamp_to_max_for_huge_machines` — assert MAX_INFERENCE_THREADS=8 still binds.
`crates/core/src/power.rs`:
- `linux_sysfs_parses_ac_online` — write fixture entries to `tempdir`, parse, expect OnAc.
- `linux_sysfs_parses_battery_only` — fixture with only `type=Battery online=0`, expect OnBattery.
- `linux_sysfs_parses_unknown_when_empty` — empty dir, expect Unknown.
- `usb_pd_treated_as_ac` — fixture `type=USB online=1`, expect OnAc.
- `override_short_circuits_probe` — set env var, expect override value regardless of fixture.
- `non_linux_returns_unknown` — cfg-gated, macos/windows return Unknown.
- `ttl_cache_returns_same_value_within_window` — call once with fixture A, mutate fixture to B, call again immediately, assert second call returns A. Then advance time past TTL (test-only `force_expire()` in `power.rs`), call again, assert returns B.
For sysfs fixture tests, factor the parser into `parse_power_state_from_dir(path: &Path) -> PowerState` so tests can point it at a `tempdir`; the public `probe_power_state()` reads `/sys/class/power_supply/` by default.
## Logging
One INFO-level line on first call per process per (workload, on_battery, gpu_offloaded) combination. Cached in a `OnceLock<Mutex<HashSet<...>>>` keyed by the tuple to avoid log spam.
```
inference_thread_count: 3 workload=Whisper gpu_offloaded=false on_battery=true clamps=[battery]
```
`clamps=[]` when nothing fired beyond the base + final clamp, `[battery]` if battery halved, `[gpu]` if GPU floor bound, `[battery,gpu]` if both. This is the empirical instrument for users running real Lumenote sessions — gives Jake real data on what the heuristic does in the wild before we tune the constants.
## Out of scope (follow-ups)
- True LLM offload-residency observability via `llama_log_set` callback parsing or upstream PR to llama-cpp-2.
- macOS native probe via `IOPSGetProvidingPowerSourceType`.
- Windows native probe via `GetSystemPowerStatus`.
- UI affordance — "Running on battery — reduced AI performance" indicator next to the model badge.
- Power-state change events — current TTL polling is sufficient at our cadence; no need for a watcher thread.
- Lumenote rebrand of `MAGNOTIA_*` env vars — separate workstream coordinated with the broader rename.
## Risk and rollback
- **Heuristic is wrong** — telemetry log lets us see real impact. If the GPU+battery whisper floor of 4 is too aggressive (transcription stutters), bump it. The truth table is data we can update; the constants live in one file.
- **Sysfs probe panics on a non-standard distro** — wrapped in a graceful fallthrough to `Unknown`, never returns a Result-bubble. Worst case: clamp doesn't fire on that distro.
- **Vulkan loader probe regresses** — moving `vulkan_loader_available()` into core means src-tauri callers route through core. Wire-test via the existing `models.rs` accelerator-list code path; if accelerator-list still reports Vulkan correctly, the move worked.
- **Rollback** — single `git revert` on the migration commit returns both call sites to the old `inference_thread_count()` (preserved as deprecated facade for one commit). The deprecation-removal commit is a separate target.
## Acceptance
- All four panels of `thread_sweep.rs` print without panic on a 6c12t Ryzen.
- New unit tests pass on Linux (and on macOS CI where applicable; sysfs tests cfg-gated to Linux).
- Existing `cargo test -p magnotia-core` and `-p magnotia-llm` continue to pass.
- A real Lumenote session with on-battery laptop logs `on_battery=true clamps=[battery]` at first inference call.
- Manual smoke: kick a Whisper Tiny transcription on battery, verify CPU usage settles around half what it was on AC.

View File

@@ -57,11 +57,6 @@ serde_json = "1"
tokio = { version = "1", features = ["rt", "sync"] } tokio = { version = "1", features = ["rt", "sync"] }
arboard = "3.6.1" arboard = "3.6.1"
# Runtime shared-library probe for the Vulkan loader (active compute
# device detection, brief item #1). We do not call any vulkan symbols
# — we only need to answer "is libvulkan resolvable from the loader's
# default search path right now?".
libloading = "0.8"
# SqlitePool is named directly from src-tauri/src/lib.rs (the AppState # SqlitePool is named directly from src-tauri/src/lib.rs (the AppState
# stores it). Must be unconditional, not Linux-only — naming a type from # stores it). Must be unconditional, not Linux-only — naming a type from

View File

@@ -6,7 +6,7 @@ use tauri::Emitter;
use crate::commands::security::ensure_main_window; use crate::commands::security::ensure_main_window;
use crate::AppState; use crate::AppState;
use magnotia_core::constants::WHISPER_SAMPLE_RATE; use magnotia_core::constants::WHISPER_SAMPLE_RATE;
use magnotia_core::hardware::{self, CpuFeatures}; use magnotia_core::hardware::{self, vulkan_loader_available, CpuFeatures};
use magnotia_core::model_registry::{self, Engine, LanguageSupport, ModelEntry}; use magnotia_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
use magnotia_core::types::{AudioSamples, ModelId, TranscriptionOptions}; use magnotia_core::types::{AudioSamples, ModelId, TranscriptionOptions};
#[cfg(feature = "whisper")] #[cfg(feature = "whisper")]
@@ -319,35 +319,6 @@ pub struct LanguageSupportInfo {
pub language_count: u16, pub language_count: u16,
} }
/// Best-effort probe for the Vulkan loader shared library.
///
/// whisper.cpp's Vulkan backend refuses to initialise if `vulkan-1.dll`
/// (Windows) / `libvulkan.so.1` (Linux) / the MoltenVK bundle (macOS)
/// is missing at runtime. We probe via `libloading::Library::new`;
/// failure means whisper.cpp will silently drop back to CPU, and the
/// user deserves to know before they start wondering why a 14-second
/// clip takes two minutes.
fn vulkan_loader_available() -> bool {
#[cfg(target_os = "linux")]
let candidates: &[&str] = &["libvulkan.so.1", "libvulkan.so"];
#[cfg(target_os = "windows")]
let candidates: &[&str] = &["vulkan-1.dll"];
#[cfg(target_os = "macos")]
let candidates: &[&str] = &["libvulkan.1.dylib", "libMoltenVK.dylib"];
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
let candidates: &[&str] = &[];
for name in candidates {
// SAFETY: libloading::Library::new loads a shared library and returns
// a handle that is dropped at the end of this iteration. No symbols
// from it are called, so we just need the open-for-probe semantics.
match unsafe { libloading::Library::new(*name) } {
Ok(_lib) => return true,
Err(_) => continue,
}
}
false
}
/// Compile-time target signalling used by `compose_accelerators`. /// Compile-time target signalling used by `compose_accelerators`.
/// Split out so the pure-function behaviour is testable without `cfg!` /// Split out so the pure-function behaviour is testable without `cfg!`