From 351af1e78a76d97eba4ecbf6f45ae0f5a6df7d5f Mon Sep 17 00:00:00 2001 From: dutch2005 Date: Tue, 3 Nov 2026 18:59:30 +0100 Subject: [PATCH] feat(windows-rocm): support native RDNA 2 speech with isolated readiness --- .github/CONTRIBUTING.md | 14 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 9 + backend/core/device_caps.py | 30 ++- backend/core/windows_rocm/asr_isolation.py | 121 +++++++++++ backend/core/windows_rocm/asr_proxy.py | 69 +++++++ backend/core/windows_rocm/ct2_kernel.py | 24 +++ backend/core/windows_rocm/ct2_recipe.py | 24 +++ backend/core/windows_rocm/install.py | 5 +- backend/core/windows_rocm/isolation_policy.py | 13 ++ backend/core/windows_rocm/native.py | 1 + backend/core/windows_rocm/pipelines.py | 15 ++ backend/core/windows_rocm/probe.py | 7 + backend/core/windows_rocm/recipe.py | 75 ++++++- backend/core/windows_rocm/repair_selection.py | 33 +++ backend/core/windows_rocm/runtime.py | 9 +- backend/core/windows_rocm/smoke.py | 4 +- backend/core/windows_rocm/torch_asr.py | 27 +++ backend/core/windows_rocm/verify.py | 1 + backend/engines/_asr_sidecar/decoding.py | 71 +++++++ backend/engines/_asr_sidecar/main.py | 142 ++----------- backend/engines/_asr_sidecar/runtime.py | 85 ++++++++ backend/engines/dots_tts/main.py | 12 ++ backend/engines/indextts/main.py | 12 ++ backend/engines/moss_tts_v15/main.py | 12 +- backend/services/_performance_probe.py | 88 ++++++++ backend/services/asr_backend.py | 98 ++++++++- backend/services/engine_evidence.py | 2 + backend/services/performance_inventory.py | 7 +- backend/services/performance_probe.py | 129 ++++++++++++ backend/services/performance_profiles.py | 161 +++++++++------ backend/services/performance_readiness.py | 64 ++++++ backend/services/performance_selection.py | 42 ++++ backend/services/pytorch_whisper_options.py | 28 +++ changelog.d/3712.added.md | 1 + docs/STRUCTURE.md | 2 +- docs/install/windows-rocm.md | 103 +++++++--- electron/src/main/runtime-project.ts | 5 +- electron/src/main/runtime-windows-rocm-abi.ts | 3 + .../main/runtime-windows-rocm-optin.test.ts | 2 +- electron/src/main/runtime-windows-rocm.ts | 3 +- scripts/windows-rocm-recipe.json | 107 ++++++++++ tests/test_dots_tts_accelerator_precision.py | 21 ++ tests/test_indextts_bf16_rocm_2372.py | 8 + tests/test_moss_tts_v15.py | 25 +++ .../test_performance_activation_readiness.py | 182 +++++++++++++++++ tests/test_performance_inventory.py | 13 +- tests/test_performance_inventory_imports.py | 134 ++++++++++++ tests/test_performance_probe.py | 157 ++++++++++++++ tests/test_performance_probe_worker.py | 120 +++++++++++ tests/test_performance_profiles.py | 16 +- tests/test_performance_selection.py | 47 +++++ tests/test_pytorch_whisper_english_only.py | 126 ++++++++++++ tests/test_setup_windows_rocm.py | 3 +- tests/test_windows_rocm_asr_cache.py | 31 +++ tests/test_windows_rocm_asr_import_child.py | 44 ++++ tests/test_windows_rocm_asr_isolation.py | 193 ++++++++++++++++++ tests/test_windows_rocm_asr_worker.py | 56 +++++ ...test_windows_rocm_cpu_capture_readiness.py | 87 ++++++++ tests/test_windows_rocm_ct2_kernels.py | 72 +++++++ tests/test_windows_rocm_failure_policy.py | 6 + tests/test_windows_rocm_heavy_gates.py | 5 +- tests/test_windows_rocm_import_exceptions.py | 6 + tests/test_windows_rocm_install.py | 1 + tests/test_windows_rocm_native_imports.py | 7 + tests/test_windows_rocm_proxy_creation.py | 89 ++++++++ tests/test_windows_rocm_proxy_evidence.py | 50 +++++ tests/test_windows_rocm_proxy_retirement.py | 47 +++++ tests/test_windows_rocm_pytorch_whisper.py | 65 ++++++ tests/test_windows_rocm_rdna2.py | 175 ++++++++++++++++ tests/test_windows_rocm_repair_selection.py | 42 ++++ tests/test_windows_rocm_sdk_integrity.py | 6 + tests/test_windows_rocm_smoke_local.py | 6 + tests/test_windows_rocm_verification.py | 2 + tests/test_windows_rocm_whisperx_isolation.py | 44 ++++ 76 files changed, 3279 insertions(+), 271 deletions(-) create mode 100644 backend/core/windows_rocm/asr_isolation.py create mode 100644 backend/core/windows_rocm/asr_proxy.py create mode 100644 backend/core/windows_rocm/ct2_kernel.py create mode 100644 backend/core/windows_rocm/ct2_recipe.py create mode 100644 backend/core/windows_rocm/isolation_policy.py create mode 100644 backend/core/windows_rocm/repair_selection.py create mode 100644 backend/core/windows_rocm/torch_asr.py create mode 100644 backend/engines/_asr_sidecar/decoding.py create mode 100644 backend/engines/_asr_sidecar/runtime.py create mode 100644 backend/services/_performance_probe.py create mode 100644 backend/services/performance_probe.py create mode 100644 backend/services/performance_readiness.py create mode 100644 backend/services/performance_selection.py create mode 100644 backend/services/pytorch_whisper_options.py create mode 100644 changelog.d/3712.added.md create mode 100644 electron/src/main/runtime-windows-rocm-abi.ts create mode 100644 tests/test_performance_activation_readiness.py create mode 100644 tests/test_performance_inventory_imports.py create mode 100644 tests/test_performance_probe.py create mode 100644 tests/test_performance_probe_worker.py create mode 100644 tests/test_performance_selection.py create mode 100644 tests/test_pytorch_whisper_english_only.py create mode 100644 tests/test_windows_rocm_asr_cache.py create mode 100644 tests/test_windows_rocm_asr_import_child.py create mode 100644 tests/test_windows_rocm_asr_isolation.py create mode 100644 tests/test_windows_rocm_asr_worker.py create mode 100644 tests/test_windows_rocm_cpu_capture_readiness.py create mode 100644 tests/test_windows_rocm_ct2_kernels.py create mode 100644 tests/test_windows_rocm_proxy_creation.py create mode 100644 tests/test_windows_rocm_proxy_evidence.py create mode 100644 tests/test_windows_rocm_proxy_retirement.py create mode 100644 tests/test_windows_rocm_pytorch_whisper.py create mode 100644 tests/test_windows_rocm_rdna2.py create mode 100644 tests/test_windows_rocm_repair_selection.py create mode 100644 tests/test_windows_rocm_whisperx_isolation.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index be26c96d59..90ed3fe52c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -503,10 +503,16 @@ test-only PR. Docker Hub overview edits still publish through the small requesting 256 KiB from the affected package's pinned URL through the setup proxy, bounded to 15 seconds, with no upload; and Hugging Face model downloads (gated on install state or an explicit user action). - - **Windows ROCm setup the user explicitly selects** (owner-approved - 2026-10-26): the matched AMD SDK and PyTorch wheels from `repo.radeon.com`, - and the pinned HIP CTranslate2 archive from OpenNMT/CTranslate2 GitHub - Releases. Every artifact is SHA-256 pinned in + - **Windows ROCm setup the user explicitly selects:** the initial sources + approved on 2026-10-26 are `repo.radeon.com` SDK/PyTorch wheels and the HIP + CTranslate2 archive from OpenNMT/CTranslate2 GitHub Releases. PR #3712 + implements a proposed RDNA 2 source-policy exception: `stable.repo.amd.com` + wheels and the unmodified expanded-target dependency mirror under + `dutch2005/VoiceStudio`. The exception is part of PR #3712's review and + requires explicit upstream maintainer acceptance before merge; development + integration/testing does not establish upstream approval. These hosts are + used only by user-selected RDNA 2 provisioning, never by readiness checks + or normal inference. Every artifact is SHA-256 pinned in `scripts/windows-rocm-recipe.json` and verified before use. This opt-in installer keeps every other package at its installed (locked) version and uses the usual package indexes only for anything new; verification diff --git a/AGENTS.md b/AGENTS.md index f3a31f122e..3b4865c475 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ licence/catalog data. CLA, identity and secret checks remain enabled. ## Change rules (see CLAUDE.md for full text) - Root-cause the class, not the instance; fail-before/pass-after regression test; smallest correct change. - Default behavior identical on macOS/Windows/Linux; platform-only features go behind explicit opt-in. Divergent default = P0. -- Local-first: no new required network calls; any HF download gated on installed-ness or explicit user action; all synthetic audio through the `mark_synthetic` chokepoint. Sanctioned calls are listed in `.github/CONTRIBUTING.md` → Quality gates: user-started first-run setup (ffmpeg from GitHub `zackees/ffmpeg_bins`, the huggingface.co/hf-mirror.com reachability probe, `uv.lock`-pinned package downloads from their locked URLs or the chosen/auto-selected regional mirror incl. the parallel wheel prefetch, the Auto region's one-wheel PyPI vs Aliyun speed check, a user-clicked setup connection check (256 KiB from the affected pinned package URL, 15-second limit, no uploads), HF model downloads), opening/enabling the galleries (jsDelivr manifest, GitHub Releases), packaged update checks, user-clicked yt-dlp updates from PyPI, user-selected Windows ROCm setup (owner-approved 2026-10-26, hash-pinned: `repo.radeon.com` wheels and the OpenNMT/CTranslate2 GitHub release), consent-gated analytics, prefilled-URL bug reports, phone content reports sent from a result's Report sheet, the Pro licence check after a key is entered, and VoiceStudio Cloud sign-in/account calls (and settings sync once cloud sync is on) only after the user turns them on (off by default; switching off stops them at once). Owner-approved exception (2026-09-28): the public GitHub star count may refresh automatically every 20 minutes, without credentials/referrer or user content; offline use remains unaffected. +- Local-first: no new required network calls; any HF download gated on installed-ness or explicit user action; all synthetic audio through the `mark_synthetic` chokepoint. Sanctioned calls are listed in `.github/CONTRIBUTING.md` → Quality gates: user-started first-run setup (ffmpeg from GitHub `zackees/ffmpeg_bins`, the huggingface.co/hf-mirror.com reachability probe, `uv.lock`-pinned package downloads from their locked URLs or the chosen/auto-selected regional mirror incl. the parallel wheel prefetch, the Auto region's one-wheel PyPI vs Aliyun speed check, a user-clicked setup connection check (256 KiB from the affected pinned package URL, 15-second limit, no uploads), HF model downloads), opening/enabling the galleries (jsDelivr manifest, GitHub Releases), packaged update checks, user-clicked yt-dlp updates from PyPI, user-selected Windows ROCm setup (initial sources approved 2026-10-26: `repo.radeon.com` and OpenNMT/CTranslate2; proposed RDNA 2 source-policy exception in #3712: `stable.repo.amd.com` and the expanded-target dependency mirror under `dutch2005/VoiceStudio`, used only for provisioning; explicit upstream maintainer acceptance is required before merge and development testing does not establish approval; every artifact is hash-pinned), consent-gated analytics, prefilled-URL bug reports, phone content reports sent from a result's Report sheet, the Pro licence check after a key is entered, and VoiceStudio Cloud sign-in/account calls (and settings sync once cloud sync is on) only after the user turns them on (off by default; switching off stops them at once). Owner-approved exception (2026-09-28): the public GitHub star count may refresh automatically every 20 minutes, without credentials/referrer or user content; offline use remains unaffected. - Every user-facing string via i18n, present in ALL 21 `electron/src/renderer/src/i18n/locales/*.json` files (the catalog the app loads) with real translations; `tests/test_locale_parity.py` and Electron `locale:check` enforce it. The legacy `electron/src/shared/i18n/` catalog is loaded only by shared-module tests. - Docs-sync in the same PR. Changelog: one quiet one-liner per PR in `changelog.d/..md` (not `CHANGELOG.md`), ending `(#N)` + `— thanks @user!` for community work; the release script folds fragments into Unreleased. - Tagged release announcements lead with the biggest user-visible change; redesigns need real UI screenshots and migrations need installer links and steps. Verify all contributor credits from the tag comparison and included PRs; list authors and bug reporters separately (see `docs/RELEASING.md`). diff --git a/CLAUDE.md b/CLAUDE.md index 0bbafb799d..1cfde6a601 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal - **Cross-platform parity**: Every fix must work on macOS (Apple Silicon; Intel Macs are limited to the app UI with a remote backend, as README.md and `docs/install/macos.md` document), Windows (x64), and Linux (AppImage + deb). No platform-only regressions; the cross-platform bug bash (PR #51) is the baseline. - **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option. **This rule governs BEHAVIOUR, not PERFORMANCE** (clarified 2026-07-30, council): hardware acceleration is expected to vary by host — CUDA, MPS, DirectML, Triton availability and `torch.compile` are all host-dependent by design, and reading the rule to forbid that would forbid GPU support itself. An optimization that is skipped where it cannot work (missing Triton, an arch the wheel lacks, a path its toolchain cannot link) is NOT a parity violation; a *feature* the user can see and use on one OS but not another is. - **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path. -- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16; US project migration 2026-09-26) is opt-in PostHog US with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); every build — installer, Docker, and source alike (owner reversal 2026-07-20, #1193) — carries the in-repo publishable write-only token and shows the same consent ask, with env/baked token overriding it. No required cloud calls, accounts, or API keys. Sanctioned calls (owner-set 2026-10-02; full list in `.github/CONTRIBUTING.md` → Quality gates): during first-run setup the user starts — the pinned ffmpeg download from GitHub `zackees/ffmpeg_bins` (`backend/services/media_tools.py`), the huggingface.co/hf-mirror.com reachability probe for restricted networks (`backend/services/endpoint_race.py`), the dependency install's download of the packages pinned in `uv.lock` from their locked URLs or the user's chosen/auto-selected regional mirror, including the parallel wheel prefetch (`electron/src/main/wheel-prefetch.ts`, owner-approved 2026-10-27), the Auto region's one-wheel files.pythonhosted.org vs mirrors.aliyun.com speed check (two small ranged requests, `electron/src/main/pypi-mirror.ts`), a user-clicked setup connection check (256 KiB from the affected pinned package URL, 15-second limit, no uploads), and Hugging Face model downloads; opening the community gallery (jsDelivr manifest, `backend/api/routers/community.py`) or enabling the voice-preview gallery (GitHub Releases, `backend/services/gallery.py`); packaged update checks against GitHub Releases (`electron/src/main/updater.ts`); yt-dlp updates from PyPI when the user clicks update; opt-in Windows ROCm setup the user selects (owner-approved 2026-10-26, hash-pinned in `scripts/windows-rocm-recipe.json`): AMD's `repo.radeon.com` SDK/PyTorch wheels and the OpenNMT/CTranslate2 GitHub release archive; phone content reports to `appapi.voicestudio.sh/v1/content-reports`, only when the user taps Send on a result's Report sheet (`mobile/contract/SPEC.md`); and, only after the user turns account features on (first-run setup or Settings → Account, off by default), VoiceStudio Cloud sign-in and account calls from Electron main (`electron/src/main/account/`), plus settings sync from the backend (`backend/services/cloud_sync/`) once cloud sync is also on, all stopped at once when switched off. Narrow owner-approved exception (2026-09-28): the GitHub Star badge may fetch the public repository star count when shown and refresh every 20 minutes while visible, without credentials or a referrer. It sends no project, recording, or account data, retains the last count on failure, and never blocks offline use. +- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16; US project migration 2026-09-26) is opt-in PostHog US with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); every build — installer, Docker, and source alike (owner reversal 2026-07-20, #1193) — carries the in-repo publishable write-only token and shows the same consent ask, with env/baked token overriding it. No required cloud calls, accounts, or API keys. Sanctioned calls (owner-set 2026-10-02; full list in `.github/CONTRIBUTING.md` → Quality gates): during first-run setup the user starts — the pinned ffmpeg download from GitHub `zackees/ffmpeg_bins` (`backend/services/media_tools.py`), the huggingface.co/hf-mirror.com reachability probe for restricted networks (`backend/services/endpoint_race.py`), the dependency install's download of the packages pinned in `uv.lock` from their locked URLs or the user's chosen/auto-selected regional mirror, including the parallel wheel prefetch (`electron/src/main/wheel-prefetch.ts`, owner-approved 2026-10-27), the Auto region's one-wheel files.pythonhosted.org vs mirrors.aliyun.com speed check (two small ranged requests, `electron/src/main/pypi-mirror.ts`), a user-clicked setup connection check (256 KiB from the affected pinned package URL, 15-second limit, no uploads), and Hugging Face model downloads; opening the community gallery (jsDelivr manifest, `backend/api/routers/community.py`) or enabling the voice-preview gallery (GitHub Releases, `backend/services/gallery.py`); packaged update checks against GitHub Releases (`electron/src/main/updater.ts`); yt-dlp updates from PyPI when the user clicks update; opt-in Windows ROCm setup the user selects (hash-pinned in `scripts/windows-rocm-recipe.json`): initial sources approved on 2026-10-26 are AMD's `repo.radeon.com` SDK/PyTorch wheels and the OpenNMT/CTranslate2 GitHub release archive; PR #3712 implements a proposed RDNA 2 source-policy exception for `stable.repo.amd.com` wheels and the unmodified expanded-target dependency mirror under `dutch2005/VoiceStudio`; those provisioning-only sources require explicit upstream maintainer acceptance before merge, and development integration/testing does not establish upstream approval; phone content reports to `appapi.voicestudio.sh/v1/content-reports`, only when the user taps Send on a result's Report sheet (`mobile/contract/SPEC.md`); and, only after the user turns account features on (first-run setup or Settings → Account, off by default), VoiceStudio Cloud sign-in and account calls from Electron main (`electron/src/main/account/`), plus settings sync from the backend (`backend/services/cloud_sync/`) once cloud sync is also on, all stopped at once when switched off. Narrow owner-approved exception (2026-09-28): the GitHub Star badge may fetch the public repository star count when shown and refresh every 20 minutes while visible, without credentials or a referrer. It sends no project, recording, or account data, retains the last count on failure, and never blocks offline use. - **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the current release line (0.5.x today) has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch from main whenever the current state is worth cutting. No `-rc` tags. No phased release. The one sanctioned prerelease is the opt-in **Beta channel** (owner-approved 2026-10-22): `vX.Y.Z-beta.N` tags cut from `main`, published as GitHub prereleases that never become `latest`, with their own `electron-beta-*` update feed and `:beta` Docker tag (see `docs/RELEASING.md` → Beta channel). It adds no soak, ceremony or side branch. No deferrals to a future minor or major version — every open issue and every open community PR gets absorbed into the current line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. diff --git a/README.md b/README.md index 2f289ffb8a..208abe2a69 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,15 @@ Start with **VoiceStudio** (default, powered by k2-fsa/OmniVoice), or pick from ## Platforms +Experimental [Windows ROCm setup](docs/install/windows-rocm.md) also supports +RDNA 2 RX 6000 GPUs. Its separately pinned CTranslate2 build includes native +`gfx1031` kernels for RX 6700 XT transcription; setup checks actual GPU kernel +execution. RX 6700 XT tests also cover native Torch speech generation and +PyTorch Whisper. ROCm 10 faster-whisper uses a separate inference process to +preserve safe CPU selection and fallback. Other cards and workloads retain the qualification limits in the guide. +Performance presets validate changed engine selections in an offline child +process before saving them, preserving fallback when an installed runtime fails. + Choose your system, then expand an installation method. diff --git a/backend/core/device_caps.py b/backend/core/device_caps.py index d0ba5f34fb..7585df5761 100644 --- a/backend/core/device_caps.py +++ b/backend/core/device_caps.py @@ -201,6 +201,25 @@ def _normalize_arch(tag: str) -> str: return str(tag).split(":")[0].strip().lower() +def _installed_rocm_arches(version: str) -> set[str]: + """TheRock wheels list targets even when their kernel packages are absent.""" + from importlib import metadata + + result = set() + for dist in metadata.distributions(): + name = str(dist.metadata.get("Name", "")).lower().replace("_", "-") + prefix = "amd-torch-device-gfx" + if not name.startswith(prefix) or dist.version != version: + continue + arch = "gfx" + name[len(prefix):] + try: + if metadata.version(f"rocm-sdk-device-{arch}") == "10.0.0": + result.add(arch) + except metadata.PackageNotFoundError: + continue # A Torch device wheel alone does not provide the SDK kernels. + return result + + def build_arch_list(torch) -> list[str]: """This torch build's compiled architecture list, or ``[]`` if unknown. @@ -211,9 +230,18 @@ def build_arch_list(torch) -> list[str]: fn = getattr(torch.cuda, name, None) if callable(fn): try: - return [str(a) for a in (fn() or [])] + arches = [str(a) for a in (fn() or [])] except Exception: return [] + version = str(getattr(torch, "__version__", "")) + if sys.platform == "win32" and version == "2.13.0+rocm10.0.0": + try: + installed = _installed_rocm_arches(version) + except Exception: # noqa: BLE001 - unknown kernel packages must not authorize the GPU + installed = set() + # A nonempty sentinel makes absence fail closed in arch_unsupported. + return [a for a in arches if _normalize_arch(a) in installed] or ["no-installed-kernels"] + return arches return [] diff --git a/backend/core/windows_rocm/asr_isolation.py b/backend/core/windows_rocm/asr_isolation.py new file mode 100644 index 0000000000..8253ba7b4e --- /dev/null +++ b/backend/core/windows_rocm/asr_isolation.py @@ -0,0 +1,121 @@ +"""Keep ROCm 10 Torch and CT2's incompatible OpenMP runtimes apart.""" +import weakref +from importlib import util +from threading import Lock + +from .isolation_policy import isolation_required + +_PROXY_LOCK = Lock() + + +def worker_device(parent_device=None): + """Resolve Windows ROCm 10 without importing Torch in the inference child.""" + if not isolation_required(): + return None + from core.device_caps import requested_device_override + from .ct2 import hip_ct2_available + + if parent_device == "cpu" or requested_device_override() == "cpu": + return "cpu" # CPU selection must not run GPU verification. + return "cuda" if hip_ct2_available() else "cpu" + + +def availability_status(): + """CPU availability must not import CT2 into the Torch parent either.""" + if not isolation_required(): + return None + if not util.find_spec("faster_whisper"): + return False, "faster-whisper is not installed" + from services.performance_probe import isolated_asr_import_ready + + if not isolated_asr_import_ready(): + return False, "faster-whisper native import could not be verified in its isolated process" + from core.device_caps import requested_device_override + from .ct2 import availability_status as hip_status + + if requested_device_override() != "cpu" and (status := hip_status()) is not None: + return status + return True, "Windows ROCm 10 CTranslate2 runs in a separate inference process" + + +def _proxy(owner): + """Concurrent first use must publish exactly one owned worker.""" + with _owner_lock(owner): + return _proxy_locked(owner) + + +def _owner_lock(owner): + """Allocate one lifecycle lock without serializing unrelated engine loads.""" + with _PROXY_LOCK: + lock = getattr(owner, "_rocm_asr_lifecycle_lock", None) + if lock is None: + lock = Lock() + owner._rocm_asr_lifecycle_lock = lock + return lock + + +def _proxy_locked(owner): + """Bind one model source and cleanup callback while creation is serialized.""" + proxy = getattr(owner, "_rocm_asr_proxy", None) + if proxy is None: + from services.asr_backend import _local_model_source, _ctranslate2_cuda_ok + from .asr_proxy import RocmASRProxy + + device = "cuda" if _ctranslate2_cuda_ok() else "cpu" + proxy = RocmASRProxy(_local_model_source(owner._model_name), device) + owner._rocm_asr_proxy = proxy + owner._rocm_asr_finalizer = weakref.finalize(owner, proxy.close, True) + owner.runs_out_of_process = True + return proxy + + +def ensure_isolated(owner): + """Initialize owned transport only for the affected native runtime.""" + if not isolation_required(): + return False + _proxy(owner).ensure_loaded() + return True + + +def transcribe_isolated(owner, audio_path, **options): + """Preserve the normal backend contract and record actual worker execution.""" + if not isolation_required(): + return None + proxy = _proxy(owner) + result = proxy.transcribe(audio_path, **options) + with _owner_lock(owner): + if getattr(owner, "_rocm_asr_proxy", None) is proxy: + owner._device = proxy.execution_device + owner._compute_type = proxy.execution_precision + owner._fallback_reason = proxy.fallback_reason + owner._fallback_stage = proxy.fallback_stage + return result + + +def unload_isolated(owner): + """Retire idle delegates without interrupting a job holding the worker.""" + lock = _owner_lock(owner) + if not lock.acquire(blocking=False): + return True # Initialization is still publishing a coherent owner. + try: + return _unload_locked(owner) + finally: + lock.release() + + +def _unload_locked(owner): + """Release the published proxy and its exact finalizer as one operation.""" + proxy = getattr(owner, "_rocm_asr_proxy", None) + if proxy is not None: + if not proxy.close(): + return True # An active job still owns the sidecar. + owner._rocm_asr_finalizer.detach() + owner._rocm_asr_proxy = None + owner._device = owner._compute_type = None + return proxy is not None or isolation_required() + + +def evidence_loaded(owner): + """Report live model ownership rather than an intended device choice.""" + proxy = getattr(owner, "_rocm_asr_proxy", None) + return proxy is not None and proxy.execution_evidence_loaded() diff --git a/backend/core/windows_rocm/asr_proxy.py b/backend/core/windows_rocm/asr_proxy.py new file mode 100644 index 0000000000..92f3d018b6 --- /dev/null +++ b/backend/core/windows_rocm/asr_proxy.py @@ -0,0 +1,69 @@ +"""Reuse the owned ASR transport while preserving the normal backend's model.""" +import atexit + +from services.subprocess_asr import IsolatedFasterWhisperBackend + + +class RocmASRProxy(IsolatedFasterWhisperBackend): + """Keep the normal backend's identity and options across owned ASR frames.""" + id = "faster-whisper" + + def __init__(self, model_source, device): + """Bind the exact model source and the parent's effective device choice.""" + super().__init__() + self.model_source = str(model_source) + self.parent_device = device + self.execution_device = self.execution_precision = None + self.fallback_reason = self.fallback_stage = None + self._retired = False + + def _spawn(self): + """Never restart a delegate after its owner has unloaded it.""" + if self._retired: + raise RuntimeError("ASR proxy was unloaded; retry with a fresh worker") + return super()._spawn() + + def _device(self): + """Describe execution failures without probing another native runtime.""" + return self.execution_device or self.parent_device + + def _send(self, message): + """Attach the model and VAD contract to normal transcription frames.""" + if message.get("op") == "transcribe": + message = dict(message, configuration={"model": self.model_source, + "vad_filter": True, "device": self.parent_device}) + return super()._send(message) + + def _recv_with_timeout(self, timeout_s): + """Accept only worker-reported native device and precision evidence.""" + reply = super()._recv_with_timeout(timeout_s) + if reply and reply.get("op") == "segments": + evidence = reply.pop("execution", {}) + device, precision = evidence.get("device"), evidence.get("compute_type") + if device not in {"cpu", "cuda"} or precision not in { + "float32", "float16", "bfloat16", "int8", "int8_float32", "int8_float16", + "int8_bfloat16", "int16", + }: + raise RuntimeError("ASR sidecar did not report its actual execution device/precision") + self.execution_device, self.execution_precision = device, precision + self.fallback_reason = evidence.get("fallback_reason") + self.fallback_stage = evidence.get("fallback_stage") + return reply + + def execution_evidence_loaded(self): + """Require a live worker and an actual native model report.""" + return (self._proc is not None and self._proc.poll() is None + and self.execution_device is not None) + + def close(self, wait=False): + """Release an idle worker and its exit hook; optionally wait for ownership.""" + if not self._lock.acquire(blocking=wait): + return False + try: + self._retired = True + self.shutdown() + # Do not retain every short-lived reference pass via its exit hook. + atexit.unregister(self.shutdown) + return True + finally: + self._lock.release() diff --git a/backend/core/windows_rocm/ct2_kernel.py b/backend/core/windows_rocm/ct2_kernel.py new file mode 100644 index 0000000000..0538f09d47 --- /dev/null +++ b/backend/core/windows_rocm/ct2_kernel.py @@ -0,0 +1,24 @@ +"""Exercise CT2's own GPU kernels without models, downloads or speech inputs.""" + + +def verify_ct2_kernel(ctranslate2): + """Round-trip exactly representable values through native GPU FP16 casts. + + Device discovery and host/device copies can succeed when the wheel lacks + kernels for this GPU. Converting on the GPU executes CT2's device code; + copying the result back synchronizes it and permits checking the values. + This establishes model-free kernel execution, not ASR model compatibility. + """ + import numpy as np + + expected = np.array([1.25, -2.5, 0.0, 8.0], dtype=np.float32) + gpu = ctranslate2.StorageView.from_array(expected).to_device(ctranslate2.Device.cuda) + if gpu.device != "cuda": + raise RuntimeError("CTranslate2 GPU kernel check fell back to CPU") + converted = gpu.to(ctranslate2.DataType.float16).to(ctranslate2.DataType.float32) + if converted.device != "cuda": + raise RuntimeError("CTranslate2 GPU conversion fell back to CPU") + actual = np.asarray(converted.to_device(ctranslate2.Device.cpu)) + if actual.shape != expected.shape or not np.array_equal(actual, expected): + raise RuntimeError("CTranslate2 GPU kernel returned incorrect values") + return actual.tolist() diff --git a/backend/core/windows_rocm/ct2_recipe.py b/backend/core/windows_rocm/ct2_recipe.py new file mode 100644 index 0000000000..57a7d13b8a --- /dev/null +++ b/backend/core/windows_rocm/ct2_recipe.py @@ -0,0 +1,24 @@ +"""Validate the separately pinned CT2 artifact before any package mutation.""" +import re +from pathlib import PurePosixPath + +_SOURCES = frozenset({ + "https://github.com/OpenNMT/CTranslate2/releases/download/v4.8.2/rocm-python-wheels-Windows.zip", + "https://github.com/dutch2005/VoiceStudio/releases/download/ct2-rocm-4.8.4-9a7a074/rocm-python-wheels-Windows.zip", +}) + + +def validate_ct2_recipe(config): + """Reject unapproved sources, unsafe members and incomplete integrity pins.""" + if config.get("archive_url") not in _SOURCES: + raise RuntimeError("Windows ROCm CT2 recipe has an unapproved source") + if type(config.get("archive_size")) is not int or not 0 < config["archive_size"] <= 512 * 1024**2: + raise RuntimeError("Windows ROCm CT2 recipe has an invalid archive size") + for field in ("archive_sha256", "wheel_sha256", "binary_sha256"): + if not re.fullmatch(r"[0-9a-f]{64}", str(config.get(field, ""))): + raise RuntimeError("Windows ROCm CT2 recipe has an invalid sha256 pin") + member = config.get("wheel_member", "") + path = PurePosixPath(member) + expected = f"ctranslate2-{config['version']}-cp312-cp312-win_amd64.whl" + if path.is_absolute() or ".." in path.parts or "\\" in member or path.name != expected: + raise RuntimeError("Windows ROCm CT2 recipe has an unsafe wheel member") diff --git a/backend/core/windows_rocm/install.py b/backend/core/windows_rocm/install.py index e8f0666a60..c46153cb18 100644 --- a/backend/core/windows_rocm/install.py +++ b/backend/core/windows_rocm/install.py @@ -14,9 +14,11 @@ from .failures import VerificationFailure, failure_report from .recipe import load_recipe, recipe_identity, validate_target from .verify import PROJECT_ROOT, verify_environment +from .repair_selection import validate_repair_selection #: Packages the overlay replaces; everything else stays at its installed version. -_OVERLAID = ("torch", "torchaudio", "torchvision", "ctranslate2", "rocm", "triton") +_OVERLAID = ("torch", "torchaudio", "torchvision", "ctranslate2", "rocm", "triton", + "amd-torch", "amd-torchvision") def _normalized(name): @@ -92,6 +94,7 @@ def provision(uv): if not exc.repairable: raise recipe = load_recipe() + validate_repair_selection(recipe) # Verify CT2 before changing any package, and discard downloads on any failure. with tempfile.TemporaryDirectory(prefix="voicestudio-windows-rocm-") as temporary: folder = Path(temporary) diff --git a/backend/core/windows_rocm/isolation_policy.py b/backend/core/windows_rocm/isolation_policy.py new file mode 100644 index 0000000000..7d4b55e8ef --- /dev/null +++ b/backend/core/windows_rocm/isolation_policy.py @@ -0,0 +1,13 @@ +"""Metadata-only isolation policy; safe for engine and pipeline admission.""" +import sys +from importlib import metadata + + +def isolation_required(): + """Keep Windows ROCm 10 inference away from Torch's OpenMP runtime.""" + if sys.platform != "win32": + return False + try: + return "+rocm10." in metadata.version("torch").lower() + except metadata.PackageNotFoundError: + return False diff --git a/backend/core/windows_rocm/native.py b/backend/core/windows_rocm/native.py index d42491725f..53f39cdc73 100644 --- a/backend/core/windows_rocm/native.py +++ b/backend/core/windows_rocm/native.py @@ -11,6 +11,7 @@ _SDK_ROOTS = { "rocm-sdk-core": "_rocm_sdk_core", "rocm-sdk-libraries-custom": "_rocm_sdk_libraries_custom", + "rocm-sdk-libraries": "_rocm_sdk_libraries", } diff --git a/backend/core/windows_rocm/pipelines.py b/backend/core/windows_rocm/pipelines.py index 13e63135df..2b281cb6e5 100644 --- a/backend/core/windows_rocm/pipelines.py +++ b/backend/core/windows_rocm/pipelines.py @@ -6,6 +6,21 @@ class PipelineCompatibilityError(ImportError): """Retryable device incompatibility, never a broken Python dependency.""" +def whisperx_error(device=None): + """WhisperX shares Torch's process; ROCm 10 CT2 CPU fallback is unsafe.""" + from .isolation_policy import isolation_required + + if isolation_required(): + return ( + "WhisperX transcription is unavailable in the Windows ROCm 10 environment: " + "Torch and CTranslate2 use incompatible OpenMP runtimes. Use faster-whisper " + "(isolated automatically) or PyTorch Whisper for transcription. CPU-only " + "wav2vec2 alignment and pyannote are separate pipelines. " + "See docs/install/windows-rocm.md." + ) + return pipeline_error(device) + + def pipeline_error(device=None): """Return an actionable gate before imports/cache use, without caching failure.""" if not windows_hip_installed(): diff --git a/backend/core/windows_rocm/probe.py b/backend/core/windows_rocm/probe.py index ad3d46e1ea..8f9f5f9c9d 100644 --- a/backend/core/windows_rocm/probe.py +++ b/backend/core/windows_rocm/probe.py @@ -1,6 +1,7 @@ """Native model-free probe. Execute ONLY as a bounded subprocess.""" import json +import os import sys import sysconfig from importlib import metadata @@ -8,14 +9,19 @@ from .artifacts import sha256 from .failures import VerificationFailure, failure_report from .native import import_native +from .ct2_kernel import verify_ct2_kernel from .recipe import expected_versions, load_recipe, validate_target from .runtime import initialize_dll_search, package_root from .verify import validate_report def probe(): + """Verify the pinned ROCm installation and execute its native GPU probes.""" validate_target() recipe = load_recipe() + if (any(arch in recipe["rdna2"]["devices"] for arch in recipe["gpu_architectures"]) + and os.environ.get("HSA_OVERRIDE_GFX_VERSION")): + raise VerificationFailure("device_runtime", "Remove HSA_OVERRIDE_GFX_VERSION; RDNA 2 uses native kernels") try: packages = {name: metadata.version(name) for name in expected_versions(recipe)} except metadata.PackageNotFoundError as exc: @@ -73,6 +79,7 @@ def probe(): "ct2_gpu_count": ctranslate2.get_cuda_device_count(), "ct2_compute_types": sorted(ctranslate2.get_supported_compute_types("cuda")), "ct2_binary_sha256": binary_hash, + "ct2_kernel_result": verify_ct2_kernel(ctranslate2), } except VerificationFailure: raise diff --git a/backend/core/windows_rocm/recipe.py b/backend/core/windows_rocm/recipe.py index e50271bf85..f06a3b9aa9 100644 --- a/backend/core/windows_rocm/recipe.py +++ b/backend/core/windows_rocm/recipe.py @@ -5,27 +5,82 @@ import re import sys import sysconfig -from pathlib import Path +from pathlib import Path, PurePosixPath +from urllib.parse import unquote from .failures import VerificationFailure +from .ct2_recipe import validate_ct2_recipe _SHA256 = re.compile(r"[0-9a-f]{64}") +_ALLOWED_SOURCES = frozenset({ + "https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/", + "https://stable.repo.amd.com/rocm/whl-next/", +}) + +# AMD's rocminfo PCI-to-gfx mapping: https://github.com/ROCm/rocminfo/blob/amd-staging_deprecated/rocm_agent_enumerator +_RDNA2_PCI_IDS = { + "gfx1030": frozenset("73a0 73a1 73a2 73a3 73a5 73a8 73a9 73ab 73ac 73ad 73ae 73af 73bf".split()), + "gfx1031": frozenset("73c0 73c1 73c3 73da 73db 73dc 73dd 73de 73df".split()), + "gfx1032": frozenset("73e0 73e1 73e2 73e3 73e8 73e9 73ea 73eb 73ec 73ed 73ef 73ff".split()), +} RECIPE_PATH = Path(__file__).resolve().parents[3] / "scripts" / "windows-rocm-recipe.json" -def load_recipe(path=None): - """Load packaged/source recipe bytes; no network or native imports.""" +def detected_rdna2_arch(): + """Select the native overlay from PCI metadata, before Torch is installed.""" + if sys.platform != "win32": + return None + from ..gpu_inventory import detect_host_gpus + + amd = [gpu for gpu in detect_host_gpus() if gpu.vendor == "amd"] + arches = [next((arch for arch, ids in _RDNA2_PCI_IDS.items() + if gpu.pci_device_id.lower().removeprefix("0x") in ids), None) for gpu in amd] + found = {arch for arch in arches if arch} + if found and (len(found) != 1 or any(arch is None for arch in arches)): + raise VerificationFailure("unsupported_gpu", "Multiple AMD architectures cannot be selected safely for Windows ROCm") + return next(iter(found), None) + + +def _validate_artifacts(section): + """Reject unsafe artifact paths and incomplete or malformed SHA-256 pins.""" + hashes = section.get("wheel_sha256") or {} + files = section["wheel_files"] + if set(files) != set(hashes): + raise RuntimeError("Windows ROCm recipe has missing or extra sha256 pins") + for name in files: + decoded = unquote(name) + path = PurePosixPath(decoded) + if (path.is_absolute() or ".." in path.parts + or not re.fullmatch(r"[A-Za-z0-9_./+%-]+", name) + or not re.fullmatch(r"[A-Za-z0-9_./+-]+", decoded)): + raise RuntimeError("Windows ROCm recipe has an unsafe artifact path") + if not _SHA256.fullmatch(str(hashes.get(name, ""))): + raise RuntimeError(f"Windows ROCm recipe has no valid sha256 for {name}") + + +def load_recipe(path=None, *, architecture=None): + """Load, validate, and select an overlay without network or native imports.""" recipe = json.loads(Path(path or RECIPE_PATH).read_text(encoding="utf-8")) if recipe["schema_version"] != 1: raise RuntimeError("Unsupported Windows ROCm recipe schema") - # Every AMD artifact is pinned by content, not just by name: a version pin - # alone would install whatever bytes the host serves under that filename. - for section in ("torch", "runtime"): - hashes = recipe[section].get("wheel_sha256") or {} - for name in recipe[section]["wheel_files"]: - if not _SHA256.fullmatch(str(hashes.get(name, ""))): - raise RuntimeError(f"Windows ROCm recipe has no valid sha256 for {name}") + variant = recipe["rdna2"] + for config in (recipe["ctranslate2"], variant["ctranslate2"]): + validate_ct2_recipe(config) + for torch in (recipe["torch"], variant["torch"]): + if torch["find_links"] not in _ALLOWED_SOURCES: + raise RuntimeError("Windows ROCm recipe has an unapproved source") + for section in (recipe["torch"], recipe["runtime"], variant["torch"], variant["runtime"], + *variant["devices"].values()): + _validate_artifacts(section) + arch = architecture if architecture is not None else detected_rdna2_arch() + if arch in variant["devices"]: + device = variant["devices"][arch] + common = variant["runtime"] + selected_runtime = {key: common[key] + device[key] for key in ("packages", "wheel_files")} + selected_runtime["wheel_sha256"] = common["wheel_sha256"] | device["wheel_sha256"] + return {**recipe, "gpu_architectures": [arch], "torch": variant["torch"], + "runtime": selected_runtime, "ctranslate2": variant["ctranslate2"]} return recipe diff --git a/backend/core/windows_rocm/repair_selection.py b/backend/core/windows_rocm/repair_selection.py new file mode 100644 index 0000000000..49bc2ff267 --- /dev/null +++ b/backend/core/windows_rocm/repair_selection.py @@ -0,0 +1,33 @@ +"""Package repair must not guess hardware or replace another ROCm family.""" +import re +import sys +from importlib import metadata + +from .failures import VerificationFailure + + +def validate_repair_selection(recipe): + """Stop ambiguous repairs before downloads; healthy reuse stays unchanged.""" + if sys.platform != "win32": + return + from ..gpu_inventory import detect_host_gpus + from .recipe import detected_rdna2_arch + + amd = [gpu for gpu in detect_host_gpus() if gpu.vendor == "amd"] + if not amd or any(not re.fullmatch(r"[0-9a-f]{4}", gpu.pci_device_id.lower().removeprefix("0x")) for gpu in amd): + raise VerificationFailure("unsupported_gpu", "Reliable AMD GPU inventory is required before ROCm repair") + architecture = detected_rdna2_arch() + if architecture and architecture not in recipe["gpu_architectures"]: + raise VerificationFailure("unsupported_gpu", "GPU architecture differs from the selected ROCm recipe") + family = "10" if "+rocm10." in recipe["torch"]["packages"][0] else "7" + library = "rocm-sdk-libraries" if family == "10" else "rocm-sdk-libraries-custom" + for name in ("torch", "rocm", "rocm-sdk-core", library): + try: + version = metadata.version(name) + except metadata.PackageNotFoundError: + continue + match = re.search(r"\+rocm(\d+)\.", version) if name == "torch" else re.match(r"(\d+)\.", version) + if match and match[1] != family: + raise VerificationFailure( + "unsupported_gpu", "Installed ROCm family differs from this recipe; use a fresh runtime instead of automatic repair" + ) diff --git a/backend/core/windows_rocm/runtime.py b/backend/core/windows_rocm/runtime.py index a585588694..e9d11e8093 100644 --- a/backend/core/windows_rocm/runtime.py +++ b/backend/core/windows_rocm/runtime.py @@ -15,6 +15,7 @@ "ctranslate2": "ctranslate2", "_rocm_sdk_core": "rocm-sdk-core", "_rocm_sdk_libraries_custom": "rocm-sdk-libraries-custom", + "_rocm_sdk_libraries": "rocm-sdk-libraries", } @@ -38,13 +39,17 @@ def package_root(package): def dll_directories(): """Discover package locations without importing (and locking) Torch/CT2.""" try: - if "rocm" not in metadata.version("torch").lower(): + torch_version = metadata.version("torch").lower() + if "rocm" not in torch_version: return [] except metadata.PackageNotFoundError: return [] + # Old and new SDK packages may coexist after switching recipes. Never put + # the other recipe's DLLs on the search path. + libraries = "_rocm_sdk_libraries" if "+rocm10." in torch_version else "_rocm_sdk_libraries_custom" directories = [] for package, suffix in (("torch", "lib"), ("ctranslate2", ""), - ("_rocm_sdk_core", "bin"), ("_rocm_sdk_libraries_custom", "bin")): + ("_rocm_sdk_core", "bin"), (libraries, "bin")): root = package_root(package) if root is not None: directory = root / suffix diff --git a/backend/core/windows_rocm/smoke.py b/backend/core/windows_rocm/smoke.py index 77f42080da..db82d253df 100644 --- a/backend/core/windows_rocm/smoke.py +++ b/backend/core/windows_rocm/smoke.py @@ -19,7 +19,9 @@ def validate_inputs(model, wav): if any("://" in str(value) for value in (model, wav)): raise ValueError("Remote model/audio URLs are not supported") model, wav = Path(model).resolve(), Path(wav).resolve() - for name in ("model.bin", "config.json", "tokenizer.json", "preprocessor_config.json"): + # faster-whisper derives feature dimensions from the model when the + # optional preprocessor file is absent (for example Systran small.en). + for name in ("model.bin", "config.json", "tokenizer.json"): if not (model / name).is_file(): raise FileNotFoundError(f"Local CT2 model requires {name}") if not wav.is_file(): diff --git a/backend/core/windows_rocm/torch_asr.py b/backend/core/windows_rocm/torch_asr.py new file mode 100644 index 0000000000..76833b17f3 --- /dev/null +++ b/backend/core/windows_rocm/torch_asr.py @@ -0,0 +1,27 @@ +"""Advertise only the matched opt-in Windows HIP Torch recipe, independent of CT2.""" +import os +import sys +from importlib import metadata + + +def gpu_compatible(default): + """Add ROCm only for an available host matching the selected native recipe.""" + if (sys.platform != "win32" + or os.environ.get("OMNIVOICE_TORCH_VARIANT", "").strip().lower() != "rocm"): + return default + from core.device_caps import detect_host_caps, KERNEL_RISK_MARKER + from .recipe import load_recipe, validate_target + + caps = detect_host_caps() + if (caps.family != "rocm" or not getattr(caps, "probe_ok", False) + or any(KERNEL_RISK_MARKER in note for note in caps.notes)): + return default + try: + validate_target() + recipe = load_recipe() + pins = recipe["torch"]["packages"] + recipe["runtime"]["packages"] + if any(metadata.version(name) != version for name, version in (pin.split("==", 1) for pin in pins)): + return default + except Exception: # noqa: BLE001 - uncertain metadata cannot authorize a native GPU claim. + return default + return tuple(dict.fromkeys((default[0], "rocm", *default[1:]))) diff --git a/backend/core/windows_rocm/verify.py b/backend/core/windows_rocm/verify.py index 4fb582db8d..3df7f164df 100644 --- a/backend/core/windows_rocm/verify.py +++ b/backend/core/windows_rocm/verify.py @@ -29,6 +29,7 @@ def validate_report(report, recipe): isinstance(report.get("ct2_gpu_count"), int) and report["ct2_gpu_count"] > 0, "float16" in report.get("ct2_compute_types", []), report.get("ct2_binary_sha256") == recipe["ctranslate2"]["binary_sha256"], + report.get("ct2_kernel_result") == [1.25, -2.5, 0.0, 8.0], ) if not all(requirements): raise VerificationFailure( diff --git a/backend/engines/_asr_sidecar/decoding.py b/backend/engines/_asr_sidecar/decoding.py new file mode 100644 index 0000000000..49279e567b --- /dev/null +++ b/backend/engines/_asr_sidecar/decoding.py @@ -0,0 +1,71 @@ +"""Validated ASR wire options and lossless faster-whisper result normalization.""" + + +def request_options(request): + """Reject malformed or unknown per-request Whisper decoding settings.""" + options = request or {} + if not isinstance(options, dict): + raise ValueError("Invalid ASR request options") + for key, value in options.items(): + if key == "language": + ok = isinstance(value, str) and 0 < len(value) <= 32 + elif key == "initial_prompt": + ok = isinstance(value, str) and len(value) <= 4096 + elif key == "temperature": + ok = type(value) in (int, float) and 0 <= value <= 1 + elif key == "task": + ok = value in ("transcribe", "translate") + else: + ok = False + if not ok: + raise ValueError("Invalid ASR request options") + return options + + +def validate_configuration(configuration): + """Validate the exact model, VAD and device contract sent by the proxy.""" + if configuration is None: + return None + if (not isinstance(configuration, dict) or set(configuration) != {"model", "vad_filter", "device"} + or not isinstance(configuration.get("model"), str) + or not 0 < len(configuration["model"]) <= 32768 + or "\x00" in configuration["model"] + or type(configuration.get("vad_filter")) is not bool + or configuration.get("device") not in {"cpu", "cuda"}): + raise ValueError("Invalid ASR model configuration") + return configuration + + +def transcribe(get_model, audio_path, word_timestamps, decode_options=None, + request=None, configuration=None): + """Run validated decoding and retain the normal backend's result fields.""" + options = decode_options or {} + if not isinstance(options, dict) or any( + key not in {"beam_size", "best_of"} or type(value) is not int or not 1 <= value <= 8 + for key, value in options.items() + ): + raise ValueError("Invalid ASR decoding options") + extra = request_options(request) + configuration = validate_configuration(configuration) + model = get_model(configuration) if configuration is not None else get_model() + if configuration is not None: + extra["vad_filter"] = configuration["vad_filter"] + segments, info = model.transcribe( + audio_path, word_timestamps=word_timestamps, condition_on_previous_text=False, + **options, **extra, + ) + out = [] + for segment in segments: + part = {"start": float(segment.start), "end": float(segment.end), "text": segment.text} + if configuration is not None or (word_timestamps and getattr(segment, "words", None)): + part["words"] = [ + {"word": word.word, "start": float(word.start), "end": float(word.end), + "probability": float(getattr(word, "probability", 0.0))} + for word in (getattr(segment, "words", None) or []) + ] if word_timestamps else [] + out.append(part) + result = {"segments": out, "text": " ".join(part["text"].strip() for part in out).strip(), + "language": getattr(info, "language", "unknown")} + if configuration is not None: + result.update(language_probability=info.language_probability, duration=info.duration) + return result diff --git a/backend/engines/_asr_sidecar/main.py b/backend/engines/_asr_sidecar/main.py index 9535d83bbd..6503efa6ca 100644 --- a/backend/engines/_asr_sidecar/main.py +++ b/backend/engines/_asr_sidecar/main.py @@ -24,7 +24,6 @@ import traceback MAX_FRAME_BYTES = 64 * 1024 * 1024 -_model = None # The parent's cuDNN 8 preload lives in the parent PROCESS, and this is a child # with its own clean import path — so without this, an install whose side-loaded @@ -43,6 +42,7 @@ def _send(stream, obj): + """Write one flushed JSON frame to the dedicated protocol stream.""" body = json.dumps(obj, separators=(",", ":")).encode("utf-8") stream.write(struct.pack("!I", len(body))) stream.write(body) @@ -50,6 +50,7 @@ def _send(stream, obj): def _recv(stream): + """Read one bounded request frame or return None at clean EOF.""" header = stream.read(4) if len(header) < 4: return None @@ -65,131 +66,21 @@ def _recv(stream): return json.loads(bytes(body).decode("utf-8")) -# NOTE: keep this compute_type fallback in lockstep with -# services/asr_backend.py:_compute_type_candidates / _is_compute_type_error. -# This sidecar runs in a child proc with a clean import path, so we duplicate a -# tiny copy rather than cross-importing the heavy services package (#551). -def _ct_candidates(device): - override = os.environ.get("ASR_COMPUTE_TYPE") - if override: - return [override] - return ["float16", "int8_float16", "int8"] if device == "cuda" else ["int8", "float32"] - - -def _is_ct_error(msg): - low = msg.lower() - return "compute type" in low or "efficient float16" in low - - -def _get_model(): - global _model - if _model is None: - from core.execstack import ensure_ctranslate2_loadable - - loadable, detail = ensure_ctranslate2_loadable() - if not loadable: - raise ImportError(f"CTranslate2 is unavailable to faster-whisper: {detail}") - from core.windows_rocm.ct2 import prepare_import - - prepare_import() - from faster_whisper import WhisperModel - # Same weights as in-process faster-whisper: ASR_MODEL_FASTER selects - # for BOTH variants, ASR_MODEL_FW stays as a sidecar-only override. - # Before this, the sidecar read only ASR_MODEL_FW while the download - # preflight read ASR_MODEL_FASTER — set one and the other variant (or - # the preflight) quietly used a different model. - name = ( - os.environ.get("ASR_MODEL_FW") - or os.environ.get("ASR_MODEL_FASTER") - or "large-v3" - ) - try: - # The probe honors the user compute-device override and the - # pinned Windows HIP CT2 boundary — the child must agree with - # the parent's device decision, not re-derive its own. - from core.windows_rocm.ct2 import gpu_device_available - device = "cuda" if gpu_device_available() else "cpu" - except Exception: - # Fail SAFE: guessing "cuda" from torch here would bypass a cpu - # override and hand CTranslate2 HIP-flavoured cuda on ROCm - # (#1529). CPU always works; say why in the sidecar log. - print("asr-sidecar: device probe failed — using cpu", file=sys.stderr, flush=True) - device = "cpu" - # Degrade fp16 → int8 rather than crash on GPUs without efficient fp16 - # (older Maxwell/Pascal, GTX 16xx, CTranslate2/cuDNN mismatch) (#551). - last_err = None - for compute in _ct_candidates(device): - try: - _model = WhisperModel(name, device=device, compute_type=compute) - break - except (ValueError, RuntimeError) as e: - last_err = e - if _is_ct_error(str(e)): - continue - raise - else: - raise last_err - return _model - - -def _request_options(request_options): - """Validate per-request Whisper options (OpenAI-route language, prompt, - temperature, task). Strictly typed like the decode options: the parent is - trusted, but a malformed frame must fail loudly, not decode wrongly.""" - options = request_options or {} - if not isinstance(options, dict): - raise ValueError("Invalid ASR request options") - for key, value in options.items(): - if key == "language": - ok = isinstance(value, str) and 0 < len(value) <= 32 - elif key == "initial_prompt": - ok = isinstance(value, str) and len(value) <= 4096 - elif key == "temperature": - ok = type(value) in (int, float) and 0 <= value <= 1 - elif key == "task": - ok = value in ("transcribe", "translate") - else: - ok = False - if not ok: - raise ValueError("Invalid ASR request options") - return options - - -def _transcribe(audio_path, word_timestamps, decode_options=None, request_options=None): - options = decode_options or {} - if not isinstance(options, dict) or any( - key not in {"beam_size", "best_of"} - or type(value) is not int or not 1 <= value <= 8 - for key, value in options.items() - ): - raise ValueError("Invalid ASR decoding options") - extra = _request_options(request_options) - model = _get_model() - segments, info = model.transcribe( - audio_path, word_timestamps=word_timestamps, - # Same as services.asr_backend.WHISPER_LONG_FORM_OPTIONS: decode each - # 30 s window on its own so one bad window cannot repeat to the end. - condition_on_previous_text=False, - **options, **extra, - ) - out = [] - for s in segments: - seg = {"start": float(s.start), "end": float(s.end), "text": s.text} - if word_timestamps and getattr(s, "words", None): - seg["words"] = [ - {"word": w.word, "start": float(w.start), "end": float(w.end), - "probability": float(getattr(w, "probability", 0.0))} - for w in s.words - ] - out.append(seg) - return { - "segments": out, - "text": " ".join(s["text"].strip() for s in out).strip(), - "language": getattr(info, "language", "unknown"), - } +# Keep these names at the entrypoint for existing integrations and tests. +from engines._asr_sidecar.runtime import get_model as _get_model +from engines._asr_sidecar.decoding import transcribe as _decode +from engines._asr_sidecar.runtime import execution_evidence + + +def _transcribe(audio_path, word_timestamps, decode_options=None, + request_options=None, configuration=None): + """Decode one frame through the shared, replaceable model loader.""" + return _decode(_get_model, audio_path, word_timestamps, decode_options, + request_options, configuration) def main() -> int: + """Serve ASR requests while keeping native stdout outside protocol frames.""" stdin = sys.stdin.buffer # Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428). # @@ -225,9 +116,10 @@ def main() -> int: elif op == "transcribe": result = _transcribe( msg.get("audio_path"), bool(msg.get("word_timestamps", True)), - msg.get("decode_options"), msg.get("request_options"), + msg.get("decode_options"), msg.get("request_options"), msg.get("configuration"), ) - _send(stdout, {"op": "segments", "result": result}) + _send(stdout, {"op": "segments", "result": result, + **({"execution": execution_evidence()} if msg.get("configuration") is not None else {})}) elif op == "shutdown": return 0 else: diff --git a/backend/engines/_asr_sidecar/runtime.py b/backend/engines/_asr_sidecar/runtime.py new file mode 100644 index 0000000000..d15332993c --- /dev/null +++ b/backend/engines/_asr_sidecar/runtime.py @@ -0,0 +1,85 @@ +"""Lazy model loading; ROCm 10 inference never imports Torch here.""" +import os +import sys + +_model = None +_identity = None +_fallback_reason = _fallback_stage = None + + +def _ct_candidates(device): + """Preserve explicit precision or use the established device fallback order.""" + override = os.environ.get("ASR_COMPUTE_TYPE") + if override: + return [override] + return ["float16", "int8_float16", "int8"] if device == "cuda" else ["int8", "float32"] + + +def _is_ct_error(message): + """Recognize precision failures that permit trying the next compute type.""" + low = message.lower() + return "compute type" in low or "efficient float16" in low + + +def get_model(configuration=None): + """Reuse the selected model or load it with verified device and CPU fallback.""" + global _model, _identity, _fallback_reason, _fallback_stage + from core.windows_rocm.asr_isolation import worker_device, isolation_required + + configured = configuration is not None + name = configuration["model"] if configured else ( + os.environ.get("ASR_MODEL_FW") or os.environ.get("ASR_MODEL_FASTER") or "large-v3") + # Preserve the old worker cache/decision outside the affected runtime. + safe_runtime = isolation_required() + device = worker_device(configuration["device"] if configured else None) if safe_runtime else None + identity = (name, device) if safe_runtime else (name,) + if _model is not None and _identity == identity: + return _model + _model = None + _fallback_reason = _fallback_stage = None + from core.execstack import ensure_ctranslate2_loadable + + loadable, detail = ensure_ctranslate2_loadable() + if not loadable: + raise ImportError(f"CTranslate2 is unavailable to faster-whisper: {detail}") + from core.windows_rocm.ct2 import prepare_import + + prepare_import() + from faster_whisper import WhisperModel + + if device is None: + try: + from core.windows_rocm.ct2 import gpu_device_available + device = "cuda" if gpu_device_available() else "cpu" + except Exception: + print("asr-sidecar: device probe failed — using cpu", file=sys.stderr, flush=True) + device = "cpu" + candidates = _ct_candidates(device) + while True: + last_error = None + for compute in candidates: + try: + _model = WhisperModel(name, device=device, compute_type=compute) + _identity = identity + return _model + except (ValueError, RuntimeError) as error: + last_error = error + if _is_ct_error(str(error)): + continue + if safe_runtime and device == "cuda" and "out of memory" in str(error).lower(): + break + raise + if safe_runtime and device == "cuda" and last_error and "out of memory" in str(last_error).lower(): + device, candidates = "cpu", _ct_candidates("cpu") + _fallback_reason = "GPU memory was exhausted while loading the engine" + _fallback_stage = "model_load" + continue + raise last_error + + +def execution_evidence(): + """Return the loaded native model's device, precision and fallback facts.""" + native = getattr(_model, "model", None) + return {"device": getattr(native, "device", None), + "compute_type": getattr(native, "compute_type", None), + "fallback_reason": _fallback_reason, "fallback_stage": _fallback_stage} diff --git a/backend/engines/dots_tts/main.py b/backend/engines/dots_tts/main.py index fe7ff3e16f..cd4ae8eca5 100644 --- a/backend/engines/dots_tts/main.py +++ b/backend/engines/dots_tts/main.py @@ -102,6 +102,16 @@ def _measure_vram_mb() -> float: _runtime = None +def _rocm_rdna2(torch): + """RDNA 2 bf16 GEMM can crash rocBLAS even when Torch claims support (#2372).""" + if not getattr(getattr(torch, "version", None), "hip", None): + return False + try: + return str(torch.cuda.get_device_properties(0).gcnArchName).lower().startswith("gfx103") + except Exception: # noqa: BLE001 - unknown ROCm architecture uses safe precision + return True + + def _load_runtime(stdout): """Cold-construct the dots.tts runtime. @@ -126,6 +136,8 @@ def _load_runtime(stdout): except Exception: default_precision = "float32" # Probe failure must not force half precision. precision = os.environ.get("OMNIVOICE_DOTS_TTS_PRECISION", default_precision) + if _rocm_rdna2(torch) and precision.strip().lower() in ("bfloat16", "bf16"): + precision = "float32" optimize = os.environ.get("OMNIVOICE_DOTS_TTS_OPTIMIZE", "0") == "1" _send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50}) diff --git a/backend/engines/indextts/main.py b/backend/engines/indextts/main.py index 2d19cf7dc2..5227de6a48 100644 --- a/backend/engines/indextts/main.py +++ b/backend/engines/indextts/main.py @@ -283,6 +283,16 @@ def _bf16_probe() -> bool: return proc.returncode == 0 +def _rocm_rdna2(torch): + """RDNA 2 bf16 GEMM can crash rocBLAS even when Torch claims support (#2372).""" + if not getattr(getattr(torch, "version", None), "hip", None): + return False + try: + return str(torch.cuda.get_device_properties(0).gcnArchName).lower().startswith("gfx103") + except Exception: # noqa: BLE001 - unknown ROCm architecture uses safe precision + return True + + def _torch_bf16_supported() -> bool: """Return whether this sidecar can safely enable IndexTTS 2.5 BF16.""" try: @@ -292,6 +302,8 @@ def _torch_bf16_supported() -> bool: if not (torch.cuda.is_available() and supported and supported()): return False if getattr(torch.version, "hip", None): + if _rocm_rdna2(torch): + return False # ROCm: the claim is necessary but not sufficient — verify it in # a child so a Tensile segfault fails only the probe (#2372) and # the model falls back to fp32 (the verified workaround was diff --git a/backend/engines/moss_tts_v15/main.py b/backend/engines/moss_tts_v15/main.py index ad1c058045..a5f85c58cc 100644 --- a/backend/engines/moss_tts_v15/main.py +++ b/backend/engines/moss_tts_v15/main.py @@ -247,6 +247,16 @@ def _snapshot_path(repo: str, revision: str) -> str: return snapshot_download(repo, revision=revision) +def _rocm_rdna2(torch): + """RDNA 2 bf16 GEMM can crash rocBLAS even when Torch claims support (#2372).""" + if not getattr(getattr(torch, "version", None), "hip", None): + return False + try: + return str(torch.cuda.get_device_properties(0).gcnArchName).lower().startswith("gfx103") + except Exception: # noqa: BLE001 - unknown ROCm architecture uses safe precision + return True + + def _load_model(stdout): """Cold-construct the MOSS-TTS-v1.5 processor + model. @@ -284,7 +294,7 @@ def _load_model(stdout): device = "cpu" # MOSS is untested on MPS; fall back to CPU for safety if device == "xpu": _patch_xpu_mem_get_info(torch) - dtype = torch.bfloat16 if device != "cpu" else torch.float32 + dtype = torch.bfloat16 if device != "cpu" and not (device == "cuda" and _rocm_rdna2(torch)) else torch.float32 # "sdpa" works on CUDA + CPU and needs no extra dep. flash_attention_2 # (Ampere+ CUDA, optional flash-attn) is opt-in via env. attn = os.environ.get("OMNIVOICE_MOSS_TTS_V15_ATTN", "sdpa") diff --git a/backend/services/_performance_probe.py b/backend/services/_performance_probe.py new file mode 100644 index 0000000000..532df1e927 --- /dev/null +++ b/backend/services/_performance_probe.py @@ -0,0 +1,88 @@ +"""One offline availability query, isolated from the API's model imports.""" +import importlib +import json +from pathlib import Path +import sys + +# Direct script execution keeps this worker out of the parent's import graph. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def _asr_ready(asr, cls, probe=None): + """Retain normal preflight and prove metadata-only CT2 probes can import.""" + if not (probe or asr._probe_available)(cls): + return False + if cls.id in {"faster-whisper", "faster-whisper-isolated"}: + from core.windows_rocm.ct2 import prepare_import + + prepare_import() + importlib.import_module("faster_whisper") + return True + + +def check(family, engine, broken=()): + """Import only the requested runtime, without constructing user models.""" + from core.windows_rocm.runtime import initialize_dll_search + + initialize_dll_search() + if family == "asr-import": + if engine != "faster-whisper": + return engine, False + module = importlib.import_module("faster_whisper") + return engine, callable(getattr(module, "WhisperModel", None)) + if family == "asr": + from services import asr_backend as asr + + asr._DEEP_IMPORT_BROKEN.update({key: "parent preflight failed" for key in broken}) + if engine == "auto": + original = asr._probe_available + + def native_probe(cls): + """Apply native import checks within the normal hardware order.""" + try: + return _asr_ready(asr, cls, original) + except Exception: + return False + + asr._probe_available = native_probe + try: + selected = asr._auto_detect() + cls = asr._REGISTRY[selected] if selected in asr._REGISTRY else None + return selected, bool(cls and native_probe(cls)) + finally: + asr._probe_available = original + cls = asr._REGISTRY[engine] if engine in asr._REGISTRY else None + return engine, bool(cls and _asr_ready(asr, cls)) + if family == "tts": + from services.tts_backend import get_backend_class + + return engine, bool(get_backend_class(engine).is_available()[0]) + if family == "translation": + from services.translation_engines import is_ready + + return engine, bool(is_ready(engine, import_check=True)) + if family == "dictation": + from services.sherpa_dictation import sherpa_available + + return engine, bool(sherpa_available()[0]) + return engine, False + + +def main(): + """Write a small identity-bound response after the native checks finish.""" + family, requested, broken_json, destination = sys.argv[1:] + engine, ready = requested, False + try: + broken = json.loads(broken_json) + if not isinstance(broken, list) or not all(isinstance(key, str) for key in broken): + raise ValueError("Invalid failed-runtime inventory") + engine, ready = check(family, requested, broken) + except Exception: + # No readiness proof was produced; the response remains unavailable. + pass + Path(destination).write_text(json.dumps({"schema": 1, "family": family, + "requested": requested, "engine": engine, "ready": ready}), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/backend/services/asr_backend.py b/backend/services/asr_backend.py index a9c880004f..e3bc835fd0 100644 --- a/backend/services/asr_backend.py +++ b/backend/services/asr_backend.py @@ -771,7 +771,8 @@ def _degrade_for_vram(self, device: str, compute_type: str) -> tuple[str, str]: @classmethod def is_available(cls) -> tuple[bool, str]: - from core.windows_rocm.pipelines import pipeline_error, prepare_audio_imports + """Report WhisperX import and native-library readiness without raising.""" + from core.windows_rocm.pipelines import whisperx_error as pipeline_error, prepare_audio_imports if reason := pipeline_error(): return False, reason @@ -800,7 +801,8 @@ def ensure_loaded(self) -> None: self._ensure_asr() def _ensure_asr(self): - from core.windows_rocm.pipelines import PipelineCompatibilityError, pipeline_error, prepare_audio_imports + """Lazily load WhisperX after compatibility checks and available-VRAM selection.""" + from core.windows_rocm.pipelines import PipelineCompatibilityError, whisperx_error as pipeline_error, prepare_audio_imports if reason := pipeline_error(self._device): raise PipelineCompatibilityError(reason) @@ -1145,6 +1147,13 @@ class FasterWhisperBackend(ASRBackend): # ROCm is admitted dynamically only for the checked Windows HIP binary. gpu_compat = ("cuda", "cpu") + @classmethod + def runtime_runs_out_of_process(cls): + """Report automatic Windows ROCm 10 inference containment.""" + from core.windows_rocm.asr_isolation import isolation_required + + return isolation_required() + @classmethod def runtime_gpu_compat(cls): from core.windows_rocm.ct2 import gpu_compatible @@ -1166,6 +1175,11 @@ def __init__(self, model_name: str | None = None): @classmethod def is_available(cls) -> tuple[bool, str]: + """Report isolated or in-process faster-whisper readiness without raising.""" + from core.windows_rocm.asr_isolation import availability_status as isolated_status + + if (status := isolated_status()) is not None: + return status from core.windows_rocm.ct2 import availability_status if (status := availability_status()) is not None: @@ -1188,6 +1202,11 @@ def is_available(cls) -> tuple[bool, str]: return _ctranslate2_cudnn_ok() def _ensure_model(self): + """Lazily initialize isolated or local faster-whisper with device fallback.""" + from core.windows_rocm.asr_isolation import ensure_isolated + + if ensure_isolated(self): + return if self._model is not None: return ok, detail = _ctranslate2_execstack_ok() @@ -1272,7 +1291,14 @@ def transcribe(self, audio_path: str, *, word_timestamps: bool = True, language: str | None = None, initial_prompt: str | None = None, temperature: float | None = None, task: str = "transcribe") -> dict: + """Transcribe through the active worker or local model and normalize segments.""" self._ensure_model() + if getattr(self, "_rocm_asr_proxy", None) is not None: + from core.windows_rocm.asr_isolation import transcribe_isolated + + return transcribe_isolated( + self, audio_path, word_timestamps=word_timestamps, language=language, + initial_prompt=initial_prompt, temperature=temperature, task=task) logger.info( "faster-whisper transcribing %s (word_timestamps=%s)", audio_path, word_timestamps, @@ -1332,6 +1358,11 @@ def unload(self) -> None: # never assigns — so the actual model in self._model was never freed and # a warm faster-whisper stayed resident for the life of the process. # Clear the real handle so the model is released. + """Release the isolated worker or local model and reclaim cached GPU memory.""" + from core.windows_rocm.asr_isolation import unload_isolated + + if unload_isolated(self): + return self._model = None self._asr = None # harmless if a subclass ever used it; keeps idempotence import gc @@ -1343,6 +1374,14 @@ def unload(self) -> None: except Exception: pass + def execution_evidence_loaded(self) -> bool: + """Check the live delegate when this instance uses isolated inference.""" + from core.windows_rocm.asr_isolation import isolation_required, evidence_loaded + + if isolation_required() or getattr(self, "_rocm_asr_proxy", None) is not None: + return evidence_loaded(self) + return super().execution_evidence_loaded() + # ── MLX Whisper (Apple Silicon optional) ──────────────────────────────────── @@ -1470,13 +1509,32 @@ class PyTorchWhisperBackend(ASRBackend): id = "pytorch-whisper" display_name = "PyTorch Whisper (CUDA / CPU via transformers pipeline)" # Pure transformers pipeline → runs wherever torch does (CUDA, MPS, CPU). - # ROCm-via-HIP would also work but is left unclaimed pending verification. + # The matched opt-in Windows HIP recipe adds ROCm dynamically below. gpu_compat = ("cuda", "mps", "cpu") + @classmethod + def runtime_gpu_compat(cls): + """Include only the qualified opt-in Windows HIP runtime in GPU claims.""" + from core.windows_rocm.torch_asr import gpu_compatible + + return gpu_compatible(cls.gpu_compat) + def __init__(self, asr_pipe=None): # Reuses the `_asr_pipe` attached to the TTS model when available. self._pipe = asr_pipe + @property + def execution_device(self): + """Read the loaded pipeline's execution device rather than its intent.""" + device = getattr(self._pipe, "device", None) + return str(device) if device is not None else None + + @property + def dtype(self): + """Read actual model parameter precision for execution diagnostics.""" + dtype = getattr(getattr(self._pipe, "model", None), "dtype", None) + return str(dtype) if dtype is not None else None + # Free VRAM needed on CUDA, where _ensure_pipe loads fp16 weights: the # weights (parameters x 2 bytes), the batch-16 decode workspace, and # headroom. Loading onto a nearly full card works, then the first @@ -1528,11 +1586,9 @@ def _model_name() -> str: @classmethod def is_available(cls) -> tuple[bool, str]: - try: - import transformers # noqa: F401 - return True, "ready" - except ImportError as e: - return False, f"transformers not installed: {e}" + from services.pytorch_whisper_options import dependency_status + + return dependency_status() @classmethod def _pick_device(cls, model_name: str | None = None) -> str: @@ -1573,6 +1629,7 @@ def ensure_loaded(self) -> None: self._ensure_pipe() def _ensure_pipe(self): + """Lazily create the standalone PyTorch Whisper pipeline on the chosen device.""" if self._pipe is not None: return # Build a standalone transformers Whisper pipeline on demand. This runs @@ -1583,7 +1640,12 @@ def _ensure_pipe(self): # When the TTS model already has an ASR head, dub_core passes it via the # constructor and this path is skipped. import torch - from transformers import pipeline as hf_pipeline + try: + from transformers import pipeline as hf_pipeline + except ImportError as exc: + # Keep ImportError for deep-load fallback, but do not relabel an + # installed package's broken transitive import as an absent package. + raise ImportError(f"PyTorch Whisper could not load Transformers pipeline: {exc}") from exc model_name = self._model_name() device = self._pick_device(model_name) asr_dtype = torch.float16 if str(device).startswith("cuda") else torch.float32 @@ -1704,16 +1766,25 @@ def _rebuild_on_cpu(self) -> None: def supports_translation(self) -> bool: # A reused TTS-model ASR head may differ from the env default: ask the # loaded pipeline first. + """Check the loaded or configured checkpoint for multilingual translation support.""" + from services.pytorch_whisper_options import english_only + + if english_only(self._pipe): + return False loaded = getattr(getattr(self._pipe, "model", None), "name_or_path", None) return whisper_checkpoint_translates(loaded or self._model_name()) def transcribe(self, audio_path: str, *, word_timestamps: bool = True, language: str | None = None, task: str = "transcribe") -> dict: + """Decode audio and normalize PyTorch Whisper output with safe generation options.""" import soundfile as sf self._ensure_pipe() # transformers' Whisper generate() takes language/task; a prompt needs # tokenizer-specific prompt_ids, so it is not advertised here. generate_kwargs = whisper_request_options(language, None, None, task) + from services.pytorch_whisper_options import generation_options + + generate_kwargs = generation_options(self._pipe, generate_kwargs) # #2039: libsndfile cannot open MP4/M4A (AAC), which /transcribe and # the MCP tool both accept. Those decode through the validated ffmpeg # path, which resamples to 16 kHz properly. Anything soundfile can @@ -2887,7 +2958,8 @@ def list_backends() -> list[dict]: _LAST_ERRORS.pop(bid, None) else: _LAST_ERRORS[bid] = scrub_text(msg) - isolation = "subprocess" if getattr(cls, "_is_subprocess_isolated", False) else "in-process" + implicit_isolation = bool(getattr(cls, "runtime_runs_out_of_process", lambda: False)()) + isolation = "subprocess" if getattr(cls, "_is_subprocess_isolated", False) or implicit_isolation else "in-process" routing = runtime_compute_profile(cls, caps) gpu_compat = routing["gpu_compat"] routing["routing_reason"] = scrub_text(routing["routing_reason"]) if routing["routing_reason"] else None @@ -2896,7 +2968,7 @@ def list_backends() -> list[dict]: # cannot leave ghost GPU/provider evidence in diagnostics. instance = ( _ISOLATED_INSTANCES.get(bid) - if isolation == "subprocess" + if isolation == "subprocess" and not implicit_isolation else _RUNTIME_INSTANCES.get(bid) ) execution_evidence = execution_snapshot( @@ -2936,6 +3008,10 @@ def _probe_available(cls) -> bool: lib that refuses to load — CTranslate2's exec-stack rejection, #692) means the engine is unusable on this host, so treat it as unavailable and fall through to the next candidate rather than crash engine selection.""" + from services.performance_readiness import inventory_active, asr_installed + + if inventory_active(): + return asr_installed(cls, _DEEP_IMPORT_BROKEN) if getattr(cls, "id", None) in _DEEP_IMPORT_BROKEN: # A previous load proved this backend's deep import chain is broken # (#1185) — the shallow probe would succeed, so consult the record diff --git a/backend/services/engine_evidence.py b/backend/services/engine_evidence.py index d4831c8483..17212e9a6f 100644 --- a/backend/services/engine_evidence.py +++ b/backend/services/engine_evidence.py @@ -55,6 +55,8 @@ def snapshot( isolated = bool( getattr(engine_cls, "_is_subprocess_isolated", False) or getattr(engine_cls, "runs_out_of_process", False) + or getattr(instance, "runs_out_of_process", False) + or getattr(engine_cls, "runtime_runs_out_of_process", lambda: False)() ) loaded = False probe_failed = False diff --git a/backend/services/performance_inventory.py b/backend/services/performance_inventory.py index 1daec57eb3..a10c0a102d 100644 --- a/backend/services/performance_inventory.py +++ b/backend/services/performance_inventory.py @@ -4,14 +4,15 @@ import os from services.performance_budget import Candidate, hardware_snapshot, make_plan +from services.performance_readiness import package_available, tts_installed def profile_plan(choice: str, overrides: dict, selections: dict) -> dict: + """Plan installed model selections within hardware budgets and explicit overrides.""" from core import prefs from services import ( diarization_runtime, - sherpa_dictation, translation_engines, tts_backend, ) @@ -66,7 +67,7 @@ def profile_plan(choice: str, overrides: dict, selections: dict) -> dict: if engine == "kittentts" and os.environ.get("OMNIVOICE_KITTENTTS_MODEL"): continue cls = tts_backend.get_backend_class(engine) - if not cls.is_available()[0]: + if not tts_installed(engine): continue routing = runtime_compute_profile(cls, detect_host_caps()) if routing["routing_status"] == "unavailable": @@ -116,7 +117,7 @@ def profile_plan(choice: str, overrides: dict, selections: dict) -> dict: if os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL") or not prefs.get("dictation.enabled", True): if "dictation" in current: fixed["dictation"] = current["dictation"] - elif sherpa_dictation.sherpa_available()[0]: + elif package_available("sherpa_onnx"): inventory["dictation"] = [ Candidate(m.kind, m.id, 4 if "parakeet-tdt-v3" in m.id else 3 if "parakeet" in m.id else 2 if "bilingual" in m.id else 1, diff --git a/backend/services/performance_probe.py b/backend/services/performance_probe.py new file mode 100644 index 0000000000..83cb451ebb --- /dev/null +++ b/backend/services/performance_probe.py @@ -0,0 +1,129 @@ +"""Offline, process-contained runtime checks before preset selections are saved.""" +import json +import os +from importlib import metadata +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time + +from core.contained_subprocess import spawn_owned + +_BACKEND = Path(__file__).resolve().parents[1] +_TIMEOUT = 120 +_MANAGED = frozenset({"asr", "tts", "translation", "dictation"}) +_AUTO_ASR = frozenset({"mlx-whisper", "whisperx", "faster-whisper", "pytorch-whisper"}) +_CACHE = {} +_CACHE_LOCK = threading.Lock() +_CACHE_SECONDS = 30 +_PACKAGES = ("torch", "ctranslate2", "faster-whisper", "whisperx", "transformers", + "mlx-whisper", "sherpa-onnx", "omnivoice", "kittentts", "argostranslate", + "rocm-sdk-core", "rocm-sdk-libraries", "rocm-sdk-libraries-custom") +_DEVICE_ENV = ("OMNIVOICE_DEVICE", "OMNIVOICE_TORCH_VARIANT", "OMNIVOICE_FORCE_CUDA", + "CUDA_VISIBLE_DEVICES", "HIP_VISIBLE_DEVICES", "ROCR_VISIBLE_DEVICES", + "HSA_OVERRIDE_GFX_VERSION", "HSA_ENABLE_DXG_DETECTION", "ROCM_PATH", + "HIP_PATH", "PATH", "HF_HUB_CACHE", "HF_HOME", "OMNIVOICE_DATA_DIR") + + +def _identity(family, engine, broken): + """Invalidate cached results when device, interpreter or installed facts change.""" + from core import prefs + + packages = [] + for name in _PACKAGES: + try: + dist = metadata.distribution(name) + root = Path(dist.locate_file("")) + packages.append((name, dist.version, str(root), root.stat().st_mtime_ns)) + except (metadata.PackageNotFoundError, OSError): + packages.append((name, None)) + try: + path = Path(prefs._PREFS_PATH) + stat = path.stat() + preference_file = (str(path), stat.st_mtime_ns, stat.st_size) + except OSError: + preference_file = (prefs._PREFS_PATH, None) + return (family, engine, sys.executable, sys.prefix, sys.platform, str(_BACKEND), + tuple(packages), tuple((key, os.environ.get(key)) for key in _DEVICE_ENV), + str(prefs.get("compute_device", "auto")), preference_file, broken) + + +def _query(family, engine): + """Return a strictly matched child result; crashes and timeouts fail closed.""" + asr = sys.modules.get("services.asr_backend") + broken = tuple(sorted(getattr(asr, "_DEEP_IMPORT_BROKEN", {}))) + key = _identity(family, engine, broken) + with _CACHE_LOCK: + cached = _CACHE.get(key) + if cached and time.monotonic() - cached[0] < _CACHE_SECONDS: + return dict(cached[1]) if cached[1] is not None else None + data = _run_query(family, engine, broken) + with _CACHE_LOCK: + if len(_CACHE) >= 32: + _CACHE.clear() + _CACHE[key] = (time.monotonic(), dict(data) if data is not None else None) + return data + + +def _run_query(family, engine, broken): + """Run one owned child and preserve negative results and transport failures.""" + env = dict(os.environ, HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", + HF_DATASETS_OFFLINE="1", HF_HUB_DISABLE_TELEMETRY="1") + with tempfile.TemporaryDirectory(prefix="voicestudio-readiness-") as directory: + result = Path(directory) / "result.json" + process = None + try: + process = spawn_owned( + [sys.executable, str(_BACKEND / "services/_performance_probe.py"), + family, engine, json.dumps(broken), str(result)], cwd=str(_BACKEND), env=env, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + if process.wait(timeout=_TIMEOUT) != 0 or result.stat().st_size > 4096: + return None + data = json.loads(result.read_text(encoding="utf-8")) + if (not isinstance(data, dict) or type(data.get("schema")) is not int or data["schema"] != 1 + or data.get("family") != family or data.get("requested") != engine + or type(data.get("ready")) is not bool + or not isinstance(data.get("engine"), str)): + return None + if data["engine"] != engine and not (engine == "auto" and data["engine"] in _AUTO_ASR): + return None + return data + except subprocess.TimeoutExpired: + if process is not None: + try: + process.kill() + process.wait(timeout=5) + except (OSError, subprocess.SubprocessError): + # Cleanup may race child exit; the timed-out query still fails closed. + pass + return None + except (OSError, ValueError, subprocess.SubprocessError): + return None + + +def isolated_asr_import_ready(): + """Prove the CT2 import in a child without re-entering backend availability.""" + result = _query("asr-import", "faster-whisper") + return bool(result and result["ready"] and result["engine"] == "faster-whisper") + + +def selection_ready(family, engine): + """Check newly managed selections without importing their runtimes here.""" + if family not in _MANAGED: + return True + result = _query(family, engine) + return bool(result and result["ready"] and result["engine"] == engine) + + +def active_asr_selection(): + """Preserve explicit choices and resolve automatic selection in a child.""" + from core import prefs + + explicit = os.environ.get("OMNIVOICE_ASR_BACKEND") or prefs.get("asr_backend") + if explicit: + return "pytorch-whisper" if explicit == "omnivoice" else explicit + result = _query("asr", "auto") + return result["engine"] if result and result["ready"] and result["engine"] in _AUTO_ASR else "auto" diff --git a/backend/services/performance_profiles.py b/backend/services/performance_profiles.py index 57de699dc5..4dcece8f52 100644 --- a/backend/services/performance_profiles.py +++ b/backend/services/performance_profiles.py @@ -5,6 +5,9 @@ import os from services.performance_budget import TIERS as _PERFORMANCE_TIERS +from services.performance_readiness import inventory_only, package_available, asr_installed +from services.performance_probe import active_asr_selection, selection_ready +from services.performance_selection import selection_snapshot, unchanged_selection #: TTS engines whose sampling the performance tiers tune: native OmniVoice and #: the same model in its registered crash-isolated sidecar. The one source for @@ -108,6 +111,7 @@ def _installed_ct2_models() -> list[dict]: def _installed_mlx_whisper_model() -> dict | None: + """Return the supported, complete MLX Whisper cache available on this host.""" from api.routers.setup.models import ( KNOWN_MODELS, _model_supported, @@ -118,7 +122,7 @@ def _installed_mlx_whisper_model() -> dict | None: if not ( asr_backend._mps_available() - and asr_backend._probe_available(asr_backend.MLXWhisperBackend) + and asr_installed(asr_backend.MLXWhisperBackend, asr_backend._DEEP_IMPORT_BROKEN) ): return None repo_id = asr_backend.mlx_whisper_model_id() @@ -159,23 +163,13 @@ def _preset_asr_models(active_engine: str | None) -> list[tuple[str, dict]]: def _faster_whisper_backend() -> str | None: + """Choose an installed in-process or isolated faster-whisper backend.""" from services import asr_backend - if asr_backend._probe_available(asr_backend.FasterWhisperBackend): + if asr_installed(asr_backend.FasterWhisperBackend, asr_backend._DEEP_IMPORT_BROKEN): return "faster-whisper" - row = next( - ( - item - for item in asr_backend.list_backends() - if item["id"] == "faster-whisper-isolated" - ), - None, - ) - return ( - "faster-whisper-isolated" - if row and row.get("available") and row.get("routing_status") != "unavailable" - else None - ) + cls = asr_backend._REGISTRY["faster-whisper-isolated"] + return "faster-whisper-isolated" if asr_installed(cls, asr_backend._DEEP_IMPORT_BROKEN) else None def _dictation_supports_locale(spec, language: str | None) -> bool: @@ -215,14 +209,16 @@ def _installed_dictation_models() -> list: def _activate_asr_model(tier: str) -> dict | None: + """Apply a ready ASR tier only while the captured user selection is unchanged.""" from core import prefs from services import asr_backend + snapshot = selection_snapshot("asr") if os.environ.get("OMNIVOICE_ASR_BACKEND") or prefs.is_env_shadowed( "ASR_MODEL_FASTER" ): return None - active = asr_backend.active_backend_id() + active = active_asr_selection() if active not in TIERED_ASR_ENGINES: return None choice = _tier_choice( @@ -231,75 +227,90 @@ def _activate_asr_model(tier: str) -> dict | None: if choice is None: return None backend_id, model = choice - repo_id = str(model["repo_id"]) - if backend_id != "mlx-whisper" and asr_backend.faster_whisper_model_id() != repo_id: - asr_backend.select_faster_whisper_model(repo_id) - if asr_backend.active_backend_id() != backend_id: - prefs.set_("asr_backend", backend_id) - return {"engine": backend_id, "model": repo_id} + if not selection_ready("asr", backend_id): + return None + with unchanged_selection(snapshot) as unchanged: + if not unchanged: + return None + repo_id = str(model["repo_id"]) + if backend_id != "mlx-whisper" and asr_backend.faster_whisper_model_id() != repo_id: + asr_backend.select_faster_whisper_model(repo_id) + if active != backend_id: + prefs.set_("asr_backend", backend_id) + return {"engine": backend_id, "model": repo_id} def _activate_dictation_model(tier: str) -> dict | None: + """Apply a ready dictation tier without overwriting concurrent user choices.""" from core import prefs - from services import asr_backend, sherpa_dictation + from services import asr_backend + snapshot = selection_snapshot("dictation") if os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL"): return None - available, _ = sherpa_dictation.sherpa_available() - if not available: + if not package_available("sherpa_onnx"): return None model = _tier_choice( _installed_dictation_models(), tier, size=lambda item: item.size_gb ) if model is None: return None - if prefs.get("dictation.model_id") != model.id: - prefs.set_("dictation.model_id", model.id) - asr_backend._capture_backend = None - asr_backend._capture_backend_key = None - return {"engine": model.kind, "model": model.id} + if not selection_ready("dictation", model.kind): + return None + with unchanged_selection(snapshot) as unchanged: + if not unchanged: + return None + if prefs.get("dictation.model_id") != model.id: + prefs.set_("dictation.model_id", model.id) + asr_backend._capture_backend = None + asr_backend._capture_backend_key = None + return {"engine": model.kind, "model": model.id} def _activate_translation_model(tier: str) -> dict | None: + """Select a ready local tier while preserving usable explicit providers.""" from core import prefs - from services import translation_engines + snapshot = selection_snapshot("translation") current = str(prefs.get("translation_backend", "argos")) # Keep a usable explicitly chosen network provider. A stale provider whose # package/key disappeared must not strand Dubbing while an installed local # translator is ready. - if current not in {"argos", "nllb"} and translation_engines.is_ready(current): + if current not in {"argos", "nllb"} and selection_ready("translation", current): return None target = str(_PERFORMANCE_TARGETS["translation"][tier]["engine"]) - if not translation_engines.is_ready(target): + if not selection_ready("translation", target): target = next( ( candidate for candidate in ("argos", "nllb") - if translation_engines.is_ready(candidate) + if selection_ready("translation", candidate) ), "", ) if not target: return None - if current != target: - prefs.set_("translation_backend", target) - return { - "engine": target, - "model": "facebook/nllb-200-distilled-600M" if target == "nllb" else target, - } + with unchanged_selection(snapshot) as unchanged: + if not unchanged: + return None + if current != target: + prefs.set_("translation_backend", target) + return { + "engine": target, + "model": "facebook/nllb-200-distilled-600M" if target == "nllb" else target, + } def _installed_selectable_families() -> set[str]: - from services import sherpa_dictation, translation_engines + """List model families with installed candidates eligible for tier selection.""" + from services import translation_engines families: set[str] = set() if _installed_asr_models(): families.add("asr") - sherpa_available, _ = sherpa_dictation.sherpa_available() - if sherpa_available and _installed_dictation_models(): + if package_available("sherpa_onnx") and _installed_dictation_models(): families.add("dictation") - if translation_engines.is_ready("nllb"): + if translation_engines.is_ready("nllb", import_check=False): families.add("translation") return families @@ -321,7 +332,9 @@ def _activate_installed_models(tier: str, family: str | None) -> dict[str, dict] +@inventory_only def profile_state(choice: str | None = None) -> dict: + """Describe active selections, effective tiers, installed choices, and resource plans.""" from core import prefs from services import asr_backend, diarization_runtime from services.sherpa_dictation import get_spec as dictation_spec @@ -334,7 +347,7 @@ def profile_state(choice: str | None = None) -> dict: ) diarisation_choices = diarization_runtime.installed_backends() tts_engine = active_tts() - asr_engine = asr_backend.active_backend_id() + asr_engine = active_asr_selection() translation_engine = str(prefs.get("translation_backend", "argos")) active_engines = { @@ -460,6 +473,7 @@ def requested_tier(family: str) -> str | None: return tier if tier in _PERFORMANCE_TIERS else None +@inventory_only def activate_maximum_capacity_models(family: str | None = None) -> dict: """Select the strongest already-installed compatible local models. @@ -470,6 +484,7 @@ def activate_maximum_capacity_models(family: str | None = None) -> dict: return _activate_installed_models("max", family) +@inventory_only def activate_performance_tier(tier: str, family: str | None = None) -> dict: """Apply installed-only model/runtime selections implied by a preset.""" if family is None: @@ -500,6 +515,9 @@ def _activate_budgeted_profile(tier: str) -> dict: from core import prefs from services import asr_backend, diarization_runtime, tts_backend + with prefs._MUTATE_LOCK: + snapshots = {family: selection_snapshot(family) for family in _PERFORMANCE_FAMILIES} + profile_snapshot = selection_snapshot("profile") state = profile_state(tier) activated = {} for family, entry in state["plan"]["families"].items(): @@ -507,30 +525,37 @@ def _activate_budgeted_profile(tier: str) -> dict: if selected is None or entry["reason"] == "kept": continue engine, model = selected["engine"], selected["model"] - if family == "tts": - if tts_backend.active_backend_id() != engine: - # Normal next-use switching releases the outgoing engine. - prefs.set_("tts_backend", engine) - elif family == "asr": - if engine != "mlx-whisper" and asr_backend.faster_whisper_model_id() != model: - asr_backend.select_faster_whisper_model(model) - prefs.set_("asr_backend", engine) - elif family == "dictation": - if prefs.get("dictation.model_id") != model: - prefs.set_("dictation.model_id", model) - asr_backend._capture_backend = None - asr_backend._capture_backend_key = None - elif family == "translation": - prefs.set_("translation_backend", engine) - elif family == "diarisation": - if diarization_runtime.selected_backend() != engine: - diarization_runtime.select_backend(engine) - if engine == diarization_runtime.SORTFORMER: - from services import model_manager - model_manager.unload_diarization_pipeline() - activated[family] = selected + if not selection_ready(family, engine): + continue + with unchanged_selection(snapshots[family]) as unchanged: + if not unchanged: + continue + if family == "tts": + if tts_backend.active_backend_id() != engine: + # Normal next-use switching releases the outgoing engine. + prefs.set_("tts_backend", engine) + elif family == "asr": + if engine != "mlx-whisper" and asr_backend.faster_whisper_model_id() != model: + asr_backend.select_faster_whisper_model(model) + prefs.set_("asr_backend", engine) + elif family == "dictation": + if prefs.get("dictation.model_id") != model: + prefs.set_("dictation.model_id", model) + asr_backend._capture_backend = None + asr_backend._capture_backend_key = None + elif family == "translation": + prefs.set_("translation_backend", engine) + elif family == "diarisation": + if diarization_runtime.selected_backend() != engine: + diarization_runtime.select_backend(engine) + if engine == diarization_runtime.SORTFORMER: + from services import model_manager + model_manager.unload_diarization_pipeline() + activated[family] = selected # Audio hot paths read the applied result, without probing model inventory. - prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {"resolved": state["effective"]}) + with unchanged_selection(profile_snapshot) as unchanged: + if unchanged: + prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {"resolved": state["effective"]}) return activated diff --git a/backend/services/performance_readiness.py b/backend/services/performance_readiness.py new file mode 100644 index 0000000000..59725f0aa5 --- /dev/null +++ b/backend/services/performance_readiness.py @@ -0,0 +1,64 @@ +"""Package inventory for presets; actual engine imports remain load-time checks.""" +from contextvars import ContextVar +from functools import wraps +from importlib import metadata, util +from pathlib import Path + +_INVENTORY = ContextVar("performance_inventory_only", default=False) + + +def inventory_only(function): + """Limit nested availability queries to metadata during one inventory call.""" + @wraps(function) + def wrapped(*args, **kwargs): + """Restore the caller's probe mode even when inventory fails.""" + token = _INVENTORY.set(True) + try: + return function(*args, **kwargs) + finally: + _INVENTORY.reset(token) + return wrapped + + +def inventory_active(): + """Whether this execution context is collecting installed model choices.""" + return _INVENTORY.get() + + +def package_available(name): + """Top-level lookup never executes a model package's __init__.""" + try: + return util.find_spec(name.split(".")[0]) is not None + except (ImportError, ValueError): + try: + metadata.version(name) + return True + except metadata.PackageNotFoundError: + return False + + +def tts_installed(engine): + """Check the package for a planner-managed TTS engine without importing it.""" + package = "omnivoice" if engine.startswith("omnivoice") else "kittentts" if engine == "kittentts" else None + return bool(package and package_available(package)) + + +def asr_installed(cls, broken=()): + """Honor known failures and pipeline gates using package inventory alone.""" + engine = cls.id + if engine in broken: + return False + packages = {"mlx-whisper": ("mlx_whisper",), "pytorch-whisper": ("transformers",), + "whisperx": ("whisperx", "ctranslate2"), + "faster-whisper": ("faster_whisper", "ctranslate2"), + "faster-whisper-isolated": ("faster_whisper", "ctranslate2")} + if engine not in packages or not all(package_available(name) for name in packages[engine]): + return False + if engine == "whisperx": + from core.windows_rocm.pipelines import whisperx_error + + if whisperx_error(): + return False + if engine == "faster-whisper-isolated": + return (Path(__file__).parents[1] / "engines/_asr_sidecar/main.py").is_file() + return True diff --git a/backend/services/performance_selection.py b/backend/services/performance_selection.py new file mode 100644 index 0000000000..6a72d09afb --- /dev/null +++ b/backend/services/performance_selection.py @@ -0,0 +1,42 @@ +"""Compare preset plans with current user selections before applying them.""" +from contextlib import contextmanager +from copy import deepcopy +import os + +from core import prefs + +_PREFS = { + "asr": ("asr_backend", "asr_model_faster"), + "tts": ("tts_backend",), + "dictation": ("dictation.model_id", "dictation.enabled", "dictation.silent_models"), + "translation": ("translation_backend",), + "diarisation": ("diarization_backend",), +} +_ENV = { + "asr": ("OMNIVOICE_ASR_BACKEND", "ASR_MODEL_FASTER", "ASR_MODEL_FW", "ASR_MODEL"), + "tts": ("OMNIVOICE_TTS_BACKEND", "OMNIVOICE_MODEL", "OMNIVOICE_KITTENTTS_MODEL"), + "dictation": ("OMNIVOICE_SHERPA_ASR_MODEL", "OMNIVOICE_SHERPA_ASR_PROVIDER"), + "diarisation": ("OMNIVOICE_DIARIZATION_BACKEND", "OMNIVOICE_DIARIZATION_MODEL"), +} + + +def _read(family): + """Copy only the selections that determine this family's planned result.""" + keys = (*_PREFS.get(family, ()), "compute_device", "performance_profile") + environment = (*_ENV.get(family, ()), "OMNIVOICE_DEVICE", "OMNIVOICE_TORCH_VARIANT") + return (deepcopy({key: prefs.get(key) for key in keys}), + {key: os.environ.get(key) for key in environment}) + + +def selection_snapshot(family): + """Capture a coherent choice before inventory or child readiness can wait.""" + with prefs._MUTATE_LOCK: + return family, _read(family) + + +@contextmanager +def unchanged_selection(snapshot): + """Hold the preferences writer lock only for revalidation and application.""" + family, previous = snapshot + with prefs._MUTATE_LOCK: + yield _read(family) == previous diff --git a/backend/services/pytorch_whisper_options.py b/backend/services/pytorch_whisper_options.py new file mode 100644 index 0000000000..b5859e90de --- /dev/null +++ b/backend/services/pytorch_whisper_options.py @@ -0,0 +1,28 @@ +"""Whisper requests must respect the loaded checkpoint's actual language support.""" + + +def dependency_status(): + """Catalogue threads must not execute Transformers' heavy lazy imports.""" + from services.performance_readiness import package_available + + installed = package_available("transformers") + return (True, "ready") if installed else (False, "transformers is not installed") + + +def english_only(pipe): + """Recognize an English-only checkpoint from its loaded generation config.""" + model = getattr(pipe, "model", None) + configuration = getattr(model, "generation_config", None) + return getattr(configuration, "is_multilingual", None) is False + + +def generation_options(pipe, options): + """English-only generation rejects even redundant English/task arguments.""" + if not english_only(pipe): + return options + if options.get("task", "transcribe") != "transcribe": + raise ValueError("English-only Whisper checkpoints cannot translate; select a multilingual checkpoint") + language = options.get("language") + if language is not None and str(language).strip().lower() not in {"en", "english", "<|en|>"}: + raise ValueError("English-only Whisper checkpoints only transcribe English; select a multilingual checkpoint") + return {key: value for key, value in options.items() if key not in {"language", "task"}} diff --git a/changelog.d/3712.added.md b/changelog.d/3712.added.md new file mode 100644 index 0000000000..17632592dc --- /dev/null +++ b/changelog.d/3712.added.md @@ -0,0 +1 @@ +Native Windows ROCm setup supports RDNA 2 RX 6000 GPUs with verified Torch and HIP CTranslate2 speech, isolated ASR and safer package repair (#3712) — thanks @dutch2005! diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index fc87dc80fb..bc30e2deb5 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -36,7 +36,7 @@ VoiceStudio/ │ │ └── setup/ setup-wizard endpoints and model acquisition │ ├── core/ shared infrastructure: configuration, persistence, job/event plumbing, │ │ auth and CSRF, safe paths, analytics consent, version and diagnostics -│ ├── services/ 116 modules of business logic spanning speech, dubbing, DSP, GPU dispatch, +│ ├── services/ 121 modules of business logic spanning speech, dubbing, DSP, GPU dispatch, │ │ engine selection and loaded-model ownership │ ├── engines/ per-engine adapters: audiocpp, confucius4, cosyvoice_gguf, │ │ cosyvoice_subprocess, dots_tts, indextts, moss_tts_nano_subprocess, diff --git a/docs/install/windows-rocm.md b/docs/install/windows-rocm.md index 9b024d3dde..909dcba46d 100644 --- a/docs/install/windows-rocm.md +++ b/docs/install/windows-rocm.md @@ -12,13 +12,15 @@ models. ## Prerequisites and exact scope -- Windows 11 x64 and a GPU listed in AMD's **7.2.1** +- Windows 11 x64 and a GPU. The existing ROCm 7.2.1 recipe targets AMD's [Radeon matrix](https://rocm.docs.amd.com/projects/radeon-ryzen/en/docs-7.2.1/docs/compatibility/compatibilityrad/windows/windows_compatibility.html) - or [Ryzen matrix](https://rocm.docs.amd.com/projects/radeon-ryzen/en/docs-7.2.1/docs/compatibility/compatibilityryz/windows/windows_compatibility.html). - A matching `gfx` architecture alone does not establish support for an unlisted product. -- AMD's required [26.2.2 graphics driver](https://www.amd.com/en/resources/support-articles/release-notes/RN-RAD-WIN-26-2-2.html), - installed by you, following the - [AMD installation guide](https://rocm.docs.amd.com/projects/radeon-ryzen/en/docs-7.2.1/docs/install/installryz/windows/install-pytorch.html). + and [Ryzen matrix](https://rocm.docs.amd.com/projects/radeon-ryzen/en/docs-7.2.1/docs/compatibility/compatibilityryz/windows/windows_compatibility.html). + The ROCm 10 recipe targets RDNA 2 `gfx1030`, `gfx1031` and `gfx1032`; + it has been tested on an RX 6700 XT (`gfx1031`). The presence of a native + wheel does not imply AMD certification of every product or driver. +- An AMD graphics driver compatible with the selected GPU and ROCm runtime. + For the 7.2.1 recipe, follow AMD's + [driver and installation guide](https://rocm.docs.amd.com/projects/radeon-ryzen/en/docs-7.2.1/docs/install/installryz/windows/install-pytorch.html). - Microsoft Visual C++ 2015–2022 x64 Redistributable. Source setup checks this prerequisite locally and stops with manual installation guidance when missing. Source and installed Electron users must install or verify the [official Microsoft x64 Redistributable](https://aka.ms/vs/17/release/vc_redist.x64.exe) @@ -26,21 +28,46 @@ models. - Space for the SDK, Python environment and temporary package downloads; optional engine environments and model weights require additional space. -The recipe selects x64 Python **3.12**, ROCm **7.2.1**, Torch/audio -**2.9.1+rocm7.2.1**, vision **0.24.1+rocm7.2.1**, and the exact HIP -CTranslate2 **4.8.2** archive/wheel/DLL hashes. Windows ARM64, Windows 10 and -unlisted GPUs, including RX 6700 XT, are outside this recipe's supported scope. -Do not use Linux ROCm wheel indexes or an `HSA_OVERRIDE_GFX_VERSION` remap to -claim support for those systems. A standalone HIP SDK on PATH is insufficient. +Both recipes select x64 Python **3.12** and exact HIP CTranslate2 +archive/wheel/DLL hashes. RDNA 3, 3.5 and 4 retain CTranslate2 **4.8.2**, ROCm **7.2.1**, +Torch/audio **2.9.1+rocm7.2.1** and vision **0.24.1+rocm7.2.1**. RDNA 2 +uses the expanded-target CTranslate2 **4.8.4** dependency below, ROCm **10.0.0**, Torch **2.13.0+rocm10.0.0**, audio +**2.11.0.2+rocm10.0.0** and vision **0.28.0+rocm10.0.0**. Setup identifies +the GPU by PCI ID and installs only that architecture's Torch, vision and SDK +device packages, with native kernels and no `HSA_OVERRIDE_GFX_VERSION`. Mixed +AMD architectures are refused because selecting the wrong device package +would be unsafe. Windows ARM64, Windows 10 and other architectures remain +outside this recipe's scope. A standalone HIP SDK on PATH is insufficient. + +Repairs require an available AMD GPU inventory and refuse to replace an +installed ROCm 7 family with ROCm 10, or vice versa. Use a fresh runtime for an +intentional family change; a failed detection must never downgrade a working install. ### What setup downloads, and how it is checked -Setup downloads only from AMD's `repo.radeon.com` (the SDK and PyTorch wheels) -and the OpenNMT/CTranslate2 GitHub release (the HIP CTranslate2 archive). Every -one of those files has a SHA-256 in the recipe, and setup refuses a file whose +Setup downloads from AMD's `repo.radeon.com` for the existing recipe or +`stable.repo.amd.com` for RDNA 2. The existing recipe uses the OpenNMT/CTranslate2 +release archive. RDNA 2 uses the [expanded-target dependency mirror](https://github.com/dutch2005/VoiceStudio/releases/tag/ct2-rocm-4.8.4-9a7a074), +which preserves the unmodified bytes from OpenNMT maintainer `jordimas`'s +[build at `9a7a074`](https://github.com/jordimas/CTranslate2/actions/runs/41108554536). +The mirror is public and requires no GitHub account or token; it avoids depending +on an expiring Actions artifact. This is an experimental dependency, not a new +official OpenNMT or VoiceStudio application release. +The additional AMD index and mirror are the proposed source-policy exception +in [PR #3712](https://github.com/debpalash/VoiceStudio/pull/3712). Upstream +maintainer acceptance is required before merging that exception; the local +integration and hardware tests do not establish upstream approval. +Every file has a SHA-256 in the recipe, and setup refuses a file whose bytes differ. Every other package keeps the version your environment already has, so the overlay never quietly upgrades dependencies. +Official CTranslate2 4.8.2 and 4.8.4 Windows wheels omit `gfx1031`; both can +detect an RX 6700 XT and load a model yet fail at inference with +`hipErrorInvalidImage`. The expanded build contains the missing kernels. +Changing the version number alone or bypassing binary checks is insufficient. +See the [hardware report](https://github.com/OpenNMT/CTranslate2/issues/2137#issuecomment-6572396042) +for provenance, hashes and the limits of the short-clip tests. + Before this release, `OMNIVOICE_TORCH_VARIANT=rocm` was ignored on Windows. It now selects this stack. If it is already set on a machine with a working CUDA or CPU runtime, the app shows the setup screen instead of switching on its @@ -97,7 +124,9 @@ Electron's setup log, using **that environment's interpreter**: Both commands without speech inputs perform **model-free** checks: exact ABI and package versions, native audio/vision imports, the pinned HIP CT2 DLL, supported GPU architecture, synchronized Torch arithmetic with checked results, and CT2 -GPU/float16 discovery. A timeout, crash or CPU fallback fails readiness. This +GPU/float16 discovery plus a checked native GPU FP32 → FP16 → FP32 conversion. +The conversion executes CT2's own kernels and rejects discovery-only successes. +A timeout, crash, wrong result or CPU fallback fails readiness. This proves neither speech inference nor WhisperX/MIOpen compatibility. For a separate **local CT2 speech** check, supply an already-installed local CT2 @@ -116,19 +145,40 @@ inference are reported as different scopes. | Path | Windows ROCm behavior | | --- | --- | -| Main Torch TTS and voice-cloning audio input | Uses the matched main environment; local soundfile compatibility preserves explicit soundfile input on torchaudio 2.9. Model/engine kernels still need real hardware testing. | -| Faster-whisper, including the crash-isolated worker | GPU is admitted only for the pinned HIP binary plus bounded local verification; CPU overrides remain effective. Auto selection can prefer this verified basic-ASR path. | -| WhisperX, pyannote speaker detection, wav2vec2 GPU alignment | GPU execution is gated before imports/cached pipelines: MIOpen Unicode-path and SDK-header compatibility is unverified. Choose CPU in Settings → Performance (`OMNIVOICE_DEVICE=cpu`) to use these pipelines. | +| Main Torch TTS and voice-cloning audio input | Uses the matched main environment. RX 6700 XT tests pass native OmniVoice TTS and synthetic-reference cloning on GPU/fp16 at 16 steps, plus CPU/fp32 TTS at four steps with four Torch threads. Reference audio retains explicit soundfile compatibility on torchaudio 2.9 and later. Speaker similarity and other engines need their own tests. | +| PyTorch Whisper | The matched opt-in Windows Torch recipe advertises ROCm independently of CT2. Both transcription APIs pass on RX 6700 XT GPU/fp16 and CPU/fp32 using tiny.en; runtime evidence reports the actual pipeline device and dtype. English-only checkpoints accept English transcription without pretending to support translation. | +| Faster-whisper, including the crash-isolated worker | GPU is admitted only for the pinned HIP binary plus bounded local verification. ROCm 10 runs normal faster-whisper in a separate inference process for both GPU and CPU, preserving CPU selection and memory fallback without mixing Torch's LLVM OpenMP with CT2's Intel OpenMP. Model, VAD, timestamps and decode/request options are preserved. | +| WhisperX transcription | ROCm 10 refuses this in-process CT2 path before imports or cached pipeline use because its CPU execution/fallback mixes incompatible OpenMP runtimes. Use faster-whisper or PyTorch Whisper. The ROCm 7 recipe retains its existing CPU path; GPU execution remains unverified. | +| pyannote speaker detection, wav2vec2 alignment | GPU execution is gated before imports/cached pipelines: MIOpen Unicode-path and SDK-header compatibility is unverified. CPU selection retains the existing Torch pipeline; it is separate from WhisperX's CT2 transcription and is not certified by the basic-ASR tests. | | Unavailable forced alignment | Keeps the transcript and native word timestamps; alignment precision can be lower. It does not discard requested speech or translation content. | | Optional engine sidecars | Separate environments and compatibility. Main readiness never certifies them. | +These are short offline API tests, not a full hardware/quality certification. +The 16-step CPU-only OmniVoice test exceeded a five-minute test deadline; +four-step CPU speech completed and transcribed correctly, but was much slower +than GPU execution. Model loading and AudioSeal startup are separate from warm +generation. Startup performance inventory checks package/model metadata rather +than importing Transformers or every installed model runtime into the API. +Before saving changed engine selections, presets validate runtime readiness in +a bounded offline child process. Failed checks preserve the existing selection; +automatic ASR selection uses the same isolated validation and retains fallback. +These model-free checks do not replace normal model load-time validation. +ROCm 10 capture selection also verifies faster-whisper's native import in an +offline child before choosing it, including CPU mode. A package whose DLLs +cannot load therefore leaves PyTorch Whisper available as the capture fallback; +CPU checks do not run GPU verification or load CT2 into the API process. +Readiness results, including failures and timeouts, are reused for at most +30 seconds to avoid repeated native imports. Package, device, interpreter or +parent import-failure changes invalidate that result immediately. + IndexTTS keeps its Python 3.10/3.11 requirement; CosyVoice keeps Torch 2.7; -VoxCPM2 keeps Torch 2.11. The cp312/Torch 2.9 recipe is **not** applied to these +VoxCPM2 keeps Torch 2.11. Neither cp312 main-environment recipe is applied to these environments. Managed Windows sidecars retain their own CPU availability and upstream requirements. Known non-HIP IndexTTS metadata keeps CPU fallback on all platforms. Only explicit Windows ROCm opt-in treats unknown sidecar metadata conservatively; default unknown-metadata behavior stays the same across platforms. -Existing manually installed HIP sidecars remain detectable; +On RDNA 2, IndexTTS, dots.tts and MOSS-TTS-v1.5 avoid bf16 GEMM because it can +crash rocBLAS. Existing manually installed HIP sidecars remain detectable; that detection alone is not an inference certification. No engine is certified GPU-ready just because the main environment passed. Native/Vulkan engines have their own installation and capability checks. @@ -153,12 +203,13 @@ bun run setup:api Use the command for your installation type, not both. `OMNIVOICE_DEVICE=cpu` alone selects CPU **execution** in the existing HIP environment; it does not -replace packages. Remove a manually set HSA override when troubleshooting an -otherwise supported GPU. Do not delete voices, projects, model caches or +replace packages. Remove a manually set HSA override before running the RDNA 2 recipe; it uses +native kernels and verification rejects an override. Do not delete voices, projects, model caches or optional-engine environments to repair the main runtime. Only explicitly started installation downloads AMD packages and the pinned -OpenNMT/CTranslate2 GitHub release, with normal dependency resolution through +OpenNMT/CTranslate2 GitHub release or the pinned expanded-target dependency mirror, +with normal dependency resolution through package indexes. Verification and local smoke are offline. This installation work incorporates focused contributions from [#2600](https://github.com/debpalash/VoiceStudio/pull/2600) and @@ -166,8 +217,8 @@ work incorporates focused contributions from their whole draft or mix the separate recipe proposed in [#2908](https://github.com/debpalash/VoiceStudio/pull/2908). -Fresh supported AMD hardware inference, packaged Windows execution and upstream -CI remain separate validation gates; a passing mocked test suite is not that evidence. +Packaged Windows execution and speech inference remain separate validation gates; +a passing model-free check does not certify an engine. Setup repairs only confirmed missing, mismatched or damaged packages, including native audio, vision and CTranslate2 imports with missing or corrupted wheel files. diff --git a/electron/src/main/runtime-project.ts b/electron/src/main/runtime-project.ts index 8cf029d9ec..6d6ed5b2fa 100644 --- a/electron/src/main/runtime-project.ts +++ b/electron/src/main/runtime-project.ts @@ -337,10 +337,11 @@ export async function runtimeDownloadHosts( ): Promise> { const lock = await readFile(join(bundle, 'uv.lock'), 'utf8'); const rocm = resolveTorchVariant(process.env, platform, arch).variant === 'rocm'; - // Windows ROCm installs from AMD's repository (the recipe), not PyTorch's index. + // Windows selects one of two AMD sources after inspecting the GPU; both + // are possible here because the setup progress map is built before that probe. const rocmHost = platform === 'win32' - ? 'repo.radeon.com' + ? 'repo.radeon.com / stable.repo.amd.com' : new URL(process.env.OMNIVOICE_TORCH_INDEX || ROCM_TORCH_INDEX).host; const hosts = setupDownloadHosts(lock, { pytorchIndex: cpuTorchApplies(platform, arch), diff --git a/electron/src/main/runtime-windows-rocm-abi.ts b/electron/src/main/runtime-windows-rocm-abi.ts new file mode 100644 index 0000000000..253ad7b4d5 --- /dev/null +++ b/electron/src/main/runtime-windows-rocm-abi.ts @@ -0,0 +1,3 @@ +/** Shared ABI guard, importable without Electron launch dependencies. */ +export const WINDOWS_ROCM_ABI_PROBE = + 'from backend.core.windows_rocm.recipe import validate_target; validate_target()'; diff --git a/electron/src/main/runtime-windows-rocm-optin.test.ts b/electron/src/main/runtime-windows-rocm-optin.test.ts index b5a207b4df..01bc0874c4 100644 --- a/electron/src/main/runtime-windows-rocm-optin.test.ts +++ b/electron/src/main/runtime-windows-rocm-optin.test.ts @@ -31,7 +31,7 @@ it('shows the Windows ROCm torch source as AMD, not the Linux PyTorch index', as process.env.OMNIVOICE_TORCH_VARIANT = 'rocm'; try { const hosts = await runtimeDownloadHosts(join(__dirname, '..', '..', '..'), 'win32', 'x64'); - expect(hosts.get('torch')).toBe('repo.radeon.com'); + expect(hosts.get('torch')).toBe('repo.radeon.com / stable.repo.amd.com'); const linux = await runtimeDownloadHosts(join(__dirname, '..', '..', '..'), 'linux', 'x64'); expect(linux.get('torch')).toBe('download.pytorch.org'); } finally { diff --git a/electron/src/main/runtime-windows-rocm.ts b/electron/src/main/runtime-windows-rocm.ts index 2d8371579f..8fdc52ebd6 100644 --- a/electron/src/main/runtime-windows-rocm.ts +++ b/electron/src/main/runtime-windows-rocm.ts @@ -3,13 +3,12 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { runtimeLaunchRefusal } from './runtime-launch-refusal'; +export { WINDOWS_ROCM_ABI_PROBE } from './runtime-windows-rocm-abi'; export const WINDOWS_ROCM_PYTHON_REQUEST = 'cpython-3.12-windows-x86_64'; export const WINDOWS_ROCM_MODULE = 'backend.core.windows_rocm.install'; export const WINDOWS_ROCM_IMPORT_PREFIX = 'from backend.core.windows_rocm.runtime import initialize_dll_search; initialize_dll_search(); '; -export const WINDOWS_ROCM_ABI_PROBE = - 'from backend.core.windows_rocm.recipe import validate_target; validate_target()'; export function windowsRocmSelected( variant = process.env.OMNIVOICE_TORCH_VARIANT?.trim().toLowerCase(), diff --git a/scripts/windows-rocm-recipe.json b/scripts/windows-rocm-recipe.json index f7d82386d4..7c9ef61276 100644 --- a/scripts/windows-rocm-recipe.json +++ b/scripts/windows-rocm-recipe.json @@ -23,5 +23,112 @@ "wheel_sha256": "d5b9b0d34b23584638fc087bf99a85b0ba9917b84860d5fbddcc73b0aedd4fd4", "binary_sha256": "37fdba6e52f87422562f7068c7bc19e342d57bf957a161da6d9fdf6d47f93317", "required_dlls": ["hipblas.dll", "amdhip64_7.dll"] + }, + "rdna2": { + "ctranslate2": { + "version": "4.8.4", + "archive_url": "https://github.com/dutch2005/VoiceStudio/releases/download/ct2-rocm-4.8.4-9a7a074/rocm-python-wheels-Windows.zip", + "archive_size": 194373107, + "archive_sha256": "430fbf0aa7c4cddcaf17e432c18f937b1f80f3c73353b89f90e528f5b8125827", + "wheel_member": "ctranslate2-4.8.4-cp312-cp312-win_amd64.whl", + "wheel_sha256": "15f057c53c2e4a9911504995ff783bd8db494c177701aba64dc467379bb8e452", + "binary_sha256": "dd4092e29b5dcf4337c2f6e8d4f9aece377b19c46ff11a8d7ec1a76608b150fa", + "required_dlls": [ + "hipblas.dll", + "amdhip64_7.dll" + ] + }, + "torch": { + "find_links": "https://stable.repo.amd.com/rocm/whl-next/", + "packages": [ + "torch==2.13.0+rocm10.0.0", + "torchaudio==2.11.0.2+rocm10.0.0", + "torchvision==0.28.0+rocm10.0.0" + ], + "wheel_files": [ + "torch/torch-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl", + "torchaudio/torchaudio-2.11.0.2%2Brocm10.0.0-cp312-cp312-win_amd64.whl", + "torchvision/torchvision-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl" + ], + "wheel_sha256": { + "torch/torch-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "4ff21da558065d6c28773f9053eadec4897c579e1f34f623db3825755198b164", + "torchaudio/torchaudio-2.11.0.2%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "25c1857654c9232474bd88da29f91979482d243a2b096e0b22381c4e6bd65cf5", + "torchvision/torchvision-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "ecf20b27fba1b8e189c029be01b984f3a582053c67e5e35f27e3b83a3202b819" + }, + "hip_prefix": "7.15." + }, + "runtime": { + "packages": [ + "rocm==10.0.0", + "rocm-bootstrap==0.3.0", + "rocm-sdk-core==10.0.0", + "rocm-sdk-libraries==10.0.0" + ], + "wheel_files": [ + "rocm/rocm-10.0.0.tar.gz", + "rocm-bootstrap/rocm_bootstrap-0.3.0-py3-none-any.whl", + "rocm-sdk-core/rocm_sdk_core-10.0.0-py3-none-win_amd64.whl", + "rocm-sdk-libraries/rocm_sdk_libraries-10.0.0-py3-none-win_amd64.whl" + ], + "wheel_sha256": { + "rocm/rocm-10.0.0.tar.gz": "65b5982249612a310135f5e061b9f28909e8ad718a1ddb3f1abba92298b7216b", + "rocm-bootstrap/rocm_bootstrap-0.3.0-py3-none-any.whl": "62d0160ecdd86f7132dbab9aec257cb2c96327df2592b0633aa6bb56399cc095", + "rocm-sdk-core/rocm_sdk_core-10.0.0-py3-none-win_amd64.whl": "066195cb0f7df02e6009facb17652dd992d13fae44d87652e5ab928d5b4f0851", + "rocm-sdk-libraries/rocm_sdk_libraries-10.0.0-py3-none-win_amd64.whl": "bfc7a1bd3b6050fb4d36d8ac2eea80d3afd70c5604a29142463d6cb890fc73bf" + } + }, + "devices": { + "gfx1030": { + "packages": [ + "rocm-sdk-device-gfx1030==10.0.0", + "amd-torch-device-gfx1030==2.13.0+rocm10.0.0", + "amd-torchvision-device-gfx1030==0.28.0+rocm10.0.0" + ], + "wheel_files": [ + "rocm-sdk-device-gfx1030/rocm_sdk_device_gfx1030-10.0.0-py3-none-win_amd64.whl", + "amd-torch-device-gfx1030/amd_torch_device_gfx1030-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl", + "amd-torchvision-device-gfx1030/amd_torchvision_device_gfx1030-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl" + ], + "wheel_sha256": { + "rocm-sdk-device-gfx1030/rocm_sdk_device_gfx1030-10.0.0-py3-none-win_amd64.whl": "19ada0359b499c19c1ecb1cba21b067fce89b4f0b0de6f0f7b76cbef85dcb85b", + "amd-torch-device-gfx1030/amd_torch_device_gfx1030-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "2408b4a3422b81763a55f9b4a3c1eb4f97b838d379b01fdea4b214c590b582ec", + "amd-torchvision-device-gfx1030/amd_torchvision_device_gfx1030-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "9039c172ff23d87a9fd694f48d7606ab13b0e3cd062d3b42fa4531520e86fe3c" + } + }, + "gfx1031": { + "packages": [ + "rocm-sdk-device-gfx1031==10.0.0", + "amd-torch-device-gfx1031==2.13.0+rocm10.0.0", + "amd-torchvision-device-gfx1031==0.28.0+rocm10.0.0" + ], + "wheel_files": [ + "rocm-sdk-device-gfx1031/rocm_sdk_device_gfx1031-10.0.0-py3-none-win_amd64.whl", + "amd-torch-device-gfx1031/amd_torch_device_gfx1031-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl", + "amd-torchvision-device-gfx1031/amd_torchvision_device_gfx1031-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl" + ], + "wheel_sha256": { + "rocm-sdk-device-gfx1031/rocm_sdk_device_gfx1031-10.0.0-py3-none-win_amd64.whl": "ab7cbfb41e1ada00fb7254c974f7386303cdd1139a170fb022e84e6f10d9f5f2", + "amd-torch-device-gfx1031/amd_torch_device_gfx1031-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "6a4d9a9a2edfafa8eac1378a8b20747f31e567129128dce78d52deeba6c326bd", + "amd-torchvision-device-gfx1031/amd_torchvision_device_gfx1031-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "ddcf26c0d35d2de88d6ca394d2d69251ba5d7016407bacf1e78455f78b30dda5" + } + }, + "gfx1032": { + "packages": [ + "rocm-sdk-device-gfx1032==10.0.0", + "amd-torch-device-gfx1032==2.13.0+rocm10.0.0", + "amd-torchvision-device-gfx1032==0.28.0+rocm10.0.0" + ], + "wheel_files": [ + "rocm-sdk-device-gfx1032/rocm_sdk_device_gfx1032-10.0.0-py3-none-win_amd64.whl", + "amd-torch-device-gfx1032/amd_torch_device_gfx1032-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl", + "amd-torchvision-device-gfx1032/amd_torchvision_device_gfx1032-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl" + ], + "wheel_sha256": { + "rocm-sdk-device-gfx1032/rocm_sdk_device_gfx1032-10.0.0-py3-none-win_amd64.whl": "5d0581e077cc1a482e70430539c63e60c4b4d7b9b82be0ac0bab6d62c3feb4de", + "amd-torch-device-gfx1032/amd_torch_device_gfx1032-2.13.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "c04b6e42b8b3b74f7f08fc20a478f1a929ffd532eaa8e77383149db05bf351a3", + "amd-torchvision-device-gfx1032/amd_torchvision_device_gfx1032-0.28.0%2Brocm10.0.0-cp312-cp312-win_amd64.whl": "2470d02b3c2e7448032c2eb686ae19b9ab6a4d9eb84514117c389751d9de0071" + } + } + } } } diff --git a/tests/test_dots_tts_accelerator_precision.py b/tests/test_dots_tts_accelerator_precision.py index c42ddb822c..4fb4c5f477 100644 --- a/tests/test_dots_tts_accelerator_precision.py +++ b/tests/test_dots_tts_accelerator_precision.py @@ -39,3 +39,24 @@ def test_precision_probe_failure_uses_safe_default_or_explicit_override(monkeypa monkeypatch.setenv("OMNIVOICE_DOTS_TTS_PRECISION", override) main._load_runtime(io.BytesIO()) assert loader.from_pretrained.call_args.kwargs["precision"] == (override or "float32") + +@pytest.mark.parametrize("override", [None, "bfloat16", " BF16 "]) +def test_rdna2_never_selects_bf16(monkeypatch, override): + """Keep RDNA 2 inference away from unsupported BF16 under every override.""" + from engines.dots_tts import main + + fake = SimpleNamespace( + version=SimpleNamespace(hip="7.15"), + cuda=SimpleNamespace(is_available=lambda: True, + get_device_properties=lambda i: SimpleNamespace(gcnArchName="gfx1031")), + ) + monkeypatch.setitem(sys.modules, "torch", fake) + loader = Mock() + monkeypatch.setitem(sys.modules, "dots_tts.runtime", SimpleNamespace(DotsTtsRuntime=loader)) + monkeypatch.setattr(main, "_runtime", None) + if override: + monkeypatch.setenv("OMNIVOICE_DOTS_TTS_PRECISION", override) + else: + monkeypatch.delenv("OMNIVOICE_DOTS_TTS_PRECISION", raising=False) + main._load_runtime(io.BytesIO()) + assert loader.from_pretrained.call_args.kwargs["precision"] == "float32" diff --git a/tests/test_indextts_bf16_rocm_2372.py b/tests/test_indextts_bf16_rocm_2372.py index 454d247302..066822bd76 100644 --- a/tests/test_indextts_bf16_rocm_2372.py +++ b/tests/test_indextts_bf16_rocm_2372.py @@ -35,6 +35,7 @@ def _fake_torch(monkeypatch, *, hip, available=True, bf16=True): fake.cuda = types.SimpleNamespace( is_available=lambda: available, is_bf16_supported=lambda: bf16, + get_device_properties=lambda index: types.SimpleNamespace(gcnArchName="gfx1100"), ) fake.version = types.SimpleNamespace(hip=hip) monkeypatch.setitem(sys.modules, "torch", fake) @@ -209,3 +210,10 @@ def test_disabling_reduced_precision_short_circuits_the_probe(sidecar, monkeypat monkeypatch.setattr(sidecar, "_bf16_probe", _must_not_probe(sidecar)) kw = sidecar._model_init_kwargs("/nowhere", version="2.5", reduced_precision=False) assert kw["use_bf16"] is False + +@pytest.mark.parametrize("gfx", ["gfx1030", "gfx1031", "gfx1032"]) +def test_rdna2_never_enables_or_probes_bf16(sidecar, monkeypatch, gfx): + fake = _fake_torch(monkeypatch, hip="7.15") + fake.cuda.get_device_properties = lambda index: types.SimpleNamespace(gcnArchName=gfx) + monkeypatch.setattr(sidecar, "_bf16_probe", _must_not_probe(sidecar)) + assert sidecar._torch_bf16_supported() is False diff --git a/tests/test_moss_tts_v15.py b/tests/test_moss_tts_v15.py index 7152fbfd57..b8d22d79c3 100644 --- a/tests/test_moss_tts_v15.py +++ b/tests/test_moss_tts_v15.py @@ -444,3 +444,28 @@ def fake_run(argv, **kwargs): assert "resolver: no wheel for torchcodec" in message assert "non-CUDA" not in message assert "docs/engines/moss-tts-v15.md" in message + + +def test_rdna2_moss_uses_fp32(monkeypatch): + import io + from types import SimpleNamespace + from unittest.mock import Mock + from engines.moss_tts_v15 import main + + torch = SimpleNamespace( + version=SimpleNamespace(hip="7.15"), + accelerator=SimpleNamespace(current_accelerator=lambda **kw: SimpleNamespace(type="cuda")), + cuda=SimpleNamespace(get_device_properties=lambda i: SimpleNamespace(gcnArchName="gfx1032")), + bfloat16="bf16", float32="fp32", + ) + monkeypatch.setitem(sys.modules, "torch", torch) + processor = Mock() + processor.model_config.sampling_rate = 24000 + factory = Mock() + factory.from_pretrained.return_value.to.return_value = factory.from_pretrained.return_value + monkeypatch.setitem(sys.modules, "transformers", SimpleNamespace( + AutoModel=factory, AutoProcessor=SimpleNamespace(from_pretrained=lambda *a, **kw: processor))) + monkeypatch.setattr(main, "_snapshot_path", lambda *a: "local-fixture") + monkeypatch.setattr(main, "_state", None) + main._load_model(io.BytesIO()) + assert factory.from_pretrained.call_args.kwargs["torch_dtype"] == "fp32" diff --git a/tests/test_performance_activation_readiness.py b/tests/test_performance_activation_readiness.py new file mode 100644 index 0000000000..1dfcba33ab --- /dev/null +++ b/tests/test_performance_activation_readiness.py @@ -0,0 +1,182 @@ +"""Optimistic inventory must not become an unready persisted runtime choice.""" +import importlib + +import pytest + + +@pytest.fixture +def activation(monkeypatch): + profiles = importlib.import_module("services.performance_profiles") + asr = importlib.import_module("services.asr_backend") + prefs = importlib.import_module("core.prefs") + ready = importlib.import_module("services.performance_readiness") + values, models = {}, [] + monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False) + monkeypatch.setattr(prefs, "get", lambda key, default=None: values.get(key, default)) + monkeypatch.setattr(prefs, "set_", lambda key, value: values.__setitem__(key, value)) + monkeypatch.setattr(prefs, "is_env_shadowed", lambda key: False) + monkeypatch.setattr(ready, "package_available", lambda name: True) + monkeypatch.setattr(asr, "_DEEP_IMPORT_BROKEN", {}) + monkeypatch.setattr(asr, "_mps_available", lambda: False) + monkeypatch.setattr(asr, "_rocm_torch", lambda: False) + monkeypatch.setattr(asr, "_ctranslate2_cuda_ok", lambda: False) + monkeypatch.setattr(asr.WhisperXBackend, "is_available", classmethod(lambda cls: (False, "CT2 unavailable"))) + monkeypatch.setattr(asr.FasterWhisperBackend, "is_available", classmethod(lambda cls: (False, "CT2 unavailable"))) + monkeypatch.setattr(asr.PyTorchWhisperBackend, "is_available", classmethod(lambda cls: (True, "ready"))) + monkeypatch.setattr(profiles, "_installed_ct2_models", lambda: [ + {"repo_id": "Systran/faster-whisper-medium", "size_gb": 2}]) + monkeypatch.setattr(profiles, "_installed_mlx_whisper_model", lambda: None) + monkeypatch.setattr(asr, "faster_whisper_model_id", lambda: "missing/old") + monkeypatch.setattr(asr, "select_faster_whisper_model", models.append) + monkeypatch.setattr(profiles, "selection_ready", lambda *args: False, raising=False) + monkeypatch.setattr(profiles, "active_asr_selection", lambda: "pytorch-whisper", raising=False) + return profiles, asr, prefs, values, models + + +def test_unusable_ct2_cannot_replace_working_auto_pytorch_fallback(activation): + """Preserve a working PyTorch fallback when the tier candidate cannot load CT2.""" + profiles, asr, _prefs, values, models = activation + assert asr.active_backend_id() == "pytorch-whisper" + assert profiles.activate_performance_tier("balanced", "asr") == {} + assert not models and "asr_backend" not in values + assert asr.active_backend_id() == "pytorch-whisper" + + +def test_validated_ready_asr_selection_applies(activation, monkeypatch): + profiles, _asr, _prefs, values, models = activation + monkeypatch.setattr(profiles, "active_asr_selection", lambda: "whisperx") + monkeypatch.setattr(profiles, "selection_ready", lambda family, engine: True) + assert profiles.activate_performance_tier("balanced", "asr") == { + "asr": {"engine": "faster-whisper", "model": "Systran/faster-whisper-medium"}} + assert values["asr_backend"] == "faster-whisper" + assert models == ["Systran/faster-whisper-medium"] + + +def test_unready_global_choices_never_write_any_managed_engine(activation, monkeypatch): + """Reject every unready global candidate before changing managed preferences.""" + profiles, _asr, prefs, values, models = activation + choices = {"tts": ("omnivoice", "k2-fsa/OmniVoice"), + "asr": ("faster-whisper", "Systran/faster-whisper-medium"), + "dictation": ("offline-whisper", "sherpa-whisper-tiny"), + "translation": ("nllb", "facebook/nllb-200-distilled-600M")} + monkeypatch.setattr(profiles, "profile_state", lambda tier: { + "effective": {family: "balanced" for family in choices}, + "plan": {"families": {family: {"selection": {"engine": engine, "model": model}, + "reason": "installed"} + for family, (engine, model) in choices.items()}}}) + monkeypatch.setattr(prefs, "update_mapping", lambda *args: None) + assert profiles.activate_performance_tier("balanced") == {} + assert not values and not models + + +def test_explicit_user_engine_pin_is_preserved_without_child_checks(activation, monkeypatch): + profiles, _asr, _prefs, values, _models = activation + monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "pytorch-whisper") + monkeypatch.setattr(profiles, "selection_ready", lambda *args: pytest.fail("Pinned engine probed")) + assert profiles.activate_performance_tier("balanced", "asr") == {} + assert not values + + +@pytest.mark.parametrize("key,environment", [ + ("asr_backend", False), ("asr_model_faster", False), ("compute_device", False), + ("performance_profile", False), ("OMNIVOICE_ASR_BACKEND", True), + ("ASR_MODEL_FASTER", True), +]) +def test_direct_asr_keeps_choice_changed_during_readiness(activation, monkeypatch, key, environment): + """Preserve ASR preferences or overrides changed during the readiness probe.""" + profiles, _asr, _prefs, values, models = activation + monkeypatch.setattr(profiles, "active_asr_selection", lambda: "whisperx") + + def ready(*args): + """Simulate a user changing the selected preference or environment override.""" + if environment: + monkeypatch.setenv(key, "new-user-choice") + else: + values[key] = "new-user-choice" + return True + + monkeypatch.setattr(profiles, "selection_ready", ready) + assert profiles.activate_performance_tier("balanced", "asr") == {} + assert not models and values.get("asr_backend") != "faster-whisper" + + +def test_direct_asr_snapshot_precedes_slow_active_resolution(activation, monkeypatch): + """Capture ASR choices before slow backend resolution can race a user change.""" + profiles, _asr, _prefs, values, models = activation + + def resolve(): + values["asr_backend"] = "pytorch-whisper" + return "whisperx" + + monkeypatch.setattr(profiles, "active_asr_selection", resolve) + monkeypatch.setattr(profiles, "selection_ready", lambda *args: True) + assert profiles.activate_performance_tier("balanced", "asr") == {} + assert values["asr_backend"] == "pytorch-whisper" and not models + + +@pytest.mark.parametrize("family,key", [ + ("tts", "tts_backend"), ("asr", "asr_backend"), + ("dictation", "dictation.model_id"), ("translation", "translation_backend"), +]) +def test_global_plan_keeps_manual_choice_made_during_probe(activation, monkeypatch, family, key): + """Preserve manual family selections changed while probing a global plan.""" + profiles, _asr, prefs, values, models = activation + monkeypatch.setattr(profiles, "profile_state", lambda tier: { + "effective": {family: "balanced"}, "plan": {"families": { + family: {"selection": {"engine": "preset-engine", "model": "preset-model"}, + "reason": "installed"}}}}) + monkeypatch.setattr(prefs, "update_mapping", lambda *args: None) + + def ready(*args): + values[key] = "new-user-choice" + return True + + monkeypatch.setattr(profiles, "selection_ready", ready) + assert profiles.activate_performance_tier("balanced") == {} + assert values[key] == "new-user-choice" and not models + + +def test_global_snapshot_precedes_profile_state_and_preserves_new_tier(activation, monkeypatch): + """Preserve engine and tier changes made while computing the global plan.""" + profiles, _asr, prefs, values, models = activation + mappings = [] + + def plan(tier): + values.update(asr_backend="pytorch-whisper", performance_profile={"global": "fast"}) + return {"effective": {"asr": "balanced"}, "plan": {"families": { + "asr": {"selection": {"engine": "faster-whisper", "model": "preset-model"}, + "reason": "installed"}}}} + + monkeypatch.setattr(profiles, "profile_state", plan) + monkeypatch.setattr(profiles, "selection_ready", lambda *args: True) + monkeypatch.setattr(prefs, "update_mapping", lambda *args: mappings.append(args)) + assert profiles.activate_performance_tier("balanced") == {} + assert values["asr_backend"] == "pytorch-whisper" and not models and not mappings + + +def test_readiness_wait_releases_preferences_lock_but_application_holds_it(activation, monkeypatch): + """Allow writes during readiness checks and serialize actual preference changes.""" + import threading + + profiles, asr, prefs, _values, _models = activation + observations = [] + monkeypatch.setattr(profiles, "active_asr_selection", lambda: "whisperx") + + def ready(*args): + """Probe whether another thread can acquire the preference lock during readiness.""" + def writer(): + """Record lock availability and release it immediately when acquired.""" + acquired = prefs._MUTATE_LOCK.acquire(timeout=1) + observations.append(acquired) + if acquired: + prefs._MUTATE_LOCK.release() + thread = threading.Thread(target=writer) + thread.start() + thread.join(timeout=2) + return True + + monkeypatch.setattr(profiles, "selection_ready", ready) + monkeypatch.setattr(asr, "select_faster_whisper_model", lambda repo: + observations.append(prefs._MUTATE_LOCK._is_owned())) + assert profiles.activate_performance_tier("balanced", "asr") + assert observations == [True, True] diff --git a/tests/test_performance_inventory.py b/tests/test_performance_inventory.py index bfa0f92b15..01e876f477 100644 --- a/tests/test_performance_inventory.py +++ b/tests/test_performance_inventory.py @@ -15,13 +15,20 @@ def current_modules(monkeypatch): profiles = importlib.import_module("services.performance_profiles") inventory = importlib.import_module("services.performance_inventory") monkeypatch.setattr(importlib.import_module("services"), "performance_profiles", profiles) + monkeypatch.setattr(profiles, "active_asr_selection", lambda: + importlib.import_module("services.asr_backend").active_backend_id()) + monkeypatch.setattr(profiles, "selection_ready", lambda *args: True) @pytest.fixture def local_inventory(monkeypatch): + """Provide a deterministic installed-model inventory with no environment overrides.""" from api.routers.setup import models from core import prefs from services import tts_backend, engine_routing, sherpa_dictation, translation_engines, diarization_runtime + readiness = importlib.import_module("services.performance_readiness") + monkeypatch.setattr(readiness, "package_available", lambda name: True) + monkeypatch.setattr(inventory, "package_available", readiness.package_available) for key in ("OMNIVOICE_TTS_BACKEND", "OMNIVOICE_MODEL", "OMNIVOICE_KITTENTTS_MODEL", "OMNIVOICE_ASR_BACKEND", "OMNIVOICE_SHERPA_ASR_MODEL", "OMNIVOICE_DIARIZATION_BACKEND"): @@ -91,10 +98,8 @@ def test_incomplete_omni_is_not_selected(local_inventory, monkeypatch): assert entry["reason"] == "installed" -def test_unavailable_omni_runtime_is_not_selected(local_inventory, monkeypatch): - from services import tts_backend - monkeypatch.setattr(tts_backend, "get_backend_class", lambda engine: SimpleNamespace( - is_available=lambda: (engine == "kittentts", "missing"))) +def test_missing_omni_package_is_not_selected(local_inventory, monkeypatch): + monkeypatch.setattr(inventory, "tts_installed", lambda engine: engine == "kittentts") assert inventory.profile_plan("max", {}, local_inventory)["families"]["tts"]["selection"]["engine"] == "kittentts" diff --git a/tests/test_performance_inventory_imports.py b/tests/test_performance_inventory_imports.py new file mode 100644 index 0000000000..c6d08b6f34 --- /dev/null +++ b/tests/test_performance_inventory_imports.py @@ -0,0 +1,134 @@ +"""Performance reconciliation inventories installs rather than importing model runtimes.""" +import importlib +from types import SimpleNamespace + +import pytest + + +def test_selectable_translation_inventory_never_imports_runtime(monkeypatch): + profiles = importlib.import_module("services.performance_profiles") + translation = importlib.import_module("services.translation_engines") + sherpa = importlib.import_module("services.sherpa_dictation") + monkeypatch.setattr(profiles, "_installed_asr_models", lambda: []) + monkeypatch.setattr(sherpa, "sherpa_available", lambda: (False, "absent")) + monkeypatch.setattr(profiles, "_installed_dictation_models", lambda: []) + def installed(engine, *, import_check=True): + assert import_check is False, "Inventory imported translation runtime" + return engine == "nllb" + monkeypatch.setattr(translation, "is_ready", installed) + assert profiles._installed_selectable_families() == {"translation"} + + +def test_family_translation_selection_validates_in_child_without_parent_import(monkeypatch): + profiles = importlib.import_module("services.performance_profiles") + translation = importlib.import_module("services.translation_engines") + prefs = importlib.import_module("core.prefs") + monkeypatch.setattr(prefs, "get", lambda key, default=None: default) + def installed(engine, *, import_check=True): + assert import_check is False, "Preset selection imported translation runtime" + return engine == "nllb" + monkeypatch.setattr(translation, "is_ready", installed) + monkeypatch.setattr(profiles, "selection_ready", lambda family, engine: engine == "nllb") + assert profiles._activate_translation_model("quality")["engine"] == "nllb" + + +def test_faster_whisper_inventory_never_enumerates_or_imports_all_engines(monkeypatch): + profiles = importlib.import_module("services.performance_profiles") + asr = importlib.import_module("services.asr_backend") + from importlib import util + monkeypatch.setattr(util, "find_spec", lambda name: SimpleNamespace(name=name)) + monkeypatch.setattr(asr, "_DEEP_IMPORT_BROKEN", {}) + monkeypatch.setattr(asr, "_probe_available", lambda cls: pytest.fail("Inventory probed a runtime")) + monkeypatch.setattr(asr, "list_backends", lambda: pytest.fail("Inventory enumerated all runtime engines")) + assert profiles._faster_whisper_backend() == "faster-whisper" + + +def test_cached_tts_inventory_does_not_call_model_runtime_availability(monkeypatch): + """Build cached TTS inventory without importing or probing model runtimes.""" + inventory = importlib.import_module("services.performance_inventory") + profiles = importlib.import_module("services.performance_profiles") + models = importlib.import_module("api.routers.setup.models") + tts = importlib.import_module("services.tts_backend") + routing = importlib.import_module("services.engine_routing") + prefs = importlib.import_module("core.prefs") + caps = importlib.import_module("core.device_caps") + translation = importlib.import_module("services.translation_engines") + sherpa = importlib.import_module("services.sherpa_dictation") + diarization = importlib.import_module("services.diarization_runtime") + from importlib import util + monkeypatch.setattr(util, "find_spec", lambda name: SimpleNamespace(name=name)) + for key in ("OMNIVOICE_MODEL", "OMNIVOICE_TTS_BACKEND", "OMNIVOICE_KITTENTTS_MODEL", "OMNIVOICE_ASR_BACKEND"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(prefs, "get", lambda key, default=None: default) + monkeypatch.setattr(prefs, "is_env_shadowed", lambda key: False) + monkeypatch.setattr(inventory, "hardware_snapshot", lambda: { + "device": "cpu", "ram_gb": 32, "vram_gb": 0, "cpu_threads": 8, "probe_ok": True}) + monkeypatch.setattr(caps, "detect_host_caps", lambda: SimpleNamespace(family="cpu")) + monkeypatch.setattr(models, "_model_supported", lambda model: True) + monkeypatch.setattr(models, "is_cached", lambda repo: True) + monkeypatch.setattr(models, "cache_is_complete", lambda model: True) + monkeypatch.setattr(tts, "get_backend_class", lambda engine: SimpleNamespace( + is_available=lambda: pytest.fail("TTS inventory imported OmniVoice"))) + monkeypatch.setattr(routing, "runtime_compute_profile", lambda *args: { + "effective_device": "cpu", "routing_status": "cpu_only"}) + monkeypatch.setattr(profiles, "_preset_asr_models", lambda engine: []) + monkeypatch.setattr(profiles, "_faster_whisper_backend", lambda: None) + monkeypatch.setattr(profiles, "_installed_dictation_models", lambda: []) + monkeypatch.setattr(sherpa, "sherpa_available", lambda: (False, "absent")) + monkeypatch.setattr(translation, "is_ready", lambda engine, **kw: False) + monkeypatch.setattr(diarization, "installed_backends", lambda: set()) + selections = {name: {"engine": "inactive", "model": None} for name in profiles._PERFORMANCE_FAMILIES} + selections["tts"] = {"engine": "omnivoice", "model": "k2-fsa/OmniVoice"} + plan = inventory.profile_plan("max", {}, selections) + assert plan["families"]["tts"]["selection"]["engine"] == "omnivoice" + + +def test_profile_auto_selection_does_not_execute_asr_availability_imports(monkeypatch): + """Resolve profile auto selections without executing heavy ASR availability imports.""" + profiles = importlib.import_module("services.performance_profiles") + inventory = importlib.import_module("services.performance_inventory") + asr = importlib.import_module("services.asr_backend") + tts = importlib.import_module("services.tts_backend") + diarization = importlib.import_module("services.diarization_runtime") + prefs = importlib.import_module("core.prefs") + from importlib import util + monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False) + monkeypatch.setattr(prefs, "get", lambda key, default=None: default) + monkeypatch.setattr(util, "find_spec", lambda name: SimpleNamespace(name=name)) + monkeypatch.setattr(asr, "_DEEP_IMPORT_BROKEN", {}) + monkeypatch.setattr(asr, "_mps_available", lambda: False) + monkeypatch.setattr(asr, "_rocm_torch", lambda: False) + monkeypatch.setattr(asr, "_ctranslate2_cuda_ok", lambda: False) + monkeypatch.setattr(asr.WhisperXBackend, "is_available", classmethod( + lambda cls: pytest.fail("Profile auto detection imported WhisperX/Transformers"))) + monkeypatch.setattr(tts, "active_backend_id", lambda: "custom") + monkeypatch.setattr(diarization, "installed_backends", lambda: set()) + monkeypatch.setattr(profiles, "_installed_selectable_families", lambda: set()) + monkeypatch.setattr(profiles, "active_asr_selection", lambda: "pytorch-whisper") + monkeypatch.setattr(inventory, "profile_plan", lambda *args: {"families": { + family: {"tier": "balanced", "selection": None, "reason": "kept"} + for family in profiles._PERFORMANCE_FAMILIES}}) + assert profiles.profile_state()["selections"]["asr"]["engine"] == "pytorch-whisper" + + +def test_inventory_context_never_suppresses_real_runtime_preflight(monkeypatch): + readiness = importlib.import_module("services.performance_readiness") + asr = importlib.import_module("services.asr_backend") + monkeypatch.setattr(readiness, "package_available", lambda name: True) + monkeypatch.setattr(asr, "_DEEP_IMPORT_BROKEN", {}) + calls = [] + class BrokenPipeline: + id = "pytorch-whisper" + @classmethod + def is_available(cls): + calls.append("real-runtime-probe") + return False, "broken transitive dependency" + @readiness.inventory_only + def inventory_failure(): + assert asr._probe_available(BrokenPipeline) + assert not calls + raise ValueError("inventory stopped") + with pytest.raises(ValueError, match="inventory stopped"): + inventory_failure() + assert not asr._probe_available(BrokenPipeline) + assert calls == ["real-runtime-probe"] diff --git a/tests/test_performance_probe.py b/tests/test_performance_probe.py new file mode 100644 index 0000000000..75fcc4b655 --- /dev/null +++ b/tests/test_performance_probe.py @@ -0,0 +1,157 @@ +"""Preset validation contains native imports and fails closed on worker failure.""" +import builtins +import importlib +import json +from pathlib import Path +import subprocess + +import pytest + + +@pytest.fixture +def probe(monkeypatch): + module = importlib.import_module("services.performance_probe") + monkeypatch.setattr(module, "_CACHE", {}) + monkeypatch.setattr(module, "_PACKAGES", ()) + return module + + +def fake_child(monkeypatch, probe, *, payload=None, code=0, timeout=False): + """Install a deterministic child transport without loading native engines.""" + observed = {} + + class Child: + killed = False + + def wait(self, timeout=None): + """Simulate child exit or a timeout until the fake child is killed.""" + observed["wait_timeout"] = timeout + if observed.get("timeout") and not self.killed: + raise subprocess.TimeoutExpired("probe", timeout) + return code + + def kill(self): + self.killed = True + + child = Child() + + def spawn(argv, **kwargs): + """Record child launch options and optionally write its readiness result.""" + observed.update(argv=argv, kwargs=kwargs, timeout=timeout) + if payload is not None: + Path(argv[-1]).write_text(payload, encoding="utf-8") + return child + + monkeypatch.setattr(probe, "spawn_owned", spawn) + return child, observed + + +def result(**changes): + """Return one matching worker response with optional corrupt fields.""" + data = dict(schema=1, family="asr", requested="faster-whisper", + engine="faster-whisper", ready=True) + return json.dumps(dict(data, **changes)) + + +@pytest.mark.parametrize("payload,code,timeout", [ + (None, 0, False), ("not JSON", 0, False), ("[]", 0, False), + (result(ready="true"), 0, False), (result(family="tts"), 0, False), + (result(requested="other"), 0, False), (result(engine="other"), 0, False), + (result(), 3, False), (result(), 0, True), (" " * 4097, 0, False), +]) +def test_invalid_or_failed_worker_cannot_authorize_selection(monkeypatch, probe, payload, code, timeout): + child, _ = fake_child(monkeypatch, probe, payload=payload, code=code, timeout=timeout) + assert not probe.selection_ready("asr", "faster-whisper") + assert child.killed == timeout + monkeypatch.setattr(probe, "spawn_owned", lambda *a, **kw: pytest.fail("Failed probe respawned")) + assert probe._query("asr", "faster-whisper") is None + + +def test_ready_worker_is_offline_bounded_and_does_not_import_parent_runtime(monkeypatch, probe): + """Keep readiness imports in an offline child with a bounded wait.""" + _, observed = fake_child(monkeypatch, probe, payload=result()) + original = builtins.__import__ + + def guarded(name, *args, **kwargs): + """Reject heavy runtime imports in the parent while delegating other imports.""" + if name.split(".")[0] in {"transformers", "faster_whisper", "ctranslate2", "omnivoice"}: + pytest.fail("Runtime imported in parent") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded) + assert probe.selection_ready("asr", "faster-whisper") + kwargs = observed["kwargs"] + assert kwargs["env"]["HF_HUB_OFFLINE"] == "1" + assert kwargs["env"]["TRANSFORMERS_OFFLINE"] == "1" + assert kwargs["stdin"] == subprocess.DEVNULL + assert observed["argv"][1].endswith("_performance_probe.py") + assert observed["timeout"] is False + assert observed["wait_timeout"] == 120 + + +def test_explicit_engine_does_not_probe_and_unresolved_auto_is_honest(monkeypatch, probe): + prefs = importlib.import_module("core.prefs") + monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False) + monkeypatch.setattr(prefs, "get", lambda key: "pytorch-whisper") + monkeypatch.setattr(probe, "_query", lambda *args: pytest.fail("Explicit pin probed")) + assert probe.active_asr_selection() == "pytorch-whisper" + monkeypatch.setattr(prefs, "get", lambda key: None) + monkeypatch.setattr(probe, "_query", lambda *args: None) + assert probe.active_asr_selection() == "auto" + + +@pytest.mark.parametrize("ready", [True, False, None]) +def test_readiness_cache_preserves_results_and_expires(monkeypatch, probe, ready): + """Bound repeated success, rejection and timeout checks without hiding later repair.""" + child, _ = fake_child(monkeypatch, probe, payload=result(ready=ready), timeout=ready is None) + clock = [100.0] + monkeypatch.setattr(probe.time, "monotonic", lambda: clock[0]) + first = probe._query("asr", "faster-whisper") + assert first == (None if ready is None else json.loads(result(ready=ready))) + assert child.killed == (ready is None) + monkeypatch.setattr(probe, "spawn_owned", lambda *a, **kw: pytest.fail("Warm probe respawned")) + assert probe._query("asr", "faster-whisper") == first + if first is not None: + first["ready"] = not ready + assert probe.selection_ready("asr", "faster-whisper") == ready + clock[0] += 31 + fake_child(monkeypatch, probe, payload=result()) + assert probe.selection_ready("asr", "faster-whisper") + + +@pytest.mark.parametrize("ready", [True, False, None]) +@pytest.mark.parametrize("change", ["device", "package", "broken", "interpreter"]) +def test_cache_matches_device_packages_and_parent_failures(monkeypatch, probe, tmp_path, change, ready): + """Invalidate cached readiness when device, packages, failures, or Python change.""" + from types import SimpleNamespace + + version = ["1.0"] + asr = importlib.import_module("services.asr_backend") + monkeypatch.setattr(asr, "_DEEP_IMPORT_BROKEN", {}) + monkeypatch.setattr(probe, "_PACKAGES", ("torch",)) + monkeypatch.setattr(probe.metadata, "distribution", lambda name: SimpleNamespace( + version=version[0], locate_file=lambda name: tmp_path)) + fake_child(monkeypatch, probe, payload=result(ready=ready), timeout=ready is None) + assert probe.selection_ready("asr", "faster-whisper") == bool(ready) + if change == "device": + monkeypatch.setenv("OMNIVOICE_DEVICE", "cpu") + elif change == "package": + version[0] = "2.0" + elif change == "broken": + asr._DEEP_IMPORT_BROKEN["faster-whisper"] = "unloadable" + else: + monkeypatch.setattr(probe.sys, "executable", "different-python") + _, observed = fake_child(monkeypatch, probe, payload=result(ready=not ready)) + assert probe.selection_ready("asr", "faster-whisper") == (not ready) + if change == "broken": + assert json.loads(observed["argv"][-2]) == ["faster-whisper"] + + +def test_failed_probe_cache_is_bounded(monkeypatch, probe): + """Retire negative identities instead of growing memory indefinitely.""" + fake_child(monkeypatch, probe, code=3) + for index in range(33): + assert probe._query("asr", f"missing-{index}") is None + fake_child(monkeypatch, probe, payload=result()) + assert probe.selection_ready("asr", "faster-whisper") + assert len(probe._CACHE) <= 32 diff --git a/tests/test_performance_probe_worker.py b/tests/test_performance_probe_worker.py new file mode 100644 index 0000000000..3c790c9355 --- /dev/null +++ b/tests/test_performance_probe_worker.py @@ -0,0 +1,120 @@ +"""Native preflight runs in the worker without loading model weights.""" +import importlib +import sys +from types import SimpleNamespace + +import pytest + + +@pytest.fixture(autouse=True) +def no_native_startup(monkeypatch): + runtime = importlib.import_module("core.windows_rocm.runtime") + asr = importlib.import_module("services.asr_backend") + monkeypatch.setattr(runtime, "initialize_dll_search", lambda: None) + monkeypatch.setattr(asr, "_DEEP_IMPORT_BROKEN", {}) + + +def test_worker_ct2_import_failure_disqualifies_optimistic_availability(monkeypatch): + """Reject optimistic ASR readiness when the actual CT2 import fails.""" + worker = importlib.import_module("services._performance_probe") + asr = importlib.import_module("services.asr_backend") + ct2 = importlib.import_module("core.windows_rocm.ct2") + monkeypatch.setattr(asr, "_probe_available", lambda cls: True) + monkeypatch.setattr(ct2, "prepare_import", lambda: None) + original = importlib.import_module + + def unavailable(name, *args): + """Simulate a native faster-whisper import failure without blocking other modules.""" + if name == "faster_whisper": + raise ImportError("native CT2 missing") + return original(name, *args) + + monkeypatch.setattr(importlib, "import_module", unavailable) + with pytest.raises(ImportError, match="native CT2 missing"): + worker.check("asr", "faster-whisper") + + +def test_auto_worker_retains_hardware_order_but_skips_unloadable_ct2(monkeypatch): + """Retain auto backend priority while skipping candidates that fail deep readiness.""" + worker = importlib.import_module("services._performance_probe") + asr = importlib.import_module("services.asr_backend") + monkeypatch.setattr(asr, "_probe_available", lambda cls: True) + monkeypatch.setattr(worker, "_asr_ready", lambda asr, cls, probe=None: cls.id == "pytorch-whisper") + + def detect(): + """Return the first available backend in the simulated hardware priority order.""" + for cls in (asr.WhisperXBackend, asr.FasterWhisperBackend, asr.PyTorchWhisperBackend): + if asr._probe_available(cls): + return cls.id + + monkeypatch.setattr(asr, "_auto_detect", detect) + assert worker.check("asr", "auto") == ("pytorch-whisper", True) + + +def test_other_managed_families_use_deep_preflight_only(monkeypatch): + """Use family-specific deep checks for TTS, translation, and dictation readiness.""" + worker = importlib.import_module("services._performance_probe") + calls = [] + monkeypatch.setitem(sys.modules, "services.tts_backend", SimpleNamespace( + get_backend_class=lambda engine: SimpleNamespace(is_available=lambda: (True, "ready")))) + monkeypatch.setitem(sys.modules, "services.translation_engines", SimpleNamespace( + is_ready=lambda engine, **kwargs: calls.append(kwargs) or False)) + monkeypatch.setitem(sys.modules, "services.sherpa_dictation", SimpleNamespace( + sherpa_available=lambda: (False, "bad native library"))) + assert worker.check("tts", "omnivoice") == ("omnivoice", True) + assert worker.check("translation", "nllb") == ("nllb", False) + assert worker.check("dictation", "offline-transducer") == ("offline-transducer", False) + assert calls == [{"import_check": True}] + + +def test_auto_fallback_is_unresolved_when_its_package_is_not_ready(monkeypatch): + worker = importlib.import_module("services._performance_probe") + asr = importlib.import_module("services.asr_backend") + monkeypatch.setattr(asr, "_auto_detect", lambda: "pytorch-whisper") + monkeypatch.setattr(asr, "_probe_available", lambda cls: False) + assert worker.check("asr", "auto") == ("pytorch-whisper", False) + + +def test_worker_retains_parent_deep_failure_exclusions(monkeypatch): + worker = importlib.import_module("services._performance_probe") + asr = importlib.import_module("services.asr_backend") + monkeypatch.setattr(asr.FasterWhisperBackend, "is_available", classmethod(lambda cls: (True, "ready"))) + assert worker.check("asr", "faster-whisper", ("faster-whisper",))[1] is False + + +def test_dll_search_precedes_tts_runtime_import(monkeypatch): + """Initialize DLL search paths before importing the TTS runtime.""" + import builtins + + worker = importlib.import_module("services._performance_probe") + runtime = importlib.import_module("core.windows_rocm.runtime") + initialized = [] + monkeypatch.setattr(runtime, "initialize_dll_search", lambda: initialized.append(True)) + original = builtins.__import__ + + def ordered(name, *args, **kwargs): + """Assert DLL search initialization precedes TTS import and delegate the import.""" + if name == "services.tts_backend": + assert initialized + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", ordered) + monkeypatch.setitem(sys.modules, "services.tts_backend", SimpleNamespace( + get_backend_class=lambda engine: SimpleNamespace(is_available=lambda: (True, "ready")))) + assert worker.check("tts", "omnivoice") == ("omnivoice", True) + + +@pytest.mark.parametrize("requested", ["faster-whisper-isolated", "auto"]) +def test_fresh_lazy_registry_resolves_isolated_readiness(monkeypatch, requested): + """Resolve an isolated backend from an initially unloaded lazy registry.""" + worker = importlib.import_module("services._performance_probe") + asr = importlib.import_module("services.asr_backend") + isolated = SimpleNamespace(id="faster-whisper-isolated") + registry = asr._LazyASRRegistry({}) + registry._LAZY = {isolated.id: lambda: isolated} + assert isolated.id in registry and not dict.__contains__(registry, isolated.id) + monkeypatch.setattr(asr, "_REGISTRY", registry) + monkeypatch.setattr(asr, "_auto_detect", lambda: isolated.id) + monkeypatch.setattr(worker, "_asr_ready", lambda _asr, cls, probe=None: cls is isolated) + assert worker.check("asr", requested) == (isolated.id, True) + assert dict.__contains__(registry, isolated.id) diff --git a/tests/test_performance_profiles.py b/tests/test_performance_profiles.py index 12e1fbc752..66138b2c94 100644 --- a/tests/test_performance_profiles.py +++ b/tests/test_performance_profiles.py @@ -10,7 +10,14 @@ @pytest.fixture(autouse=True) def no_metal_whisper(monkeypatch): + """Remove host-specific MLX choices and provide deterministic family readiness.""" + from services import asr_backend, translation_engines + monkeypatch.setattr(profiles, "_installed_mlx_whisper_model", lambda: None) + monkeypatch.setattr(profiles, "active_asr_selection", lambda: asr_backend.active_backend_id()) + monkeypatch.setattr(profiles, "selection_ready", lambda family, engine: + translation_engines.is_ready(engine, import_check=False) + if family == "translation" else True) def test_diarisation_is_applicable_only_with_a_real_runtime_choice(monkeypatch): @@ -159,7 +166,7 @@ def test_preset_recovers_from_an_unavailable_network_translator(monkeypatch): monkeypatch.setattr( translation_engines, "is_ready", - lambda engine: engine in {"argos", "nllb"}, + lambda engine, **_kw: engine in {"argos", "nllb"}, ) result = profiles.activate_performance_tier("max", "translation") @@ -180,7 +187,7 @@ def test_preset_keeps_a_ready_explicit_network_translator(monkeypatch): writes = [] monkeypatch.setattr(prefs, "get", lambda key, default=None: "google") monkeypatch.setattr(prefs, "set_", lambda key, value: writes.append((key, value))) - monkeypatch.setattr(translation_engines, "is_ready", lambda engine: engine == "google") + monkeypatch.setattr(translation_engines, "is_ready", lambda engine, **_kw: engine == "google") assert profiles.activate_performance_tier("max", "translation") == {} assert writes == [] @@ -193,7 +200,7 @@ def test_preset_uses_the_remaining_ready_local_translator(monkeypatch): writes = [] monkeypatch.setattr(prefs, "get", lambda key, default=None: "google") monkeypatch.setattr(prefs, "set_", lambda key, value: writes.append((key, value))) - monkeypatch.setattr(translation_engines, "is_ready", lambda engine: engine == "argos") + monkeypatch.setattr(translation_engines, "is_ready", lambda engine, **_kw: engine == "argos") result = profiles.activate_performance_tier("max", "translation") @@ -271,8 +278,9 @@ def test_tier_applies_to_the_omnivoice_sidecar(monkeypatch): @pytest.fixture def metal_host(monkeypatch): from api.routers.setup import models - from services import asr_backend + from services import asr_backend, performance_readiness + monkeypatch.setattr(performance_readiness, "package_available", lambda name: True) monkeypatch.delenv("ASR_MODEL", raising=False) monkeypatch.setattr(asr_backend, "_mps_available", lambda: True) monkeypatch.setattr(asr_backend, "_probe_available", lambda cls: True) diff --git a/tests/test_performance_selection.py b/tests/test_performance_selection.py new file mode 100644 index 0000000000..542e020906 --- /dev/null +++ b/tests/test_performance_selection.py @@ -0,0 +1,47 @@ +"""Selection snapshots cover non-ASR presets and detached mutable values.""" +import importlib +from types import SimpleNamespace + +import pytest + + +@pytest.mark.parametrize("family,key,environment", [ + ("dictation", "dictation.model_id", False), + ("dictation", "OMNIVOICE_SHERPA_ASR_MODEL", True), + ("translation", "translation_backend", False), +]) +def test_direct_family_keeps_manual_choice_made_during_readiness(monkeypatch, family, key, environment): + """Preserve dictation or translation choices changed during readiness checks.""" + profiles = importlib.import_module("services.performance_profiles") + prefs = importlib.import_module("core.prefs") + values, writes = {}, [] + monkeypatch.delenv("OMNIVOICE_SHERPA_ASR_MODEL", raising=False) + monkeypatch.setattr(prefs, "get", lambda key, default=None: values.get(key, default)) + monkeypatch.setattr(prefs, "set_", lambda *args: writes.append(args)) + monkeypatch.setattr(profiles, "package_available", lambda name: True) + monkeypatch.setattr(profiles, "_installed_dictation_models", lambda: [ + SimpleNamespace(id="preset-model", kind="offline-transducer", size_gb=1)]) + + def ready(*args): + """Simulate a user changing a preference or environment selection during probing.""" + if environment: + monkeypatch.setenv(key, "new-user-choice") + else: + values[key] = "new-user-choice" + return True + + monkeypatch.setattr(profiles, "selection_ready", ready) + assert profiles.activate_performance_tier("balanced", family) == {} + assert not writes + + +def test_snapshot_copies_mutable_profile_without_retaining_its_live_dict(monkeypatch): + """Detect profile mutations through an independent selection snapshot.""" + selection = importlib.import_module("services.performance_selection") + prefs = importlib.import_module("core.prefs") + profile = {"global": "balanced"} + monkeypatch.setattr(prefs, "get", lambda key: profile if key == "performance_profile" else None) + snapshot = selection.selection_snapshot("asr") + profile["global"] = "fast" + with selection.unchanged_selection(snapshot) as unchanged: + assert not unchanged diff --git a/tests/test_pytorch_whisper_english_only.py b/tests/test_pytorch_whisper_english_only.py new file mode 100644 index 0000000000..f5c386307c --- /dev/null +++ b/tests/test_pytorch_whisper_english_only.py @@ -0,0 +1,126 @@ +"""English-only Whisper must accept English transcription without invented multilingual support.""" +import importlib +from types import SimpleNamespace + +import numpy as np +import pytest +import soundfile as sf + + +@pytest.fixture +def english(monkeypatch, tmp_path): + """Provide an English-only pipeline that rejects language and task options.""" + backend = importlib.import_module("services.asr_backend").PyTorchWhisperBackend + path = tmp_path / "speech.wav" + sf.write(path, np.zeros(1600, dtype=np.float32), 16000) + calls = [] + class Pipeline: + device = "cpu" + model = SimpleNamespace(generation_config=SimpleNamespace(is_multilingual=False), + name_or_path="custom-checkpoint", dtype="torch.float32") + def __call__(self, audio, **options): + """Record generation options and reject multilingual options for the fake model.""" + calls.append(options) + supplied = options.get("generate_kwargs", {}) + if any(key in supplied for key in ("language", "task")): + raise ValueError("Cannot specify task or language for an English-only model") + return {"text": "hello", "chunks": [{"text": "hello", "timestamp": (0., .1)}]} + pipe = Pipeline() + return backend(asr_pipe=pipe), path, calls + + +@pytest.mark.parametrize("language", [None, "en", "English", "<|en|>"]) +def test_english_transcription_removes_only_redundant_language_options(english, language): + backend, path, calls = english + result = backend.transcribe(str(path), language=language, word_timestamps=False) + assert result["text"] == "hello" + assert not {"language", "task", "is_multilingual"} & calls[0].get("generate_kwargs", {}).keys() + assert calls[0]["return_timestamps"] is True + + +@pytest.mark.parametrize("language,task", [("de", "transcribe"), ("en", "translate"), (None, "translate")]) +def test_english_only_rejects_unsupported_requests_before_inference(english, language, task): + backend, path, calls = english + with pytest.raises(ValueError, match="English-only"): + backend.transcribe(str(path), language=language, task=task) + assert not calls + + +@pytest.mark.parametrize("config", [SimpleNamespace(is_multilingual=True), SimpleNamespace()]) +def test_multilingual_and_unknown_configs_keep_request_options(english, config): + backend, path, calls = english + backend._pipe.model = SimpleNamespace(generation_config=config) + def run(audio, rate, words, options): + calls.append(options) + return {"text": "hallo"} + backend._run_with_oom_fallback = run + assert backend.transcribe(str(path), language="de", task="translate")["text"] == "hallo" + assert calls == [{"language": "de", "task": "translate"}] + + +def test_translation_capability_uses_loaded_english_only_config(english): + backend, _path, _calls = english + # The arbitrary local name must not override the actual generation config. + assert backend.supports_translation() is False + + +def test_execution_evidence_tracks_actual_pipeline_and_model(english): + backend, _path, _calls = english + evidence = importlib.import_module("services.engine_evidence") + backend._pipe.device = "cuda:0" + backend._pipe.model.dtype = "torch.float16" + snapshot = evidence.snapshot(engine_id="pytorch-whisper", engine_cls=type(backend), + instance=backend, routing={"routing_status": "accelerated"}, + caps=SimpleNamespace(family="cpu", device_name="")) + assert snapshot["actual_execution_device"] == "cuda:0" + assert snapshot["precision_or_quantization"] == "torch.float16" + backend._pipe.device = "cpu" + backend._pipe.model.dtype = "torch.float32" + snapshot = evidence.snapshot(engine_id="pytorch-whisper", engine_cls=type(backend), + instance=backend, routing={"routing_status": "cpu_only"}, + caps=SimpleNamespace(family="cpu", device_name="")) + assert snapshot["actual_execution_device"] == "cpu" + assert snapshot["precision_or_quantization"] == "torch.float32" + + +def test_availability_checks_metadata_without_executing_transformers(monkeypatch): + """Check installation metadata without executing the Transformers runtime.""" + import builtins + from importlib import util + backend = importlib.import_module("services.asr_backend").PyTorchWhisperBackend + original = builtins.__import__ + def no_transformers(name, *args, **kwargs): + """Reject Transformers runtime imports while allowing unrelated imports.""" + if name == "transformers" or name.startswith("transformers."): + pytest.fail("Background availability executed the heavy Transformers import") + return original(name, *args, **kwargs) + monkeypatch.setattr(util, "find_spec", lambda name: SimpleNamespace(name=name)) + monkeypatch.setattr(builtins, "__import__", no_transformers) + assert backend.is_available() == (True, "ready") + + +def test_availability_reports_missing_transformers(monkeypatch): + """Report a missing Transformers installation as unavailable.""" + from importlib import util + backend = importlib.import_module("services.asr_backend").PyTorchWhisperBackend + monkeypatch.setattr(util, "find_spec", lambda name: None) + available, detail = backend.is_available() + assert not available and "transformers" in detail + + +def test_deep_pipeline_import_failure_keeps_context_and_import_error_category(monkeypatch): + """Preserve transitive import errors without misdiagnosing an installed package.""" + import builtins + backend = importlib.import_module("services.asr_backend").PyTorchWhisperBackend() + original = builtins.__import__ + def broken(name, *args, **kwargs): + """Simulate a transitive Transformers import failure and delegate other imports.""" + if name == "transformers": + raise ImportError("missing transitive audio helper", name="transformers") + return original(name, *args, **kwargs) + monkeypatch.setattr(builtins, "__import__", broken) + with pytest.raises(ImportError, match="PyTorch Whisper.*Transformers pipeline") as error: + backend.ensure_loaded() + assert "missing transitive audio helper" in str(error.value) + assert isinstance(error.value.__cause__, ImportError) + assert error.value.name is None # do not falsely diagnose the installed package as absent diff --git a/tests/test_setup_windows_rocm.py b/tests/test_setup_windows_rocm.py index a3402910e4..78515ca2c1 100644 --- a/tests/test_setup_windows_rocm.py +++ b/tests/test_setup_windows_rocm.py @@ -56,8 +56,9 @@ def test_windows_prerequisites_mode_never_imports_or_installs_torch(monkeypatch) @pytest.mark.skipif(sys.platform != "win32" or sys.version_info[:2] == (3, 12), reason="needs non-cp312 Windows interpreter") def test_electron_reuse_probe_rejects_wrong_abi_with_python_optimization(): + """Keep ABI validation effective when optimized Python disables assertions.""" root = Path(__file__).parents[1] - module = (root / "electron/src/main/runtime-windows-rocm.ts").as_uri() + module = (root / "electron/src/main/runtime-windows-rocm-abi.ts").as_uri() script = f"const m = await import({json.dumps(module)}); console.log(m.WINDOWS_ROCM_ABI_PROBE);" code = subprocess.check_output(["node", "--input-type=module", "-e", script], text=True).strip() result = subprocess.run([sys.executable, "-O", "-c", code], cwd=root, diff --git a/tests/test_windows_rocm_asr_cache.py b/tests/test_windows_rocm_asr_cache.py new file mode 100644 index 0000000000..f6027fb6c4 --- /dev/null +++ b/tests/test_windows_rocm_asr_cache.py @@ -0,0 +1,31 @@ +"""The worker cache identity persists across requests and invalidates on selection changes.""" +import importlib +import sys +from types import SimpleNamespace + + +def test_worker_reuses_same_model_and_reloads_changed_model_or_device(monkeypatch): + runtime = importlib.import_module("engines._asr_sidecar.runtime") + isolation = importlib.import_module("core.windows_rocm.asr_isolation") + ct2 = importlib.import_module("core.windows_rocm.ct2") + execstack = importlib.import_module("core.execstack") + monkeypatch.setattr(runtime, "_model", None) + monkeypatch.setattr(runtime, "_identity", None) + monkeypatch.setattr(isolation, "isolation_required", lambda: True) + monkeypatch.setattr(isolation, "worker_device", lambda device: device) + monkeypatch.setattr(execstack, "ensure_ctranslate2_loadable", lambda: (True, "ready")) + monkeypatch.setattr(ct2, "prepare_import", lambda: None) + loads = [] + def model(source, **options): + loads.append((source, options["device"])) + return SimpleNamespace(source=source, device=options["device"]) + monkeypatch.setitem(sys.modules, "faster_whisper", SimpleNamespace(WhisperModel=model)) + first = {"model": "local-a", "device": "cpu", "vad_filter": True} + instance = runtime.get_model(first) + assert runtime.get_model(first) is instance + second = runtime.get_model({**first, "model": "local-b"}) + assert second is not instance + assert runtime.get_model({**first, "model": "local-b"}) is second + gpu = runtime.get_model({**first, "model": "local-b", "device": "cuda"}) + assert gpu is not second + assert loads == [("local-a", "cpu"), ("local-b", "cpu"), ("local-b", "cuda")] diff --git a/tests/test_windows_rocm_asr_import_child.py b/tests/test_windows_rocm_asr_import_child.py new file mode 100644 index 0000000000..6e71694d94 --- /dev/null +++ b/tests/test_windows_rocm_asr_import_child.py @@ -0,0 +1,44 @@ +"""Import-only readiness has no registry recursion, model load or GPU probe.""" +import importlib +from types import SimpleNamespace + +import pytest + + +def test_import_only_worker_prepares_dlls_without_calling_backend_availability(monkeypatch): + """Prove the direct child mode cannot recursively launch an availability probe.""" + worker = importlib.import_module('services._performance_probe') + runtime = importlib.import_module('core.windows_rocm.runtime') + asr = importlib.import_module('services.asr_backend') + initialized = [] + monkeypatch.setattr(runtime, 'initialize_dll_search', lambda: initialized.append(True)) + monkeypatch.setattr(asr, '_probe_available', lambda *args: pytest.fail('recursive probe')) + + def imported(name): + """Expose a constructor only after the native DLL paths are registered.""" + assert initialized and name == 'faster_whisper' + return SimpleNamespace(WhisperModel=lambda *args: pytest.fail('model loaded')) + + monkeypatch.setattr(importlib, 'import_module', imported) + assert worker.check('asr-import', 'faster-whisper') == ('faster-whisper', True) + + +@pytest.mark.parametrize('broken', [False, True]) +def test_real_child_import_result_preserves_parent_isolation(monkeypatch, tmp_path, broken): + """Transport an actual child's import success/failure without importing its package here.""" + probe = importlib.import_module('services.performance_probe') + monkeypatch.setattr(probe, '_CACHE', {}) + package = tmp_path / 'faster_whisper' + package.mkdir() + code = ( + 'import os, sys\n' + 'assert os.environ["HF_HUB_OFFLINE"] == "1"\n' + 'assert "torch" not in sys.modules\n' + + ('raise ImportError("simulated missing native DLL")\n' if broken else + 'class WhisperModel:\n def __init__(self): raise AssertionError("model loaded")\n') + ) + (package / '__init__.py').write_text(code) + monkeypatch.setenv('PYTHONPATH', str(tmp_path)) + result = probe._query('asr-import', 'faster-whisper') + assert result and result['ready'] is not broken + assert result['family'] == 'asr-import' and result['engine'] == 'faster-whisper' diff --git a/tests/test_windows_rocm_asr_isolation.py b/tests/test_windows_rocm_asr_isolation.py new file mode 100644 index 0000000000..dccaa16e87 --- /dev/null +++ b/tests/test_windows_rocm_asr_isolation.py @@ -0,0 +1,193 @@ +"""ROCm 10 ASR must execute outside Torch's process without losing its contract.""" +import importlib +import json +import sys +from types import SimpleNamespace + +import pytest + + +_CHILD = ''' +import builtins, json, os, sys +sys.path.insert(0, {backend!r}) +original_import = builtins.__import__ +def guarded(name, *args, **kwargs): + if name == "torch" or name.startswith("torch."): + raise AssertionError("Torch imported in CT2 inference process") + return original_import(name, *args, **kwargs) +builtins.__import__ = guarded +from types import SimpleNamespace +from core.windows_rocm import ct2 +def hip_ready(): + if os.environ.get("ASR_TEST_FORBID_GPU_PROBE") == "1": + raise AssertionError("CPU selection probed the GPU") + return os.environ.get("ASR_TEST_HIP_READY", "1") == "1" +ct2.hip_ct2_available = hip_ready +from core.windows_rocm import asr_isolation +# Parent platform/metadata monkeypatches do not cross this process boundary. +asr_isolation.isolation_required = lambda: True +class Model: + def __init__(self, source, device, compute_type): + if "oom" in source and device == "cuda": + raise RuntimeError("HIP out of memory") + self.source = source + self.model = SimpleNamespace(device=device, compute_type=compute_type) + def transcribe(self, audio, **options): + if audio == "crash.wav": os._exit(3) + text = json.dumps(dict(source=self.source, options=options)) + word = SimpleNamespace(word="hello", start=0., end=1., probability=.9) + segment = SimpleNamespace(text=text, start=0., end=1., words=[word]) + return iter([segment]), SimpleNamespace(language="en", language_probability=.98, duration=2.) +sys.modules["faster_whisper"] = SimpleNamespace(WhisperModel=Model) +from engines._asr_sidecar import main +raise SystemExit(main.main()) +''' + + +@pytest.fixture +def isolated(monkeypatch, tmp_path): + """Provide an isolated ASR backend backed by a deterministic local fake child.""" + from pathlib import Path + from importlib import metadata + ab = importlib.import_module("services.asr_backend") + transport = importlib.import_module("services.subprocess_asr") + base = importlib.import_module("services.subprocess_backend") + manager = importlib.import_module("services.model_manager") + endpoint = importlib.import_module("services.endpoint_race") + real_version = metadata.version + monkeypatch.setattr(metadata, "version", lambda name: ( + "2.13.0+rocm10.0.0" if name == "torch" else real_version(name))) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("OMNIVOICE_DEVICE", "cpu") + monkeypatch.setenv("OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S", "0") + monkeypatch.setattr(manager, "running_on_gpu_pool", lambda: True) + monkeypatch.setattr(endpoint, "download_endpoint", lambda **kw: None) + monkeypatch.setattr(base, "_ensure_reaper_running", lambda: None) + monkeypatch.setattr(ab, "_local_model_source", lambda name: "local-" + name) + monkeypatch.setattr(ab, "_ctranslate2_cuda_ok", lambda: ( + __import__("os").environ.get("OMNIVOICE_DEVICE") != "cpu")) + profile = importlib.import_module("services.performance_profiles") + monkeypatch.setattr(profile, "asr_decode_defaults", lambda: {"beam_size": 2, "best_of": 3}) + script = tmp_path / "fake_ct2_child.py" + script.write_text(_CHILD.format(backend=str(Path(__file__).parents[1] / "backend"))) + monkeypatch.setattr(transport.IsolatedFasterWhisperBackend, "sidecar_script", classmethod(lambda cls: script)) + def forbidden(*args, **kwargs): + pytest.fail("CT2 inference loaded in the Torch parent process") + monkeypatch.setitem(sys.modules, "faster_whisper", SimpleNamespace( + WhisperModel=forbidden, __spec__=SimpleNamespace())) + engines = [] + def create(name="snapshot"): + engine = ab.FasterWhisperBackend(model_name=name) + engines.append(engine) + return engine + create.engines = engines + yield create + for engine in engines: + engine.unload() + + +def test_normal_backend_preserves_exact_model_options_and_result_in_child(isolated): + """Preserve decoding options, normalized results, and execution evidence across IPC.""" + engine = isolated() + result = engine.transcribe("clip.wav", language="de", initial_prompt="names", + temperature=.2, task="translate") + heard = json.loads(result["segments"][0]["text"]) + assert heard == {"source": "local-snapshot", "options": { + "word_timestamps": True, "condition_on_previous_text": False, "vad_filter": True, + "beam_size": 2, "best_of": 3, "language": "de", "initial_prompt": "names", + "temperature": .2, "task": "translate"}} + assert result["duration"] == 2. and result["language_probability"] == .98 + assert result["segments"][0]["words"] == [ + {"word": "hello", "start": 0., "end": 1., "probability": .9}] + assert result["chunks"][0]["timestamp"] == (0., 1.) + assert engine._device == "cpu" and engine._compute_type == "int8" + assert engine.execution_evidence_loaded() + + +def test_cpu_override_and_disabled_timestamps_survive_isolation(isolated, monkeypatch): + monkeypatch.setenv("ASR_TEST_FORBID_GPU_PROBE", "1") + engine = isolated() + result = engine.transcribe("clip.wav", word_timestamps=False) + assert result["segments"][0]["words"] == [] + assert engine._device == "cpu" + engine.unload() + assert not engine.execution_evidence_loaded() + assert engine.transcribe("again.wav")["language"] == "en" + + +def test_native_crash_fails_one_job_and_next_request_respawns(isolated): + engine = isolated() + with pytest.raises(RuntimeError, match="crashed"): + engine.transcribe("crash.wav") + assert engine.transcribe("again.wav")["language"] == "en" + + +def test_gpu_memory_failure_keeps_cpu_fallback_and_reports_actual_device(isolated, monkeypatch): + """Report CPU execution and memory fallback evidence after a child GPU load failure.""" + monkeypatch.setenv("OMNIVOICE_DEVICE", "auto") + engine = isolated("oom-snapshot") + assert engine.transcribe("clip.wav")["language"] == "en" + assert engine._device == "cpu" and engine._compute_type == "int8" + assert engine._fallback_stage == "model_load" + assert "memory" in engine._fallback_reason.lower() + + +def test_effective_parent_cpu_choice_skips_gpu_in_child(isolated, monkeypatch): + ab = importlib.import_module("services.asr_backend") + monkeypatch.setenv("OMNIVOICE_DEVICE", "xpu") + monkeypatch.setenv("ASR_TEST_FORBID_GPU_PROBE", "1") + monkeypatch.setattr(ab, "_ctranslate2_cuda_ok", lambda: False) + engine = isolated() + assert engine.transcribe("clip.wav")["language"] == "en" + assert engine._device == "cpu" + + +def test_catalogue_reports_actual_isolated_cpu_execution(isolated, monkeypatch): + ab = importlib.import_module("services.asr_backend") + caps = importlib.import_module("core.device_caps") + routing = importlib.import_module("services.engine_routing") + monkeypatch.setattr(caps, "detect_host_caps", lambda: SimpleNamespace(family="cpu", device_name="")) + monkeypatch.setattr(routing, "runtime_compute_profile", lambda *a: { + "gpu_compat": ["cpu"], "routing_status": "cpu_only", "routing_reason": None}) + engine = isolated() + engine.transcribe("clip.wav") + monkeypatch.setattr(ab, "_REGISTRY", {"faster-whisper": ab.FasterWhisperBackend}) + monkeypatch.setattr(ab, "_RUNTIME_INSTANCES", {"faster-whisper": engine}) + row, = ab.list_backends() + assert row["isolation_mode"] == "subprocess" + assert row["execution_evidence"]["actual_execution_device"] == "cpu" + assert row["execution_evidence"]["parent_memory_observable"] is False + + +def test_dropping_a_direct_backend_releases_its_owned_child(isolated): + import gc + import weakref + + engine = isolated() + engine.transcribe("clip.wav") + proxy = engine._rocm_asr_proxy + process = proxy._proc + isolated.engines.remove(engine) + reference = weakref.ref(engine) + del engine + gc.collect() + try: + assert reference() is None + assert process.poll() is not None + finally: + proxy.close() + + +@pytest.mark.parametrize("fallback", [False, True]) +def test_error_reports_actual_cpu_device_without_torch_discovery(isolated, monkeypatch, fallback): + """Decorate child failures with the actual device without native Torch discovery.""" + transport = importlib.import_module("services.subprocess_asr") + if fallback: + monkeypatch.setenv("OMNIVOICE_DEVICE", "auto") + engine = isolated("oom-snapshot" if fallback else "snapshot") + engine.transcribe("clip.wav") + def forbidden(): + pytest.fail("Error decoration attempted native Torch device discovery") + monkeypatch.setattr(transport.SubprocessASRBackend, "_device", staticmethod(forbidden)) + with pytest.raises(RuntimeError, match=r"crashed.*device=cpu"): + engine.transcribe("crash.wav") diff --git a/tests/test_windows_rocm_asr_worker.py b/tests/test_windows_rocm_asr_worker.py new file mode 100644 index 0000000000..75da9d6260 --- /dev/null +++ b/tests/test_windows_rocm_asr_worker.py @@ -0,0 +1,56 @@ +"""Device admission and unaffected defaults without native execution.""" +import builtins +import importlib +import sys + +import pytest + + +@pytest.mark.parametrize("parent,override,verified,want", [ + ("cpu", "auto", True, "cpu"), + ("cuda", "cpu", True, "cpu"), + ("cuda", "auto", False, "cpu"), + ("cuda", "rocm", True, "cuda"), + ("cuda", "not-a-device", True, "cuda"), + ("cuda", "mps", True, "cuda"), # unavailable overrides retain auto on HIP +]) +def test_worker_never_imports_torch_and_requires_its_own_gpu_gate( + monkeypatch, parent, override, verified, want): + """Require child GPU verification while honoring CPU overrides without importing Torch.""" + isolation = importlib.import_module("core.windows_rocm.asr_isolation") + policy = importlib.import_module("core.windows_rocm.isolation_policy") + ct2 = importlib.import_module("core.windows_rocm.ct2") + monkeypatch.setattr(policy.sys, "platform", "win32") + monkeypatch.setattr(policy.metadata, "version", lambda name: "2.13.0+rocm10.0.0") + monkeypatch.setenv("OMNIVOICE_DEVICE", override) + def gate(): + """Reject GPU verification for CPU selections and return the simulated GPU result.""" + if parent == "cpu" or override == "cpu": + pytest.fail("CPU selected but GPU verification ran") + return verified + monkeypatch.setattr(ct2, "hip_ct2_available", gate) + original = builtins.__import__ + def no_torch(name, *args, **kwargs): + """Reject Torch imports during device selection and delegate unrelated imports.""" + if name == "torch" or name.startswith("torch."): + pytest.fail("inference device selection imported Torch") + return original(name, *args, **kwargs) + monkeypatch.setattr(builtins, "__import__", no_torch) + assert isolation.worker_device(parent) == want + + +@pytest.mark.parametrize("platform,version", [ + ("win32", "2.9.1+rocm7.2.1"), ("win32", "2.8.0+cu128"), + ("win32", "2.8.0+cpu"), ("linux", "2.13.0+rocm10.0.0"), + ("darwin", "2.8.0"), +]) +def test_other_runtimes_retain_in_process_execution(monkeypatch, platform, version): + isolation = importlib.import_module("core.windows_rocm.asr_isolation") + policy = importlib.import_module("core.windows_rocm.isolation_policy") + ab = importlib.import_module("services.asr_backend") + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr(policy.metadata, "version", lambda name: version) + assert not isolation.isolation_required() + assert isolation.worker_device() is None + assert isolation.availability_status() is None + assert not ab.FasterWhisperBackend.runtime_runs_out_of_process() diff --git a/tests/test_windows_rocm_cpu_capture_readiness.py b/tests/test_windows_rocm_cpu_capture_readiness.py new file mode 100644 index 0000000000..d2a91f017a --- /dev/null +++ b/tests/test_windows_rocm_cpu_capture_readiness.py @@ -0,0 +1,87 @@ +"""CPU capture must prefer a usable recognizer over a merely present package.""" +import builtins +import importlib + +import pytest + + +@pytest.fixture +def cpu_capture(monkeypatch): + """Provide isolated ROCm CPU routing without importing native model runtimes.""" + asr = importlib.import_module('services.asr_backend') + isolation = importlib.import_module('core.windows_rocm.asr_isolation') + caps = importlib.import_module('core.device_caps') + ct2 = importlib.import_module('core.windows_rocm.ct2') + probe = importlib.import_module('services.performance_probe') + monkeypatch.setattr(isolation, 'isolation_required', lambda: True) + original = isolation.util.find_spec + monkeypatch.setattr(isolation.util, 'find_spec', lambda name: ( + object() if name == 'faster_whisper' else original(name))) + monkeypatch.setattr(caps, 'requested_device_override', lambda: 'cpu') + monkeypatch.setattr(ct2, 'availability_status', lambda: pytest.fail('CPU checked GPU')) + monkeypatch.setattr(asr, '_capture_backend', None) + monkeypatch.setattr(asr, '_capture_backend_key', None) + monkeypatch.setattr(asr, '_touch_capture', lambda: None) + monkeypatch.setattr(asr, '_capture_prefers_parakeet', lambda: False) + monkeypatch.setattr(asr.MLXWhisperBackend, 'is_available', classmethod( + lambda cls: (False, 'not installed'))) + monkeypatch.setattr(asr.PyTorchWhisperBackend, 'is_available', classmethod( + lambda cls: (True, 'ready'))) + monkeypatch.setattr(asr, 'ensure_backend_licence', lambda backend: None) + return asr, isolation, probe + + +@pytest.mark.parametrize('ready', [False, True]) +def test_capture_checks_native_import_before_caching_engine(cpu_capture, monkeypatch, ready): + """Retain PyTorch fallback on broken CT2 and reuse a proven capture selection.""" + asr, _isolation, probe = cpu_capture + calls = [] + + def query(family, engine): + """Simulate the bounded child's identity-matched readiness result.""" + calls.append((family, engine)) + return {'ready': ready, 'engine': engine} + + monkeypatch.setattr(probe, '_query', query) + engine = asr.get_capture_asr_backend(skip_sherpa=True) + expected = asr.FasterWhisperBackend if ready else asr.PyTorchWhisperBackend + assert isinstance(engine, expected) + assert asr.get_capture_asr_backend(skip_sherpa=True) is engine + assert calls == [('asr-import', 'faster-whisper')] + + +@pytest.mark.parametrize('result', [None, {'ready': False, 'engine': 'faster-whisper'}]) +def test_failed_or_timed_out_child_does_not_admit_capture(cpu_capture, monkeypatch, result): + """An absent or negative child proof must not select the CT2 capture backend.""" + asr, isolation, probe = cpu_capture + monkeypatch.setattr(probe, '_query', lambda *args: result) + assert not isolation.availability_status()[0] + assert isinstance(asr.get_capture_asr_backend(skip_sherpa=True), asr.PyTorchWhisperBackend) + + +def test_cpu_availability_does_not_import_native_libraries_in_parent(cpu_capture, monkeypatch): + """Keep native imports in the child and avoid GPU discovery in CPU mode.""" + _asr, isolation, probe = cpu_capture + monkeypatch.setattr(probe, '_query', lambda family, engine: {'ready': True, 'engine': engine}) + original = builtins.__import__ + + def guarded(name, *args, **kwargs): + """Reject an attempted native import in this parent process.""" + if name.split('.')[0] in {'torch', 'faster_whisper', 'ctranslate2'}: + pytest.fail('native runtime imported in parent') + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', guarded) + assert isolation.availability_status()[0] + + +@pytest.mark.parametrize('verified', [False, True]) +def test_import_success_does_not_bypass_gpu_verification(cpu_capture, monkeypatch, verified): + """A proven import must still retain the existing native HIP admission result.""" + asr, _isolation, probe = cpu_capture + caps = importlib.import_module('core.device_caps') + ct2 = importlib.import_module('core.windows_rocm.ct2') + monkeypatch.setattr(caps, 'requested_device_override', lambda: 'auto') + monkeypatch.setattr(probe, '_query', lambda family, engine: {'ready': True, 'engine': engine}) + monkeypatch.setattr(ct2, 'availability_status', lambda: (verified, 'native GPU result')) + assert asr.FasterWhisperBackend.is_available() == (verified, 'native GPU result') diff --git a/tests/test_windows_rocm_ct2_kernels.py b/tests/test_windows_rocm_ct2_kernels.py new file mode 100644 index 0000000000..e3d49f2176 --- /dev/null +++ b/tests/test_windows_rocm_ct2_kernels.py @@ -0,0 +1,72 @@ +"""CT2 discovery must not certify a binary whose GPU kernels cannot run.""" +import importlib +import sys +from types import SimpleNamespace + +import pytest + + +def test_rdna2_selects_the_verified_expanded_target_binary(): + recipe = importlib.import_module("core.windows_rocm.recipe") + rdna2 = recipe.load_recipe(architecture="gfx1031") + base = recipe.load_recipe(architecture="gfx1150") + assert recipe.expected_versions(rdna2)["ctranslate2"] == "4.8.4" + assert rdna2["ctranslate2"]["binary_sha256"] == "dd4092e29b5dcf4337c2f6e8d4f9aece377b19c46ff11a8d7ec1a76608b150fa" + assert base["ctranslate2"]["version"] == "4.8.2" + + +def test_discovery_only_report_is_rejected(): + recipe = importlib.import_module("core.windows_rocm.recipe") + verify = importlib.import_module("core.windows_rocm.verify") + selected = recipe.load_recipe(architecture="gfx1031") + report = dict(ready=True, scope="model-free", python=sys.executable, + architecture="win-amd64", abi="cp312", hip="7.15.26333", + packages=recipe.expected_versions(selected), torch_gpu=True, + kernel_result=4.0, gpu_architecture="gfx1031", ct2_gpu_count=1, + ct2_compute_types=["float16"], + ct2_binary_sha256=selected["ctranslate2"]["binary_sha256"]) + with pytest.raises(RuntimeError, match="validation"): + verify.validate_report(report, selected) + + +class View: + def __init__(self, values, device="cpu", broken=False): + self.values, self.device, self.broken = values, device, broken + + def to_device(self, device): + return View(self.values, device, self.broken) + + def to(self, dtype): + """Simulate a successful cast or the native invalid-image kernel failure.""" + if self.broken: + raise RuntimeError("parallel_for failed: hipErrorInvalidImage") + return self + + def __array__(self, dtype=None, copy=None): + import numpy as np + return np.asarray(self.values, dtype=dtype) + + +@pytest.mark.parametrize("broken", [False, True]) +def test_native_cast_requires_an_executable_gpu_kernel(broken): + """Require a successful native GPU cast rather than accepting allocation alone.""" + module = importlib.import_module("core.windows_rocm.ct2_kernel") + ct2 = SimpleNamespace( + StorageView=SimpleNamespace(from_array=lambda a: View(a, broken=broken)), + Device=SimpleNamespace(cuda="cuda", cpu="cpu"), + DataType=SimpleNamespace(float16="float16", float32="float32")) + if broken: + with pytest.raises(RuntimeError, match="hipErrorInvalidImage"): + module.verify_ct2_kernel(ct2) + else: + assert module.verify_ct2_kernel(ct2) == [1.25, -2.5, 0.0, 8.0] + + +def test_cpu_fallback_cannot_pass_the_gpu_kernel_check(): + module = importlib.import_module("core.windows_rocm.ct2_kernel") + ct2 = SimpleNamespace( + StorageView=SimpleNamespace(from_array=lambda a: View(a)), + Device=SimpleNamespace(cuda="cpu", cpu="cpu"), + DataType=SimpleNamespace(float16="float16", float32="float32")) + with pytest.raises(RuntimeError, match="GPU"): + module.verify_ct2_kernel(ct2) diff --git a/tests/test_windows_rocm_failure_policy.py b/tests/test_windows_rocm_failure_policy.py index f16b682c7c..0d707d1225 100644 --- a/tests/test_windows_rocm_failure_policy.py +++ b/tests/test_windows_rocm_failure_policy.py @@ -7,6 +7,12 @@ import pytest +@pytest.fixture(autouse=True) +def native_failure_test_without_host_selection(monkeypatch): + module = importlib.import_module("core.windows_rocm.install") + monkeypatch.setattr(module, "validate_repair_selection", lambda recipe: None) + + def module(name): return importlib.import_module(f"core.windows_rocm.{name}") diff --git a/tests/test_windows_rocm_heavy_gates.py b/tests/test_windows_rocm_heavy_gates.py index 6e09d602cd..4f805fb3fc 100644 --- a/tests/test_windows_rocm_heavy_gates.py +++ b/tests/test_windows_rocm_heavy_gates.py @@ -8,8 +8,11 @@ @pytest.fixture def windows_hip(monkeypatch): - from core.windows_rocm import pipelines + from core.windows_rocm import isolation_policy, pipelines + # This cohort covers the original ROCm 7 MIOpen gate. ROCm 10's + # distinct OpenMP refusal has its own WhisperX isolation regressions. + monkeypatch.setattr(isolation_policy, "isolation_required", lambda: False) monkeypatch.setattr(pipelines, "windows_hip_installed", lambda: True) monkeypatch.setattr(ab, "_LAST_ERRORS", {}) caps = SimpleNamespace(family="rocm") diff --git a/tests/test_windows_rocm_import_exceptions.py b/tests/test_windows_rocm_import_exceptions.py index bfd52acad9..f2f118e3dd 100644 --- a/tests/test_windows_rocm_import_exceptions.py +++ b/tests/test_windows_rocm_import_exceptions.py @@ -10,6 +10,12 @@ import pytest + +@pytest.fixture(autouse=True) +def native_import_test_without_host_selection(monkeypatch): + module = importlib.import_module("core.windows_rocm.install") + monkeypatch.setattr(module, "validate_repair_selection", lambda recipe: None) + SOURCES = [ b"def broken(:\n", # A damaged Python source must exercise a real SyntaxError. b"raise ValueError('import failed')\n", diff --git a/tests/test_windows_rocm_install.py b/tests/test_windows_rocm_install.py index 03b3028280..4fc1676844 100644 --- a/tests/test_windows_rocm_install.py +++ b/tests/test_windows_rocm_install.py @@ -12,6 +12,7 @@ def installer(monkeypatch): module = importlib.import_module("core.windows_rocm.install") monkeypatch.setattr(module, "validate_target", lambda: None) + monkeypatch.setattr(module, "validate_repair_selection", lambda recipe: None) return module diff --git a/tests/test_windows_rocm_native_imports.py b/tests/test_windows_rocm_native_imports.py index 8f9e620ddd..3582200188 100644 --- a/tests/test_windows_rocm_native_imports.py +++ b/tests/test_windows_rocm_native_imports.py @@ -9,6 +9,12 @@ import pytest + +@pytest.fixture(autouse=True) +def native_import_test_without_host_selection(monkeypatch): + module = importlib.import_module("core.windows_rocm.install") + monkeypatch.setattr(module, "validate_repair_selection", lambda recipe: None) + ROOT = Path(__file__).resolve().parents[1] PROBE_FIXTURE = r""" import builtins @@ -24,6 +30,7 @@ probe.package_root = lambda name: __import__('pathlib').Path('unused') probe.sha256 = lambda path: recipe['ctranslate2']['binary_sha256'] probe.initialize_dll_search = lambda: None +probe.verify_ct2_kernel = lambda ct2: [1.25, -2.5, 0.0, 8.0] probe.sysconfig.get_platform = lambda: 'win-amd64' class Tensor: diff --git a/tests/test_windows_rocm_proxy_creation.py b/tests/test_windows_rocm_proxy_creation.py new file mode 100644 index 0000000000..95cb330c7c --- /dev/null +++ b/tests/test_windows_rocm_proxy_creation.py @@ -0,0 +1,89 @@ +"""Concurrent first use must share one inference worker and lifecycle owner.""" +from concurrent.futures import ThreadPoolExecutor +import importlib +import threading + + +def test_concurrent_first_use_creates_one_owned_proxy(monkeypatch): + """Two first callers must share one worker after bounded device admission.""" + isolation = importlib.import_module("core.windows_rocm.asr_isolation") + proxies = importlib.import_module("core.windows_rocm.asr_proxy") + backend = importlib.import_module("services.asr_backend") + first_probe, second_probe = threading.Event(), threading.Event() + release, second_started = threading.Event(), threading.Event() + calls, created = [], [] + + def probe(): + """Block the first probe and signal subsequent probes to expose creation races.""" + calls.append(True) + if len(calls) == 1: + first_probe.set() + assert release.wait(10) + else: + second_probe.set() + return False + + class Proxy: + def __init__(self, source, device): + created.append(self) + def close(self, wait=False): + return True + + class Owner: + _model_name = "local-snapshot" + + owner = Owner() + monkeypatch.setattr(backend, "_ctranslate2_cuda_ok", probe) + monkeypatch.setattr(backend, "_local_model_source", lambda source: source) + monkeypatch.setattr(proxies, "RocmASRProxy", Proxy) + def second_call(): + second_started.set() + return isolation._proxy(owner) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(isolation._proxy, owner) + try: + assert first_probe.wait(10) + second = pool.submit(second_call) + assert second_started.wait(10) + second_probe.wait(.5) + finally: + release.set() + assert first.result(timeout=10) is second.result(timeout=10) + assert len(created) == 1 + assert owner._rocm_asr_proxy is created[0] + + +def test_unload_cannot_observe_partial_proxy_publication(monkeypatch): + """An unload during initialization must leave the eventual owner coherent.""" + isolation = importlib.import_module("core.windows_rocm.asr_isolation") + proxies = importlib.import_module("core.windows_rocm.asr_proxy") + backend = importlib.import_module("services.asr_backend") + entered, release = threading.Event(), threading.Event() + class Owner: + _model_name = "local-snapshot" + class Proxy: + def close(self, wait=False): + return True + owner = Owner() + original = isolation.weakref.finalize + def finalize(instance, *args): + """Pause finalization for the selected owner before invoking the real finalizer.""" + if instance is owner: + entered.set() + assert release.wait(10) + return original(instance, *args) + monkeypatch.setattr(isolation, "isolation_required", lambda: True) + monkeypatch.setattr(backend, "_ctranslate2_cuda_ok", lambda: False) + monkeypatch.setattr(backend, "_local_model_source", lambda source: source) + monkeypatch.setattr(proxies, "RocmASRProxy", lambda *args: Proxy()) + monkeypatch.setattr(isolation.weakref, "finalize", finalize) + with ThreadPoolExecutor(max_workers=1) as pool: + creation = pool.submit(isolation._proxy, owner) + try: + assert entered.wait(10) + assert isolation.unload_isolated(owner) + finally: + release.set() + creation.result(timeout=10) + assert isolation.unload_isolated(owner) + assert owner._rocm_asr_proxy is None diff --git a/tests/test_windows_rocm_proxy_evidence.py b/tests/test_windows_rocm_proxy_evidence.py new file mode 100644 index 0000000000..b4b27440fd --- /dev/null +++ b/tests/test_windows_rocm_proxy_evidence.py @@ -0,0 +1,50 @@ +"""A completed old request must not overwrite a replacement worker's evidence.""" +from concurrent.futures import ThreadPoolExecutor +import importlib +import threading + + +def test_stale_completion_keeps_current_worker_execution_evidence(monkeypatch): + """Return both completed jobs while publishing only the current worker.""" + isolation = importlib.import_module("core.windows_rocm.asr_isolation") + proxies = importlib.import_module("core.windows_rocm.asr_proxy") + backend = importlib.import_module("services.asr_backend") + entered, release = threading.Event(), threading.Event() + created = [] + class Proxy: + def __init__(self, source, device): + """Assign distinct CPU and GPU evidence to successive fake workers.""" + self.execution_device = "cpu" if not created else "cuda" + self.execution_precision = "int8" if not created else "float16" + self.fallback_reason = self.fallback_stage = None + created.append(self) + def transcribe(self, audio_path, **options): + """Pause the original worker to expose evidence races with its replacement.""" + if self is created[0]: + entered.set() + assert release.wait(10) + return {"text": audio_path} + def close(self, wait=False): + return True + class Owner: + _model_name = "local-snapshot" + owner = Owner() + monkeypatch.setattr(isolation, "isolation_required", lambda: True) + monkeypatch.setattr(backend, "_ctranslate2_cuda_ok", lambda: False) + monkeypatch.setattr(backend, "_local_model_source", lambda source: source) + monkeypatch.setattr(proxies, "RocmASRProxy", Proxy) + with ThreadPoolExecutor(max_workers=1) as pool: + old = pool.submit(isolation.transcribe_isolated, owner, "old.wav") + try: + assert entered.wait(10) + assert isolation.unload_isolated(owner) + assert isolation.transcribe_isolated(owner, "new.wav") == {"text": "new.wav"} + assert owner._device == "cuda" + finally: + release.set() + assert old.result(timeout=10) == {"text": "old.wav"} + try: + assert owner._device == owner._rocm_asr_proxy.execution_device == "cuda" + assert owner._compute_type == "float16" + finally: + isolation.unload_isolated(owner) diff --git a/tests/test_windows_rocm_proxy_retirement.py b/tests/test_windows_rocm_proxy_retirement.py new file mode 100644 index 0000000000..606dd0bd8c --- /dev/null +++ b/tests/test_windows_rocm_proxy_retirement.py @@ -0,0 +1,47 @@ +"""A request retaining an unloaded proxy must not respawn an unowned child.""" +from concurrent.futures import ThreadPoolExecutor +import importlib +import threading + +import pytest + + +def test_unload_between_proxy_lookup_and_worker_start_prevents_orphan(monkeypatch): + isolation = importlib.import_module("core.windows_rocm.asr_isolation") + proxies = importlib.import_module("core.windows_rocm.asr_proxy") + transport = importlib.import_module("services.subprocess_backend") + backend = importlib.import_module("services.asr_backend") + entered, release = threading.Event(), threading.Event() + spawned = [] + original = proxies.RocmASRProxy.ensure_loaded + def paused(proxy): + entered.set() + assert release.wait(10) + original(proxy) + class Owner: + _model_name = "installed-snapshot" + owner = Owner() + monkeypatch.setattr(isolation, "isolation_required", lambda: True) + monkeypatch.setattr(backend, "_ctranslate2_cuda_ok", lambda: False) + monkeypatch.setattr(backend, "_local_model_source", lambda source: source) + monkeypatch.setattr(transport.SubprocessBackend, "_spawn", lambda proxy: spawned.append(proxy)) + monkeypatch.setattr(proxies.RocmASRProxy, "ensure_loaded", paused) + with ThreadPoolExecutor(max_workers=1) as pool: + request = pool.submit(isolation.ensure_isolated, owner) + try: + assert entered.wait(10) + old_proxy = owner._rocm_asr_proxy + assert isolation.unload_isolated(owner) + assert owner._rocm_asr_proxy is None + release.set() + with pytest.raises(RuntimeError, match="unloaded|retired"): + request.result(timeout=10) + assert not spawned + # The next request gets a fresh, usable owner and cleanup callback. + replacement = isolation._proxy(owner) + assert replacement is not old_proxy + replacement.ensure_loaded() + assert spawned == [replacement] + finally: + release.set() + isolation.unload_isolated(owner) diff --git a/tests/test_windows_rocm_pytorch_whisper.py b/tests/test_windows_rocm_pytorch_whisper.py new file mode 100644 index 0000000000..2d56482606 --- /dev/null +++ b/tests/test_windows_rocm_pytorch_whisper.py @@ -0,0 +1,65 @@ +"""Pure Torch ASR may declare verified Windows HIP without depending on CT2.""" +import importlib +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def hip(monkeypatch): + """Provide pinned RDNA 2 Torch capabilities while forbidding CT2 capability probes.""" + ab = importlib.import_module("services.asr_backend") + recipe = importlib.import_module("core.windows_rocm.recipe") + caps = importlib.import_module("core.device_caps") + ct2 = importlib.import_module("core.windows_rocm.ct2") + selected = recipe.load_recipe(architecture="gfx1031") + pins = recipe.expected_versions(selected) + pins.pop("ctranslate2") + monkeypatch.setattr(recipe.sys, "platform", "win32") + monkeypatch.setenv("OMNIVOICE_TORCH_VARIANT", "rocm") + monkeypatch.setattr(recipe, "load_recipe", lambda: selected) + monkeypatch.setattr(recipe, "validate_target", lambda: None) + monkeypatch.setattr(caps, "detect_host_caps", lambda: SimpleNamespace( + family="rocm", probe_ok=True, notes=())) + def version(name): + """Return pinned Torch package versions and reject any CT2 metadata query.""" + if name == "ctranslate2": + pytest.fail("Pure Torch ASR capability checked CT2") + if name not in pins: + raise ct2.metadata.PackageNotFoundError(name) + return pins[name] + monkeypatch.setattr(ct2.metadata, "version", version) + monkeypatch.setattr(ct2, "hip_ct2_available", lambda: pytest.fail("Pure Torch ASR probed CT2")) + return ab.PyTorchWhisperBackend, recipe, caps, pins + + +def test_exact_opt_in_windows_torch_recipe_advertises_rocm_independent_of_ct2(hip): + backend, _recipe, _caps, _pins = hip + assert backend.runtime_gpu_compat() == ("cuda", "rocm", "mps", "cpu") + + +@pytest.mark.parametrize("package", ["torch", "rocm-sdk-core", "amd-torch-device-gfx1031"]) +def test_mismatched_native_packages_do_not_advertise_verified_rocm(hip, package): + backend, _recipe, _caps, pins = hip + pins[package] = "wrong-version" + assert "rocm" not in backend.runtime_gpu_compat() + + +@pytest.mark.parametrize("family,ok,notes", [ + ("cpu", True, ()), ("rocm", False, ()), + ("rocm", True, ("GPU may fail at kernel launch",)), +]) +def test_cpu_override_or_unsafe_host_never_claims_verified_gpu(hip, monkeypatch, family, ok, notes): + backend, _recipe, caps, _pins = hip + monkeypatch.setattr(caps, "detect_host_caps", lambda: SimpleNamespace( + family=family, probe_ok=ok, notes=notes)) + assert "rocm" not in backend.runtime_gpu_compat() + + +@pytest.mark.parametrize("platform,opt_in", [("linux", "rocm"), ("darwin", "rocm"), ("win32", "")]) +def test_default_and_other_platform_compatibility_stays_unchanged(hip, monkeypatch, platform, opt_in): + backend, recipe, caps, _pins = hip + monkeypatch.setattr(recipe.sys, "platform", platform) + monkeypatch.setenv("OMNIVOICE_TORCH_VARIANT", opt_in) + monkeypatch.setattr(caps, "detect_host_caps", lambda: pytest.fail("Default path probed HIP")) + assert backend.runtime_gpu_compat() == ("cuda", "mps", "cpu") diff --git a/tests/test_windows_rocm_rdna2.py b/tests/test_windows_rocm_rdna2.py new file mode 100644 index 0000000000..060984f43b --- /dev/null +++ b/tests/test_windows_rocm_rdna2.py @@ -0,0 +1,175 @@ +"""Windows ROCm RDNA 2 uses pinned native kernels and safe precision.""" +import json +import importlib +import re +import types + +import pytest + +@pytest.fixture(autouse=True) +def application_modules(): + """Bind canonical modules for each test after any sys.modules replacement.""" + global device_caps, install, recipe + device_caps = importlib.import_module("core.device_caps") + install = importlib.import_module("core.windows_rocm.install") + recipe = importlib.import_module("core.windows_rocm.recipe") + + +def test_recipe_has_rdna2_native_variant(): + """Select only the native gfx1031 runtime packages for that RDNA 2 variant.""" + selected = recipe.load_recipe(architecture="gfx1031") + assert selected["gpu_architectures"] == ["gfx1031"] + assert selected["torch"]["hip_prefix"] == "7.15." + assert "amd-torch-device-gfx1031==2.13.0+rocm10.0.0" in selected["runtime"]["packages"] + assert all("gfx1030" not in p and "gfx1032" not in p for p in selected["runtime"]["packages"]) + + +def test_rdna2_urls_are_allowlisted_and_hashed(): + """Require allowlisted AMD artifact URLs and valid SHA-256 pins for RDNA 2.""" + selected = recipe.load_recipe(architecture="gfx1031") + for line in install.hashed_requirements(selected).splitlines(): + url, digest = line.split(" --hash=sha256:") + assert url.startswith("https://stable.repo.amd.com/rocm/whl-next/") + assert re.fullmatch(r"[0-9a-f]{64}", digest) + + +def test_missing_rdna2_hash_is_rejected(tmp_path): + data = json.loads(recipe.RECIPE_PATH.read_text(encoding="utf-8")) + device = data["rdna2"]["devices"]["gfx1031"] + del device["wheel_sha256"][device["wheel_files"][0]] + path = tmp_path / "recipe.json" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(RuntimeError, match="sha256"): + recipe.load_recipe(path, architecture="gfx1031") + + +def test_unapproved_rdna2_source_is_rejected(tmp_path): + data = json.loads(recipe.RECIPE_PATH.read_text(encoding="utf-8")) + data["rdna2"]["torch"]["find_links"] = "https://unapproved.example/" + path = tmp_path / "recipe.json" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(RuntimeError, match="source"): + recipe.load_recipe(path, architecture="gfx1031") + + +def test_windows_rocm10_only_reports_installed_architectures(monkeypatch): + monkeypatch.setattr(device_caps.sys, "platform", "win32") + monkeypatch.delenv("HSA_OVERRIDE_GFX_VERSION", raising=False) + fake = types.SimpleNamespace( + __version__="2.13.0+rocm10.0.0", version=types.SimpleNamespace(hip="7.15"), + cuda=types.SimpleNamespace(is_available=lambda: True, + get_arch_list=lambda: ["gfx1030", "gfx1031"], + get_device_properties=lambda index: types.SimpleNamespace(gcnArchName="gfx1031"))) + monkeypatch.setattr(device_caps, "_installed_rocm_arches", lambda version: {"gfx1031"}) + assert device_caps.arch_unsupported(fake) is None + assert device_caps.build_arch_list(fake) == ["gfx1031"] + assert device_caps.rocm_override_target("gfx1031", device_caps.build_arch_list(fake)) is None + +@pytest.mark.parametrize("pci,gfx", [("73bf", "gfx1030"), ("73df", "gfx1031"), ("73ff", "gfx1032")]) +def test_pci_device_selects_the_exact_recipe(monkeypatch, pci, gfx): + from core import gpu_inventory + monkeypatch.setattr(recipe.sys, "platform", "win32") + monkeypatch.setattr(gpu_inventory, "detect_host_gpus", lambda: ( + gpu_inventory.HostGPU(vendor="amd", name="Radeon", pci_device_id=pci),)) + assert recipe.load_recipe()["gpu_architectures"] == [gfx] + + +def test_mixed_amd_architectures_fail_closed(monkeypatch): + from core import gpu_inventory + monkeypatch.setattr(recipe.sys, "platform", "win32") + monkeypatch.setattr(gpu_inventory, "detect_host_gpus", lambda: ( + gpu_inventory.HostGPU(vendor="amd", name="Radeon", pci_device_id="73df"), + gpu_inventory.HostGPU(vendor="amd", name="Radeon", pci_device_id="744c"))) + with pytest.raises(RuntimeError, match="Multiple AMD"): + recipe.load_recipe() + + +@pytest.mark.parametrize("gfx", ["gfx1030", "gfx1031", "gfx1032"]) +def test_each_selected_artifact_is_pinned(gfx): + """Require complete artifact hashes and the matching architecture-specific packages.""" + selected = recipe.load_recipe(architecture=gfx) + lines = install.hashed_requirements(selected).splitlines() + assert len(lines) == 10 + assert all(re.search(r" --hash=sha256:[0-9a-f]{64}$", line) for line in lines) + assert sum(f"device-{gfx}/" in line for line in lines) == 3 + + +def test_the_amd_kernel_and_sdk_packages_are_both_required(monkeypatch): + from importlib import metadata + class Dist: + metadata = {"Name": "amd-torch-device-gfx1031"} + version = "2.13.0+rocm10.0.0" + monkeypatch.setattr(metadata, "distributions", lambda: [Dist()]) + def missing_sdk(name): + raise metadata.PackageNotFoundError(name) + monkeypatch.setattr(metadata, "version", missing_sdk) + assert device_caps._installed_rocm_arches(Dist.version) == set() + + +def test_native_gfx1031_is_reported_as_accelerated(): + from core.gpu_report import build_gpu_report + caps = device_caps.HostCaps(family="rocm", available_families=("rocm", "cpu"), device_name="Radeon") + report = build_gpu_report(caps, (), {"kind": "rocm", "version": "2.13.0+rocm10.0.0"}, [], [], platform="win32") + assert report["state"] == "accelerated" + assert report["options"] == [] + + +def test_windows_never_sets_hsa_override(monkeypatch): + from services import model_manager + monkeypatch.setattr(model_manager.sys, "platform", "win32") + monkeypatch.delenv("HSA_OVERRIDE_GFX_VERSION", raising=False) + fake = types.SimpleNamespace( + version=types.SimpleNamespace(hip="7.15"), + cuda=types.SimpleNamespace(get_device_name=lambda i: "Radeon", + get_device_properties=lambda i: types.SimpleNamespace(gcnArchName="gfx1031"), + get_arch_list=lambda: ["gfx1030"])) + model_manager._configure_rocm_if_needed(fake) + assert "HSA_OVERRIDE_GFX_VERSION" not in model_manager.os.environ + + +def test_rocm10_dll_search_excludes_stale_rocm7_libraries(monkeypatch, tmp_path): + """Exclude stale custom ROCm 7 libraries from the ROCm 10 DLL search path.""" + from core.windows_rocm import runtime + for package in ("torch", "ctranslate2", "_rocm_sdk_core", "_rocm_sdk_libraries", "_rocm_sdk_libraries_custom"): + (tmp_path / package / ("lib" if package == "torch" else "" if package == "ctranslate2" else "bin")).mkdir(parents=True) + monkeypatch.setattr(runtime.metadata, "version", lambda name: "2.13.0+rocm10.0.0") + monkeypatch.setattr(runtime, "package_root", lambda name: tmp_path / name) + directories = runtime.dll_directories() + assert (tmp_path / "_rocm_sdk_libraries" / "bin").resolve() in directories + assert (tmp_path / "_rocm_sdk_libraries_custom" / "bin").resolve() not in directories + + +def test_rdna2_refuses_an_hsa_override_before_imports(monkeypatch): + from core.windows_rocm import probe + selected = recipe.load_recipe(architecture="gfx1031") + monkeypatch.setattr(probe, "validate_target", lambda: None) + monkeypatch.setattr(probe, "load_recipe", lambda: selected) + monkeypatch.setenv("HSA_OVERRIDE_GFX_VERSION", "10.3.0") + monkeypatch.setattr(probe.metadata, "version", lambda name: pytest.fail("unexpected package probe")) + with pytest.raises(RuntimeError, match="Remove HSA_OVERRIDE_GFX_VERSION"): + probe.probe() + + +def test_missing_rocm10_kernel_metadata_fails_closed(monkeypatch): + """Reject GPU capability when installed ROCm kernel metadata cannot be read.""" + monkeypatch.setattr(device_caps.sys, "platform", "win32") + fake = types.SimpleNamespace(__version__="2.13.0+rocm10.0.0", + version=types.SimpleNamespace(hip="7.15"), + cuda=types.SimpleNamespace(is_available=lambda: True, + get_arch_list=lambda: ["gfx1031"], + get_device_properties=lambda i: types.SimpleNamespace(gcnArchName="gfx1031"))) + monkeypatch.setattr(device_caps, "_installed_rocm_arches", lambda version: (_ for _ in ()).throw(OSError("metadata"))) + assert device_caps.arch_unsupported(fake) is not None + + +def test_recipe_rejects_a_requirements_file_injection(tmp_path): + data = json.loads(recipe.RECIPE_PATH.read_text(encoding="utf-8")) + section = data["rdna2"]["torch"] + name = section["wheel_files"][0] + injected = name + "\nhttps://unapproved.example/wheel.whl" + section["wheel_files"][0] = injected + section["wheel_sha256"][injected] = section["wheel_sha256"].pop(name) + path = tmp_path / "recipe.json" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(RuntimeError, match="unsafe artifact"): + recipe.load_recipe(path, architecture="gfx1031") diff --git a/tests/test_windows_rocm_repair_selection.py b/tests/test_windows_rocm_repair_selection.py new file mode 100644 index 0000000000..fe16d56a78 --- /dev/null +++ b/tests/test_windows_rocm_repair_selection.py @@ -0,0 +1,42 @@ +"""Uncertain inventory or another installed SDK family must never trigger repair.""" +import importlib + +import pytest + + +@pytest.mark.parametrize("arch,inventory,versions", [ + ("gfx1150", (), {}), + ("gfx1150", ("",), {}), + ("gfx1150", ("zzzz",), {}), + ("gfx1150", ("73df",), {"torch": "2.13.0+rocm10.0.0", "rocm-sdk-core": "10.0.0"}), + ("gfx1031", ("73df",), {"rocm-sdk-core": "7.2.1"}), + ("gfx1031", ("73df",), {"rocm-sdk-core": "10.0.0", "rocm": "7.2.1"}), +]) +def test_unsafe_selection_stops_before_download_or_package_changes(monkeypatch, arch, inventory, versions): + """Reject unsafe GPU or runtime selections before downloads or package mutations.""" + installer = importlib.import_module("core.windows_rocm.install") + recipe = importlib.import_module("core.windows_rocm.recipe") + gpu = importlib.import_module("core.gpu_inventory") + failures = importlib.import_module("core.windows_rocm.failures") + from importlib import metadata + + monkeypatch.setattr(installer.sys, "platform", "win32") + selected = recipe.load_recipe(architecture=arch) + monkeypatch.setattr(installer, "validate_target", lambda: None) + monkeypatch.setattr(installer, "load_recipe", lambda: selected) + monkeypatch.setattr(gpu, "detect_host_gpus", lambda: tuple( + gpu.HostGPU(vendor="amd", name="Radeon", pci_device_id=pci) for pci in inventory)) + + def version(name): + """Return simulated package versions or report the requested package as absent.""" + if name not in versions: + raise metadata.PackageNotFoundError(name) + return versions[name] + + monkeypatch.setattr(metadata, "version", version) + monkeypatch.setattr(installer, "verify_environment", lambda: (_ for _ in ()).throw( + failures.VerificationFailure("package_version", "repairable package mismatch"))) + monkeypatch.setattr(installer, "download_archive", lambda *a: pytest.fail("unsafe download")) + monkeypatch.setattr(installer, "run_install", lambda *a: pytest.fail("unsafe package changes")) + with pytest.raises(failures.VerificationFailure, match="inventory|family|families|architecture"): + installer.provision("uv") diff --git a/tests/test_windows_rocm_sdk_integrity.py b/tests/test_windows_rocm_sdk_integrity.py index 87147481ed..b9647a9a28 100644 --- a/tests/test_windows_rocm_sdk_integrity.py +++ b/tests/test_windows_rocm_sdk_integrity.py @@ -7,6 +7,12 @@ import pytest + +@pytest.fixture(autouse=True) +def sdk_integrity_test_without_host_selection(monkeypatch): + module = importlib.import_module("core.windows_rocm.install") + monkeypatch.setattr(module, "validate_repair_selection", lambda recipe: None) + SDK_FILES = [ ("rocm-sdk-core", "_rocm_sdk_core/bin/amdhip64_7.dll"), ("rocm-sdk-libraries-custom", "_rocm_sdk_libraries_custom/bin/hipblas.dll"), diff --git a/tests/test_windows_rocm_smoke_local.py b/tests/test_windows_rocm_smoke_local.py index 06cb36f873..c9aa64855c 100644 --- a/tests/test_windows_rocm_smoke_local.py +++ b/tests/test_windows_rocm_smoke_local.py @@ -37,6 +37,12 @@ def test_missing_tokenizer_refused_to_prevent_library_network_fallback(tmp_path) smoke().validate_inputs(model, wav) +def test_local_whisper_model_without_optional_preprocessor_is_valid(tmp_path): + model, wav = assets(tmp_path) + (model / "preprocessor_config.json").unlink() + assert smoke().validate_inputs(model, wav) == (model.resolve(), wav.resolve()) + + def test_no_inputs_are_honestly_model_free(monkeypatch): module = smoke() monkeypatch.setattr(module, "verify_environment", lambda: {"ready": True, "scope": "model-free"}) diff --git a/tests/test_windows_rocm_verification.py b/tests/test_windows_rocm_verification.py index e6f5ebd061..1d728861e8 100644 --- a/tests/test_windows_rocm_verification.py +++ b/tests/test_windows_rocm_verification.py @@ -20,6 +20,7 @@ def good_report(): "torch_gpu": True, "kernel_result": 4.0, "gpu_architecture": "gfx1100", "ct2_gpu_count": 1, "ct2_compute_types": ["float16"], "ct2_binary_sha256": recipe.load_recipe()["ctranslate2"]["binary_sha256"], + "ct2_kernel_result": [1.25, -2.5, 0.0, 8.0], } @@ -44,6 +45,7 @@ def fail(*args, **kwargs): ("python", "/another/python"), ("ct2_binary_sha256", "stock"), ("packages", {"torch": "2.9.1+cu128"}), ("ready", False), ("gpu_architecture", "gfx1031"), + ("ct2_kernel_result", None), ("ct2_kernel_result", [0.0, 0.0, 0.0, 0.0]), ]) def test_probe_rejects_cpu_fallback_wrong_abi_and_unpinned_packages(monkeypatch, field, value): verify = module("verify") diff --git a/tests/test_windows_rocm_whisperx_isolation.py b/tests/test_windows_rocm_whisperx_isolation.py new file mode 100644 index 0000000000..4f63543f07 --- /dev/null +++ b/tests/test_windows_rocm_whisperx_isolation.py @@ -0,0 +1,44 @@ +"""WhisperX must not execute CT2 in Torch's incompatible ROCm 10 process.""" +import importlib +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def gate(monkeypatch): + pipelines = importlib.import_module("core.windows_rocm.pipelines") + isolation = importlib.import_module("core.windows_rocm.isolation_policy") + monkeypatch.setattr(isolation, "isolation_required", lambda: True) + return pipelines + + +@pytest.mark.parametrize("device", ["cpu", "cuda", "rocm", None]) +def test_whisperx_refuses_incompatible_openmp_before_device_probe(gate, monkeypatch, device): + """Reject incompatible WhisperX OpenMP libraries before generic device probing.""" + def forbidden(*args): + pytest.fail("WhisperX touched the generic device gate before refusing unsafe CT2") + monkeypatch.setattr(gate, "pipeline_error", forbidden) + reason = gate.whisperx_error(device) + assert "OpenMP" in reason and "faster-whisper" in reason + + +def test_other_recipes_keep_the_existing_whisperx_gate(gate, monkeypatch): + isolation = importlib.import_module("core.windows_rocm.isolation_policy") + monkeypatch.setattr(isolation, "isolation_required", lambda: False) + monkeypatch.setattr(gate, "pipeline_error", lambda device: f"prior gate: {device}") + assert gate.whisperx_error("cpu") == "prior gate: cpu" + + +def test_pure_torch_cpu_pipelines_remain_available(gate, monkeypatch): + monkeypatch.setattr(gate, "windows_hip_installed", lambda: True) + assert gate.pipeline_error("cpu") is None + + +def test_cached_whisperx_pipeline_cannot_bypass_the_gate(gate): + backend = importlib.import_module("services.asr_backend").WhisperXBackend + instance = object.__new__(backend) + instance._device = "cpu" + instance._asr = SimpleNamespace() + with pytest.raises(gate.PipelineCompatibilityError, match="OpenMP"): + instance._ensure_asr()