From 1c299d2dc79beb429a7a7a8f5f55dbfbd3d3a9c5 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 21 Oct 2026 00:07:01 -0700 Subject: [PATCH] fix(whispercpp): map GPU picker to ggml devices and size threads by effective backend (#1001) * fix(whispercpp): map GPU picker to ggml devices and size threads by effective backend The GPU picker and auto selection used vulkaninfo indices, but whisper.cpp's gpu_device is an ordinal over the GPU/IGPU devices ggml registered. ggml skips Vulkan devices it cannot use (for example a Haswell iGPU), so the indices shift and a selected NVIDIA card could fall back to CPU while logs claimed Vulkan. Read ggml's device registry through the loaded pywhispercpp extension, select and label devices by that ordinal, run on CPU explicitly when ggml has no GPU, log the effective backend after load, and pick n_threads from the effective backend instead of the presence of GPU libraries. Fixes #987 Fixes #990 * test(whispercpp): move new ggml probe tests into their test class --- .../speech_recognition/recognition_manager.py | 193 +++++++++++---- src/vocalinux/ui/settings_dialog.py | 35 +-- src/vocalinux/utils/whispercpp_model_info.py | 61 ++++- tests/conftest.py | 15 ++ .../test_recognition_manager_device_config.py | 223 ++++++++++++++++++ tests/test_settings_dialog.py | 78 ++++++ tests/test_whispercpp_model_info_ext.py | 154 +++++++++++- 7 files changed, 693 insertions(+), 66 deletions(-) diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index c00fe5a..19c5ef0 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -137,6 +137,26 @@ def _pywhispercpp_distribution_version() -> Optional[tuple[int, ...]]: return tuple(parts) if parts else None +def _select_ggml_gpu_device( + devices: list[dict[str, Any]], requested_index: Optional[int] +) -> Optional[dict[str, Any]]: + if requested_index is not None and requested_index >= 0: + selected = next((device for device in devices if device["index"] == requested_index), None) + if selected is not None: + return selected + logger.warning( + "Requested whisper.cpp GPU index %s is unavailable; usable ggml GPU devices: %s. " + "Selecting automatically.", + requested_index, + devices, + ) + + return next( + (device for device in devices if device["device_type"] == "discrete"), + devices[0] if devices else None, + ) + + def resolve_whisper_language(language: str) -> Optional[str]: """Map a catalog language id to a Whisper / whisper.cpp language code. @@ -1515,12 +1535,21 @@ class SpeechRecognitionManager: return compatible_kwargs def _load_model_with_compatible_params( - self, model_path: str, model_kwargs: dict, gpu_device: Optional[int] = None - ): + self, + model_path: str, + model_kwargs: dict[str, Any], + gpu_device: Optional[int] = None, + use_gpu: Optional[bool] = None, + ) -> Any: from pywhispercpp.model import Model compatible_kwargs = self._filter_whispercpp_model_kwargs(model_kwargs) + context_params: dict[str, Any] = {} + if use_gpu is not None: + context_params["use_gpu"] = use_gpu if gpu_device is not None and gpu_device >= 0: + context_params["gpu_device"] = gpu_device + if context_params: dist_version = _pywhispercpp_distribution_version() too_old_for_context_params = dist_version is not None and dist_version < (1, 5, 0) try: @@ -1540,7 +1569,7 @@ class SpeechRecognitionManager: version_label, ) elif supports_context_params: - compatible_kwargs["context_params"] = {"gpu_device": gpu_device} + compatible_kwargs["context_params"] = context_params else: logger.warning( "pywhispercpp does not support context_params; " @@ -1582,62 +1611,118 @@ class SpeechRecognitionManager: _prefer_discrete_vulkan_device, detect_compute_backend, detect_cpu_info, + detect_ggml_gpu_devices, detect_vulkan_devices, get_backend_display_name, ) 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") selected_gpu_device = None + use_gpu: Optional[bool] = None runtime_backend = ComputeBackend.CPU runtime_info = detect_cpu_info() if actual_gpu_backend == "cuda": - # pywhispercpp CUDA uses CUDA device ordinals (0 = first NVIDIA GPU). - # Vulkan enumeration on hybrid laptops lists iGPU as GPU0 and dGPU as - # GPU1; passing that Vulkan index to CUDA selects the CPU fallback. - vulkan_gpu_index = self.whispercpp_gpu_device - if vulkan_gpu_index is None or vulkan_gpu_index < 0: - vulkan_gpu_index = _prefer_discrete_vulkan_device() - if vulkan_gpu_index not in (None, 0): - logger.info( - "pywhispercpp is CUDA-backed; using CUDA device 0 " - f"(Vulkan GPU index {vulkan_gpu_index} does not map to CUDA ordinals)" + if ggml_devices == []: + use_gpu = False + skipped_devices = [ + device["name"] + for device in detect_vulkan_devices() + if device.get("device_type") != "software" + ] + logger.warning( + "whisper.cpp found no usable ggml GPU device and will use CPU. " + "Vulkan hardware devices skipped by ggml: %s", + ", ".join(skipped_devices) if skipped_devices else "none found", ) else: - logger.info("pywhispercpp is CUDA-backed; using CUDA device 0") - selected_gpu_device = 0 - runtime_backend = ComputeBackend.CUDA - runtime_info = host_info if host_backend == ComputeBackend.CUDA else "NVIDIA GPU" + # CUDA device ordinals are independent of vulkaninfo indices. + vulkan_gpu_index = self.whispercpp_gpu_device + if vulkan_gpu_index is None or vulkan_gpu_index < 0: + vulkan_gpu_index = _prefer_discrete_vulkan_device() + if vulkan_gpu_index not in (None, 0): + logger.info( + "pywhispercpp is CUDA-backed; using CUDA device 0 " + "(Vulkan GPU index %s does not map to CUDA ordinals)", + vulkan_gpu_index, + ) + else: + logger.info("pywhispercpp is CUDA-backed; using CUDA device 0") + selected_gpu_device = 0 + runtime_backend = ComputeBackend.CUDA + runtime_info = host_info if host_backend == ComputeBackend.CUDA else "NVIDIA GPU" elif actual_gpu_backend == "vulkan": - gpu_device_index = self.whispercpp_gpu_device - if gpu_device_index is None or gpu_device_index < 0: - gpu_device_index = _prefer_discrete_vulkan_device() - - devices = detect_vulkan_devices() - runtime_backend = ComputeBackend.VULKAN - if gpu_device_index is not None: - device_name = next( - (d["name"] for d in devices if d["index"] == gpu_device_index), - "unknown", - ) - logger.info(f"Using Vulkan GPU [{gpu_device_index}]: {device_name}") - selected_gpu_device = gpu_device_index - runtime_info = device_name - elif devices: - runtime_info = devices[0]["name"] - logger.warning( - "pywhispercpp is Vulkan-backed but no preferred GPU was selected. " - "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." - ) + if ggml_devices is not None: + vulkan_devices = detect_vulkan_devices() + if ggml_devices: + selected_device = _select_ggml_gpu_device( + ggml_devices, self.whispercpp_gpu_device + ) + ggml_names = {device["name"] for device in ggml_devices} + skipped_devices = [ + device["name"] + for device in vulkan_devices + if device.get("device_type") != "software" + and device["name"] not in ggml_names + ] + if skipped_devices: + logger.info( + "Vulkan devices not registered by ggml: %s", + ", ".join(skipped_devices), + ) + if selected_device is not None: + runtime_backend = ComputeBackend.VULKAN + selected_gpu_device = selected_device["index"] + runtime_info = selected_device["name"] + logger.info( + "Requesting Vulkan GPU [%s]: %s (%s)", + selected_device["index"], + selected_device["name"], + selected_device["backend_name"], + ) + else: + use_gpu = False + skipped_devices = [ + device["name"] + for device in vulkan_devices + if device.get("device_type") != "software" + ] + logger.warning( + "whisper.cpp found no usable Vulkan device and will use CPU. " + "Vulkan hardware devices skipped by ggml: %s", + ", ".join(skipped_devices) if skipped_devices else "none found", + ) else: - logger.warning( - "pywhispercpp is Vulkan-backed but vulkaninfo found no devices. " - "Install vulkan-tools so Vocalinux can pick a discrete GPU. " - "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." - ) + gpu_device_index = self.whispercpp_gpu_device + if gpu_device_index is None or gpu_device_index < 0: + gpu_device_index = _prefer_discrete_vulkan_device() + + devices = detect_vulkan_devices() + runtime_backend = ComputeBackend.VULKAN + if gpu_device_index is not None: + device_name = next( + (d["name"] for d in devices if d["index"] == gpu_device_index), + "unknown", + ) + logger.info(f"Using Vulkan GPU [{gpu_device_index}]: {device_name}") + selected_gpu_device = gpu_device_index + runtime_info = device_name + elif devices: + runtime_info = devices[0]["name"] + logger.warning( + "pywhispercpp is Vulkan-backed but no preferred GPU was selected. " + "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." + ) + else: + logger.warning( + "pywhispercpp is Vulkan-backed but vulkaninfo found no devices. " + "Install vulkan-tools so Vocalinux can pick a discrete GPU. " + "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." + ) elif host_backend != ComputeBackend.CPU: logger.warning( "Host reports %s (%s), but pywhispercpp has no GPU libraries. " @@ -1649,7 +1734,11 @@ class SpeechRecognitionManager: host_info, ) - if actual_gpu_backend in ("vulkan", "cuda") and host_backend != actual_gpu_backend: + if ( + runtime_backend == actual_gpu_backend + and actual_gpu_backend in ("vulkan", "cuda") + and host_backend != actual_gpu_backend + ): logger.info( "Host capability label is %s; bundled pywhispercpp libraries are %s. " "Using the bundled %s backend.", @@ -1659,7 +1748,9 @@ class SpeechRecognitionManager: ) logger.info( - f"whisper.cpp using {get_backend_display_name(runtime_backend)} backend: {runtime_info}" + "whisper.cpp requesting %s backend: %s", + get_backend_display_name(runtime_backend), + runtime_info, ) import psutil @@ -1678,9 +1769,8 @@ class SpeechRecognitionManager: logger.info(f"Loading whisper.cpp '{self.model_size}' model...") self.model = None # Release previous model if re-initializing - has_gpu_libs = actual_gpu_backend in ("vulkan", "cuda") - - if has_gpu_libs and not ( + has_gpu_backend = runtime_backend in (ComputeBackend.VULKAN, ComputeBackend.CUDA) + if has_gpu_backend and not ( self.whispercpp_n_threads is not None and self.whispercpp_n_threads > 0 ): n_threads = max(1, multiprocessing.cpu_count() // 4) @@ -1695,18 +1785,25 @@ class SpeechRecognitionManager: try: self.model = self._load_model_with_compatible_params( - model_path, model_kwargs, gpu_device=selected_gpu_device + model_path, model_kwargs, gpu_device=selected_gpu_device, use_gpu=use_gpu ) except RuntimeError as model_error: loaded_backend = self._handle_gpu_fallback( model_error, model_path, model_kwargs, ComputeBackend.CPU ) + runtime_info = detect_cpu_info() effective_threads = self._whispercpp_effective_threads if effective_threads is None: effective_threads = n_threads load_duration = time.time() - load_start_time + logger.info( + "whisper.cpp loaded with effective %s backend: %s (n_threads=%d)", + get_backend_display_name(loaded_backend), + runtime_info, + effective_threads, + ) logger.info( f"whisper.cpp configured with n_threads={effective_threads} " f"({loaded_backend} backend)" diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 9f10287..710113b 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2422,6 +2422,24 @@ class ModelDownloadDialog(Gtk.Dialog): self.add_button("OK", Gtk.ResponseType.OK) +def _populate_gpu_device_choices(combo: Any, saved_device: Optional[int]) -> None: + combo.remove_all() + combo.append("-1", "Auto (discrete GPU)") + + from ..utils.whispercpp_model_info import detect_ggml_gpu_devices + + devices = detect_ggml_gpu_devices() + if devices is None: + devices = detect_vulkan_devices() + for device in devices: + type_label = device["device_type"].capitalize() + label = f"[{device['index']}] {device['name']} ({type_label})" + combo.append(str(device["index"]), label) + + if saved_device is None or not combo.set_active_id(str(saved_device)): + combo.set_active_id("-1") + + class SettingsDialog(AboutSectionMixin): """Modern GTK Dialog for configuring Vocalinux settings.""" @@ -9592,23 +9610,10 @@ For now, the engine has been reverted to VOSK.""" error_dialog.destroy() return False - def _populate_gpu_devices(self): + def _populate_gpu_devices(self) -> None: """Populate the GPU device dropdown with available Vulkan devices.""" - self.gpu_device_combo.remove_all() - self.gpu_device_combo.append("-1", "Auto (discrete GPU)") - - devices = detect_vulkan_devices() - for device in devices: - type_label = device["device_type"].capitalize() - label = f"[{device['index']}] {device['name']} ({type_label})" - self.gpu_device_combo.append(str(device["index"]), label) - saved_device = self.config_manager.get("advanced", "whispercpp_gpu_device", None) - if saved_device is None: - self.gpu_device_combo.set_active_id("-1") - else: - if not self.gpu_device_combo.set_active_id(str(saved_device)): - self.gpu_device_combo.set_active_id("-1") + _populate_gpu_device_choices(self.gpu_device_combo, saved_device) def _populate_audio_devices(self): """Populate the audio device dropdown with available input devices.""" diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index 1ddf6e4..961f6e6 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -5,13 +5,15 @@ This module provides model metadata and hardware acceleration detection for whisper.cpp, supporting Vulkan, CUDA, and CPU backends. """ +import ctypes import logging import math import os import re import subprocess -from functools import lru_cache -from typing import Optional, cast +from functools import cache, lru_cache +from importlib.metadata import PackageNotFoundError, version +from typing import Any, Optional, cast from .host_process import host_env from .model_checksums import whispercpp_revision @@ -347,6 +349,61 @@ def detect_vulkan_devices() -> list[dict]: return devices +@cache +def detect_ggml_gpu_devices() -> Optional[list[dict[str, Any]]]: + """Return whisper.cpp's usable GPU devices in its device-selection order.""" + try: + raw_version = version("pywhispercpp") + except (ImportError, PackageNotFoundError): + return None + + version_match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?", raw_version) + if version_match is None: + return None + installed_version = tuple(int(part or 0) for part in version_match.groups()) + if installed_version < (1, 5, 0): + return None + + try: + from . import pywhispercpp_loader + + pywhispercpp_loader.preload_shared_libraries() + import _pywhispercpp + + library = ctypes.CDLL(_pywhispercpp.__file__) + library.ggml_backend_dev_count.argtypes = [] + library.ggml_backend_dev_count.restype = ctypes.c_size_t + library.ggml_backend_dev_get.argtypes = [ctypes.c_size_t] + library.ggml_backend_dev_get.restype = ctypes.c_void_p + library.ggml_backend_dev_name.argtypes = [ctypes.c_void_p] + library.ggml_backend_dev_name.restype = ctypes.c_char_p + library.ggml_backend_dev_description.argtypes = [ctypes.c_void_p] + library.ggml_backend_dev_description.restype = ctypes.c_char_p + library.ggml_backend_dev_type.argtypes = [ctypes.c_void_p] + library.ggml_backend_dev_type.restype = ctypes.c_int + + devices: list[dict[str, Any]] = [] + for registry_index in range(library.ggml_backend_dev_count()): + device = library.ggml_backend_dev_get(registry_index) + device_type = library.ggml_backend_dev_type(device) + if device_type not in (1, 2): + continue + # whisper.cpp counts every GPU/IGPU device, so never skip one here. + backend_name = library.ggml_backend_dev_name(device) or b"unknown" + description = library.ggml_backend_dev_description(device) or backend_name + devices.append( + { + "index": len(devices), + "name": description.decode("utf-8", errors="replace"), + "backend_name": backend_name.decode("utf-8", errors="replace"), + "device_type": "discrete" if device_type == 1 else "integrated", + } + ) + return devices + except (ImportError, OSError, AttributeError): + return None + + def _prefer_discrete_vulkan_device() -> Optional[int]: """Return the index of the preferred hardware Vulkan GPU (discrete if available).""" devices = _hardware_vulkan_devices() diff --git a/tests/conftest.py b/tests/conftest.py index 1bc61d5..4d4d06e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -210,6 +210,7 @@ def _clear_hardware_detection_cache(): "detect_cuda_support", "detect_compute_backend", "detect_cpu_info", + "detect_ggml_gpu_devices", ): fn = getattr(_wmi, fn_name, None) if fn is not None and hasattr(fn, "cache_clear"): @@ -219,6 +220,20 @@ def _clear_hardware_detection_cache(): yield +@pytest.fixture(autouse=True) +def _use_legacy_ggml_gpu_detection( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None, None, None]: + """Keep existing tests on the legacy path unless they opt into ggml probing.""" + from vocalinux.utils import whispercpp_model_info as _wmi + + def _return_none() -> None: + return None + + monkeypatch.setattr(_wmi, "detect_ggml_gpu_devices", _return_none) + yield + + @pytest.fixture(autouse=True) def _cleanup_ibus_server(): """Stop any leftover IBus socket server threads after each test. diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index 0ef06ef..bb83948 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -20,6 +20,7 @@ import tempfile import threading import time import unittest +from typing import Any, Optional from unittest import mock from unittest.mock import MagicMock, Mock, call, patch @@ -1977,6 +1978,228 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): ) +class TestWhispercppGgmlDeviceSelection(unittest.TestCase): + """Test manager selection against whisper.cpp's native GPU registry.""" + + def setUp(self: "TestWhispercppGgmlDeviceSelection") -> None: + verifier = patch.object( + SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True + ) + verifier.start() + self.addCleanup(verifier.stop) + self.device_helper = TestWhispercppGpuDeviceSelection() + + def _load_with_ggml_devices( + self: "TestWhispercppGgmlDeviceSelection", + manager: SpeechRecognitionManager, + ggml_devices: list[dict[str, Any]], + vulkan_devices: list[dict[str, Any]], + actual_backend: str = "vulkan", + host_backend: Optional[str] = None, + ) -> list[tuple[str, dict[str, Any]]]: + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + if host_backend is None: + host_backend = ( + whispercpp_info.ComputeBackend.CUDA + if actual_backend == "cuda" + else whispercpp_info.ComputeBackend.VULKAN + ) + with ( + patch.object( + whispercpp_info, + "detect_ggml_gpu_devices", + return_value=ggml_devices, + ), + patch( + "vocalinux.speech_recognition.recognition_manager.multiprocessing.cpu_count", + return_value=8, + ), + ): + return self.device_helper._init_with_backends( + manager, + host_backend=host_backend, + host_info="Vulkan or CUDA test device", + actual_backend=actual_backend, + vulkan_devices=vulkan_devices, + prefer_index=1, + ) + + def test_auto_selection_uses_ggml_ordinal_not_vulkaninfo_index( + self: "TestWhispercppGgmlDeviceSelection", + ) -> None: + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None) + manager.model_size = "tiny" + vulkan_devices = [ + { + "index": 0, + "name": "Intel(R) HD Graphics 4600 (HSW GT2)", + "device_type": "integrated", + }, + { + "index": 1, + "name": "NVIDIA GeForce GTX 1050", + "device_type": "discrete", + }, + ] + ggml_devices = [ + { + "index": 0, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan0", + "device_type": "discrete", + } + ] + + with self.assertLogs( + "vocalinux.speech_recognition.recognition_manager", level="INFO" + ) as logs: + calls = self._load_with_ggml_devices( + manager, + ggml_devices, + vulkan_devices, + host_backend=whispercpp_info.ComputeBackend.VULKAN, + ) + + self.assertEqual(calls[-1][1]["context_params"], {"gpu_device": 0}) + self.assertEqual(calls[-1][1]["n_threads"], 2) + self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output)) + + def test_stale_explicit_index_falls_back_to_ggml_auto_selection( + self: "TestWhispercppGgmlDeviceSelection", + ) -> None: + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1) + manager.model_size = "tiny" + ggml_devices = [ + { + "index": 0, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan0", + "device_type": "discrete", + } + ] + vulkan_devices = [ + {"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"}, + {"index": 1, "name": "NVIDIA GeForce GTX 1050", "device_type": "discrete"}, + ] + + with self.assertLogs( + "vocalinux.speech_recognition.recognition_manager", level="WARNING" + ) as logs: + calls = self._load_with_ggml_devices( + manager, + ggml_devices, + vulkan_devices, + host_backend=whispercpp_info.ComputeBackend.VULKAN, + ) + + self.assertEqual(calls[-1][1]["context_params"], {"gpu_device": 0}) + self.assertTrue(any("index 1 is unavailable" in entry for entry in logs.output)) + self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output)) + + def test_empty_ggml_registry_uses_cpu_and_lists_skipped_devices( + self: "TestWhispercppGgmlDeviceSelection", + ) -> None: + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None) + manager.model_size = "tiny" + vulkan_devices = [ + { + "index": 0, + "name": "Intel(R) HD Graphics 4600 (HSW GT2)", + "device_type": "integrated", + }, + { + "index": 1, + "name": "NVIDIA GeForce GTX 1050", + "device_type": "discrete", + }, + ] + + with self.assertLogs( + "vocalinux.speech_recognition.recognition_manager", level="INFO" + ) as logs: + calls = self._load_with_ggml_devices( + manager, + [], + vulkan_devices, + host_backend=whispercpp_info.ComputeBackend.VULKAN, + ) + + self.assertEqual(calls[-1][1]["context_params"], {"use_gpu": False}) + self.assertEqual(calls[-1][1]["n_threads"], 6) + self.assertTrue(any("effective cpu backend" in entry.lower() for entry in logs.output)) + self.assertTrue(any("Intel(R) HD Graphics 4600" in entry for entry in logs.output)) + self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output)) + + def test_positive_thread_override_applies_to_cpu_and_gpu( + self: "TestWhispercppGgmlDeviceSelection", + ) -> None: + manager = _make_manager( + engine="whisper_cpp", + whispercpp_gpu_device=None, + whispercpp_n_threads=3, + ) + manager.model_size = "tiny" + vulkan_devices = [ + {"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"}, + {"index": 1, "name": "NVIDIA GPU", "device_type": "discrete"}, + ] + ggml_devices = [ + { + "index": 0, + "name": "NVIDIA GPU", + "backend_name": "Vulkan0", + "device_type": "discrete", + } + ] + + gpu_calls = self._load_with_ggml_devices(manager, ggml_devices, vulkan_devices) + self.assertEqual(gpu_calls[-1][1]["n_threads"], 3) + self.assertEqual(gpu_calls[-1][1]["context_params"], {"gpu_device": 0}) + + cpu_manager = _make_manager( + engine="whisper_cpp", + whispercpp_gpu_device=None, + whispercpp_n_threads=3, + ) + cpu_manager.model_size = "tiny" + cpu_calls = self._load_with_ggml_devices(cpu_manager, [], vulkan_devices) + self.assertEqual(cpu_calls[-1][1]["n_threads"], 3) + self.assertEqual(cpu_calls[-1][1]["context_params"], {"use_gpu": False}) + + def test_cuda_runtime_with_empty_ggml_registry_uses_cpu_threads( + self: "TestWhispercppGgmlDeviceSelection", + ) -> None: + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None) + manager.model_size = "tiny" + vulkan_devices = [ + {"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"}, + {"index": 1, "name": "NVIDIA GeForce GTX 1050", "device_type": "discrete"}, + ] + with self.assertLogs( + "vocalinux.speech_recognition.recognition_manager", level="INFO" + ) as logs: + calls = self._load_with_ggml_devices( + manager, + [], + vulkan_devices, + actual_backend="cuda", + host_backend=whispercpp_info.ComputeBackend.CUDA, + ) + + self.assertEqual(calls[-1][1]["n_threads"], 6) + self.assertEqual(calls[-1][1]["context_params"], {"use_gpu": False}) + self.assertTrue(any("effective cpu backend" in entry.lower() for entry in logs.output)) + + class TestTranscription(unittest.TestCase): """Test transcription methods.""" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 035b4e6..db49d21 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -16,6 +16,7 @@ import sys import textwrap import time import unittest +from typing import Optional from unittest.mock import MagicMock, Mock, call, patch # Mock GTK before importing anything that might use it @@ -1719,5 +1720,82 @@ class TestSettingsControlColumn(unittest.TestCase): self.assertNotIn("Tip: ", self.source) +def _gpu_settings_dialog(saved_device: Optional[int]) -> MagicMock: + dialog = MagicMock() + dialog.config_manager.get.return_value = saved_device + dialog.gpu_device_combo.set_active_id.return_value = True + return dialog + + +def test_gpu_dropdown_uses_ggml_indices_and_labels() -> None: + from vocalinux.ui import settings_dialog + from vocalinux.utils import whispercpp_model_info + + dialog = _gpu_settings_dialog(None) + ggml_devices = [ + { + "index": 0, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan0", + "device_type": "discrete", + }, + { + "index": 1, + "name": "Intel Arc", + "backend_name": "Vulkan1", + "device_type": "integrated", + }, + ] + with patch.object(whispercpp_model_info, "detect_ggml_gpu_devices", return_value=ggml_devices): + settings_dialog._populate_gpu_device_choices(dialog.gpu_device_combo, None) + + dialog.gpu_device_combo.append.assert_any_call("0", "[0] NVIDIA GeForce GTX 1050 (Discrete)") + dialog.gpu_device_combo.append.assert_any_call("1", "[1] Intel Arc (Integrated)") + + +def test_gpu_dropdown_falls_back_to_vulkaninfo_when_ggml_probe_is_unavailable() -> None: + from vocalinux.ui import settings_dialog + from vocalinux.utils import whispercpp_model_info + + dialog = _gpu_settings_dialog(None) + vulkan_devices = [ + {"index": 1, "name": "NVIDIA GPU", "device_type": "discrete"}, + ] + with ( + patch.object(whispercpp_model_info, "detect_ggml_gpu_devices", return_value=None), + patch.object(settings_dialog, "detect_vulkan_devices", return_value=vulkan_devices), + ): + settings_dialog._populate_gpu_device_choices(dialog.gpu_device_combo, None) + + dialog.gpu_device_combo.append.assert_any_call("1", "[1] NVIDIA GPU (Discrete)") + + +def test_gpu_dropdown_selects_auto_for_stale_saved_index() -> None: + from vocalinux.ui import settings_dialog + from vocalinux.utils import whispercpp_model_info + + dialog = _gpu_settings_dialog(9) + + def _set_active_id(device_id: str) -> bool: + return device_id in {"-1", "0"} + + dialog.gpu_device_combo.set_active_id.side_effect = _set_active_id + ggml_devices = [ + { + "index": 0, + "name": "NVIDIA GPU", + "backend_name": "Vulkan0", + "device_type": "discrete", + } + ] + with patch.object(whispercpp_model_info, "detect_ggml_gpu_devices", return_value=ggml_devices): + settings_dialog._populate_gpu_device_choices(dialog.gpu_device_combo, 9) + + assert dialog.gpu_device_combo.set_active_id.call_args_list[-2:] == [ + call("9"), + call("-1"), + ] + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index b779e93..45cf9f3 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -5,9 +5,63 @@ Tests all functions and constants in the module. """ import subprocess +import sys import unittest +from types import SimpleNamespace +from typing import Any, Optional from unittest.mock import MagicMock, patch +from vocalinux.utils import whispercpp_model_info as _whispercpp_model_info + +_REAL_GGML_DEVICE_PROBE = _whispercpp_model_info.detect_ggml_gpu_devices + + +def _fake_ggml_library(registry: list[tuple[str, str, int]]) -> MagicMock: + library = MagicMock() + library.ggml_backend_dev_count.return_value = len(registry) + + def _get_device(index: int) -> int: + return index + 1 + + def _get_device_type(pointer: int) -> int: + return registry[pointer - 1][2] + + def _get_device_name(pointer: int) -> bytes: + return registry[pointer - 1][0].encode("utf-8") + + def _get_device_description(pointer: int) -> bytes: + return registry[pointer - 1][1].encode("utf-8") + + library.ggml_backend_dev_get.side_effect = _get_device + library.ggml_backend_dev_type.side_effect = _get_device_type + library.ggml_backend_dev_name.side_effect = _get_device_name + library.ggml_backend_dev_description.side_effect = _get_device_description + return library + + +def _run_ggml_probe( + registry: list[tuple[str, str, int]], + version_string: str = "1.5.0", + library: Optional[Any] = None, + library_error: Optional[Exception] = None, + missing_extension: bool = False, +) -> Optional[list[dict[str, Any]]]: + _REAL_GGML_DEVICE_PROBE.cache_clear() + native_library = library if library is not None else _fake_ggml_library(registry) + extension = None if missing_extension else SimpleNamespace(__file__="/fake/_pywhispercpp.so") + from vocalinux.utils import pywhispercpp_loader + + with ( + patch.object(_whispercpp_model_info, "version", return_value=version_string), + patch.object(pywhispercpp_loader, "preload_shared_libraries"), + patch.dict(sys.modules, {"_pywhispercpp": extension}), + ): + if library_error is not None: + with patch.object(_whispercpp_model_info.ctypes, "CDLL", side_effect=library_error): + return _REAL_GGML_DEVICE_PROBE() + with patch.object(_whispercpp_model_info.ctypes, "CDLL", return_value=native_library): + return _REAL_GGML_DEVICE_PROBE() + class TestComputeBackendClass(unittest.TestCase): """Test cases for ComputeBackend class.""" @@ -435,7 +489,105 @@ class TestPreferDiscreteVulkanDevice(unittest.TestCase): from vocalinux.utils.whispercpp_model_info import _prefer_discrete_vulkan_device preferred_idx = _prefer_discrete_vulkan_device() - self.assertEqual(preferred_idx, 0) + self.assertEqual(preferred_idx, 0) + + +class TestDetectGgmlGpuDevices(unittest.TestCase): + """Test ggml's native GPU registry probe.""" + + def test_probe_assigns_zero_to_first_discrete_gpu( + self: "TestDetectGgmlGpuDevices", + ) -> None: + devices = _run_ggml_probe( + [ + ("Vulkan0", "NVIDIA GeForce GTX 1050", 1), + ("CPU", "CPU backend", 0), + ] + ) + + self.assertEqual( + devices, + [ + { + "index": 0, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan0", + "device_type": "discrete", + } + ], + ) + + def test_probe_uses_gpu_and_igpu_ordinals(self: "TestDetectGgmlGpuDevices") -> None: + devices = _run_ggml_probe( + [ + ("Vulkan0", "Intel Arc", 2), + ("CPU", "CPU backend", 0), + ("Vulkan1", "NVIDIA GeForce GTX 1050", 1), + ("ACCEL", "Accelerator", 3), + ] + ) + + self.assertEqual( + devices, + [ + { + "index": 0, + "name": "Intel Arc", + "backend_name": "Vulkan0", + "device_type": "integrated", + }, + { + "index": 1, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan1", + "device_type": "discrete", + }, + ], + ) + + def test_probe_returns_empty_list_for_cpu_only_registry( + self: "TestDetectGgmlGpuDevices", + ) -> None: + self.assertEqual(_run_ggml_probe([("CPU", "CPU backend", 0)]), []) + + def test_probe_returns_none_when_runtime_is_unavailable( + self: "TestDetectGgmlGpuDevices", + ) -> None: + self.assertIsNone(_run_ggml_probe([], version_string="1.4.9")) + self.assertIsNone(_run_ggml_probe([], missing_extension=True)) + self.assertIsNone(_run_ggml_probe([], library_error=OSError("unavailable"))) + self.assertIsNone(_run_ggml_probe([], library=SimpleNamespace())) + + def test_probe_keeps_ordinal_for_device_without_description( + self: "TestDetectGgmlGpuDevices", + ) -> None: + library = _fake_ggml_library( + [("Vulkan0", "", 1), ("Vulkan1", "NVIDIA GeForce GTX 1050", 1)] + ) + library.ggml_backend_dev_description.side_effect = lambda pointer: ( + None if pointer == 1 else b"NVIDIA GeForce GTX 1050" + ) + + devices = _run_ggml_probe([], library=library) + + assert devices is not None + self.assertEqual([device["index"] for device in devices], [0, 1]) + self.assertEqual(devices[0]["name"], "Vulkan0") + self.assertEqual(devices[1]["name"], "NVIDIA GeForce GTX 1050") + + def test_probe_accepts_prerelease_version(self: "TestDetectGgmlGpuDevices") -> None: + devices = _run_ggml_probe([("Vulkan0", "GPU", 1)], version_string="1.5.1.dev3") + assert devices is not None + self.assertEqual(len(devices), 1) + + +class TestPreferDiscreteVulkanDeviceMore(unittest.TestCase): + """Exercise additional Vulkan selection edge cases.""" + + def setUp(self: "TestPreferDiscreteVulkanDeviceMore") -> None: + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + detect_vulkan_devices.cache_clear() def test_prefer_discrete_returns_none_when_empty(self): """Test that None is returned when no devices found."""