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:
@@ -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 5–10× 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, ¶keet))
|
||||
.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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user