From b945137d60d409f8f3f637a3f8c415ede8dd7e43 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 26 Jul 2026 21:25:20 -0700 Subject: [PATCH] fix(vulkan): auto-select discrete GPU and add manual device selection (#590) * fix(vulkan): auto-select discrete GPU and add manual device selection On hybrid GPU systems (Intel iGPU + NVIDIA dGPU), whisper.cpp's Vulkan backend always selected device 0 (the integrated GPU), resulting in significantly slower performance. Changes: - Add detect_vulkan_devices() to enumerate all Vulkan GPUs and identify discrete vs integrated devices - Set GGML_VULKAN_DEVICE env var before model init to select the correct GPU (auto-selects discrete GPU when available) - Add whispercpp_gpu_device config option for manual device selection - Add GPU device dropdown in Settings > Advanced for users with multiple Vulkan GPUs Fixes #589 * test: add coverage for Vulkan GPU device selection - Test detect_vulkan_devices() with single/multiple GPUs - Test _prefer_discrete_vulkan_device() preference logic - Test GPU device selection in recognition_manager - Verify GGML_VULKAN_DEVICE env var is set correctly * fix(speech_recognition): pass whispercpp GPU device to pywhispercpp Model context_params The Vulkan device-selection logic from PR #590 set the GGML_VULKAN_DEVICE environment variable, but pywhispercpp did not pick it up. Pass the selected GPU device through the pywhispercpp Model context_params so whisper.cpp actually initializes the requested Vulkan device. Fixes #589 * fix(speech_recognition): fallback when pywhispercpp lacks context_params Older pywhispercpp releases (< ~1.4.0) reject the context_params keyword and raise TypeError. Detect that specific failure and retry without GPU device selection so the engine still loads on those versions. Fixes #589 * refactor(speech_recognition): inline context_params assignment Simplify the GPU device selection path and remove the temporary dict. * fix(vulkan): parse real vulkaninfo GPU0: headers detect_vulkan_devices() only recognized fictional "GPU id = N" headers, so real vulkaninfo --summary output (GPU0:/GPU1:) returned no devices and hybrid NVIDIA systems fell through to CUDA. Also resolve preferred device names by GPU id instead of list position. Fixes #589 * refactor(whispercpp): drop ineffective GGML_VULKAN_DEVICE env var pywhispercpp ignores GGML_VULKAN_DEVICE; device selection already goes through context_params={"gpu_device": N}. Remove the misleading env write so maintainers do not think the env var is the selection path. --- src/vocalinux/main.py | 1 + .../speech_recognition/recognition_manager.py | 48 +++- src/vocalinux/ui/config_manager.py | 1 + src/vocalinux/ui/settings_dialog.py | 43 +++ src/vocalinux/utils/whispercpp_model_info.py | 137 +++++++-- tests/test_main.py | 1 + .../test_recognition_manager_device_config.py | 201 +++++++++++++ tests/test_whispercpp_model_info_ext.py | 271 +++++++++++++++++- 8 files changed, 675 insertions(+), 28 deletions(-) diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 124ef1f..45c9225 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -399,6 +399,7 @@ def main(): whispercpp_logprob_thold=advanced_settings.get("whispercpp_logprob_thold", -1.0), 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), remote_api_url=saved_settings.get("remote_api_url", ""), remote_api_key=saved_settings.get("remote_api_key", ""), remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"), diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 58b6bea..26cb2ed 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -842,6 +842,7 @@ class SpeechRecognitionManager: self.whispercpp_logprob_thold = kwargs.get("whispercpp_logprob_thold", -1.0) self.whispercpp_no_speech_thold = kwargs.get("whispercpp_no_speech_thold", 0.6) self.whispercpp_n_threads = kwargs.get("whispercpp_n_threads", None) + self.whispercpp_gpu_device = kwargs.get("whispercpp_gpu_device", None) # Remote API settings self.remote_api_url = kwargs.get("remote_api_url", "") @@ -1198,11 +1199,31 @@ class SpeechRecognitionManager: ) return compatible_kwargs - def _load_model_with_compatible_params(self, model_path: str, model_kwargs: dict): + def _load_model_with_compatible_params( + self, model_path: str, model_kwargs: dict, gpu_device: Optional[int] = None + ): from pywhispercpp.model import Model compatible_kwargs = self._filter_whispercpp_model_kwargs(model_kwargs) - return Model(model_path, **compatible_kwargs) + if gpu_device is not None and gpu_device >= 0: + compatible_kwargs["context_params"] = {"gpu_device": gpu_device} + + try: + return Model(model_path, **compatible_kwargs) + except TypeError as exc: + # Older pywhispercpp releases (< ~1.4.0) do not accept context_params. + # Retry without GPU device selection so the engine still loads. + if "context_params" in compatible_kwargs and ( + "context_params" in str(exc) or "unexpected keyword" in str(exc) + ): + logger.warning( + "pywhispercpp does not support context_params; " + "upgrade pywhispercpp to enable GPU device selection. " + f"Falling back to default device. Error: {exc}" + ) + compatible_kwargs.pop("context_params") + return Model(model_path, **compatible_kwargs) + raise def _detect_pywhispercpp_gpu_backend(self) -> str: """Detect whether pywhispercpp's native library actually has GPU support.""" @@ -1235,7 +1256,9 @@ class SpeechRecognitionManager: from ..utils.whispercpp_model_info import ( ComputeBackend, + _prefer_discrete_vulkan_device, detect_compute_backend, + detect_vulkan_devices, get_backend_display_name, ) @@ -1246,6 +1269,22 @@ class SpeechRecognitionManager: f"whisper.cpp using {get_backend_display_name(backend)} backend: {backend_info}" ) + # Select Vulkan GPU via pywhispercpp context_params (GGML_VULKAN_DEVICE is ignored). + selected_gpu_device = None + if backend == ComputeBackend.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() + + if gpu_device_index is not None: + devices = detect_vulkan_devices() + 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 + # Log hardware summary import psutil @@ -1279,7 +1318,9 @@ class SpeechRecognitionManager: # Attempt to load model; filter unsupported params and fall back to CPU if needed try: - self.model = self._load_model_with_compatible_params(model_path, model_kwargs) + self.model = self._load_model_with_compatible_params( + model_path, model_kwargs, gpu_device=selected_gpu_device + ) except RuntimeError as model_error: loaded_backend = self._handle_gpu_fallback( model_error, model_path, model_kwargs, ComputeBackend.CPU @@ -2895,6 +2936,7 @@ class SpeechRecognitionManager: "whispercpp_logprob_thold", "whispercpp_no_speech_thold", "whispercpp_n_threads", + "whispercpp_gpu_device", ): if param_name in kwargs: setattr(self, param_name, kwargs[param_name]) diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index e1f0c57..fa44f02 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -75,6 +75,7 @@ DEFAULT_CONFIG = { "whispercpp_logprob_thold": -1.0, "whispercpp_no_speech_thold": 0.6, "whispercpp_n_threads": 0, # 0 = auto-detect optimal thread count; set to override + "whispercpp_gpu_device": None, # None = auto-select discrete GPU; int = specific device index }, } diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 36acbbb..d95b84a 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -36,6 +36,7 @@ from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES from ..utils.whispercpp_model_info import ( WHISPERCPP_MODEL_INFO, detect_compute_backend, + detect_vulkan_devices, get_backend_display_name, ) from ..utils.whispercpp_model_info import get_model_size as get_whispercpp_model_size @@ -2243,6 +2244,22 @@ class SettingsDialog(Gtk.Dialog): controls_box.pack_start(info_box, False, False, 0) controls_box.pack_start(group, False, False, 0) + gpu_group = PreferencesGroup(title="GPU Device") + self.gpu_device_combo = Gtk.ComboBoxText() + self.gpu_device_combo.set_size_request(250, -1) + self.gpu_device_combo.set_tooltip_text( + "Select which GPU to use for whisper.cpp Vulkan acceleration" + ) + _prevent_scroll_on_hover(self.gpu_device_combo) + self._populate_gpu_devices() + gpu_row = PreferenceRow( + title="Vulkan GPU", + subtitle="Device for hardware acceleration", + widget=self.gpu_device_combo, + ) + gpu_group.add_row(gpu_row) + controls_box.pack_start(gpu_group, False, False, 0) + separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) separator.set_margin_top(8) separator.set_margin_bottom(8) @@ -2262,6 +2279,8 @@ class SettingsDialog(Gtk.Dialog): self.advanced_initial_prompt_buffer = self.advanced_initial_prompt_textview.get_buffer() self.advanced_initial_prompt_buffer.connect("changed", self._on_advanced_prompt_changed) + self.gpu_device_combo.connect("changed", self._on_advanced_param_changed) + self.power_user_switch.connect("state-set", self._on_power_user_toggled) def _build_remote_server_section(self): @@ -2465,6 +2484,7 @@ class SettingsDialog(Gtk.Dialog): self.advanced_entropy_thold_spin.set_value(defaults["whispercpp_entropy_thold"]) self.advanced_logprob_thold_spin.set_value(defaults["whispercpp_logprob_thold"]) self.advanced_no_speech_thold_spin.set_value(defaults["whispercpp_no_speech_thold"]) + self.gpu_device_combo.set_active_id("-1") finally: self._applying_settings = False @@ -3284,6 +3304,11 @@ class SettingsDialog(Gtk.Dialog): "whispercpp_no_speech_thold": self.advanced_no_speech_thold_spin.get_value(), } + gpu_device_id = self.gpu_device_combo.get_active_id() + if gpu_device_id is not None: + gpu_device_val = int(gpu_device_id) + settings["whispercpp_gpu_device"] = None if gpu_device_val == -1 else gpu_device_val + # Remote API additional settings if engine == "remote_api": settings["remote_api_url"] = self.remote_api_url_entry.get_text().strip() @@ -3534,6 +3559,24 @@ For now, the engine has been reverted to VOSK.""" error_dialog.destroy() return False + def _populate_gpu_devices(self): + """Populate the GPU device dropdown with available Vulkan devices.""" + self.gpu_device_combo.remove_all() + self.gpu_device_combo.append("-1", "Auto (prefer 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") + def _populate_audio_devices(self): """Populate the audio device dropdown with available input devices.""" from ..speech_recognition.recognition_manager import get_audio_input_devices diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index a38a1b9..916da73 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -7,6 +7,7 @@ for whisper.cpp, supporting Vulkan, CUDA, and CPU backends. import logging import os +import re import subprocess from functools import lru_cache from typing import Optional @@ -125,41 +126,131 @@ class ComputeBackend: CPU = "cpu" +# Real vulkaninfo --summary headers look like "GPU0:"; some older/alternate +# tools print "GPU id = 0". Accept both so hybrid detection does not silently +# fall through to CUDA when Vulkan is present. +_VULKANINFO_GPU_HEADER_RE = re.compile( + r"^(?:GPU(\d+)\s*:|GPU\s+id\s*[:=]\s*(\d+))\s*$", + re.IGNORECASE, +) + + +def _parse_vulkaninfo_gpu_header(line: str) -> Optional[int]: + """Return a GPU index from a vulkaninfo device header line, if present.""" + match = _VULKANINFO_GPU_HEADER_RE.match(line.strip()) + if not match: + return None + return int(match.group(1) or match.group(2)) + + +def _append_vulkan_device( + devices: list[dict], + current_index: Optional[int], + current_name: Optional[str], + current_type: str, +) -> None: + """Append a parsed Vulkan device when index and name are both known.""" + if current_index is None or current_name is None: + return + devices.append( + { + "index": current_index, + "name": current_name, + "device_type": current_type, + } + ) + + @lru_cache(maxsize=1) -def detect_vulkan_support() -> tuple[bool, Optional[str]]: - """ - Detect if Vulkan is available and get device info. +def detect_vulkan_devices() -> list[dict]: + """Enumerate all Vulkan-capable GPU devices. Returns: - Tuple of (is_available, device_name) - """ - """ - Detect if Vulkan is available and get device info. - - Returns: - Tuple of (is_available, device_name) + List of dicts with keys: index (int), name (str), + device_type (str: "discrete" or "integrated" or "other"). """ + devices: list[dict] = [] try: - # Check for vulkaninfo command result = subprocess.run( ["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5, ) - if result.returncode == 0: - # Try to extract GPU name from output - for line in result.stdout.split("\n"): - if "deviceName" in line or "GPU" in line: - device_name = line.split(":")[-1].strip() - if device_name: - logger.info(f"Vulkan support detected: {device_name}") - return True, device_name - logger.info("Vulkan support detected") - return True, "Vulkan GPU" - except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: - logger.debug(f"Vulkan detection failed: {e}") + if result.returncode != 0: + return devices + current_index = None + current_name = None + current_type = "other" + + for line in result.stdout.split("\n"): + stripped = line.strip() + header_index = _parse_vulkaninfo_gpu_header(stripped) + + if header_index is not None: + _append_vulkan_device(devices, current_index, current_name, current_type) + current_index = header_index + current_name = None + current_type = "other" + continue + + if "deviceName" in stripped and "=" in stripped: + current_name = stripped.split("=", 1)[-1].strip() + + if "deviceType" in stripped and "=" in stripped: + type_val = stripped.split("=", 1)[-1].strip().upper() + if "DISCRETE" in type_val: + current_type = "discrete" + elif "INTEGRATED" in type_val: + current_type = "integrated" + + _append_vulkan_device(devices, current_index, current_name, current_type) + + except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: + logger.debug(f"Vulkan device enumeration failed: {e}") + + return devices + + +def _prefer_discrete_vulkan_device() -> Optional[int]: + """Return the index of the preferred Vulkan GPU (discrete if available).""" + devices = detect_vulkan_devices() + for device in devices: + if device["device_type"] == "discrete": + return device["index"] + return devices[0]["index"] if devices else None + + +def _vulkan_device_name_by_index(devices: list[dict], device_index: Optional[int]) -> Optional[str]: + """Resolve a Vulkan device name by GPU index (not list position).""" + if not devices: + return None + if device_index is None: + return devices[0]["name"] + for device in devices: + if device["index"] == device_index: + return device["name"] + return devices[0]["name"] + + +@lru_cache(maxsize=1) +def detect_vulkan_support() -> tuple[bool, Optional[str]]: + """Detect if Vulkan is available and get device info. + + When multiple Vulkan GPUs exist, prefers the discrete GPU. + + Returns: + Tuple of (is_available, device_name) + """ + devices = detect_vulkan_devices() + if devices: + preferred_idx = _prefer_discrete_vulkan_device() + device_name = _vulkan_device_name_by_index(devices, preferred_idx) + logger.info(f"Vulkan support detected: {device_name}") + return True, device_name + + logger.debug("Vulkan detection failed") return False, None diff --git a/tests/test_main.py b/tests/test_main.py index 44ae86d..5898b39 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -241,6 +241,7 @@ class TestMainModule(unittest.TestCase): whispercpp_logprob_thold=-1.0, whispercpp_no_speech_thold=0.6, whispercpp_n_threads=0, + whispercpp_gpu_device=None, remote_api_url="", remote_api_key="", remote_api_endpoint="/inference", diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index 9d2ab2c..666e534 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -501,6 +501,207 @@ class TestWhispercppInitialization(unittest.TestCase): manager._init_whispercpp() +class TestWhispercppGpuDeviceSelection(unittest.TestCase): + """Test whisper.cpp GPU device selection logic.""" + + def test_gpu_device_auto_select_discrete(self): + """Test auto-selection of discrete GPU when configured as None.""" + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None) + manager.model_size = "tiny" + + mock_pywhispercpp = MagicMock() + mock_model = MagicMock() + mock_pywhispercpp.Model = MagicMock(return_value=mock_model) + mock_pywhispercpp.model.Model = MagicMock(return_value=mock_model) + + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024 + + vulkan_devices = [ + {"index": 0, "name": "Intel UHD 630", "device_type": "integrated"}, + {"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"}, + ] + + with patch( + "vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}} + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.get_model_path", + return_value="/fake/model.bin", + ): + with patch("os.path.getsize", return_value=100000000): + with patch("os.path.exists", return_value=True): + with patch.dict( + "sys.modules", + { + "pywhispercpp": mock_pywhispercpp, + "pywhispercpp.model": mock_pywhispercpp, + "psutil": mock_psutil, + }, + ): + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + with patch.object( + whispercpp_info, + "detect_compute_backend", + return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"), + ): + with patch.object( + whispercpp_info, + "get_backend_display_name", + return_value="Vulkan", + ): + with patch.object( + whispercpp_info, + "detect_vulkan_devices", + return_value=vulkan_devices, + ): + with patch.object( + whispercpp_info, + "_prefer_discrete_vulkan_device", + return_value=1, + ): + manager._init_whispercpp() + assert mock_pywhispercpp.Model.call_args.kwargs.get( + "context_params" + ) == {"gpu_device": 1} + + def test_gpu_device_explicit_index(self): + """Test explicit GPU device index selection.""" + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=0) + manager.model_size = "tiny" + + mock_pywhispercpp = MagicMock() + mock_model = MagicMock() + mock_pywhispercpp.Model = MagicMock(return_value=mock_model) + mock_pywhispercpp.model.Model = MagicMock(return_value=mock_model) + + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024 + + vulkan_devices = [ + {"index": 0, "name": "Intel UHD 630", "device_type": "integrated"}, + {"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"}, + ] + + with patch( + "vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}} + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.get_model_path", + return_value="/fake/model.bin", + ): + with patch("os.path.getsize", return_value=100000000): + with patch("os.path.exists", return_value=True): + with patch.dict( + "sys.modules", + { + "pywhispercpp": mock_pywhispercpp, + "pywhispercpp.model": mock_pywhispercpp, + "psutil": mock_psutil, + }, + ): + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + with patch.object( + whispercpp_info, + "detect_compute_backend", + return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"), + ): + with patch.object( + whispercpp_info, + "get_backend_display_name", + return_value="Vulkan", + ): + with patch.object( + whispercpp_info, + "detect_vulkan_devices", + return_value=vulkan_devices, + ): + manager._init_whispercpp() + assert mock_pywhispercpp.Model.call_args.kwargs.get( + "context_params" + ) == {"gpu_device": 0} + + def test_gpu_device_context_params_fallback_on_old_pywhispercpp(self): + """Test graceful fallback when pywhispercpp rejects context_params.""" + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1) + manager.model_size = "tiny" + + mock_pywhispercpp = MagicMock() + mock_model = MagicMock() + + def _model_side_effect(*args, **kwargs): + if kwargs.get("context_params"): + raise TypeError("Model() got an unexpected keyword argument 'context_params'") + return mock_model + + mock_pywhispercpp.Model.side_effect = _model_side_effect + + with patch.dict( + "sys.modules", + { + "pywhispercpp": mock_pywhispercpp, + "pywhispercpp.model": mock_pywhispercpp, + }, + ): + result = manager._load_model_with_compatible_params("/fake/model.bin", {}, gpu_device=1) + assert result is mock_model + assert mock_pywhispercpp.Model.call_count == 2 + assert mock_pywhispercpp.Model.call_args_list[0].kwargs.get("context_params") == { + "gpu_device": 1 + } + assert "context_params" not in mock_pywhispercpp.Model.call_args_list[1].kwargs + + def test_gpu_device_not_set_for_cpu_backend(self): + """Test that GPU device selection is skipped on CPU backend.""" + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None) + manager.model_size = "tiny" + + mock_pywhispercpp = MagicMock() + mock_model = MagicMock() + mock_pywhispercpp.Model = MagicMock(return_value=mock_model) + mock_pywhispercpp.model.Model = MagicMock(return_value=mock_model) + + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024 + + with patch( + "vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}} + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.get_model_path", + return_value="/fake/model.bin", + ): + with patch("os.path.getsize", return_value=100000000): + with patch("os.path.exists", return_value=True): + with patch.dict( + "sys.modules", + { + "pywhispercpp": mock_pywhispercpp, + "pywhispercpp.model": mock_pywhispercpp, + "psutil": mock_psutil, + }, + ): + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + with patch.object( + whispercpp_info, + "detect_compute_backend", + return_value=(whispercpp_info.ComputeBackend.CPU, "CPU"), + ): + with patch.object( + whispercpp_info, + "get_backend_display_name", + return_value="CPU", + ): + manager._init_whispercpp() + assert ( + "context_params" + not in mock_pywhispercpp.Model.call_args.kwargs + ) + + class TestTranscription(unittest.TestCase): """Test transcription methods.""" diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index fd9406e..721f8e6 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -205,9 +205,221 @@ class TestModelInfo(unittest.TestCase): self.assertFalse(is_english_only_model("large-v3-turbo")) +class TestDetectVulkanDevices(unittest.TestCase): + """Test cases for detect_vulkan_devices function.""" + + def setUp(self): + """Clear lru_cache before each test.""" + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + detect_vulkan_devices.cache_clear() + + def test_detect_vulkan_devices_empty_when_unavailable(self): + """Test that detect_vulkan_devices returns empty list when vulkaninfo unavailable.""" + with patch("subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError("vulkaninfo not found") + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(devices, []) + + def test_detect_vulkan_devices_single_discrete(self): + """Test detection of a single discrete GPU from real vulkaninfo --summary.""" + vulkaninfo_output = ( + "Devices:\n" + "========\n" + "GPU0:\n" + "\tapiVersion = 1.3.296\n" + "\tdriverVersion = 550.54.14\n" + "\tvendorID = 0x10de\n" + "\tdeviceID = 0x2206\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA RTX 3080\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0]["index"], 0) + self.assertEqual(devices[0]["name"], "NVIDIA RTX 3080") + self.assertEqual(devices[0]["device_type"], "discrete") + + def test_detect_vulkan_devices_hybrid_system(self): + """Test detection on hybrid system with real vulkaninfo --summary output.""" + vulkaninfo_output = ( + "Devices:\n" + "========\n" + "GPU0:\n" + "\tapiVersion = 1.3.296\n" + "\tdriverVersion = 24.2.8\n" + "\tvendorID = 0x8086\n" + "\tdeviceID = 0x3e92\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU\n" + "\tdeviceName = Intel(R) UHD Graphics 630 (CFL GT2)\n" + "GPU1:\n" + "\tapiVersion = 1.3.242\n" + "\tdriverVersion = 550.54.14\n" + "\tvendorID = 0x10de\n" + "\tdeviceID = 0x1f08\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA GeForce RTX 2060\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0]["index"], 0) + self.assertEqual(devices[0]["name"], "Intel(R) UHD Graphics 630 (CFL GT2)") + self.assertEqual(devices[0]["device_type"], "integrated") + self.assertEqual(devices[1]["index"], 1) + self.assertEqual(devices[1]["name"], "NVIDIA GeForce RTX 2060") + self.assertEqual(devices[1]["device_type"], "discrete") + + def test_detect_vulkan_devices_legacy_gpu_id_format(self): + """Test compatibility with alternate 'GPU id = N' vulkaninfo headers.""" + vulkaninfo_output = ( + "GPU id = 0\n" + "deviceName = Intel UHD 630\n" + "deviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU\n" + "GPU id = 1\n" + "deviceName = NVIDIA RTX 2060\n" + "deviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0]["index"], 0) + self.assertEqual(devices[1]["index"], 1) + self.assertEqual(devices[1]["device_type"], "discrete") + + def test_detect_vulkan_devices_on_timeout(self): + """Test that timeout returns empty list.""" + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired("vulkaninfo", 5) + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(devices, []) + + def test_detect_vulkan_devices_on_error(self): + """Test that error returncode returns empty list.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1, stdout="") + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(devices, []) + + +class TestPreferDiscreteVulkanDevice(unittest.TestCase): + """Test cases for _prefer_discrete_vulkan_device function.""" + + def setUp(self): + """Clear lru_cache before each test.""" + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + detect_vulkan_devices.cache_clear() + + def test_prefer_discrete_when_available(self): + """Test that discrete GPU is preferred when available.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU\n" + "\tdeviceName = Intel UHD 630\n" + "GPU1:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA RTX 2060\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import _prefer_discrete_vulkan_device + + preferred_idx = _prefer_discrete_vulkan_device() + self.assertEqual(preferred_idx, 1) + + def test_prefer_discrete_falls_back_to_first(self): + """Test that first device is used when no discrete GPU available.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU\n" + "\tdeviceName = Intel UHD 630\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import _prefer_discrete_vulkan_device + + preferred_idx = _prefer_discrete_vulkan_device() + self.assertEqual(preferred_idx, 0) + + def test_prefer_discrete_returns_none_when_empty(self): + """Test that None is returned when no devices found.""" + with patch("subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError("vulkaninfo not found") + + from vocalinux.utils.whispercpp_model_info import _prefer_discrete_vulkan_device + + preferred_idx = _prefer_discrete_vulkan_device() + self.assertIsNone(preferred_idx) + + def test_prefer_discrete_with_noncontiguous_gpu_ids(self): + """Preferred index is the Vulkan GPU id, not the list position.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU\n" + "\tdeviceName = Intel UHD 630\n" + "GPU2:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA RTX 2060\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import ( + _prefer_discrete_vulkan_device, + detect_vulkan_support, + ) + + preferred_idx = _prefer_discrete_vulkan_device() + self.assertEqual(preferred_idx, 2) + + detect_vulkan_support.cache_clear() + is_available, device_name = detect_vulkan_support() + self.assertTrue(is_available) + self.assertEqual(device_name, "NVIDIA RTX 2060") + + class TestDetectVulkanSupport(unittest.TestCase): """Test cases for detect_vulkan_support function.""" + def setUp(self): + """Clear lru_cache before each test.""" + from vocalinux.utils.whispercpp_model_info import ( + detect_compute_backend, + detect_cuda_support, + detect_vulkan_devices, + detect_vulkan_support, + ) + + detect_vulkan_support.cache_clear() + detect_vulkan_devices.cache_clear() + detect_cuda_support.cache_clear() + detect_compute_backend.cache_clear() + def test_detect_vulkan_support_returns_tuple(self): """Test that detect_vulkan_support returns a tuple.""" from vocalinux.utils.whispercpp_model_info import detect_vulkan_support @@ -218,15 +430,70 @@ class TestDetectVulkanSupport(unittest.TestCase): def test_detect_vulkan_support_when_available(self): """Test Vulkan detection when vulkaninfo is available.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = Intel Arc\n" + ) with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="deviceName: Intel Arc\n") + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) from vocalinux.utils.whispercpp_model_info import detect_vulkan_support is_available, device_name = detect_vulkan_support() self.assertTrue(is_available) - self.assertIsNotNone(device_name) + self.assertEqual(device_name, "Intel Arc") + + def test_detect_vulkan_support_prefers_discrete_on_hybrid(self): + """Hybrid systems should report the discrete GPU name.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU\n" + "\tdeviceName = Intel(R) UHD Graphics 630 (CFL GT2)\n" + "GPU1:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA GeForce RTX 2060\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_support + + is_available, device_name = detect_vulkan_support() + + self.assertTrue(is_available) + self.assertEqual(device_name, "NVIDIA GeForce RTX 2060") + + def test_compute_backend_stays_vulkan_with_real_summary_and_nvidia(self): + """Regression: real GPU0: summary must not fall through to CUDA.""" + vulkaninfo_output = ( + "Devices:\n" + "========\n" + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU\n" + "\tdeviceName = Intel(R) UHD Graphics 630 (CFL GT2)\n" + "GPU1:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA GeForce RTX 2060\n" + ) + + def run_side_effect(cmd, **kwargs): + if cmd and cmd[0] == "vulkaninfo": + return MagicMock(returncode=0, stdout=vulkaninfo_output) + if cmd and cmd[0] == "nvidia-smi": + return MagicMock(returncode=0, stdout="NVIDIA GeForce RTX 2060, 6144 MiB") + return MagicMock(returncode=1, stdout="") + + with patch("subprocess.run", side_effect=run_side_effect): + from vocalinux.utils.whispercpp_model_info import ( + ComputeBackend, + detect_compute_backend, + ) + + backend, backend_info = detect_compute_backend() + self.assertEqual(backend, ComputeBackend.VULKAN) + self.assertEqual(backend_info, "NVIDIA GeForce RTX 2060") def test_detect_vulkan_support_when_unavailable(self): """Test Vulkan detection when vulkaninfo is not available."""