From e766c7cbfb69d568ecab90b37f4890319886433f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Juan=20C=20Mu=C3=B1oz=20P?=
<167046844+JuanCMPDev@users.noreply.github.com>
Date: Tue, 30 Jun 2026 17:43:18 -0500
Subject: [PATCH] feat(windows): Native AMD ROCm GPU Acceleration (Resolves
#531) (#538)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(windows): add native ROCm support for AMD GPUs
Implements native ROCm architecture for Windows.
- Adds backend build pipeline for voicebox-server-rocm.exe
- Detects AMD GPUs dynamically and routes PyTorch allocations
- Adds automatic download and update logic for ROCm dependencies
- Refactors UI in GpuPage.tsx and GpuAcceleration.tsx to add AMD flows
- Fixes 'Switch to CPU' lock on Windows via Tauri backend_override state
- Resolves PyInstaller/rocm_sdk UnboundLocalError silent crashes
- Resolves Numba/NumPy 2.x incompatibilities during Qwen3-TTS load
- Resolves HF_HUB_OFFLINE Catch-22 for CustomVoice processor caching
* fix(rocm): host libs archive under the app release tag, drop offline-load regression
Align the ROCm libs download with the CUDA pattern: both the server core and
the libs archive are published under the app-version release tag, with the libs
content version encoded in the filename only. The previous code fetched libs
from a separate rocm7.2-v1 tag, which disagreed with the download test.
Also revert the unrelated Qwen CustomVoice changes that wrapped model loading in
force_offline_if_cached (not imported — a NameError on load for every platform)
and re-added a Base-model cache gate. The inference-path offline guard was
deliberately removed previously.
* feat(rocm): gate download on AMD detection and persist the backend variant
The ROCm download section now only shows when the backend reports an AMD GPU on
Windows (new supports_rocm health field, backed by the memoized
is_amd_gpu_windows detection that was previously unused), or when ROCm is already
downloaded/active.
Make the backend override honor a pinned variant: set_backend_override persists
the choice to disk so it survives an app restart, start_server reads it back,
and a cuda/rocm pin now actually selects that variant instead of always
preferring ROCm. A stale pin to a deleted backend self-heals to the default
order rather than forcing CPU. Add the web no-op stub for the new method.
* chore(rocm): drop incomplete vitest harness for the unused GpuAcceleration component
GpuAcceleration.tsx is not routed anywhere (GpuPage is the live settings view),
and the added vitest setup referenced testing-library/vitest deps that were not
in the lockfile, breaking the web typecheck. Remove the dead component's test
and its scaffolding to keep this PR scoped to the ROCm feature.
* ci(rocm): add ROCm release-artifact pipeline
Mirror the CUDA packaging path for ROCm so the runtime download has artifacts to
fetch. scripts/package_rocm.py splits the PyInstaller --rocm onedir into
voicebox-server-rocm.tar.gz (core) + rocm-libs-rocm7.2-v1.tar.gz (AMD runtime:
HIP DLLs, rocBLAS Tensile data, MIOpen kernel DBs) + rocm-libs.json, matching
the names services/rocm.py expects, both under the app-version release tag.
The new build-rocm-windows job in release.yml builds on windows-latest/cp312 and
lets build_binary.py --rocm pull the official AMD Radeon wheels.
The file classifier can't be validated against a real AMD build on CI, so it has
unit coverage (test_package_rocm.py) against a synthetic onedir layout. The
prefixes/dir markers may need a tweak after the first real build on AMD
hardware — the packager hard-fails loudly if it classifies zero ROCm files.
---------
Co-authored-by: Jamie Pine
---
.github/workflows/release.yml | 61 +++
.gitignore | Bin 738 -> 948 bytes
.../ServerSettings/GpuAcceleration.tsx | 412 +++++++++++----
app/src/components/ServerTab/GpuPage.tsx | 415 ++++++++++++----
app/src/i18n/locales/en/translation.json | 46 +-
app/src/lib/api/client.ts | 18 +
app/src/lib/api/models/ModelStatus.ts | 2 +-
app/src/lib/api/types.ts | 23 +-
app/src/platform/types.ts | 1 +
backend/app.py | 2 +
backend/backends/base.py | 5 +
backend/backends/hume_backend.py | 10 +-
backend/build_binary.py | 303 ++++++++++--
backend/models.py | 3 +-
backend/pyi_rth_rocm_sdk.py | 85 ++++
backend/requirements-rocm.txt | 4 +
backend/routes/__init__.py | 2 +
backend/routes/health.py | 22 +-
backend/routes/rocm.py | 79 +++
backend/server.py | 23 +-
backend/services/rocm.py | 467 ++++++++++++++++++
backend/tests/test_amd_gpu_detect.py | 96 ++++
backend/tests/test_package_rocm.py | 121 +++++
backend/tests/test_rocm_backends.py | 68 +++
backend/tests/test_rocm_build.py | 129 +++++
backend/tests/test_rocm_download.py | 203 ++++++++
backend/tests/test_rocm_requirements.py | 130 +++++
backend/utils/platform_detect.py | 55 ++-
scripts/package_rocm.py | 251 ++++++++++
tauri/src-tauri/src/main.rs | 227 +++++++--
tauri/src/platform/lifecycle.ts | 9 +
web/src/platform/lifecycle.ts | 4 +
32 files changed, 2967 insertions(+), 309 deletions(-)
create mode 100644 backend/pyi_rth_rocm_sdk.py
create mode 100644 backend/requirements-rocm.txt
create mode 100644 backend/routes/rocm.py
create mode 100644 backend/services/rocm.py
create mode 100644 backend/tests/test_amd_gpu_detect.py
create mode 100644 backend/tests/test_package_rocm.py
create mode 100644 backend/tests/test_rocm_backends.py
create mode 100644 backend/tests/test_rocm_build.py
create mode 100644 backend/tests/test_rocm_download.py
create mode 100644 backend/tests/test_rocm_requirements.py
create mode 100644 scripts/package_rocm.py
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e94bcd70..e1bcd3e3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -340,3 +340,64 @@ jobs:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda/
retention-days: 7
+
+ build-rocm-windows:
+ runs-on: windows-latest
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ # ROCm wheels are cp312-cp312-specific — build_binary.py --rocm enforces this.
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Install Python dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install pyinstaller
+ pip install -r backend/requirements.txt
+ pip install --no-deps chatterbox-tts
+ pip install --no-deps hume-tada
+
+ - name: Build ROCm server binary (onedir)
+ shell: bash
+ working-directory: backend
+ # build_binary.py --rocm pulls the official AMD Radeon torch + rocm_sdk
+ # wheels (rocm-rel-7.2.1) itself when ROCm torch is not already present,
+ # then restores the dev torch afterwards.
+ run: python build_binary.py --rocm
+
+ - name: Package into server core + ROCm libs archives
+ shell: bash
+ run: |
+ python scripts/package_rocm.py \
+ backend/dist/voicebox-server-rocm/ \
+ --output release-assets/ \
+ --rocm-libs-version rocm7.2-v1 \
+ --torch-compat ">=2.9.0,<2.10.0"
+
+ - name: Upload archives to GitHub Release
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ release-assets/voicebox-server-rocm.tar.gz
+ release-assets/voicebox-server-rocm.tar.gz.sha256
+ release-assets/rocm-libs-rocm7.2-v1.tar.gz
+ release-assets/rocm-libs-rocm7.2-v1.tar.gz.sha256
+ release-assets/rocm-libs.json
+ draft: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload onedir as workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: voicebox-server-rocm-windows
+ path: backend/dist/voicebox-server-rocm/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index bcc1927cfbb9acb3a74f2690d850495089f27f89..853c5060975fbeadd1fbd442d6dfb9c9a648813d 100644
GIT binary patch
literal 948
zcma)4O^e$w5Y^e>|KO4yT0>;$KS)U^fu&u@(jH1NvNUnDktHF?Nqp&V?~}cOw%bE7
zo{{Fg`PQo1k|(RkN=>mWtW(quPK}0QQx@H5xpDH`l||e7NeX$QwgpggKYf=@{lM|9
zpSUz4!oB9vl?8vC(#hGfxRAYoyvW_>uZv@FgHL6#sy>d|sLGAWj|t97#{@=~tuvGQ
zey#1%-7jU4MCd7#YA(FbN)3Hhbfc_>sAnUg;F@o|-w&b(lC$l%JCt^bsG1OgYeiy?
z6t8oncy)04xsbmcz}OzzLvjKBPp5I{B3B5TLv2M8)w?lLSodTph(zi=8h{xQ-^`l#
zI-Q9SI(t00ejbl;C>J6RH`{miqJy&oSxkguP>ak%7iOV+x@V}48e1s~blx~DO?b_p
zW1e#oW6(_ua=n~7ZAHL7oBB0|f}2f@lp>cR{2RYGEva)iuBAo7zr*JcUWBBD;oe|t
zOQDa`-o_-1A%w+C@FW-Do3_ebW0hTwLgroD@uj;b8oUc4Oh_|$OeMhpRdZZlX7piK
zC5q{H|4zs=o^6xuu?ZAM1C7V?hyAE;X#@zukpkX0wR3CyDlq8(*>%1r%
z43U78Ks_ydP7!8|vxYV7A3v=bB~6*(u6tj7a9ygLD-$?R-0l&qsc;;(KAzIAJ+LVw
zieRHm&Jx`;Hq{L!PUIVcgT(us50e=Yc(7(6oHIwtTuK*nDrQk4I3u
zV}ImS5T4I<8c4aJF+0Pids7L^cEpIqFuJ$k1l90rUDW9Lf|#57A&sr~kVcSCJQHjm
zXJ1mBFfu4HVGYel6B_;D_g}k{7n?y^(@ADvvRB_o++$OV*HrBG=Wg)P6q%0R{6k5F
z`kn}xlr`l=dQRTrkZ#J`NG~d~DeO&9Imk3xg-^>N5}OdPG}AKLhJNUCsQzQob?#080_};Q#;t
diff --git a/app/src/components/ServerSettings/GpuAcceleration.tsx b/app/src/components/ServerSettings/GpuAcceleration.tsx
index d058ffc3..46e0d4bd 100644
--- a/app/src/components/ServerSettings/GpuAcceleration.tsx
+++ b/app/src/components/ServerSettings/GpuAcceleration.tsx
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
-import type { CudaDownloadProgress } from '@/lib/api/types';
+import type { CudaDownloadProgress, RocmDownloadProgress } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -21,6 +21,9 @@ export function GpuAcceleration() {
const [restartPhase, setRestartPhase] = useState('idle');
const [error, setError] = useState(null);
const [downloadProgress, setDownloadProgress] = useState(null);
+ const [rocmDownloadProgress, setRocmDownloadProgress] = useState(
+ null,
+ );
const healthPollRef = useRef | null>(null);
// Query CUDA backend status
@@ -36,10 +39,26 @@ export function GpuAcceleration() {
enabled: !!health, // Only fetch when backend is reachable
});
+ // Query ROCm backend status
+ const {
+ data: rocmStatus,
+ isLoading: _rocmStatusLoading,
+ refetch: refetchRocmStatus,
+ } = useQuery({
+ queryKey: ['rocm-status', serverUrl],
+ queryFn: () => apiClient.getRocmStatus(),
+ refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
+ retry: 1,
+ enabled: !!health, // Only fetch when backend is reachable
+ });
+
// Derived state
const isCurrentlyCuda = health?.backend_variant === 'cuda';
+ const isCurrentlyRocm = health?.backend_variant === 'rocm';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
+ const rocmAvailable = rocmStatus?.available ?? false;
+ const rocmDownloading = rocmStatus?.downloading ?? false;
// Clean up health poll on unmount
useEffect(() => {
@@ -51,7 +70,7 @@ export function GpuAcceleration() {
};
}, []);
- // SSE progress tracking during download
+ // SSE progress tracking during CUDA download
useEffect(() => {
if (!cudaDownloading || !serverUrl) {
return;
@@ -88,6 +107,43 @@ export function GpuAcceleration() {
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
+ // SSE progress tracking during ROCm download
+ useEffect(() => {
+ if (!rocmDownloading || !serverUrl) {
+ return;
+ }
+
+ const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data) as RocmDownloadProgress;
+ setRocmDownloadProgress(data);
+
+ if (data.status === 'complete') {
+ eventSource.close();
+ setRocmDownloadProgress(null);
+ refetchRocmStatus();
+ } else if (data.status === 'error') {
+ eventSource.close();
+ setError(data.error || 'Download failed');
+ setRocmDownloadProgress(null);
+ refetchRocmStatus();
+ }
+ } catch (e) {
+ console.error('Error parsing ROCm progress event:', e);
+ }
+ };
+
+ eventSource.onerror = () => {
+ eventSource.close();
+ };
+
+ return () => {
+ eventSource.close();
+ };
+ }, [rocmDownloading, serverUrl, refetchRocmStatus]);
+
// Start aggressive health polling during restart
const startHealthPolling = useCallback(() => {
if (healthPollRef.current) return;
@@ -113,7 +169,7 @@ export function GpuAcceleration() {
}, 1000);
}, [queryClient]);
- const handleDownload = async () => {
+ const handleDownloadCuda = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
@@ -128,6 +184,21 @@ export function GpuAcceleration() {
}
};
+ const handleDownloadRocm = async () => {
+ setError(null);
+ try {
+ await apiClient.downloadRocmBackend();
+ refetchRocmStatus();
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : 'Failed to start download';
+ if (msg.includes('already downloaded')) {
+ refetchRocmStatus();
+ } else {
+ setError(msg);
+ }
+ }
+ };
+
const handleRestart = async () => {
setError(null);
setRestartPhase('stopping');
@@ -154,18 +225,17 @@ export function GpuAcceleration() {
}
};
- const handleSwitchToCpu = async () => {
- // To switch to CPU: delete the CUDA binary, then restart.
- // start_server always prefers CUDA if present, so we must remove it first.
+ const handleSwitchToCpuFromCuda = async () => {
setError(null);
setRestartPhase('stopping');
try {
- await apiClient.deleteCudaBackend();
+ // Tell Rust launcher to skip GPU binary detection on next start.
+ // We cannot delete an active .exe on Windows, so we override instead.
+ await platform.lifecycle.setBackendOverride('cpu');
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
- // Invoke resolved — server is likely ready
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
@@ -184,7 +254,36 @@ export function GpuAcceleration() {
}
};
- const handleDelete = async () => {
+ const handleSwitchToCpuFromRocm = async () => {
+ setError(null);
+ setRestartPhase('stopping');
+
+ try {
+ // Tell Rust launcher to skip GPU binary detection on next start.
+ // We cannot delete an active .exe on Windows, so we override instead.
+ await platform.lifecycle.setBackendOverride('cpu');
+ setRestartPhase('waiting');
+ startHealthPolling();
+ await platform.lifecycle.restartServer();
+ if (healthPollRef.current) {
+ clearInterval(healthPollRef.current);
+ healthPollRef.current = null;
+ }
+ setRestartPhase('ready');
+ queryClient.invalidateQueries();
+ setTimeout(() => setRestartPhase('idle'), 2000);
+ } catch (e: unknown) {
+ setRestartPhase('idle');
+ if (healthPollRef.current) {
+ clearInterval(healthPollRef.current);
+ healthPollRef.current = null;
+ }
+ setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
+ refetchRocmStatus();
+ }
+ };
+
+ const handleDeleteCuda = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
@@ -194,6 +293,16 @@ export function GpuAcceleration() {
}
};
+ const handleDeleteRocm = async () => {
+ setError(null);
+ try {
+ await apiClient.deleteRocmBackend();
+ refetchRocmStatus();
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Failed to delete ROCm backend');
+ }
+ };
+
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -205,7 +314,7 @@ export function GpuAcceleration() {
// Don't render until health data is available
if (!health) return null;
- // If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
+ // If the system already has native GPU (MPS, ROCm active, etc.), only show info - no download needed
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
@@ -241,8 +350,6 @@ export function GpuAcceleration() {
)}
- {/* Native GPU detected - no CUDA download needed */}
-
{/* Currently running CUDA - show switch back to CPU */}
{isCurrentlyCuda && platform.metadata.isTauri && (
<>
@@ -261,7 +368,12 @@ export function GpuAcceleration() {
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
re-download later).
-