diff --git a/src/vocalinux/speech_recognition/model_ladder.py b/src/vocalinux/speech_recognition/model_ladder.py index a410036..9b0148f 100644 --- a/src/vocalinux/speech_recognition/model_ladder.py +++ b/src/vocalinux/speech_recognition/model_ladder.py @@ -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", diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 3ca03f0..b80aa61 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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)", diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index 252628d..602cbd4 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -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)" diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 1a1523d..ceff0f6 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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") diff --git a/tests/test_model_ladder.py b/tests/test_model_ladder.py index 674be9f..80be4f0 100644 --- a/tests/test_model_ladder.py +++ b/tests/test_model_ladder.py @@ -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 ):