feat(A.1 #1): surface active compute device + runtime-warning banner

Extends get_runtime_capabilities() with three new fields:
  - activeComputeDevice: {kind, label, reason} — "GPU (Vulkan)" on
    the happy path, "CPU (fallback)" with a reason when the Vulkan
    loader is absent at runtime. libloading::Library::new probes
    libvulkan.so.1 / vulkan-1.dll / libMoltenVK.dylib per target OS.
  - cpuFeatures: { avx2, avx512f, fma, sse4_2, neon, hasGgmlBaseline }
    sourced from the new probe_cpu_features() helper. hasGgmlBaseline
    is the one flag the Settings banner actually reads.
  - parallelModeAvailable: placeholder false until Phase A.4 lands
    the real GPU VRAM probe + GpuGuard semaphore permit logic.

Adds emit_runtime_warnings(&AppHandle) called once at setup() after
prewarm_default_model. Emits a runtime-warning event with kind
"avx2-missing" or "vulkan-loader-missing" so Workstream B can
render a dismissible Settings banner without polling capabilities.

Contract matches docs/whisper-ecosystem/workstream-A.md §Item #1 for
Workstream B to consume without waiting for Phase A.2 to land the
real whisper_print_system_info bridge.

Co-authored-by: jars <jakejars@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-21 11:18:08 +00:00
committed by Jake
parent 770516460d
commit fafa0fc878
3 changed files with 196 additions and 0 deletions

View File

@@ -39,6 +39,12 @@ serde_json = "1"
tokio = { version = "1", features = ["rt", "sync"] }
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
# stores it). Must be unconditional, not Linux-only — naming a type from
# a transitive dep requires the dep be listed here too.

View File

@@ -4,6 +4,7 @@ use serde::Serialize;
use tauri::Emitter;
use crate::AppState;
use kon_core::hardware::{self, CpuFeatures};
use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
use kon_core::types::ModelId;
use kon_transcription::model_manager;
@@ -195,6 +196,59 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
pub struct RuntimeCapabilities {
pub accelerators: Vec<String>,
pub engines: Vec<EngineRuntimeCapabilities>,
/// Which compute device Whisper / whisper.cpp actually booted against.
/// Distinct from `accelerators` (which lists what the _build_ supports):
/// a Vulkan-built binary on a CPU-only box reports accelerators=[cpu, vulkan]
/// but activeComputeDevice.kind = "cpu" with a reason.
pub active_compute_device: ActiveComputeDevice,
/// Runtime-detected CPU feature flags. Surfaced so Settings can warn
/// the user that performance will be poor without AVX2 / FMA.
pub cpu_features: CpuFeaturesInfo,
/// True when the detected hardware can sustain Whisper + LLM on the
/// same GPU concurrently (≥16 GB VRAM). Item #28 gates a user-facing
/// toggle on this.
pub parallel_mode_available: bool,
}
/// Serialisable summary of whichever backend whisper.cpp / ggml wired
/// up on this boot. For MVP (Phase A.1) we derive this from
/// compile-time features + a runtime Vulkan loader probe; Phase A.2
/// wires `whisper_print_system_info` for the real answer.
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ActiveComputeDevice {
/// One of "cuda" | "vulkan" | "metal" | "cpu".
pub kind: String,
/// Human-readable label, e.g. "GPU (Vulkan)" or "CPU (fallback)".
pub label: String,
/// Set only when we fell back from a richer backend, explains why
/// (e.g. "Vulkan loader not found"). None on the happy path.
pub reason: Option<String>,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CpuFeaturesInfo {
pub avx2: bool,
pub avx512f: bool,
pub fma: bool,
pub sse4_2: bool,
pub neon: bool,
/// Shortcut for Settings: false means we fall back to a slow path.
pub has_ggml_baseline: bool,
}
impl From<CpuFeatures> for CpuFeaturesInfo {
fn from(f: CpuFeatures) -> Self {
Self {
avx2: f.avx2,
avx512f: f.avx512f,
fma: f.fma,
sse4_2: f.sse4_2,
neon: f.neon,
has_ggml_baseline: f.has_ggml_baseline(),
}
}
}
#[derive(Serialize)]
@@ -224,6 +278,127 @@ pub struct LanguageSupportInfo {
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
}
/// Report which backend whisper.cpp was actually able to initialise
/// against. The whisper-rs build here is compiled with the `vulkan`
/// feature unconditionally; on macOS that's still Metal via MoltenVK,
/// on Linux/Windows it's Vulkan. If the Vulkan loader is missing
/// we surface the CPU fallback path explicitly so the UI can warn.
pub fn detect_active_compute_device() -> ActiveComputeDevice {
#[cfg(target_os = "macos")]
{
if vulkan_loader_available() {
return ActiveComputeDevice {
kind: "metal".into(),
label: "GPU (Metal via MoltenVK)".into(),
reason: None,
};
}
return ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime."
.into(),
),
};
}
#[cfg(not(target_os = "macos"))]
{
if vulkan_loader_available() {
return ActiveComputeDevice {
kind: "vulkan".into(),
label: "GPU (Vulkan)".into(),
reason: None,
};
}
return ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
"Vulkan loader not found — install the Vulkan runtime (Windows) or \
libvulkan1 (Linux) to enable GPU acceleration."
.into(),
),
};
}
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase", tag = "kind", content = "message")]
pub enum RuntimeWarning {
/// CPU lacks AVX2 / FMA on x86_64 — ggml fallback path will be
/// dramatically slower. User should install a non-AVX2 build or
/// accept the hit.
Avx2Missing(String),
/// Vulkan loader is missing at runtime. Emitted once at startup
/// when `active_compute_device.reason` includes a loader-missing
/// message.
VulkanLoaderMissing(String),
/// CUDA driver mismatch forced a fallback (future-proofed for
/// when we add CUDA-detect; not emitted today).
#[allow(dead_code)]
CudaFallback(String),
}
/// Emit any runtime warnings the frontend should surface as a banner.
/// Called once at setup() after `prewarm_default_model`.
pub fn emit_runtime_warnings(app: &tauri::AppHandle) {
let cpu_features = hardware::probe_cpu_features();
let device = detect_active_compute_device();
if !cpu_features.has_ggml_baseline() {
let _ = app.emit(
"runtime-warning",
RuntimeWarning::Avx2Missing(
"Your CPU is missing AVX2/FMA. Whisper and the local LLM will run on a \
dramatically slower fallback path — expect 510× the latency of a \
2015-or-newer CPU."
.into(),
),
);
}
if device.kind == "cpu" {
if let Some(reason) = device.reason.as_ref() {
let _ = app.emit(
"runtime-warning",
RuntimeWarning::VulkanLoaderMissing(reason.clone()),
);
}
}
}
#[tauri::command]
pub fn get_runtime_capabilities(
state: tauri::State<'_, AppState>,
@@ -242,6 +417,9 @@ pub fn get_runtime_capabilities(
.map(|entry| model_capability(entry, &parakeet))
.collect();
let active_compute_device = detect_active_compute_device();
let cpu_features: CpuFeaturesInfo = hardware::probe_cpu_features().into();
// Kon's desktop build links transcribe-rs with the `whisper-vulkan`
// feature unconditionally (see crates/transcription/Cargo.toml), so
// whisper.cpp boots with Vulkan backend on any machine with a Vulkan
@@ -268,6 +446,13 @@ pub fn get_runtime_capabilities(
models: parakeet_models,
},
],
active_compute_device,
cpu_features,
// Phase A.1 ships detection of the VRAM budget only via
// hardware::probe_gpu; that currently returns None, so we
// default to sequential. When Phase A.4 (GpuGuard) and the
// real probe_gpu land, flip this based on detected VRAM ≥ 16 GB.
parallel_mode_available: false,
})
}

View File

@@ -229,6 +229,11 @@ pub fn run() {
crate::commands::models::prewarm_default_model(whisper);
}
// Runtime-warning banner: push CPU-feature + Vulkan-loader
// fallbacks to the frontend so Settings can render a one-line
// hint. No-ops on a fully-supported box.
crate::commands::models::emit_runtime_warnings(&app.handle());
if let Err(e) = tray::setup(app) {
eprintln!("Failed to setup tray: {e}");
}