feat(model): hard-code per-backend ladder thresholds from ticket #19

The whisper.cpp ladder keyed both GPU backends to one provisional pair of
thresholds. Issue #19 (comment 12913) measured each rung's transcribe-time
VRAM delta on the installed build. The thresholds are those deltas plus the
512 MiB desktop headroom:

- vulkan: q8_0 1644 MiB, q5_0 1359 MiB (was 1644 and 1358)
- cuda:   q8_0 1690 MiB, q5_0 1404 MiB

select_ladder_rung and resolve_startup_rung take the installed build's
backend. CPU and unknown backends keep the Vulkan thresholds; the RAM rule
proposed in #19 is not implemented.

The first-run default in config_manager now reads the memory of the
installed build (VRAM for a GPU build, RAM otherwise). It used to read VRAM
whenever an NVIDIA card was present, even on a CPU build.
This commit is contained in:
2026-11-07 01:46:38 +05:30
parent 0c48db5b5e
commit 64020a132d
5 changed files with 164 additions and 56 deletions
@@ -8,14 +8,13 @@ Rungs (quantized Turbo first, small floor last)::
large-v3-turbo-q8_0 -> large-v3-turbo-q5_0 -> small.en / small
The thresholds are PROVISIONAL. They are the rule
``scripts/measure_perf_baseline.py::recommend_thresholds`` applies (measured
footprint plus the 512 MiB desktop headroom) to the transcribe peaks
recorded for ticket #19 (1132 MiB for q8_0, 846 MiB for q5_0). Ticket #19
is still open and its measurement is not recorded in this repository, so
these numbers are the calibration to replace, not evidence. A re-measure
changes the constants here, and ``tests/test_model_ladder.py`` pins them so
that change is deliberate.
The thresholds are hard-coded from the ticket #19 installed-build measurement
(issue #19, comment 12913): each rung's transcribe-time VRAM delta plus the
512 MiB desktop headroom that ``scripts/measure_perf_baseline.py`` applies.
They are per GPU backend, because CUDA's transcribe footprint is larger than
Vulkan's on the same card. ``tests/test_model_ladder.py`` pins the constants
and the measured inputs they derive from, so a re-measure changes them on
purpose.
Backend order follows the same readiness rule: CUDA ranks before Vulkan
only when a complete architecture-matching CUDA toolkit is installed
@@ -47,14 +46,22 @@ LADDER_RUNGS: tuple[str, ...] = (DEFAULT_RUNG, LOW_MEMORY_RUNG)
#: the baseline protocol adds to measured footprints.
HEADROOM_MIB: float = 512.0
#: Provisional transcribe peaks (MiB) from ticket #19, not yet recorded in the repo.
_MEASURED_Q8_0_PEAK_MIB: float = 1132.0
_MEASURED_Q5_0_PEAK_MIB: float = 846.0
#: Free-memory thresholds (MiB) per GPU backend, from ticket #19 (comment 12913):
#: the measured transcribe-time VRAM delta plus HEADROOM_MIB.
#: vulkan: q8_0 1132 + 512 = 1644, q5_0 847 + 512 = 1359
#: cuda: q8_0 1178 + 512 = 1690, q5_0 892 + 512 = 1404
_PREFER_ABOVE_MIB: dict[str, dict[str, float]] = {
"vulkan": {DEFAULT_RUNG: 1644.0, LOW_MEMORY_RUNG: 1359.0},
"cuda": {DEFAULT_RUNG: 1690.0, LOW_MEMORY_RUNG: 1404.0},
}
#: Free memory at or above this keeps the default q8_0 rung.
PREFER_Q8_0_ABOVE_MIB: float = _MEASURED_Q8_0_PEAK_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 = _MEASURED_Q5_0_PEAK_MIB + HEADROOM_MIB
#: Free memory at or above this keeps the default q8_0 rung on Vulkan.
PREFER_Q8_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["vulkan"][DEFAULT_RUNG]
#: Free memory at or above this keeps the q5_0 rung on Vulkan; below it is the floor.
PREFER_Q5_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["vulkan"][LOW_MEMORY_RUNG]
#: The CUDA thresholds, named for callers and tests that compare against them.
PREFER_CUDA_Q8_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["cuda"][DEFAULT_RUNG]
PREFER_CUDA_Q5_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["cuda"][LOW_MEMORY_RUNG]
#: Minimum CUDA compute capability (major, minor) the bundled backend
#: targets. Older architectures cannot run its kernels.
@@ -79,14 +86,31 @@ def _floor_rung(language_is_english: bool) -> str:
return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL
def select_ladder_rung(language_is_english: bool, free_mib: float) -> str:
def _thresholds_for(gpu_backend: str) -> tuple[float, float]:
"""Return ``(q8_0, q5_0)`` free-memory thresholds for the installed build's backend.
``cuda`` has its own pair. ``vulkan``, ``cpu`` and any unknown name share the
Vulkan pair: a CPU build reads system RAM against the same rule as before.
"""
table = _PREFER_ABOVE_MIB.get(gpu_backend, _PREFER_ABOVE_MIB["vulkan"])
return table[DEFAULT_RUNG], table[LOW_MEMORY_RUNG]
def select_ladder_rung(
language_is_english: bool,
free_mib: float,
gpu_backend: str = "vulkan",
) -> 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.
``gpu_backend`` is the backend the installed build has (``vulkan``, ``cuda``
or ``cpu``); it selects the thresholds. Pure function of its inputs so the
policy is testable without hardware.
"""
if free_mib >= PREFER_Q8_0_ABOVE_MIB:
q8_threshold, q5_threshold = _thresholds_for(gpu_backend)
if free_mib >= q8_threshold:
return DEFAULT_RUNG
if free_mib >= PREFER_Q5_0_ABOVE_MIB:
if free_mib >= q5_threshold:
return LOW_MEMORY_RUNG
return _floor_rung(language_is_english)
@@ -95,15 +119,16 @@ def resolve_startup_rung(
pinned_variant: str,
language_is_english: bool,
free_mib: float,
gpu_backend: str = "vulkan",
) -> 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.
their rung from free memory, on the thresholds of ``gpu_backend``.
"""
if pinned_variant:
return pinned_variant
return select_ladder_rung(language_is_english, free_mib)
return select_ladder_rung(language_is_english, free_mib, gpu_backend)
def next_rung_down(rung: str, language_is_english: bool) -> str | None:
@@ -354,6 +379,8 @@ __all__ = [
"HEADROOM_MIB",
"PREFER_Q8_0_ABOVE_MIB",
"PREFER_Q5_0_ABOVE_MIB",
"PREFER_CUDA_Q8_0_ABOVE_MIB",
"PREFER_CUDA_Q5_0_ABOVE_MIB",
"MIN_CUDA_COMPUTE_CAPABILITY",
"GPU_BACKENDS",
"select_ladder_rung",
+13 -3
View File
@@ -776,13 +776,23 @@ class ConfigManager:
# 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
from ..speech_recognition.model_ladder import (
free_memory_for_backend,
resolve_startup_rung,
)
from ..speech_recognition.recognition_manager import detect_pywhispercpp_gpu_backend
language_id = str(sr_config.get("language", "auto"))
language_info = cast(dict[str, str], SUPPORTED_LANGUAGES.get(language_id, {}))
free_mib = free_memory_mib()
# The memory the installed build uses (VRAM for a GPU build, RAM otherwise)
# and the thresholds of that same backend, as the recommendation does.
gpu_backend = detect_pywhispercpp_gpu_backend()
free_mib, _source = free_memory_for_backend(gpu_backend)
rung = resolve_startup_rung(
"", language_info.get("whisper") == "en", free_mib if free_mib is not None else 0.0
"",
language_info.get("whisper") == "en",
free_mib if free_mib is not None else 0.0,
gpu_backend,
)
logger.info(
"whisper.cpp startup rung %s (free memory %.0f MiB)",
+1 -1
View File
@@ -571,7 +571,7 @@ def get_recommended_model() -> tuple[str, str]:
if free_mib is None:
logger.info("Free memory unreadable; recommending the floor model")
return FLOOR_RUNG_MULTILINGUAL, "Free memory unreadable; floor model"
rung = resolve_startup_rung("", False, free_mib)
rung = resolve_startup_rung("", False, free_mib, gpu_backend)
return rung, f"{rung}: {free_mib:.0f} MiB free {source} ({gpu_backend} build)"
+10 -1
View File
@@ -527,7 +527,16 @@ class TestConfigManager(unittest.TestCase):
# The migrated whisper.cpp default ("tiny", unpinned) resolves through
# the memory-aware ladder instead of staying tiny (ticket #20): what
# matters here is that vosk's "large" does not leak across engines.
with patch("vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=100.0):
with (
patch(
"vocalinux.speech_recognition.model_ladder.free_memory_for_backend",
return_value=(100.0, "RAM"),
),
patch(
"vocalinux.speech_recognition.recognition_manager.detect_pywhispercpp_gpu_backend",
return_value="cpu",
),
):
# "auto" language pairs the floor with the multilingual rung.
self.assertEqual(config_manager.get_model_size_for_engine("whisper_cpp"), "small")
self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "tiny")
+92 -30
View File
@@ -1,14 +1,14 @@
"""Ticket #20: memory-aware model ladder, conditional backend order, retry.
All behavior is exercised with mocked probes and loaders: no GPU, no
network, no hardware. The threshold-derivation test pins the rung
thresholds to the provisional transcribe peaks (1132 MiB for q8_0, 846 MiB
for q5_0, ticket #19, not yet recorded in the repo) plus the 512 MiB
protocol headroom. Replacing them with a recorded measurement is a
deliberate change to this test.
network, no hardware. The threshold tests pin the per-backend rung thresholds
to the ticket #19 installed-build measurement (issue #19, comment 12913): the
transcribe-time VRAM delta of each rung plus the 512 MiB protocol headroom.
Re-measuring changes those constants, and this file must change with them.
"""
import os
from contextlib import ExitStack
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -21,6 +21,8 @@ from vocalinux.speech_recognition.model_ladder import (
FLOOR_RUNG_MULTILINGUAL,
HEADROOM_MIB,
LOW_MEMORY_RUNG,
PREFER_CUDA_Q5_0_ABOVE_MIB,
PREFER_CUDA_Q8_0_ABOVE_MIB,
PREFER_Q5_0_ABOVE_MIB,
PREFER_Q8_0_ABOVE_MIB,
cuda_toolkit_ready_from_probe,
@@ -38,22 +40,40 @@ def _size_mb(name: str) -> float:
class TestThresholdDerivation:
"""Thresholds match the baseline ticket's recorded recommendation."""
"""Thresholds are the ticket #19 recorded transcribe-time deltas plus headroom.
#: Ticket #19 measured transcribe peaks (MiB) per rung.
Q8_0_MEASURED_MIB: float = 1132.0
Q5_0_MEASURED_MIB: float = 846.0
The inputs are copied from issue #19, comment 12913 ("Per-rung load
footprint", transcribe delta column), not recomputed from the code.
"""
def test_q8_threshold_is_measured_footprint_plus_headroom(self) -> None:
assert PREFER_Q8_0_ABOVE_MIB == self.Q8_0_MEASURED_MIB + HEADROOM_MIB
#: Transcribe-time VRAM delta (MiB), peak minus before, per backend and rung.
VULKAN_Q8_0_DELTA_MIB: float = 1132.0
VULKAN_Q5_0_DELTA_MIB: float = 847.0
CUDA_Q8_0_DELTA_MIB: float = 1178.0
CUDA_Q5_0_DELTA_MIB: float = 892.0
def test_headroom_is_the_protocol_value(self) -> None:
assert HEADROOM_MIB == 512.0
def test_vulkan_q8_threshold_is_measured_delta_plus_headroom(self) -> None:
assert PREFER_Q8_0_ABOVE_MIB == self.VULKAN_Q8_0_DELTA_MIB + HEADROOM_MIB
assert PREFER_Q8_0_ABOVE_MIB == 1644.0
def test_q5_threshold_is_measured_footprint_plus_headroom(self) -> None:
assert PREFER_Q5_0_ABOVE_MIB == self.Q5_0_MEASURED_MIB + HEADROOM_MIB
assert PREFER_Q5_0_ABOVE_MIB == 1358.0
def test_vulkan_q5_threshold_is_measured_delta_plus_headroom(self) -> None:
assert PREFER_Q5_0_ABOVE_MIB == self.VULKAN_Q5_0_DELTA_MIB + HEADROOM_MIB
assert PREFER_Q5_0_ABOVE_MIB == 1359.0
def test_rungs_are_ordered(self) -> None:
def test_cuda_q8_threshold_is_measured_delta_plus_headroom(self) -> None:
assert PREFER_CUDA_Q8_0_ABOVE_MIB == self.CUDA_Q8_0_DELTA_MIB + HEADROOM_MIB
assert PREFER_CUDA_Q8_0_ABOVE_MIB == 1690.0
def test_cuda_q5_threshold_is_measured_delta_plus_headroom(self) -> None:
assert PREFER_CUDA_Q5_0_ABOVE_MIB == self.CUDA_Q5_0_DELTA_MIB + HEADROOM_MIB
assert PREFER_CUDA_Q5_0_ABOVE_MIB == 1404.0
def test_rungs_are_ordered_on_each_backend(self) -> None:
assert PREFER_Q8_0_ABOVE_MIB > PREFER_Q5_0_ABOVE_MIB
assert PREFER_CUDA_Q8_0_ABOVE_MIB > PREFER_CUDA_Q5_0_ABOVE_MIB
assert _size_mb(DEFAULT_RUNG) > _size_mb(LOW_MEMORY_RUNG)
@@ -77,11 +97,44 @@ class TestSelectLadderRung:
def test_recorded_boundaries_select_recorded_rungs(self) -> None:
assert select_ladder_rung(True, 1644.0) == DEFAULT_RUNG
assert select_ladder_rung(True, 1500.0) == LOW_MEMORY_RUNG
assert select_ladder_rung(True, 1358.0) == LOW_MEMORY_RUNG
assert select_ladder_rung(True, 1359.0) == LOW_MEMORY_RUNG
assert select_ladder_rung(True, 1358.0) == FLOOR_RUNG_ENGLISH
assert select_ladder_rung(True, 1200.0) == FLOOR_RUNG_ENGLISH
assert select_ladder_rung(False, 1200.0) == FLOOR_RUNG_MULTILINGUAL
class TestBackendThresholds:
"""Each GPU backend keys to its own measured thresholds (ticket #19)."""
def test_cuda_keeps_default_only_above_its_own_q8_threshold(self) -> None:
assert select_ladder_rung(True, 1690.0, "cuda") == DEFAULT_RUNG
assert select_ladder_rung(True, 1689.0, "cuda") == LOW_MEMORY_RUNG
# 1644 MiB is enough for Vulkan's q8_0 but not for CUDA's.
assert select_ladder_rung(True, 1644.0, "cuda") == LOW_MEMORY_RUNG
assert select_ladder_rung(True, 1644.0, "vulkan") == DEFAULT_RUNG
def test_cuda_q5_threshold_is_its_own(self) -> None:
assert select_ladder_rung(True, 1404.0, "cuda") == LOW_MEMORY_RUNG
assert select_ladder_rung(True, 1403.0, "cuda") == FLOOR_RUNG_ENGLISH
assert select_ladder_rung(True, 1359.0, "vulkan") == LOW_MEMORY_RUNG
assert select_ladder_rung(True, 1359.0, "cuda") == FLOOR_RUNG_ENGLISH
def test_cpu_and_unknown_backends_use_the_vulkan_thresholds(self) -> None:
for backend in ("cpu", "unknown"):
assert select_ladder_rung(True, 1644.0, backend) == DEFAULT_RUNG
assert select_ladder_rung(True, 1359.0, backend) == LOW_MEMORY_RUNG
assert select_ladder_rung(True, 1358.0, backend) == FLOOR_RUNG_ENGLISH
def test_default_backend_is_vulkan(self) -> None:
assert select_ladder_rung(True, 1690.0) == DEFAULT_RUNG
assert select_ladder_rung(True, 1644.0) == DEFAULT_RUNG
def test_resolve_passes_the_backend_through(self) -> None:
assert resolve_startup_rung("", True, 1644.0, "cuda") == LOW_MEMORY_RUNG
assert resolve_startup_rung("", True, 1644.0, "vulkan") == DEFAULT_RUNG
assert resolve_startup_rung("tiny", True, 0.0, "cuda") == "tiny"
class TestResolveStartupRung:
"""A manual pin wins verbatim; only unpinned loads use the ladder."""
@@ -372,6 +425,24 @@ class TestStepDownRetry:
assert loader.call_count == 1
def _probe_memory(free_mib: float, backend: str = "cpu"):
"""Stub the startup probes: free memory of the installed build, and its backend."""
stack = ExitStack()
stack.enter_context(
patch(
"vocalinux.speech_recognition.model_ladder.free_memory_for_backend",
return_value=(free_mib, "RAM"),
)
)
stack.enter_context(
patch(
"vocalinux.speech_recognition.recognition_manager.detect_pywhispercpp_gpu_backend",
return_value=backend,
)
)
return stack
class TestStartupRungSelection:
"""Unpinned defaults resolve through the ladder; explicit picks do not."""
@@ -390,9 +461,7 @@ class TestStartupRungSelection:
def test_unpinned_default_selects_rung_by_memory(self) -> None:
manager = self._manager_with("tiny", "", "en-us")
with patch(
"vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=99999.0
):
with _probe_memory(99999.0):
with patch(
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
):
@@ -400,10 +469,7 @@ class TestStartupRungSelection:
def test_unpinned_default_low_memory_selects_q5(self) -> None:
manager = self._manager_with("tiny", "", "en-us")
with patch(
"vocalinux.speech_recognition.model_ladder.free_memory_mib",
return_value=PREFER_Q5_0_ABOVE_MIB,
):
with _probe_memory(PREFER_Q5_0_ABOVE_MIB):
with patch(
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
):
@@ -411,7 +477,7 @@ class TestStartupRungSelection:
def test_unpinned_default_tight_memory_selects_floor(self) -> None:
manager = self._manager_with("tiny", "", "pl")
with patch("vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=100.0):
with _probe_memory(100.0):
with patch(
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
):
@@ -419,16 +485,12 @@ class TestStartupRungSelection:
def test_pinned_variant_never_uses_ladder(self) -> None:
manager = self._manager_with("tiny", LOW_MEMORY_RUNG, "en-us")
with patch(
"vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=99999.0
):
with _probe_memory(99999.0):
assert manager.get_model_size_for_engine("whisper_cpp") == LOW_MEMORY_RUNG
def test_explicit_size_keeps_legacy_derivation(self) -> None:
manager = self._manager_with("medium", "", "en-us")
with patch(
"vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=99999.0
):
with _probe_memory(99999.0):
with patch(
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
):