feat(models): memory-aware whisper.cpp ladder with conditional backend order
Free memory picks the load-time rung (large-v3-turbo-q8_0 default, q5_0 under 1086 MiB free, small/small.en floor) for unpinned default configs; thresholds derive from catalog sizes plus the protocol's 512 MiB headroom. A manual pin is never overridden, and a failed load steps one rung down and retries exactly once before surfacing. CUDA ranks before Vulkan only with a complete architecture-matching toolkit (nvidia-smi GPU, nvcc, compute capability >= 7.0); otherwise Vulkan, then CPU. The driver-only CUDA recommendation now demotes to CPU logic instead of naming an unreachable VRAM ladder. Refs #20
This commit is contained in:
@@ -547,6 +547,9 @@ def main():
|
||||
# loads the wrong model whenever the two disagree.
|
||||
model_size = config_manager.get_model_size_for_engine(engine)
|
||||
logger.info(f"Using model={model_size} (saved for engine {engine})")
|
||||
# A CLI --model is an explicit pick; otherwise the variant pin state
|
||||
# decides whether the loader may step the ladder down on failure (#20).
|
||||
variant_pinned = cli_model_set or bool(config_manager.get_model_variant_for_engine(engine))
|
||||
|
||||
vad_sensitivity = saved_settings.get("vad_sensitivity", 3)
|
||||
silence_timeout = saved_settings.get("silence_timeout", 1.2)
|
||||
@@ -614,6 +617,7 @@ def main():
|
||||
whispercpp_no_speech_thold=advanced_settings.get("whispercpp_no_speech_thold", 0.6),
|
||||
whispercpp_n_threads=advanced_settings.get("whispercpp_n_threads", 0),
|
||||
whispercpp_gpu_device=advanced_settings.get("whispercpp_gpu_device", None),
|
||||
whispercpp_variant_pinned=variant_pinned,
|
||||
dictionary_manager=dictionary_manager,
|
||||
remote_api_url=saved_settings.get("remote_api_url", ""),
|
||||
remote_api_key=saved_settings.get("remote_api_key", ""),
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Memory-aware whisper.cpp model ladder for Voculinux (ticket #20).
|
||||
|
||||
The default model is not one-size-fits-all on a 4 GB dGPU: the ladder picks
|
||||
the rung that fits free memory at load time instead of failing halfway
|
||||
through a Turbo load.
|
||||
|
||||
Rungs (quantized Turbo first, small floor last)::
|
||||
|
||||
large-v3-turbo-q8_0 -> large-v3-turbo-q5_0 -> small.en / small
|
||||
|
||||
Thresholds derive from the catalog on-disk sizes (874/574 MiB) plus the
|
||||
512 MiB desktop headroom from the locked baseline protocol
|
||||
(``scripts/measure_perf_baseline.py::recommend_thresholds``). Ticket #19
|
||||
recorded no measured per-rung footprints, so these are size-plus-headroom
|
||||
proxies: recalibrate them from hardware numbers when they land. The
|
||||
derivation is pinned by ``tests/test_model_ladder.py`` so a catalog size
|
||||
bump forces a threshold review.
|
||||
|
||||
Backend order follows the same readiness rule: CUDA ranks before Vulkan
|
||||
only when a complete architecture-matching CUDA toolkit is installed
|
||||
(``nvidia-smi`` sees a GPU, ``nvcc`` answers, compute capability >= 7.0);
|
||||
otherwise the order is Vulkan, then CPU. A driver without the toolkit is
|
||||
exactly what made the old VRAM-aware recommendation unreachable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_RUNG: str = "large-v3-turbo-q8_0"
|
||||
LOW_MEMORY_RUNG: str = "large-v3-turbo-q5_0"
|
||||
FLOOR_RUNG_ENGLISH: str = "small.en"
|
||||
FLOOR_RUNG_MULTILINGUAL: str = "small"
|
||||
|
||||
LADDER_RUNGS: tuple[str, ...] = (DEFAULT_RUNG, LOW_MEMORY_RUNG)
|
||||
|
||||
#: MiB of headroom kept for the desktop and other GPU clients. Same value
|
||||
#: the baseline protocol adds to measured footprints.
|
||||
HEADROOM_MIB: float = 512.0
|
||||
|
||||
#: Catalog on-disk sizes (MiB) the thresholds below are derived from.
|
||||
_Q8_0_SIZE_MIB: float = 874.0
|
||||
_Q5_0_SIZE_MIB: float = 574.0
|
||||
|
||||
#: Free memory at or above this keeps the default q8_0 rung.
|
||||
PREFER_Q8_0_ABOVE_MIB: float = _Q8_0_SIZE_MIB + HEADROOM_MIB
|
||||
#: Free memory at or above this keeps the q5_0 rung; below it is the floor.
|
||||
PREFER_Q5_0_ABOVE_MIB: float = _Q5_0_SIZE_MIB + HEADROOM_MIB
|
||||
|
||||
#: Minimum CUDA compute capability (major, minor) the bundled backend
|
||||
#: targets. Older architectures cannot run its kernels.
|
||||
MIN_CUDA_COMPUTE_CAPABILITY: tuple[int, int] = (7, 0)
|
||||
|
||||
|
||||
def select_ladder_rung(language_is_english: bool, free_mib: float) -> str:
|
||||
"""Return the ladder rung that fits ``free_mib`` MiB of free memory.
|
||||
|
||||
Pure function of its inputs so the policy is testable without hardware.
|
||||
"""
|
||||
if free_mib >= PREFER_Q8_0_ABOVE_MIB:
|
||||
return DEFAULT_RUNG
|
||||
if free_mib >= PREFER_Q5_0_ABOVE_MIB:
|
||||
return LOW_MEMORY_RUNG
|
||||
return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL
|
||||
|
||||
|
||||
def resolve_startup_rung(
|
||||
pinned_variant: str,
|
||||
language_is_english: bool,
|
||||
free_mib: float,
|
||||
) -> str:
|
||||
"""Return the rung to load: a manual pin wins verbatim, otherwise the ladder.
|
||||
|
||||
A manual selection is never overridden here; only unpinned loads derive
|
||||
their rung from free memory.
|
||||
"""
|
||||
if pinned_variant:
|
||||
return pinned_variant
|
||||
return select_ladder_rung(language_is_english, free_mib)
|
||||
|
||||
|
||||
def next_rung_down(rung: str, language_is_english: bool) -> str | None:
|
||||
"""Return the rung one step below ``rung``, or None at the floor.
|
||||
|
||||
The floor itself never steps down: a second consecutive failure is a
|
||||
real error and must surface instead of looping.
|
||||
"""
|
||||
if rung == DEFAULT_RUNG:
|
||||
return LOW_MEMORY_RUNG
|
||||
if rung == LOW_MEMORY_RUNG:
|
||||
return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL
|
||||
return None
|
||||
|
||||
|
||||
def _run_probe(argv: list[str], timeout: int = 10) -> str | None:
|
||||
"""Run a probe command, returning stdout or None when unavailable.
|
||||
|
||||
Host binaries inherit a stripped host environment so probes keep working
|
||||
inside the AppImage bundle (see ``utils/host_process.py``).
|
||||
"""
|
||||
from ..utils.host_process import host_env
|
||||
|
||||
if shutil.which(argv[0]) is None:
|
||||
return None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv, capture_output=True, text=True, timeout=timeout, env=host_env()
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.debug("Probe %s failed: %s", argv[0], error)
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def _parse_first_free_mib(smi_output: str) -> float | None:
|
||||
"""Parse the first GPU's free memory (MiB) from nvidia-smi CSV output."""
|
||||
for line in smi_output.splitlines():
|
||||
fields = [field.strip() for field in line.split(",")]
|
||||
if len(fields) >= 1:
|
||||
try:
|
||||
return float(fields[0])
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_first_compute_cap(smi_output: str) -> tuple[int, int] | None:
|
||||
"""Parse the first GPU's compute capability from nvidia-smi CSV output."""
|
||||
for line in smi_output.splitlines():
|
||||
fields = [field.strip() for field in line.split(",")]
|
||||
if len(fields) >= 2:
|
||||
try:
|
||||
major, _, minor = fields[1].partition(".")
|
||||
return (int(major), int((minor or "0").strip()))
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def cuda_toolkit_ready_from_probe(
|
||||
has_nvidia_gpu: bool,
|
||||
nvcc_ok: bool,
|
||||
compute_cap: tuple[int, int] | None,
|
||||
) -> bool:
|
||||
"""Return whether a complete architecture-matching CUDA toolkit is present.
|
||||
|
||||
Pure function of its inputs: an NVIDIA GPU alone (driver only) is not a
|
||||
toolkit, and a toolkit that cannot target this architecture is not
|
||||
usable either.
|
||||
"""
|
||||
if not has_nvidia_gpu or not nvcc_ok or compute_cap is None:
|
||||
return False
|
||||
return compute_cap >= MIN_CUDA_COMPUTE_CAPABILITY
|
||||
|
||||
|
||||
def cuda_toolkit_ready() -> bool:
|
||||
"""Probe the host for a complete architecture-matching CUDA toolkit."""
|
||||
smi = _run_probe(
|
||||
["nvidia-smi", "--query-gpu=memory.free,compute_cap", "--format=csv,noheader,nounits"]
|
||||
)
|
||||
if not smi:
|
||||
return False
|
||||
nvcc = _run_probe(["nvcc", "--version"])
|
||||
return cuda_toolkit_ready_from_probe(
|
||||
has_nvidia_gpu=True,
|
||||
nvcc_ok=nvcc is not None,
|
||||
compute_cap=_parse_first_compute_cap(smi),
|
||||
)
|
||||
|
||||
|
||||
def free_vram_mib() -> float | None:
|
||||
"""Return the first NVIDIA GPU's free VRAM in MiB, or None without one."""
|
||||
smi = _run_probe(["nvidia-smi", "--query-gpu=memory.free", "--format=csv,noheader,nounits"])
|
||||
if not smi:
|
||||
return None
|
||||
return _parse_first_free_mib(smi)
|
||||
|
||||
|
||||
def free_system_ram_mib() -> float | None:
|
||||
"""Return free system RAM in MiB (psutil, falling back to /proc/meminfo)."""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
return float(psutil.virtual_memory().available) / (1024**2)
|
||||
except (ImportError, AttributeError, OSError) as error:
|
||||
logger.debug("psutil free RAM unavailable: %s", error)
|
||||
try:
|
||||
with open("/proc/meminfo", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("MemAvailable:"):
|
||||
return float(line.split()[1]) / 1024.0
|
||||
except OSError as error:
|
||||
logger.debug("/proc/meminfo unavailable: %s", error)
|
||||
return None
|
||||
|
||||
|
||||
def free_memory_mib() -> float | None:
|
||||
"""Return free memory in MiB: discrete-GPU VRAM when present, else RAM."""
|
||||
vram = free_vram_mib()
|
||||
if vram is not None:
|
||||
return vram
|
||||
return free_system_ram_mib()
|
||||
|
||||
|
||||
def preferred_backend_order() -> tuple[str, ...]:
|
||||
"""Return the backend attempt order for this host.
|
||||
|
||||
CUDA ranks before Vulkan only when a complete architecture-matching
|
||||
toolkit is installed; otherwise Vulkan, then CPU.
|
||||
"""
|
||||
if cuda_toolkit_ready():
|
||||
return ("cuda", "vulkan", "cpu")
|
||||
return ("vulkan", "cpu")
|
||||
|
||||
|
||||
def describe_environment() -> str:
|
||||
"""Return a one-line summary of the ladder inputs for logging."""
|
||||
vram = free_vram_mib()
|
||||
if vram is not None:
|
||||
return f"free VRAM {vram:.0f} MiB, CUDA toolkit ready: {cuda_toolkit_ready()}"
|
||||
ram = free_system_ram_mib()
|
||||
if ram is not None:
|
||||
return f"no dGPU, free RAM {ram:.0f} MiB"
|
||||
return "memory unreadable; assuming the floor"
|
||||
|
||||
|
||||
def is_ladder_rung(variant: str) -> bool:
|
||||
"""Return whether ``variant`` is a rung the loader may step between."""
|
||||
return variant in LADDER_RUNGS or variant in (FLOOR_RUNG_ENGLISH, FLOOR_RUNG_MULTILINGUAL)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_RUNG",
|
||||
"LOW_MEMORY_RUNG",
|
||||
"FLOOR_RUNG_ENGLISH",
|
||||
"FLOOR_RUNG_MULTILINGUAL",
|
||||
"LADDER_RUNGS",
|
||||
"HEADROOM_MIB",
|
||||
"PREFER_Q8_0_ABOVE_MIB",
|
||||
"PREFER_Q5_0_ABOVE_MIB",
|
||||
"MIN_CUDA_COMPUTE_CAPABILITY",
|
||||
"select_ladder_rung",
|
||||
"resolve_startup_rung",
|
||||
"next_rung_down",
|
||||
"is_ladder_rung",
|
||||
"cuda_toolkit_ready_from_probe",
|
||||
"cuda_toolkit_ready",
|
||||
"free_memory_mib",
|
||||
"preferred_backend_order",
|
||||
"describe_environment",
|
||||
]
|
||||
@@ -506,6 +506,10 @@ class SpeechRecognitionManager:
|
||||
# change a queued segment's language (#805).
|
||||
self._session_language: Optional[str] = None
|
||||
self.stop_sound_guard_ms = kwargs.get("stop_sound_guard_ms", 200)
|
||||
# Whether the whisper.cpp variant was explicitly pinned in Settings.
|
||||
# A manual selection is never overridden: neither the ladder nor the
|
||||
# single step-down retry applies to it (ticket #20).
|
||||
self._whispercpp_variant_pinned = bool(kwargs.get("whispercpp_variant_pinned", False))
|
||||
self.dictionary_manager: Optional["CustomDictionaryManager"] = kwargs.get(
|
||||
"dictionary_manager"
|
||||
)
|
||||
@@ -1509,7 +1513,43 @@ class SpeechRecognitionManager:
|
||||
logger.error(f"Error in faster-whisper transcription: {e}", exc_info=True)
|
||||
return ""
|
||||
|
||||
def _ladder_step_down_rung(self) -> Optional[str]:
|
||||
"""Return the retry rung, or None when stepping down is not allowed.
|
||||
|
||||
Only ladder-selected (unpinned) loads step down, exactly one rung.
|
||||
A manual pin and the small floor both fail loudly instead.
|
||||
"""
|
||||
if self._whispercpp_variant_pinned:
|
||||
return None
|
||||
from .model_ladder import is_ladder_rung, next_rung_down
|
||||
|
||||
if not is_ladder_rung(self.model_size):
|
||||
return None
|
||||
language_info = SUPPORTED_LANGUAGES.get(self.language, {})
|
||||
language_is_english = (
|
||||
isinstance(language_info, dict) and language_info.get("whisper") == "en"
|
||||
)
|
||||
return next_rung_down(self.model_size, language_is_english)
|
||||
|
||||
def _init_whispercpp(self) -> None:
|
||||
"""Initialize the whisper.cpp engine, stepping one rung down on failure."""
|
||||
fallback = self._ladder_step_down_rung()
|
||||
try:
|
||||
self._init_whispercpp_once()
|
||||
return
|
||||
except (RuntimeError, OSError) as first_error:
|
||||
if fallback is None or fallback == self.model_size:
|
||||
raise
|
||||
logger.warning(
|
||||
"whisper.cpp %s failed to load (%s); stepping down to %s once",
|
||||
self.model_size,
|
||||
first_error,
|
||||
fallback,
|
||||
)
|
||||
self.model_size = fallback
|
||||
self._init_whispercpp_once()
|
||||
|
||||
def _init_whispercpp_once(self) -> None:
|
||||
"""Initialize the whisper.cpp speech recognition engine."""
|
||||
try:
|
||||
_preload_pywhispercpp_shared_libraries()
|
||||
@@ -1739,8 +1779,9 @@ class SpeechRecognitionManager:
|
||||
def _load_whispercpp_model(self, model_path: str):
|
||||
"""Load the whisper.cpp model file and configure the compute backend.
|
||||
|
||||
Detects the optimal backend (Vulkan → CUDA → CPU), loads the model,
|
||||
and falls back to CPU if the GPU backend is incompatible.
|
||||
Detects the optimal backend (CUDA first only with a complete
|
||||
toolkit, otherwise Vulkan → CPU), loads the model, and falls back
|
||||
to CPU if the GPU backend is incompatible.
|
||||
|
||||
Args:
|
||||
model_path: Filesystem path to the GGML model file.
|
||||
@@ -1754,10 +1795,15 @@ class SpeechRecognitionManager:
|
||||
get_backend_display_name,
|
||||
)
|
||||
|
||||
from .model_ladder import preferred_backend_order
|
||||
|
||||
host_backend, host_info = detect_compute_backend()
|
||||
actual_gpu_backend = self._detect_pywhispercpp_gpu_backend()
|
||||
ggml_devices = detect_ggml_gpu_devices()
|
||||
logger.info("whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU")
|
||||
logger.info(
|
||||
"whisper.cpp backend selection priority: %s",
|
||||
" -> ".join(preferred_backend_order()),
|
||||
)
|
||||
|
||||
selected_gpu_device = None
|
||||
use_gpu: Optional[bool] = None
|
||||
|
||||
@@ -769,6 +769,30 @@ class ConfigManager:
|
||||
if engine.lower() != "whisper_cpp":
|
||||
return cast(str, saved)
|
||||
|
||||
pinned = self.get_model_variant_for_engine(engine)
|
||||
# A fresh (or reset) install never chose a model: the saved value is
|
||||
# still the "tiny" default and no variant is pinned. That default now
|
||||
# resolves through the memory-aware ladder instead of the bare tiny
|
||||
# id, so first run loads large-v3-turbo-q8_0 where it fits (ticket
|
||||
# #20). Any explicit size or pin keeps the behavior below untouched.
|
||||
if not pinned and str(saved or "tiny").lower() == "tiny":
|
||||
from ..speech_recognition.model_ladder import free_memory_mib, resolve_startup_rung
|
||||
|
||||
language_id = str(sr_config.get("language", "auto"))
|
||||
language_info = cast(dict[str, str], SUPPORTED_LANGUAGES.get(language_id, {}))
|
||||
free_mib = free_memory_mib()
|
||||
rung = resolve_startup_rung(
|
||||
"", language_info.get("whisper") == "en", free_mib if free_mib is not None else 0.0
|
||||
)
|
||||
logger.info(
|
||||
"whisper.cpp startup rung %s (free memory %.0f MiB)",
|
||||
rung,
|
||||
free_mib if free_mib is not None else -1.0,
|
||||
)
|
||||
return on_disk_stand_in(
|
||||
rung, get_whispercpp_model_size(rung), language_info.get("whisper") == "en"
|
||||
)
|
||||
|
||||
# Unpinned configs store the bare size; the engine still needs the
|
||||
# language-derived loadable id (and leftover ``{size}.en`` must not
|
||||
# block a later language change). The derived default stands down for
|
||||
@@ -776,7 +800,7 @@ class ConfigManager:
|
||||
# sibling download when a usable model is already on disk (#916).
|
||||
return resolve_whispercpp_variant(
|
||||
saved,
|
||||
self.get_model_variant_for_engine(engine),
|
||||
pinned,
|
||||
sr_config.get("language", "auto"),
|
||||
prefer_on_disk=True,
|
||||
)
|
||||
|
||||
@@ -581,15 +581,22 @@ def get_recommended_model() -> tuple[str, str]:
|
||||
else:
|
||||
return "base", f"Vulkan GPU with {ram_gb}GB RAM"
|
||||
elif backend == ComputeBackend.CUDA:
|
||||
vram_gb = _parse_cuda_vram_gib(backend_info or "")
|
||||
if vram_gb is not None:
|
||||
if vram_gb >= 8:
|
||||
return "medium", f"CUDA GPU with {vram_gb:g}GB VRAM"
|
||||
if vram_gb >= 4:
|
||||
return "small", f"CUDA GPU with {vram_gb:g}GB VRAM"
|
||||
return "base", "CUDA GPU with limited VRAM"
|
||||
return "small", "CUDA GPU detected"
|
||||
else:
|
||||
# A driver without the complete toolkit cannot run the CUDA
|
||||
# build, so the VRAM ladder below would be unreachable. Fall
|
||||
# through to the Vulkan/CPU branches instead (ticket #20).
|
||||
from ..speech_recognition.model_ladder import cuda_toolkit_ready
|
||||
|
||||
if cuda_toolkit_ready():
|
||||
vram_gb = _parse_cuda_vram_gib(backend_info or "")
|
||||
if vram_gb is not None:
|
||||
if vram_gb >= 8:
|
||||
return "medium", f"CUDA GPU with {vram_gb:g}GB VRAM"
|
||||
if vram_gb >= 4:
|
||||
return "small", f"CUDA GPU with {vram_gb:g}GB VRAM"
|
||||
return "base", "CUDA GPU with limited VRAM"
|
||||
return "small", "CUDA GPU detected"
|
||||
backend = ComputeBackend.CPU
|
||||
if backend == ComputeBackend.CPU:
|
||||
# CPU-only recommendations based on RAM
|
||||
if ram_gb >= 16:
|
||||
return "base", f"{ram_gb}GB RAM - CPU inference"
|
||||
|
||||
Reference in New Issue
Block a user