From 279d99fea05362c79e23150c2540684b72d6f029 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 16 Aug 2026 18:29:07 -0700 Subject: [PATCH] fix(whisper): honor bundled GPU libs and skip software Vulkan devices (#674) * fix(whisper): use bundled GPU libs and skip software Vulkan devices AppImage 0.15.0 shipped a CPU-only pywhispercpp wheel, so the Settings GPU picker and host vulkaninfo never moved inference off the CPU. Rebuild pywhispercpp with Vulkan in the AppImage, log the libraries that actually load, skip llvmpipe in auto-select, and document how to verify. Fixes #604 * fix(appimage): pin gcc/g++ for Vulkan pywhispercpp rebuild Hosts that default cc/c++ to clang fail CMAKE's C++ test because clang does not search gcc's libstdc++ path. Default the rebuild to gcc/g++. --- .github/workflows/nightly.yml | 18 ++- .github/workflows/release.yml | 18 ++- .github/workflows/unified-pipeline.yml | 9 +- docs/INSTALL.md | 12 +- docs/USER_GUIDE.md | 25 ++- packaging/appimage/build.sh | 95 ++++++++++- .../speech_recognition/recognition_manager.py | 97 ++++++++---- src/vocalinux/ui/settings_dialog.py | 19 ++- src/vocalinux/utils/whispercpp_model_info.py | 138 +++++++++++----- tests/test_appimage_packaging.py | 11 ++ .../test_recognition_manager_device_config.py | 148 +++++++++++++++++- tests/test_settings_dialog.py | 1 + tests/test_whispercpp_model_info_ext.py | 89 +++++++++++ 13 files changed, 587 insertions(+), 93 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9e6c6de..e3d5351 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -49,7 +49,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -97,6 +102,8 @@ jobs: ls -la dist/ - name: Build AppImage (x86_64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ steps.version.outputs.version }}" dist ls -la dist/*.AppImage @@ -298,7 +305,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -310,6 +322,8 @@ jobs: ls -la dist/ - name: Build AppImage (aarch64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ needs.nightly.outputs.version }}" dist ls -la dist/*.AppImage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 930c9bd..6d920a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -82,6 +87,8 @@ jobs: ls -la dist/ - name: Build AppImage (x86_64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist ls -la dist/*.AppImage @@ -177,7 +184,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -196,6 +208,8 @@ jobs: ls -la dist/ - name: Build AppImage (aarch64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist ls -la dist/*.AppImage diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 0677527..975310d 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -213,7 +213,12 @@ jobs: gobject-introspection \ libcairo-gobject2 \ gir1.2-gtk-3.0 \ - portaudio19-dev + portaudio19-dev \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install GObject introspection dependencies run: | @@ -227,6 +232,8 @@ jobs: ls -la dist/ - name: Build AppImage + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py) WHEEL=$(ls dist/*.whl) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 4cb80bc..0ec6648 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -38,8 +38,11 @@ chmod +x Vocalinux-*-x86_64.AppImage # or aarch64 ``` AppImage still needs host text-injection tools (`xdotool` on X11; `wtype` / -`ydotool` / clipboard tools on Wayland), same as the PyPI path. Prefer the -one-liner installer above when you want system deps and models set up for you. +`ydotool` / clipboard tools on Wayland), same as the PyPI path. Current +AppImages rebuild whisper.cpp with Vulkan and use the host GPU driver; you +still need working Vulkan on the machine (`vulkaninfo --summary`). Prefer the +one-liner installer above when you want system deps, a CUDA build, and models +set up for you. ### From PyPI @@ -501,6 +504,11 @@ vulkaninfo --summary | grep -i "deviceName" # In Vocalinux, look for these log messages: # [INFO] whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU # [INFO] whisper.cpp using Vulkan GPU backend: AMD Radeon RX 6800 +# [INFO] whisper.cpp configured with n_threads=4 (GPU backend: vulkan) + +# If you instead see "CPU-only; pywhispercpp lacks GPU libraries", the +# bundled pywhispercpp was built without GPU libs (older AppImage, or a +# CPU pip wheel). install.sh rebuilds it with Vulkan/CUDA when possible. ``` ### Reusing Existing whisper.cpp Builds diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8b8653a..6fd7263 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -159,22 +159,31 @@ is hidden when there is nothing unused to delete. ### GPU Acceleration -**whisper.cpp** automatically uses GPU acceleration when available: +**whisper.cpp** uses GPU acceleration when the bundled pywhispercpp libraries include Vulkan or CUDA: -- **Vulkan** (AMD, Intel, NVIDIA) - Automatically detected and used -- **CUDA** (NVIDIA only) - Fallback if Vulkan not available -- **CPU** - Always works as fallback +- **Vulkan** (AMD, Intel, NVIDIA) when `libggml-vulkan` is present +- **CUDA** (NVIDIA) when `libggml-cuda` is present +- **CPU** when those libraries are missing, even if Settings lists a GPU -On multi-GPU machines, Vocalinux prefers a **discrete** Vulkan device when one is present. You can override the device under **Advanced** settings (`whispercpp_gpu_device`). +Host tools such as `vulkaninfo` only describe the machine. The engine follows the libraries actually loaded. Look for `GPU backend: vulkan` or `GPU backend: cuda` after the model loads. `CPU-only; pywhispercpp lacks GPU libraries` means inference is on the CPU. + +On multi-GPU machines, Vocalinux prefers a discrete Vulkan device when one is present and skips software renderers such as llvmpipe. Override the device under **Settings → Performance → Vulkan GPU**. Pip wheels of pywhispercpp are often CUDA builds. In that case Vocalinux always uses CUDA device 0 (the first NVIDIA GPU), because Vulkan GPU indices do not match CUDA ordinals. On a hybrid laptop the NVIDIA GPU is usually Vulkan GPU 1; feeding that index into CUDA would land on the CPU fallback instead. -If you need a second NVIDIA GPU, either set `CUDA_VISIBLE_DEVICES` before starting Vocalinux or install a Vulkan-built pywhispercpp so the Advanced GPU picker applies. +If you need a second NVIDIA GPU, either set `CUDA_VISIBLE_DEVICES` before starting Vocalinux or install a Vulkan-built pywhispercpp so the Performance GPU picker applies. -To check which backend is being used, look for these log messages when starting Vocalinux: +`install.sh` rebuilds pywhispercpp with Vulkan or CUDA when it can. Prefer that launcher (or the wrapper it installs) so `LD_LIBRARY_PATH` includes the GPU libs. Raw `venv/bin` binaries can come up CPU-only. + +AppImages built from this tree compile pywhispercpp with Vulkan and use the host Vulkan driver at runtime. Older AppImages shipped the CPU-only wheel; if you see the `lacks GPU libraries` line, download a newer AppImage or use `install.sh`. + +Hybrid / PRIME / Optimus laptops: picking NVIDIA in Settings is enough when pywhispercpp is Vulkan-built. `prime-run` is not required for Vulkan, and it cannot fix a CPU-only wheel. Install `vulkan-tools` so `vulkaninfo` can enumerate devices; without it, whisper.cpp may default to GPU 0 (often the iGPU). + +To check which backend is being used, look for these log messages when starting Vocalinux (`vocalinux --debug`): ``` [INFO] whisper.cpp using Vulkan GPU backend: AMD Radeon RX 6800 -[INFO] whisper.cpp configured with n_threads=16 +[INFO] Using Vulkan GPU [0]: AMD Radeon RX 6800 +[INFO] whisper.cpp configured with n_threads=4 (GPU backend: vulkan) ``` ### Auto-pause and model keep-alive diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index d7dbbee..2db7d1e 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -15,8 +15,13 @@ # bundled, same runtime prerequisite as the PyPI install path documented # in docs/INSTALL.md. Add bundling if users hit missing-binary complaints. # -# GPU note: the pip pywhispercpp wheel is CPU-only. Vulkan/CUDA rebuilds -# stay on install.sh for now; the AppImage logs when GPU libs are absent. +# GPU: pip wheels of pywhispercpp are CPU-only. This script rebuilds +# pywhispercpp from source with GGML_VULKAN=1 when Vulkan headers and a +# shader compiler are on the build host. The AppImage uses the host Vulkan +# loader/ICDs at runtime (do not bundle NVIDIA/AMD driver ICDs). +# Set VOCALINUX_APPIMAGE_REQUIRE_VULKAN=1 in CI so a failed rebuild fails +# the job instead of shipping a CPU-only image. Set +# VOCALINUX_APPIMAGE_SKIP_VULKAN=1 to force the CPU wheel. set -euo pipefail WHEEL="$1" @@ -176,6 +181,90 @@ copy_gi_runtime_libs() { done } +has_vulkan_build_deps() { + if [ -f /usr/include/vulkan/vulkan.h ] || pkg-config --exists vulkan 2>/dev/null; then + : + else + return 1 + fi + command -v cmake >/dev/null 2>&1 || return 1 + command -v g++ >/dev/null 2>&1 || command -v clang++ >/dev/null 2>&1 || return 1 + command -v glslc >/dev/null 2>&1 || command -v glslangValidator >/dev/null 2>&1 || return 1 + return 0 +} + +remove_appdir_pywhispercpp() { + find "$APPDIR/usr" -depth \( \ + -name 'pywhispercpp' -o \ + -name 'pywhispercpp.libs' -o \ + -name 'pywhispercpp-*.dist-info' -o \ + -name '_pywhispercpp*' \ + \) -exec rm -rf {} + 2>/dev/null || true +} + +copy_whisper_native_libs_to_usr_lib() { + mkdir -p "$APPDIR/usr/lib" + local lib + while IFS= read -r lib; do + [ -n "$lib" ] || continue + cp -aL "$lib" "$APPDIR/usr/lib/" 2>/dev/null || cp -a "$lib" "$APPDIR/usr/lib/" + echo " staged $(basename "$lib") -> usr/lib" + done < <(find "$APPDIR/usr" \( -name 'libggml*.so*' -o -name 'libwhisper.so*' \) ! -path '*/usr/lib/*' 2>/dev/null || true) +} + +rebuild_pywhispercpp_vulkan() { + local require_vulkan="${VOCALINUX_APPIMAGE_REQUIRE_VULKAN:-0}" + if [ "${VOCALINUX_APPIMAGE_SKIP_VULKAN:-0}" = "1" ]; then + echo "== Skipping pywhispercpp Vulkan rebuild (VOCALINUX_APPIMAGE_SKIP_VULKAN=1) ==" + return 0 + fi + + if ! has_vulkan_build_deps; then + echo "Vulkan build deps missing (libvulkan-dev, cmake, g++, glslc/glslangValidator)." >&2 + if [ "$require_vulkan" = "1" ]; then + echo "VOCALINUX_APPIMAGE_REQUIRE_VULKAN=1: refusing to ship a CPU-only AppImage." >&2 + exit 1 + fi + echo "Warning: AppImage will use the CPU-only pywhispercpp wheel." >&2 + return 0 + fi + + echo "== Rebuilding pywhispercpp with Vulkan ==" + remove_appdir_pywhispercpp + local pip_log="$WORKDIR/pywhispercpp-vulkan.log" + export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)}" + # Hosts that default cc/c++ to clang (this Cloud Agent image) fail the + # CMAKE C++ compiler test: clang does not search gcc's libstdc++ path. + if ! CC="${CC:-gcc}" CXX="${CXX:-g++}" \ + GGML_VULKAN=1 \ + CMAKE_ARGS='-DCMAKE_INSTALL_RPATH=$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON' \ + "$PYTHON" -m pip install --verbose --no-cache-dir --force-reinstall \ + --no-binary pywhispercpp --ignore-installed --prefix "$APPDIR/usr" \ + --log "$pip_log" pywhispercpp; then + echo "pywhispercpp Vulkan rebuild failed. Last 80 log lines:" >&2 + tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true + if [ "$require_vulkan" = "1" ]; then + exit 1 + fi + echo "Warning: continuing with the CPU-only pywhispercpp wheel." >&2 + return 0 + fi + + local vk_lib + vk_lib="$(find "$APPDIR/usr" -name 'libggml-vulkan.so*' 2>/dev/null | head -1 || true)" + if [ -z "$vk_lib" ]; then + echo "Vulkan rebuild did not produce libggml-vulkan.so." >&2 + if [ "$require_vulkan" = "1" ]; then + tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true + exit 1 + fi + echo "Warning: AppImage will use CPU-only pywhispercpp." >&2 + return 0 + fi + echo " found $vk_lib" + copy_whisper_native_libs_to_usr_lib +} + echo "== Copying GObject-Introspection typelibs (not handled by linuxdeploy-plugin-gtk) ==" copy_typelibs "$APPDIR/usr/lib/girepository-1.0" 0 @@ -198,6 +287,8 @@ APPRUN ln -sfn "python${PY_VER}" "$APPDIR/usr/lib/python3" chmod +x "$APPDIR/AppRun" +rebuild_pywhispercpp_vulkan + echo "== Running linuxdeploy (resolves the shared-library closure + GTK theming) ==" export DEPLOY_GTK_VERSION=3 "$TOOLDIR/linuxdeploy" --appdir "$APPDIR" \ diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 946c933..11edca5 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -899,6 +899,22 @@ def _get_system_model_paths() -> list: SYSTEM_MODELS_DIRS = _get_system_model_paths() +def detect_pywhispercpp_gpu_backend() -> str: + """Detect whether pywhispercpp's native library actually has GPU support.""" + _preload_pywhispercpp_shared_libraries() + for library_dir in _find_pywhispercpp_shared_library_dirs(): + root = Path(library_dir) + for pattern in ("libggml-vulkan*.so*", "libggml-cuda*.so*"): + matches = list(root.glob(pattern)) + if matches: + lib_name = matches[0].name.lower() + if "vulkan" in lib_name: + return "vulkan" + if "cuda" in lib_name: + return "cuda" + return "cpu" + + class SpeechRecognitionManager: """ Manager class for speech recognition engines. @@ -1225,7 +1241,7 @@ class SpeechRecognitionManager: """Initialize the whisper.cpp speech recognition engine.""" try: _preload_pywhispercpp_shared_libraries() - from pywhispercpp.model import Model # noqa: F401 — used in _load_whispercpp_model + from pywhispercpp.model import Model # noqa: F401 — fail fast if missing # Validate model size for whisper.cpp valid_models = list(WHISPERCPP_MODEL_INFO.keys()) @@ -1374,18 +1390,7 @@ class SpeechRecognitionManager: def _detect_pywhispercpp_gpu_backend(self) -> str: """Detect whether pywhispercpp's native library actually has GPU support.""" - _preload_pywhispercpp_shared_libraries() - for library_dir in _find_pywhispercpp_shared_library_dirs(): - root = Path(library_dir) - for pattern in ("libggml-vulkan*.so*", "libggml-cuda*.so*"): - matches = list(root.glob(pattern)) - if matches: - lib_name = matches[0].name.lower() - if "vulkan" in lib_name: - return "vulkan" - if "cuda" in lib_name: - return "cuda" - return "cpu" + return detect_pywhispercpp_gpu_backend() def _load_whispercpp_model(self, model_path: str): """Load the whisper.cpp model file and configure the compute backend. @@ -1399,27 +1404,23 @@ class SpeechRecognitionManager: import multiprocessing import time - from pywhispercpp.model import Model - from ..utils.whispercpp_model_info import ( ComputeBackend, _prefer_discrete_vulkan_device, detect_compute_backend, + detect_cpu_info, detect_vulkan_devices, get_backend_display_name, ) - # Detect and log compute backend - backend, backend_info = detect_compute_backend() - logger.info(f"whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU") - logger.info( - f"whisper.cpp using {get_backend_display_name(backend)} backend: {backend_info}" - ) - + host_backend, host_info = detect_compute_backend() actual_gpu_backend = self._detect_pywhispercpp_gpu_backend() + logger.info("whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU") - # Select GPU device for pywhispercpp context_params. selected_gpu_device = 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 @@ -1435,27 +1436,66 @@ class SpeechRecognitionManager: 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: - 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 + 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. " + "Inference will use the CPU. The Vulkan GPU picker in Settings cannot " + "accelerate a CPU-only wheel or AppImage. Install via install.sh " + "(rebuilds pywhispercpp with Vulkan/CUDA) or use an AppImage built " + "with GPU support.", + get_backend_display_name(host_backend), + host_info, + ) + + if 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.", + host_backend, + actual_gpu_backend, + actual_gpu_backend, + ) + + logger.info( + f"whisper.cpp using {get_backend_display_name(runtime_backend)} backend: {runtime_info}" + ) - # Log hardware summary import psutil total_ram_gb = psutil.virtual_memory().total // (1024**3) - logger.info(f"whisper.cpp hardware: {backend} | {backend_info} | RAM: {total_ram_gb}GB") + logger.info( + f"whisper.cpp hardware: {runtime_backend} | {runtime_info} | RAM: {total_ram_gb}GB" + ) - # Validate model file exists and get size if os.path.exists(model_path): model_size_mb = os.path.getsize(model_path) / (1024 * 1024) logger.info(f"whisper.cpp model file: {model_path} ({model_size_mb:.1f} MB)") @@ -1475,11 +1515,10 @@ class SpeechRecognitionManager: n_threads = max(1, min(multiprocessing.cpu_count() - 2, 8)) load_start_time = time.time() - loaded_backend = backend + loaded_backend = runtime_backend model_kwargs = self._build_whispercpp_model_kwargs(n_threads) - # 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, gpu_device=selected_gpu_device diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 2dd92ea..b524ce5 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -3598,6 +3598,20 @@ class SettingsDialog(Gtk.Dialog): self.release_notes_group.show() return False + def _gpu_acceleration_subtitle(self) -> str: + """Describe whether the bundled pywhispercpp can use the GPU picker.""" + try: + from ..speech_recognition.recognition_manager import detect_pywhispercpp_gpu_backend + + bundled = detect_pywhispercpp_gpu_backend() + except (ImportError, OSError, AttributeError): + return "Device used by the whisper.cpp engine" + if bundled == "cpu": + return "This install has no GPU libraries; whisper.cpp will run on the CPU" + if bundled == "cuda": + return "CUDA build uses NVIDIA device 0; this picker applies to Vulkan builds" + return "Device used by the whisper.cpp Vulkan engine" + def _build_gpu_section(self): """Build the GPU device group on the Performance page. @@ -3608,13 +3622,14 @@ class SettingsDialog(Gtk.Dialog): self.gpu_device_combo = Gtk.ComboBoxText() self.gpu_device_combo.set_size_request(_CONTROL_WIDTH, -1) self.gpu_device_combo.set_tooltip_text( - "Select which GPU to use for whisper.cpp Vulkan acceleration" + "Select which GPU to use for whisper.cpp Vulkan acceleration. " + "Has no effect when pywhispercpp was built without GPU libraries." ) _prevent_scroll_on_hover(self.gpu_device_combo) self._populate_gpu_devices() gpu_row = PreferenceRow( title="Vulkan GPU", - subtitle="Device used by the whisper.cpp engine", + subtitle=self._gpu_acceleration_subtitle(), widget=self.gpu_device_combo, keywords=("graphics", "hardware", "acceleration", "device"), ) diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index e26ca0f..36db7aa 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -126,6 +126,16 @@ class ComputeBackend: CPU = "cpu" +# Match install.sh: these are not usable whisper.cpp GPUs for auto-select. +_SOFTWARE_VULKAN_NAME_MARKERS = ( + "llvmpipe", + "swiftshader", + "lavapipe", + "zink", + "virtio", + "venus", +) + # 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. @@ -143,6 +153,38 @@ def _parse_vulkaninfo_gpu_header(line: str) -> Optional[int]: return int(match.group(1) or match.group(2)) +def _is_software_vulkan_name(name: Optional[str]) -> bool: + """Return True when a Vulkan device name looks like a software renderer.""" + if not name: + return False + lowered = name.lower() + return any(marker in lowered for marker in _SOFTWARE_VULKAN_NAME_MARKERS) + + +def _classify_vulkan_device_type(type_val: str, name: Optional[str]) -> str: + """Map vulkaninfo deviceType/name to discrete, integrated, software, or other.""" + if _is_software_vulkan_name(name) or "CPU" in type_val.upper(): + return "software" + upper = type_val.upper() + if "DISCRETE" in upper: + return "discrete" + if "INTEGRATED" in upper: + return "integrated" + return "other" + + +def _is_software_vulkan_device(device: dict) -> bool: + """Return True when a parsed Vulkan device should not be auto-selected.""" + return device.get("device_type") == "software" or _is_software_vulkan_name(device.get("name")) + + +def _hardware_vulkan_devices(devices: Optional[list[dict]] = None) -> list[dict]: + """Return Vulkan devices that are not software renderers.""" + if devices is None: + devices = detect_vulkan_devices() + return [device for device in devices if not _is_software_vulkan_device(device)] + + def _append_vulkan_device( devices: list[dict], current_index: Optional[int], @@ -152,70 +194,85 @@ def _append_vulkan_device( """Append a parsed Vulkan device when index and name are both known.""" if current_index is None or current_name is None: return + device_type = current_type + if _is_software_vulkan_name(current_name): + device_type = "software" devices.append( { "index": current_index, "name": current_name, - "device_type": current_type, + "device_type": device_type, } ) +def _run_vulkaninfo_stdout() -> str: + """Return vulkaninfo output, falling back when --summary is missing or empty.""" + commands = (["vulkaninfo", "--summary"], ["vulkaninfo"]) + for args in commands: + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + timeout=8, + ) + except FileNotFoundError: + return "" + except (subprocess.TimeoutExpired, OSError) as exc: + logger.debug(f"vulkaninfo {args} failed: {exc}") + continue + + stdout = result.stdout or "" + if "deviceName" in stdout or _VULKANINFO_GPU_HEADER_RE.search(stdout): + return stdout + if result.returncode == 0 and stdout.strip(): + return stdout + return "" + + @lru_cache(maxsize=1) def detect_vulkan_devices() -> list[dict]: """Enumerate all Vulkan-capable GPU devices. Returns: List of dicts with keys: index (int), name (str), - device_type (str: "discrete" or "integrated" or "other"). + device_type (str: "discrete", "integrated", "software", or "other"). """ devices: list[dict] = [] - try: - result = subprocess.run( - ["vulkaninfo", "--summary"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - return devices + stdout = _run_vulkaninfo_stdout() + if not stdout: + return devices - current_index = None - current_name = None - current_type = "other" + 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) + for line in 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 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 "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}") + if "deviceType" in stripped and "=" in stripped: + type_val = stripped.split("=", 1)[-1].strip() + current_type = _classify_vulkan_device_type(type_val, current_name) + _append_vulkan_device(devices, current_index, current_name, current_type) return devices def _prefer_discrete_vulkan_device() -> Optional[int]: - """Return the index of the preferred Vulkan GPU (discrete if available).""" - devices = detect_vulkan_devices() + """Return the index of the preferred hardware Vulkan GPU (discrete if available).""" + devices = _hardware_vulkan_devices() for device in devices: if device["device_type"] == "discrete": return device["index"] @@ -244,9 +301,10 @@ def detect_vulkan_support() -> tuple[bool, Optional[str]]: Tuple of (is_available, device_name) """ devices = detect_vulkan_devices() - if devices: + hardware = _hardware_vulkan_devices(devices) + if hardware: preferred_idx = _prefer_discrete_vulkan_device() - device_name = _vulkan_device_name_by_index(devices, preferred_idx) + device_name = _vulkan_device_name_by_index(hardware, preferred_idx) logger.info(f"Vulkan support detected: {device_name}") return True, device_name diff --git a/tests/test_appimage_packaging.py b/tests/test_appimage_packaging.py index 848ebc0..b93f8ff 100644 --- a/tests/test_appimage_packaging.py +++ b/tests/test_appimage_packaging.py @@ -56,3 +56,14 @@ def test_appimage_build_smokes_gi_without_host_typelibs(): text = BUILD_SH.read_text() assert "smoke_gi_imports" in text assert "unshare --user --mount" in text + + +def test_appimage_build_rebuilds_pywhispercpp_with_vulkan(): + text = BUILD_SH.read_text() + assert "GGML_VULKAN=1" in text + assert "libggml-vulkan" in text + assert "VOCALINUX_APPIMAGE_REQUIRE_VULKAN" in text + assert "VOCALINUX_APPIMAGE_SKIP_VULKAN" in text + assert "rebuild_pywhispercpp_vulkan" in text + assert 'CC="${CC:-gcc}"' in text + assert 'CXX="${CXX:-g++}"' in text diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index c2c526c..dd3736f 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -1105,6 +1105,76 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): "'whisper_full_params' object has no attribute 'context_params'" ) + def _init_with_backends( + self, + manager, + *, + host_backend, + host_info, + actual_backend, + vulkan_devices=None, + prefer_index=None, + ): + mock_pywhispercpp = MagicMock() + self.ContextParamsModel.calls = [] + mock_pywhispercpp.Model = self.ContextParamsModel + mock_pywhispercpp.model.Model = self.ContextParamsModel + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024 + vulkan_devices = vulkan_devices or [] + + 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=(host_backend, host_info), + ): + with patch.object( + whispercpp_info, + "get_backend_display_name", + side_effect=lambda backend: backend, + ): + with patch.object( + whispercpp_info, + "detect_cpu_info", + return_value="Test CPU", + ): + with patch.object( + whispercpp_info, + "detect_vulkan_devices", + return_value=vulkan_devices, + ): + with patch.object( + whispercpp_info, + "_prefer_discrete_vulkan_device", + return_value=prefer_index, + ): + with patch.object( + manager, + "_detect_pywhispercpp_gpu_backend", + return_value=actual_backend, + ): + manager._init_whispercpp() + return self.ContextParamsModel.calls + 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) @@ -1488,6 +1558,69 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): } assert "context_params" not in self.AttributeErrorContextParamsModel.calls[1][1] + def test_host_cuda_label_runtime_vulkan_selects_discrete(self): + """Device selection follows bundled Vulkan libs, not a host CUDA label (#604).""" + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None) + manager.model_size = "tiny" + vulkan_devices = [ + {"index": 0, "name": "Intel UHD 630", "device_type": "integrated"}, + {"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"}, + ] + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + calls = self._init_with_backends( + manager, + host_backend=whispercpp_info.ComputeBackend.CUDA, + host_info="NVIDIA GeForce RTX 2060 (6 GiB)", + actual_backend="vulkan", + vulkan_devices=vulkan_devices, + prefer_index=1, + ) + assert calls[-1][1].get("context_params") == {"gpu_device": 1} + + def test_cpu_wheel_skips_device_when_host_reports_vulkan(self): + """CPU-only pywhispercpp must not pass gpu_device even if vulkaninfo sees a GPU.""" + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1) + manager.model_size = "tiny" + vulkan_devices = [ + {"index": 1, "name": "NVIDIA GeForce GTX 950M", "device_type": "discrete"}, + ] + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + with self.assertLogs( + "vocalinux.speech_recognition.recognition_manager", level="WARNING" + ) as logs: + calls = self._init_with_backends( + manager, + host_backend=whispercpp_info.ComputeBackend.VULKAN, + host_info="NVIDIA GeForce GTX 950M", + actual_backend="cpu", + vulkan_devices=vulkan_devices, + prefer_index=1, + ) + assert "context_params" not in calls[-1][1] + assert any("no GPU libraries" in message for message in logs.output) + + def test_vulkan_runtime_without_enumerated_devices_skips_context_params(self): + """Vulkan libs with no vulkaninfo devices leave gpu_device unset.""" + manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None) + manager.model_size = "tiny" + import vocalinux.utils.whispercpp_model_info as whispercpp_info + + with self.assertLogs( + "vocalinux.speech_recognition.recognition_manager", level="WARNING" + ) as logs: + calls = self._init_with_backends( + manager, + host_backend=whispercpp_info.ComputeBackend.CUDA, + host_info="NVIDIA GeForce RTX 2060 (6 GiB)", + actual_backend="vulkan", + vulkan_devices=[], + prefer_index=None, + ) + assert "context_params" not in calls[-1][1] + assert any("vulkaninfo found no devices" in message for message in logs.output) + 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) @@ -1530,11 +1663,16 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): "get_backend_display_name", return_value="CPU", ): - manager._init_whispercpp() - assert ( - "context_params" - not in mock_pywhispercpp.Model.call_args.kwargs - ) + with patch.object( + manager, + "_detect_pywhispercpp_gpu_backend", + return_value="cpu", + ): + manager._init_whispercpp() + assert ( + "context_params" + not in mock_pywhispercpp.Model.call_args.kwargs + ) class TestTranscription(unittest.TestCase): diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 9501edb..49b69eb 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -984,6 +984,7 @@ class TestSettingsNavigation(unittest.TestCase): self.assertIn("def _build_gpu_section(self):", self.source_code) gpu_body = self.source_code.split("def _build_gpu_section")[1].split("\n def ")[0] self.assertIn("self.power_tab.pack_start(gpu_group", gpu_body) + self.assertIn("_gpu_acceleration_subtitle", gpu_body) advanced_body = self.source_code.split("def _build_advanced_section")[1].split( "\n def " )[0] diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index 721f8e6..bcc1c3a 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -323,6 +323,48 @@ class TestDetectVulkanDevices(unittest.TestCase): devices = detect_vulkan_devices() self.assertEqual(devices, []) + def test_detect_vulkan_devices_falls_back_without_summary(self): + """Older vulkaninfo without --summary still enumerates GPUs.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA RTX 2060\n" + ) + + def run_side_effect(cmd, **kwargs): + if cmd == ["vulkaninfo", "--summary"]: + return MagicMock(returncode=1, stdout="") + if cmd == ["vulkaninfo"]: + return MagicMock(returncode=0, stdout=vulkaninfo_output) + return MagicMock(returncode=1, stdout="") + + with patch("subprocess.run", side_effect=run_side_effect): + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0]["name"], "NVIDIA RTX 2060") + self.assertEqual(devices[0]["device_type"], "discrete") + + def test_detect_vulkan_devices_marks_llvmpipe_software(self): + """Software renderers are classified separately from hardware GPUs.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n" + "\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\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_devices + + devices = detect_vulkan_devices() + self.assertEqual(devices[0]["device_type"], "software") + self.assertEqual(devices[1]["device_type"], "discrete") + class TestPreferDiscreteVulkanDevice(unittest.TestCase): """Test cases for _prefer_discrete_vulkan_device function.""" @@ -376,6 +418,53 @@ class TestPreferDiscreteVulkanDevice(unittest.TestCase): preferred_idx = _prefer_discrete_vulkan_device() self.assertIsNone(preferred_idx) + def test_prefer_discrete_skips_software_renderer(self): + """Auto-select must not pick llvmpipe when a real GPU exists.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n" + "\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\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_ignores_software_only(self): + """Software-only Vulkan should not produce an auto-selected device.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n" + "\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\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 ( + ComputeBackend, + _prefer_discrete_vulkan_device, + detect_compute_backend, + detect_cuda_support, + detect_vulkan_support, + ) + + self.assertIsNone(_prefer_discrete_vulkan_device()) + detect_vulkan_support.cache_clear() + is_available, device_name = detect_vulkan_support() + self.assertFalse(is_available) + self.assertIsNone(device_name) + + detect_cuda_support.cache_clear() + detect_compute_backend.cache_clear() + backend, _info = detect_compute_backend() + self.assertNotEqual(backend, ComputeBackend.VULKAN) + def test_prefer_discrete_with_noncontiguous_gpu_ids(self): """Preferred index is the Vulkan GPU id, not the list position.""" vulkaninfo_output = (