From c1e3771cca0fc0711637b6c228ca9bc68ee338a2 Mon Sep 17 00:00:00 2001 From: Mike Key Date: Wed, 1 Jul 2026 06:48:59 -0600 Subject: [PATCH 1/4] fix: native Linux CUDA/ROCm backend download (Resolves #767) Installing the CUDA or ROCm backend on Linux downloaded a Windows .exe and silently fell back to CPU: the release assets weren't platform-specific and only Windows binaries were published. - Platform-qualify GPU server assets: voicebox-server-{cuda,rocm}-.tar.gz and {cuda,rocm}-libs--.tar.gz, selected at download time via server_asset_platform(). - Add build-cuda-linux / build-rocm-linux release jobs. ROCm pulls torch from the PyTorch ROCm index (whose wheels bundle the HIP/rocBLAS/hipBLASLt/aotriton runtime) instead of the Windows-only rocm_sdk wheels; GPU torch is installed before requirements.txt so pip never resolves the default CUDA stack. - build_binary.py --rocm: gate the Windows rocm_sdk collection/hook on Windows; on Linux PyInstaller's torch hook collects the bundled .so runtime directly. - package_{rocm,cuda}.py: --platform flag; Linux-aware classifier (leading-lib and versioned .so, torch/lib// + torch/share/miopen kernel-data trees). - Offer the ROCm download on Linux AMD hosts (/dev/kfd), gated to platforms we actually build so unsupported arches aren't offered a 404. - Pin free-disk-space by SHA; unit-test the asset-platform contract. Verified end-to-end on Linux/gfx1201 (3x Radeon AI PRO R9700): the built ELF reports gpu_available + ROCm + backend_variant=rocm. CUDA Linux path is build/test-verified only (no NVIDIA hardware). --- .github/workflows/release.yml | 150 +++++++++++++++++++++-- CHANGELOG.md | 10 ++ app/src/components/ServerTab/GpuPage.tsx | 4 +- app/src/lib/api/types.ts | 2 +- backend/build_binary.py | 8 +- backend/models.py | 2 +- backend/routes/health.py | 4 +- backend/services/cuda.py | 6 +- backend/services/rocm.py | 6 +- backend/tests/test_amd_gpu_detect.py | 70 ++++++++++- backend/tests/test_package_rocm.py | 30 ++++- backend/tests/test_rocm_download.py | 23 ++-- backend/utils/platform_detect.py | 41 +++++++ scripts/package_cuda.py | 53 +++++--- scripts/package_rocm.py | 76 ++++++++---- 15 files changed, 413 insertions(+), 72 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1bcd3e3f..f82c8ed8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -317,6 +317,7 @@ jobs: python scripts/package_cuda.py \ backend/dist/voicebox-server-cuda/ \ --output release-assets/ \ + --platform windows-x86_64 \ --cuda-libs-version cu128-v1 \ --torch-compat ">=2.7.0,<2.11.0" @@ -325,10 +326,10 @@ jobs: uses: softprops/action-gh-release@v2 with: files: | - release-assets/voicebox-server-cuda.tar.gz - release-assets/voicebox-server-cuda.tar.gz.sha256 - release-assets/cuda-libs-cu128-v1.tar.gz - release-assets/cuda-libs-cu128-v1.tar.gz.sha256 + release-assets/voicebox-server-cuda-windows-x86_64.tar.gz + release-assets/voicebox-server-cuda-windows-x86_64.tar.gz.sha256 + release-assets/cuda-libs-windows-x86_64-cu128-v1.tar.gz + release-assets/cuda-libs-windows-x86_64-cu128-v1.tar.gz.sha256 release-assets/cuda-libs.json draft: true env: @@ -341,6 +342,70 @@ jobs: path: backend/dist/voicebox-server-cuda/ retention-days: 7 + build-cuda-linux: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Free up disk space + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be + with: + tool-cache: true + large-packages: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install CUDA PyTorch (Linux) first so requirements see it satisfied + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install torch torchaudio \ + --index-url https://download.pytorch.org/whl/cu128 + + - name: Install remaining Python dependencies + run: | + pip install -r backend/requirements.txt + pip install --no-deps chatterbox-tts + pip install --no-deps hume-tada + + - name: Verify CUDA support in torch + run: python -c "import torch; assert torch.version.cuda, 'torch is not a CUDA build'; print('CUDA torch', torch.__version__)" + + - name: Build CUDA server binary (onedir) + working-directory: backend + env: + TORCH_CUDA_ARCH_LIST: "8.0;8.6;8.9;9.0;12.0+PTX" + run: python build_binary.py --cuda + + - name: Package into server core + CUDA libs archives + run: | + python scripts/package_cuda.py \ + backend/dist/voicebox-server-cuda/ \ + --output release-assets/ \ + --platform linux-x86_64 \ + --cuda-libs-version cu128-v1 \ + --torch-compat ">=2.7.0,<2.11.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-cuda-linux-x86_64.tar.gz + release-assets/voicebox-server-cuda-linux-x86_64.tar.gz.sha256 + release-assets/cuda-libs-linux-x86_64-cu128-v1.tar.gz + release-assets/cuda-libs-linux-x86_64-cu128-v1.tar.gz.sha256 + draft: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-rocm-windows: runs-on: windows-latest permissions: @@ -378,6 +443,7 @@ jobs: python scripts/package_rocm.py \ backend/dist/voicebox-server-rocm/ \ --output release-assets/ \ + --platform windows-x86_64 \ --rocm-libs-version rocm7.2-v1 \ --torch-compat ">=2.9.0,<2.10.0" @@ -386,10 +452,10 @@ jobs: 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/voicebox-server-rocm-windows-x86_64.tar.gz + release-assets/voicebox-server-rocm-windows-x86_64.tar.gz.sha256 + release-assets/rocm-libs-windows-x86_64-rocm7.2-v1.tar.gz + release-assets/rocm-libs-windows-x86_64-rocm7.2-v1.tar.gz.sha256 release-assets/rocm-libs.json draft: true env: @@ -401,3 +467,71 @@ jobs: name: voicebox-server-rocm-windows path: backend/dist/voicebox-server-rocm/ retention-days: 7 + + build-rocm-linux: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Free up disk space + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be + with: + tool-cache: true + large-packages: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install ROCm PyTorch (Linux) first so requirements see it satisfied + # Unlike Windows, the Linux ROCm runtime ships inside the torch wheels + # (torch/lib/*.so + rocBLAS/hipBLASLt/aotriton kernel data), so we pull + # it from the PyTorch ROCm index instead of the AMD Radeon SDK wheels. + # Installing it before requirements.txt keeps pip from first pulling the + # default (CUDA) torch stack that requirements.txt's unqualified torch + # would otherwise resolve — avoiding wasted downloads and disk pressure. + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install torch torchaudio \ + --index-url https://download.pytorch.org/whl/rocm7.2 + + - name: Install remaining Python dependencies + run: | + pip install -r backend/requirements.txt + pip install --no-deps chatterbox-tts + pip install --no-deps hume-tada + + - name: Verify ROCm support in torch + run: python -c "import torch; assert torch.version.hip, 'torch is not a ROCm build'; print('ROCm torch', torch.__version__)" + + - name: Build ROCm server binary (onedir) + working-directory: backend + run: python build_binary.py --rocm + + - name: Package into server core + ROCm libs archives + run: | + python scripts/package_rocm.py \ + backend/dist/voicebox-server-rocm/ \ + --output release-assets/ \ + --platform linux-x86_64 \ + --rocm-libs-version rocm7.2-v1 \ + --torch-compat ">=2.12.0,<2.13.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-linux-x86_64.tar.gz + release-assets/voicebox-server-rocm-linux-x86_64.tar.gz.sha256 + release-assets/rocm-libs-linux-x86_64-rocm7.2-v1.tar.gz + release-assets/rocm-libs-linux-x86_64-rocm7.2-v1.tar.gz.sha256 + draft: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b03f5e46..945fbcfbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ Ubuntu render/video group IDs; the container joins the groups that own the GPU device nodes at startup. Native Linux setup now picks ROCm wheels for AMD GPUs and CUDA wheels for NVIDIA GPUs before installing backend dependencies. +- **Downloadable GPU backends install a native Linux binary, not a Windows + `.exe`.** Installing the CUDA or ROCm backend on Linux previously downloaded a + Windows executable and silently fell back to CPU, because the release assets + weren't platform-specific and only Windows binaries were published. GPU server + assets are now platform-qualified (`voicebox-server-{cuda,rocm}-.tar.gz`) + and new `build-cuda-linux` / `build-rocm-linux` release jobs build the Linux + bundles (ROCm from the PyTorch ROCm index, whose wheels bundle the + HIP/rocBLAS/hipBLASLt/aotriton runtime). The ROCm download is now offered on + Linux hosts with an AMD ROCm driver (`/dev/kfd`), not only on Windows. Fixes + the Linux CUDA-installs-a-Windows-exe bug. ## [0.5.0] - 2026-04-22 diff --git a/app/src/components/ServerTab/GpuPage.tsx b/app/src/components/ServerTab/GpuPage.tsx index 906c7ea54..40c651e3f 100644 --- a/app/src/components/ServerTab/GpuPage.tsx +++ b/app/src/components/ServerTab/GpuPage.tsx @@ -161,8 +161,8 @@ export function GpuPage() { const cudaDownloading = cudaStatus?.downloading ?? false; const rocmAvailable = rocmStatus?.available ?? false; const rocmDownloading = rocmStatus?.downloading ?? false; - // The ROCm backend only applies to AMD GPUs on Windows. Show the section when - // the backend detects applicable hardware, or it is already downloaded/active. + // The ROCm backend applies to AMD GPUs on Windows or Linux (/dev/kfd). Show the + // section when the backend detects applicable hardware, or it is already downloaded/active. const supportsRocm = (health?.supports_rocm ?? false) || rocmAvailable || isCurrentlyRocm; useEffect(() => { diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 00d84ed5a..961b79386 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -270,7 +270,7 @@ export interface HealthResponse { vram_used_mb?: number; backend_type?: string; backend_variant?: string; // "cpu", "cuda", or "rocm" - supports_rocm?: boolean; // AMD GPU on Windows — the ROCm backend is applicable + supports_rocm?: boolean; // AMD GPU on Windows or Linux (/dev/kfd) — the ROCm backend is applicable } export interface CudaDownloadProgress { diff --git a/backend/build_binary.py b/backend/build_binary.py index 90829c73d..171e00719 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -348,7 +348,13 @@ def build_server(cuda=False, rocm=False): ) args.extend(gpu_hidden) - if rocm: + if rocm and platform.system() == "Windows": + # Windows delivers the ROCm runtime as separate rocm_sdk wheels (there is + # no system ROCm install). On Linux the download.pytorch.org/whl/rocm + # torch wheels bundle the HIP/MIOpen/rocBLAS .so runtime inside + # torch/lib/ directly, so none of the rocm_sdk collection below applies — + # PyInstaller's torch hook picks those .so up automatically. + # # rocm_sdk imports its backend packages dynamically via # importlib.import_module(py_package_name), which PyInstaller's # static analyzer cannot see. We must collect them explicitly — diff --git a/backend/models.py b/backend/models.py index 01f59b03b..a70a03cf4 100644 --- a/backend/models.py +++ b/backend/models.py @@ -443,7 +443,7 @@ class HealthResponse(BaseModel): vram_used_mb: Optional[float] = None backend_type: Optional[str] = None # Backend type (mlx or pytorch) backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm) - supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable + supports_rocm: bool = False # AMD GPU on Windows or Linux (/dev/kfd) — ROCm backend applicable gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported diff --git a/backend/routes/health.py b/backend/routes/health.py index 1568455dc..007e9b17c 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -13,7 +13,7 @@ from .. import config, models from ..services import tts from ..database import get_db -from ..utils.platform_detect import get_backend_type, is_amd_gpu_windows +from ..utils.platform_detect import get_backend_type, is_amd_rocm_capable router = APIRouter() @@ -186,7 +186,7 @@ async def health(): vram_used_mb=vram_used, backend_type=backend_type, backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", default_variant), - supports_rocm=is_amd_gpu_windows(), + supports_rocm=is_amd_rocm_capable(), gpu_compatibility_warning=gpu_compat_warning, ) diff --git a/backend/services/cuda.py b/backend/services/cuda.py index 87fd8fb3a..53591ca1f 100644 --- a/backend/services/cuda.py +++ b/backend/services/cuda.py @@ -22,6 +22,7 @@ from typing import Optional from ..config import get_data_dir +from ..utils.platform_detect import server_asset_platform from ..utils.progress import get_progress_manager from .. import __version__ @@ -286,8 +287,9 @@ async def _download_cuda_binary_locked(version: Optional[str] = None): ) base_url = f"{GITHUB_RELEASES_URL}/{version}" - server_archive = "voicebox-server-cuda.tar.gz" - libs_archive = f"cuda-libs-{CUDA_LIBS_VERSION}.tar.gz" + plat = server_asset_platform() + server_archive = f"voicebox-server-cuda-{plat}.tar.gz" + libs_archive = f"cuda-libs-{plat}-{CUDA_LIBS_VERSION}.tar.gz" try: async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client: diff --git a/backend/services/rocm.py b/backend/services/rocm.py index f0a001704..7bbc87563 100644 --- a/backend/services/rocm.py +++ b/backend/services/rocm.py @@ -23,6 +23,7 @@ from typing import Optional from ..config import get_data_dir +from ..utils.platform_detect import server_asset_platform from ..utils.progress import get_progress_manager from .. import __version__ @@ -287,8 +288,9 @@ async def _download_rocm_binary_locked(version: Optional[str] = None): # release tag; the libs content version is encoded in the filename only. server_base_url = f"{GITHUB_RELEASES_URL}/{version}" libs_base_url = server_base_url - server_archive = "voicebox-server-rocm.tar.gz" - libs_archive = f"rocm-libs-{ROCM_LIBS_VERSION}.tar.gz" + plat = server_asset_platform() + server_archive = f"voicebox-server-rocm-{plat}.tar.gz" + libs_archive = f"rocm-libs-{plat}-{ROCM_LIBS_VERSION}.tar.gz" # Always stage when any download is needed, then atomically rename over # rocm_dir on success. This prevents a failed mid-extraction from leaving diff --git a/backend/tests/test_amd_gpu_detect.py b/backend/tests/test_amd_gpu_detect.py index cb7c6695d..0a00d1936 100644 --- a/backend/tests/test_amd_gpu_detect.py +++ b/backend/tests/test_amd_gpu_detect.py @@ -11,7 +11,11 @@ import pytest -from backend.utils.platform_detect import is_amd_gpu_windows +from backend.utils.platform_detect import ( + is_amd_gpu_windows, + is_amd_rocm_capable, + server_asset_platform, +) class TestAmdGpuWindows: @@ -43,8 +47,13 @@ def test_detects_amd_via_wmi(self, _mock_run, _mock_system): "backend.utils.platform_detect.subprocess.run", return_value=MagicMock(stdout="0\n", returncode=0), ) - def test_no_amd_via_wmi(self, _mock_run, _mock_system): - """WMI reporting zero AMD adapters should return False.""" + @patch("torch.cuda.is_available", return_value=False) + def test_no_amd_via_wmi(self, _mock_avail, _mock_run, _mock_system): + """WMI reporting zero AMD adapters should return False. + + torch.cuda.is_available is pinned False so the fallback probe can't pick + up real AMD hardware when this runs on an actual ROCm host. + """ assert is_amd_gpu_windows() is False @patch("backend.utils.platform_detect.platform.system", return_value="Windows") @@ -94,3 +103,58 @@ def test_torch_not_installed(self, _mock_run, _mock_system): """When torch is not installed, should return False without crashing.""" with patch.dict("sys.modules", {"torch": None}): assert is_amd_gpu_windows() is False + + +class TestServerAssetPlatform: + """Pin the exact release-asset platform tokens the download URLs depend on.""" + + @pytest.mark.parametrize( + "system,machine,expected", + [ + ("Linux", "x86_64", "linux-x86_64"), + ("Windows", "AMD64", "windows-x86_64"), + ("Linux", "aarch64", "linux-arm64"), + ("Darwin", "arm64", "darwin-arm64"), + ], + ) + def test_tokens(self, system, machine, expected): + with ( + patch("backend.utils.platform_detect.platform.system", return_value=system), + patch("backend.utils.platform_detect.platform.machine", return_value=machine), + ): + assert server_asset_platform() == expected + + +class TestAmdRocmCapable: + """Linux ROCm capability gate (drives the ROCm download UI).""" + + @pytest.fixture(autouse=True) + def _clear_cache(self): + is_amd_rocm_capable.cache_clear() + yield + is_amd_rocm_capable.cache_clear() + + @patch("backend.utils.platform_detect.platform.machine", return_value="x86_64") + @patch("backend.utils.platform_detect.platform.system", return_value="Linux") + @patch("backend.utils.platform_detect.os.path.exists", return_value=True) + def test_linux_x86_with_kfd_is_capable(self, _mock_exists, _mock_system, _mock_machine): + """/dev/kfd present => ROCm-capable, even before ROCm torch is installed.""" + assert is_amd_rocm_capable() is True + + @patch("backend.utils.platform_detect.platform.machine", return_value="x86_64") + @patch("backend.utils.platform_detect.platform.system", return_value="Linux") + @patch("backend.utils.platform_detect.os.path.exists", return_value=False) + def test_linux_without_kfd_is_not_capable(self, _mock_exists, _mock_system, _mock_machine): + assert is_amd_rocm_capable() is False + + @patch("backend.utils.platform_detect.platform.machine", return_value="aarch64") + @patch("backend.utils.platform_detect.platform.system", return_value="Linux") + @patch("backend.utils.platform_detect.os.path.exists", return_value=True) + def test_linux_arm_is_not_offered_unbuilt_asset(self, _mock_exists, _mock_system, _mock_machine): + """ARM has /dev/kfd but no published asset — must not be offered a 404.""" + assert is_amd_rocm_capable() is False + + @patch("backend.utils.platform_detect.platform.machine", return_value="arm64") + @patch("backend.utils.platform_detect.platform.system", return_value="Darwin") + def test_macos_is_not_capable(self, _mock_system, _mock_machine): + assert is_amd_rocm_capable() is False diff --git a/backend/tests/test_package_rocm.py b/backend/tests/test_package_rocm.py index f2d9d326f..aa142f4fb 100644 --- a/backend/tests/test_package_rocm.py +++ b/backend/tests/test_package_rocm.py @@ -37,6 +37,19 @@ class TestIsRocmFile: "_internal/_rocm_sdk_libraries_custom/lib/miopen/db/kernels.kdb", # Windows path separators must be handled too. "_internal\\torch\\lib\\rccl.dll", + # Linux layout: leading 'lib', .so (incl. version suffix), and the + # torch/lib// kernel-data trees the rocm torch wheels bundle. + "_internal/torch/lib/libamdhip64.so", + "_internal/torch/lib/librocblas.so", + "_internal/torch/lib/libMIOpen.so", + "_internal/torch/lib/libaotriton_v2.so.0.11.2", + "_internal/torch/lib/rocblas/library/TensileLibrary_gfx1201.dat", + "_internal/torch/lib/hipblaslt/library/Kernels.so-000.co", + "_internal/torch/lib/aotriton.images/images/amd-gfx120x/flash.aks2", + "_internal/torch/lib/aotriton.images/images/amd-gfx942/__signature__", + "_internal/torch/lib/librocroller.so", + "_internal/torch/share/miopen/db/gfx1201_110.HIP.fdb.txt", + "_internal/torch/share/miopen/db/gfx1201_110.db.txt", ], ) def test_runtime_files_are_rocm(self, rel_path): @@ -49,6 +62,9 @@ def test_runtime_files_are_rocm(self, rel_path): "_internal/python312.dll", "_internal/torch/lib/torch_cpu.dll", "_internal/torch/lib/c10.dll", + # Linux core torch shared objects (leading 'lib', not ROCm). + "_internal/torch/lib/libtorch_cpu.so", + "_internal/torch/lib/libc10.so", # Pure-python rocm_sdk glue stays in the core, even under an SDK dir. "_internal/rocm_sdk/__init__.py", "_internal/_rocm_sdk_core/_dist_info.py", @@ -86,14 +102,14 @@ def test_split_and_manifest(self, tmp_path): ) out = tmp_path / "release-assets" - package_rocm.package(onedir, out, "rocm7.2-v1", ">=2.9.0,<2.10.0") + package_rocm.package(onedir, out, "rocm7.2-v1", ">=2.9.0,<2.10.0", "linux-x86_64") - server = out / "voicebox-server-rocm.tar.gz" - libs = out / "rocm-libs-rocm7.2-v1.tar.gz" + server = out / "voicebox-server-rocm-linux-x86_64.tar.gz" + libs = out / "rocm-libs-linux-x86_64-rocm7.2-v1.tar.gz" assert server.exists() assert libs.exists() - assert (out / "voicebox-server-rocm.tar.gz.sha256").exists() - assert (out / "rocm-libs-rocm7.2-v1.tar.gz.sha256").exists() + assert (out / "voicebox-server-rocm-linux-x86_64.tar.gz.sha256").exists() + assert (out / "rocm-libs-linux-x86_64-rocm7.2-v1.tar.gz.sha256").exists() with tarfile.open(libs) as tar: lib_names = set(tar.getnames()) @@ -118,4 +134,6 @@ def test_empty_rocm_set_exits(self, tmp_path): _write(onedir / "_internal" / "torch" / "lib" / "torch_cpu.dll") with pytest.raises(SystemExit): - package_rocm.package(onedir, tmp_path / "out", "rocm7.2-v1", ">=2.9.0,<2.10.0") + package_rocm.package( + onedir, tmp_path / "out", "rocm7.2-v1", ">=2.9.0,<2.10.0", "linux-x86_64" + ) diff --git a/backend/tests/test_rocm_download.py b/backend/tests/test_rocm_download.py index 174ed1a9d..a495098d7 100644 --- a/backend/tests/test_rocm_download.py +++ b/backend/tests/test_rocm_download.py @@ -136,26 +136,35 @@ async def test_download_rocm_binary_progress_reporting(mock_backends_dir, fake_t server_sha = hashlib.sha256(fake_tar_gz).hexdigest() libs_sha = hashlib.sha256(fake_tar_gz).hexdigest() + # Pin the platform token so this exercises the exact production URL contract + # rather than whatever server_asset_platform() happens to return on the host. + plat = "linux-x86_64" + base = "https://github.com/jamiepine/voicebox/releases/download/v0.2.3" + server_name = f"voicebox-server-rocm-{plat}.tar.gz" + libs_name = f"rocm-libs-{plat}-{rocm.ROCM_LIBS_VERSION}.tar.gz" responses = { - "https://github.com/jamiepine/voicebox/releases/download/v0.2.3/voicebox-server-rocm.tar.gz": FakeResponse( + f"{base}/{server_name}": FakeResponse( content=fake_tar_gz, headers={"content-length": str(len(fake_tar_gz))}, ), - "https://github.com/jamiepine/voicebox/releases/download/v0.2.3/voicebox-server-rocm.tar.gz.sha256": FakeResponse( - content=f"{server_sha} voicebox-server-rocm.tar.gz\n".encode(), + f"{base}/{server_name}.sha256": FakeResponse( + content=f"{server_sha} {server_name}\n".encode(), ), - f"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/rocm-libs-{rocm.ROCM_LIBS_VERSION}.tar.gz": FakeResponse( + f"{base}/{libs_name}": FakeResponse( content=fake_tar_gz, headers={"content-length": str(len(fake_tar_gz))}, ), - f"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/rocm-libs-{rocm.ROCM_LIBS_VERSION}.tar.gz.sha256": FakeResponse( - content=f"{libs_sha} rocm-libs.tar.gz\n".encode(), + f"{base}/{libs_name}.sha256": FakeResponse( + content=f"{libs_sha} {libs_name}\n".encode(), ), } fake_client = FakeHttpxClient(responses) - with patch("httpx.AsyncClient", return_value=fake_client): + with ( + patch("httpx.AsyncClient", return_value=fake_client), + patch.object(rocm, "server_asset_platform", return_value=plat), + ): await rocm.download_rocm_binary(version="v0.2.3") # Verify extraction diff --git a/backend/utils/platform_detect.py b/backend/utils/platform_detect.py index 99bf97b5b..7e157fcf5 100644 --- a/backend/utils/platform_detect.py +++ b/backend/utils/platform_detect.py @@ -2,6 +2,7 @@ Platform detection for backend selection. """ +import os import platform import subprocess from functools import lru_cache @@ -69,6 +70,46 @@ def is_amd_gpu_windows() -> bool: return False +# Platform tokens for which we actually build and publish downloadable GPU +# server assets. Anything else must not be offered a download (it would 404). +SUPPORTED_GPU_ASSET_PLATFORMS = frozenset({"linux-x86_64", "windows-x86_64"}) + + +@lru_cache(maxsize=1) +def is_amd_rocm_capable() -> bool: + """Whether this host can run the downloadable ROCm backend. + + Decides whether to offer the ROCm backend download in the UI, so it must + work before ROCm torch is installed (can't rely on torch.version.hip). + + Windows: reuse the WMI/torch AMD probe. Linux: /dev/kfd is created by the + amdgpu kernel driver whenever ROCm compute is available — the same gate the + Docker entrypoint and setup recipe use. Gated on a platform we publish + assets for so unsupported arches (e.g. linux-arm64) aren't offered a 404. + """ + if server_asset_platform() not in SUPPORTED_GPU_ASSET_PLATFORMS: + return False + system = platform.system() + if system == "Windows": + return is_amd_gpu_windows() + if system == "Linux": + return os.path.exists("/dev/kfd") + return False + + +def server_asset_platform() -> str: + """Platform token identifying which downloadable GPU server assets to fetch. + + Windows and Linux ROCm/CUDA builds are distinct binaries published as + separate release assets, so the download URL must be qualified by platform. + Only the platforms that ship a downloadable GPU backend are meaningful here + (macOS uses MLX/Metal and has no CUDA/ROCm server to download). + """ + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x86_64" + return f"{platform.system().lower()}-{arch}" + + def get_backend_type() -> Literal["mlx", "pytorch"]: """ Detect the best backend for the current platform. diff --git a/scripts/package_cuda.py b/scripts/package_cuda.py index 558f7174e..46dd254d7 100644 --- a/scripts/package_cuda.py +++ b/scripts/package_cuda.py @@ -1,10 +1,11 @@ """ Package the PyInstaller --onedir CUDA build into two archives. -Takes the PyInstaller --onedir output directory and splits it into: - 1. voicebox-server-cuda.tar.gz — server core (exe + non-NVIDIA deps) - 2. cuda-libs-cu128.tar.gz — NVIDIA runtime libraries only - 3. cuda-libs.json — version manifest for the CUDA libs +Takes the PyInstaller --onedir output directory and splits it into +(names carry a platform token, e.g. linux-x86_64, via --platform): + 1. voicebox-server-cuda-{platform}.tar.gz — server core (exe + non-NVIDIA deps) + 2. cuda-libs-{platform}-{version}.tar.gz — NVIDIA runtime libraries only + 3. cuda-libs.json — version manifest for the CUDA libs Usage: python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ @@ -73,12 +74,14 @@ def is_nvidia_file(rel_path: str) -> bool: if part == "nvidia": return True - # NVIDIA DLLs anywhere in the tree (e.g. _internal/torch/lib/cublas64_12.dll) + # NVIDIA shared objects anywhere. Windows: cublas64_12.dll. Linux: + # libcublas.so / libcublas.so.12 — a leading "lib" and a version suffix the + # simple ".dll"/".so" split would otherwise miss. name = rel_lower.rsplit("/", 1)[-1] - if name.endswith(".dll") or name.endswith(".so"): - name_no_ext = name.rsplit(".", 1)[0] + if name.endswith(".dll") or ".so" in name: + stem = name.split(".dll", 1)[0].split(".so", 1)[0].removeprefix("lib") for prefix in NVIDIA_DLL_PREFIXES: - if name_no_ext.startswith(prefix): + if stem.startswith(prefix): return True return False @@ -96,11 +99,21 @@ def sha256_file(path: Path) -> str: return h.hexdigest() +def platform_tag() -> str: + """Platform token for release-asset names (mirrors backend server_asset_platform).""" + import platform + + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x86_64" + return f"{platform.system().lower()}-{arch}" + + def package( onedir_path: Path, output_dir: Path, cuda_libs_version: str, torch_compat: str, + plat: str, ): output_dir.mkdir(parents=True, exist_ok=True) @@ -140,27 +153,29 @@ def package( # Create server core archive # Files are stored relative to the archive root (no parent directory prefix) # so extracting to backends/cuda/ puts everything at the right level. - server_archive = output_dir / "voicebox-server-cuda.tar.gz" + server_name = f"voicebox-server-cuda-{plat}.tar.gz" + server_archive = output_dir / server_name print(f"\nCreating server core archive: {server_archive.name}") with tarfile.open(server_archive, "w:gz") as tar: for rel_str, full_path in core_files: tar.add(full_path, arcname=rel_str) server_sha = sha256_file(server_archive) - (output_dir / "voicebox-server-cuda.tar.gz.sha256").write_text( - f"{server_sha} voicebox-server-cuda.tar.gz\n" + (output_dir / f"{server_name}.sha256").write_text( + f"{server_sha} {server_name}\n" ) print(f" Size: {server_archive.stat().st_size / (1024**2):.1f} MB") print(f" SHA-256: {server_sha[:16]}...") # Create CUDA libs archive - cuda_libs_archive = output_dir / f"cuda-libs-{cuda_libs_version}.tar.gz" + cuda_libs_name = f"cuda-libs-{plat}-{cuda_libs_version}.tar.gz" + cuda_libs_archive = output_dir / cuda_libs_name print(f"\nCreating CUDA libs archive: {cuda_libs_archive.name}") with tarfile.open(cuda_libs_archive, "w:gz") as tar: for rel_str, full_path in nvidia_files: tar.add(full_path, arcname=rel_str) cuda_sha = sha256_file(cuda_libs_archive) - (output_dir / f"cuda-libs-{cuda_libs_version}.tar.gz.sha256").write_text( - f"{cuda_sha} cuda-libs-{cuda_libs_version}.tar.gz\n" + (output_dir / f"{cuda_libs_name}.sha256").write_text( + f"{cuda_sha} {cuda_libs_name}\n" ) print(f" Size: {cuda_libs_archive.stat().st_size / (1024**2):.1f} MB") print(f" SHA-256: {cuda_sha[:16]}...") @@ -217,6 +232,13 @@ def main(): default=">=2.7.0,<2.11.0", help="Torch version compatibility range (default: >=2.6.0,<2.11.0)", ) + parser.add_argument( + "--platform", + type=str, + default=None, + help="Platform token for asset names, e.g. linux-x86_64 " + "(default: auto-detect from the current host)", + ) args = parser.parse_args() if not args.input.is_dir(): @@ -225,7 +247,8 @@ def main(): sys.exit(1) output_dir = args.output or args.input.parent - package(args.input, output_dir, args.cuda_libs_version, args.torch_compat) + plat = args.platform or platform_tag() + package(args.input, output_dir, args.cuda_libs_version, args.torch_compat, plat) if __name__ == "__main__": diff --git a/scripts/package_rocm.py b/scripts/package_rocm.py index 2bd5ea271..902866100 100644 --- a/scripts/package_rocm.py +++ b/scripts/package_rocm.py @@ -1,10 +1,11 @@ """ Package the PyInstaller --onedir ROCm build into two archives. -Takes the PyInstaller --onedir output directory and splits it into: - 1. voicebox-server-rocm.tar.gz — server core (exe + non-AMD deps) - 2. rocm-libs-{version}.tar.gz — AMD/ROCm runtime libraries only - 3. rocm-libs.json — version manifest for the ROCm libs +Takes the PyInstaller --onedir output directory and splits it into +(names carry a platform token, e.g. linux-x86_64, via --platform): + 1. voicebox-server-rocm-{platform}.tar.gz — server core (exe + non-AMD deps) + 2. rocm-libs-{platform}-{version}.tar.gz — AMD/ROCm runtime libraries only + 3. rocm-libs.json — version manifest for the ROCm libs Mirrors scripts/package_cuda.py. The split lets the server core re-download on every app update while the much larger ROCm runtime stays cached until the @@ -43,6 +44,7 @@ "rocrand", "rocsolver", "rocsparse", + "rocroller", "rocprofiler", "roctracer", "roctx", @@ -51,23 +53,31 @@ "rccl", "hsa-runtime", "hsa", + "aotriton", ) # Directory markers for the bundled ROCm SDK runtime packages. Everything under # these trees except Python sources (the pure-python rocm_sdk glue) is part of # the runtime payload — this is where rocBLAS Tensile data and MIOpen kernel # databases live, which dominate the download size. +# +# Windows: the ROCm runtime ships as separate rocm_sdk wheels (the _rocm_sdk_* +# trees). Linux: the download.pytorch.org/whl/rocm torch wheels bundle the +# runtime kernel data under torch/lib// instead (rocBLAS/hipBLASLt +# Tensile data ~5 GB, aotriton kernel images ~0.9 GB). Any file under either +# tree is runtime payload and belongs in the cached libs archive. ROCM_LIB_DIR_MARKERS = ( "_rocm_sdk_core", "_rocm_sdk_libraries_custom", "rocm_sdk_core", "rocm_sdk_libraries_custom", + "torch/lib/rocblas/", + "torch/lib/hipblaslt/", + "torch/lib/hipsparselt/", + "torch/lib/aotriton.images/", + "torch/share/miopen/", ) -# Heavy native/data extensions shipped by the ROCm runtime (HIP fat binaries, -# rocBLAS Tensile data, MIOpen kernel DBs). -ROCM_LIB_EXTS = (".dll", ".so", ".dat", ".db", ".kdb", ".hsaco", ".co", ".bc") - # Python sources stay in the server core so the rocm_sdk import glue remains # alongside the exe. (Both archives extract into backends/rocm/, so this only # affects which archive carries the file, not runtime resolution.) @@ -88,16 +98,18 @@ def is_rocm_file(rel_path: str) -> bool: if name.endswith(_PYTHON_EXTS): return False - # Native payload inside the bundled ROCm SDK package trees. + # Everything under a ROCm runtime payload tree (Windows rocm_sdk wheels or + # Linux torch/lib// kernel data) is cached-libs content. if any(marker in rel_lower for marker in ROCM_LIB_DIR_MARKERS): - if name.endswith(ROCM_LIB_EXTS): - return True + return True - # ROCm/HIP DLLs/shared objects anywhere (e.g. _internal/torch/lib/amdhip64.dll). - if name.endswith((".dll", ".so")): - name_no_ext = name.rsplit(".", 1)[0] + # ROCm/HIP shared objects anywhere. Windows names them amdhip64.dll; Linux + # names them libamdhip64.so / libaotriton_v2.so.0.11.2 — a leading "lib" and + # a version suffix the simple ".dll"/".so" split would otherwise miss. + if name.endswith(".dll") or ".so" in name: + stem = name.split(".dll", 1)[0].split(".so", 1)[0].removeprefix("lib") for prefix in ROCM_DLL_PREFIXES: - if name_no_ext.startswith(prefix): + if stem.startswith(prefix): return True return False @@ -115,11 +127,21 @@ def sha256_file(path: Path) -> str: return h.hexdigest() +def platform_tag() -> str: + """Platform token for release-asset names (mirrors backend server_asset_platform).""" + import platform + + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x86_64" + return f"{platform.system().lower()}-{arch}" + + def package( onedir_path: Path, output_dir: Path, rocm_libs_version: str, torch_compat: str, + plat: str, ): output_dir.mkdir(parents=True, exist_ok=True) @@ -159,27 +181,29 @@ def package( # Create server core archive. Files are stored relative to the archive root # (no parent prefix) so extracting to backends/rocm/ lands at the right level. - server_archive = output_dir / "voicebox-server-rocm.tar.gz" + server_name = f"voicebox-server-rocm-{plat}.tar.gz" + server_archive = output_dir / server_name print(f"\nCreating server core archive: {server_archive.name}") with tarfile.open(server_archive, "w:gz") as tar: for rel_str, full_path in core_files: tar.add(full_path, arcname=rel_str) server_sha = sha256_file(server_archive) - (output_dir / "voicebox-server-rocm.tar.gz.sha256").write_text( - f"{server_sha} voicebox-server-rocm.tar.gz\n" + (output_dir / f"{server_name}.sha256").write_text( + f"{server_sha} {server_name}\n" ) print(f" Size: {server_archive.stat().st_size / (1024**2):.1f} MB") print(f" SHA-256: {server_sha[:16]}...") # Create ROCm libs archive. - rocm_libs_archive = output_dir / f"rocm-libs-{rocm_libs_version}.tar.gz" + rocm_libs_name = f"rocm-libs-{plat}-{rocm_libs_version}.tar.gz" + rocm_libs_archive = output_dir / rocm_libs_name print(f"\nCreating ROCm libs archive: {rocm_libs_archive.name}") with tarfile.open(rocm_libs_archive, "w:gz") as tar: for rel_str, full_path in rocm_files: tar.add(full_path, arcname=rel_str) rocm_sha = sha256_file(rocm_libs_archive) - (output_dir / f"rocm-libs-{rocm_libs_version}.tar.gz.sha256").write_text( - f"{rocm_sha} rocm-libs-{rocm_libs_version}.tar.gz\n" + (output_dir / f"{rocm_libs_name}.sha256").write_text( + f"{rocm_sha} {rocm_libs_name}\n" ) print(f" Size: {rocm_libs_archive.stat().st_size / (1024**2):.1f} MB") print(f" SHA-256: {rocm_sha[:16]}...") @@ -236,6 +260,13 @@ def main(): default=">=2.9.0,<2.10.0", help="Torch version compatibility range (default: >=2.9.0,<2.10.0)", ) + parser.add_argument( + "--platform", + type=str, + default=None, + help="Platform token for asset names, e.g. linux-x86_64 " + "(default: auto-detect from the current host)", + ) args = parser.parse_args() if not args.input.is_dir(): @@ -244,7 +275,8 @@ def main(): sys.exit(1) output_dir = args.output or args.input.parent - package(args.input, output_dir, args.rocm_libs_version, args.torch_compat) + plat = args.platform or platform_tag() + package(args.input, output_dir, args.rocm_libs_version, args.torch_compat, plat) if __name__ == "__main__": From eb613a60f8e5b38935f56ea122a1bdf126d2c573 Mon Sep 17 00:00:00 2001 From: Mike Key Date: Fri, 3 Jul 2026 13:00:25 -0600 Subject: [PATCH 2/4] refactor: dedupe GPU packaging scripts and matrix the Linux release jobs - require --platform in package_cuda/package_rocm; drop the triplicated platform_tag() auto-detect (CI always passes it explicitly) - share matches_lib_prefix() and sha256_file() from package_cuda in package_rocm instead of copy-pasting - collapse build-cuda-linux/build-rocm-linux into one matrixed build-gpu-linux job so the variants can't drift --- .github/workflows/release.yml | 125 ++++++++++--------------------- backend/utils/platform_detect.py | 2 +- scripts/package_cuda.py | 38 ++++------ scripts/package_rocm.py | 47 +++--------- 4 files changed, 67 insertions(+), 145 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f82c8ed8f..157b645d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,10 +342,27 @@ jobs: path: backend/dist/voicebox-server-cuda/ retention-days: 7 - build-cuda-linux: + build-gpu-linux: runs-on: ubuntu-latest permissions: contents: write + strategy: + fail-fast: false + matrix: + include: + - backend: cuda + torch_index: https://download.pytorch.org/whl/cu128 + torch_attr: cuda + libs_version: cu128-v1 + torch_compat: ">=2.7.0,<2.11.0" + # Ampere through Blackwell; +PTX keeps forward-compat for newer arches. + arch_list: "8.0;8.6;8.9;9.0;12.0+PTX" + - backend: rocm + torch_index: https://download.pytorch.org/whl/rocm7.2 + torch_attr: hip + libs_version: rocm7.2-v1 + torch_compat: ">=2.12.0,<2.13.0" + arch_list: "" steps: - uses: actions/checkout@v4 @@ -362,12 +379,18 @@ jobs: python-version: "3.12" cache: "pip" - - name: Install CUDA PyTorch (Linux) first so requirements see it satisfied + - name: Install GPU PyTorch (Linux) first so requirements see it satisfied + # The Linux GPU runtime ships inside the torch wheels (for ROCm: + # torch/lib/*.so + rocBLAS/hipBLASLt/aotriton kernel data — unlike + # Windows, which uses separate rocm_sdk wheels). Installing it before + # requirements.txt keeps pip from first pulling the default torch stack + # that requirements.txt's unqualified torch would otherwise resolve — + # avoiding wasted downloads and disk pressure. run: | python -m pip install --upgrade pip pip install pyinstaller pip install torch torchaudio \ - --index-url https://download.pytorch.org/whl/cu128 + --index-url ${{ matrix.torch_index }} - name: Install remaining Python dependencies run: | @@ -375,33 +398,33 @@ jobs: pip install --no-deps chatterbox-tts pip install --no-deps hume-tada - - name: Verify CUDA support in torch - run: python -c "import torch; assert torch.version.cuda, 'torch is not a CUDA build'; print('CUDA torch', torch.__version__)" + - name: Verify GPU support in torch + run: python -c "import torch; assert torch.version.${{ matrix.torch_attr }}, 'torch is not a ${{ matrix.backend }} build'; print(torch.__version__)" - - name: Build CUDA server binary (onedir) + - name: Build GPU server binary (onedir) working-directory: backend env: - TORCH_CUDA_ARCH_LIST: "8.0;8.6;8.9;9.0;12.0+PTX" - run: python build_binary.py --cuda + TORCH_CUDA_ARCH_LIST: ${{ matrix.arch_list }} + run: python build_binary.py --${{ matrix.backend }} - - name: Package into server core + CUDA libs archives + - name: Package into server core + GPU libs archives run: | - python scripts/package_cuda.py \ - backend/dist/voicebox-server-cuda/ \ + python scripts/package_${{ matrix.backend }}.py \ + backend/dist/voicebox-server-${{ matrix.backend }}/ \ --output release-assets/ \ --platform linux-x86_64 \ - --cuda-libs-version cu128-v1 \ - --torch-compat ">=2.7.0,<2.11.0" + --${{ matrix.backend }}-libs-version ${{ matrix.libs_version }} \ + --torch-compat "${{ matrix.torch_compat }}" - name: Upload archives to GitHub Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: files: | - release-assets/voicebox-server-cuda-linux-x86_64.tar.gz - release-assets/voicebox-server-cuda-linux-x86_64.tar.gz.sha256 - release-assets/cuda-libs-linux-x86_64-cu128-v1.tar.gz - release-assets/cuda-libs-linux-x86_64-cu128-v1.tar.gz.sha256 + release-assets/voicebox-server-${{ matrix.backend }}-linux-x86_64.tar.gz + release-assets/voicebox-server-${{ matrix.backend }}-linux-x86_64.tar.gz.sha256 + release-assets/${{ matrix.backend }}-libs-linux-x86_64-${{ matrix.libs_version }}.tar.gz + release-assets/${{ matrix.backend }}-libs-linux-x86_64-${{ matrix.libs_version }}.tar.gz.sha256 draft: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -467,71 +490,3 @@ jobs: name: voicebox-server-rocm-windows path: backend/dist/voicebox-server-rocm/ retention-days: 7 - - build-rocm-linux: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - - - name: Free up disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be - with: - tool-cache: true - large-packages: false - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: "pip" - - - name: Install ROCm PyTorch (Linux) first so requirements see it satisfied - # Unlike Windows, the Linux ROCm runtime ships inside the torch wheels - # (torch/lib/*.so + rocBLAS/hipBLASLt/aotriton kernel data), so we pull - # it from the PyTorch ROCm index instead of the AMD Radeon SDK wheels. - # Installing it before requirements.txt keeps pip from first pulling the - # default (CUDA) torch stack that requirements.txt's unqualified torch - # would otherwise resolve — avoiding wasted downloads and disk pressure. - run: | - python -m pip install --upgrade pip - pip install pyinstaller - pip install torch torchaudio \ - --index-url https://download.pytorch.org/whl/rocm7.2 - - - name: Install remaining Python dependencies - run: | - pip install -r backend/requirements.txt - pip install --no-deps chatterbox-tts - pip install --no-deps hume-tada - - - name: Verify ROCm support in torch - run: python -c "import torch; assert torch.version.hip, 'torch is not a ROCm build'; print('ROCm torch', torch.__version__)" - - - name: Build ROCm server binary (onedir) - working-directory: backend - run: python build_binary.py --rocm - - - name: Package into server core + ROCm libs archives - run: | - python scripts/package_rocm.py \ - backend/dist/voicebox-server-rocm/ \ - --output release-assets/ \ - --platform linux-x86_64 \ - --rocm-libs-version rocm7.2-v1 \ - --torch-compat ">=2.12.0,<2.13.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-linux-x86_64.tar.gz - release-assets/voicebox-server-rocm-linux-x86_64.tar.gz.sha256 - release-assets/rocm-libs-linux-x86_64-rocm7.2-v1.tar.gz - release-assets/rocm-libs-linux-x86_64-rocm7.2-v1.tar.gz.sha256 - draft: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/backend/utils/platform_detect.py b/backend/utils/platform_detect.py index 7e157fcf5..819cf51d8 100644 --- a/backend/utils/platform_detect.py +++ b/backend/utils/platform_detect.py @@ -72,7 +72,7 @@ def is_amd_gpu_windows() -> bool: # Platform tokens for which we actually build and publish downloadable GPU # server assets. Anything else must not be offered a download (it would 404). -SUPPORTED_GPU_ASSET_PLATFORMS = frozenset({"linux-x86_64", "windows-x86_64"}) +SUPPORTED_GPU_ASSET_PLATFORMS = ("linux-x86_64", "windows-x86_64") @lru_cache(maxsize=1) diff --git a/scripts/package_cuda.py b/scripts/package_cuda.py index 46dd254d7..62e97224c 100644 --- a/scripts/package_cuda.py +++ b/scripts/package_cuda.py @@ -74,16 +74,20 @@ def is_nvidia_file(rel_path: str) -> bool: if part == "nvidia": return True - # NVIDIA shared objects anywhere. Windows: cublas64_12.dll. Linux: - # libcublas.so / libcublas.so.12 — a leading "lib" and a version suffix the - # simple ".dll"/".so" split would otherwise miss. - name = rel_lower.rsplit("/", 1)[-1] + # NVIDIA shared objects anywhere (e.g. _internal/torch/lib/cublas64_12.dll). + return matches_lib_prefix(rel_lower.rsplit("/", 1)[-1], NVIDIA_DLL_PREFIXES) + + +def matches_lib_prefix(name: str, prefixes: tuple) -> bool: + """Whether a shared-library file name matches one of the GPU lib prefixes. + + Handles Windows (cublas64_12.dll) and Linux (libcublas.so, libcublas.so.12) + naming — a leading "lib" and a version suffix the simple ".dll"/".so" split + would otherwise miss. Shared with package_rocm.py. + """ if name.endswith(".dll") or ".so" in name: stem = name.split(".dll", 1)[0].split(".so", 1)[0].removeprefix("lib") - for prefix in NVIDIA_DLL_PREFIXES: - if stem.startswith(prefix): - return True - + return stem.startswith(prefixes) return False @@ -99,15 +103,6 @@ def sha256_file(path: Path) -> str: return h.hexdigest() -def platform_tag() -> str: - """Platform token for release-asset names (mirrors backend server_asset_platform).""" - import platform - - machine = platform.machine().lower() - arch = "arm64" if machine in ("arm64", "aarch64") else "x86_64" - return f"{platform.system().lower()}-{arch}" - - def package( onedir_path: Path, output_dir: Path, @@ -235,9 +230,9 @@ def main(): parser.add_argument( "--platform", type=str, - default=None, - help="Platform token for asset names, e.g. linux-x86_64 " - "(default: auto-detect from the current host)", + required=True, + help="Platform token for asset names, e.g. linux-x86_64. Explicit so a " + "cross-platform CI runner can never mislabel an asset.", ) args = parser.parse_args() @@ -247,8 +242,7 @@ def main(): sys.exit(1) output_dir = args.output or args.input.parent - plat = args.platform or platform_tag() - package(args.input, output_dir, args.cuda_libs_version, args.torch_compat, plat) + package(args.input, output_dir, args.cuda_libs_version, args.torch_compat, args.platform) if __name__ == "__main__": diff --git a/scripts/package_rocm.py b/scripts/package_rocm.py index 902866100..ff7a5fcca 100644 --- a/scripts/package_rocm.py +++ b/scripts/package_rocm.py @@ -18,12 +18,14 @@ """ import argparse -import hashlib import json import sys import tarfile from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from package_cuda import matches_lib_prefix, sha256_file # noqa: E402 + # DLL/.so name prefixes that identify AMD ROCm/HIP runtime libraries. They may # sit in torch/lib/ (torch's bundled HIP runtime) or inside the bundled ROCm SDK # packages. Matched case-insensitively against the file's base name. @@ -103,37 +105,9 @@ def is_rocm_file(rel_path: str) -> bool: if any(marker in rel_lower for marker in ROCM_LIB_DIR_MARKERS): return True - # ROCm/HIP shared objects anywhere. Windows names them amdhip64.dll; Linux - # names them libamdhip64.so / libaotriton_v2.so.0.11.2 — a leading "lib" and - # a version suffix the simple ".dll"/".so" split would otherwise miss. - if name.endswith(".dll") or ".so" in name: - stem = name.split(".dll", 1)[0].split(".so", 1)[0].removeprefix("lib") - for prefix in ROCM_DLL_PREFIXES: - if stem.startswith(prefix): - return True - - return False - - -def sha256_file(path: Path) -> str: - """Compute SHA-256 hex digest of a file.""" - h = hashlib.sha256() - with open(path, "rb") as f: - while True: - chunk = f.read(1024 * 1024) - if not chunk: - break - h.update(chunk) - return h.hexdigest() - - -def platform_tag() -> str: - """Platform token for release-asset names (mirrors backend server_asset_platform).""" - import platform - - machine = platform.machine().lower() - arch = "arm64" if machine in ("arm64", "aarch64") else "x86_64" - return f"{platform.system().lower()}-{arch}" + # ROCm/HIP shared objects anywhere (amdhip64.dll, libamdhip64.so, + # libaotriton_v2.so.0.11.2 — lib prefix and version suffix handled). + return matches_lib_prefix(name, ROCM_DLL_PREFIXES) def package( @@ -263,9 +237,9 @@ def main(): parser.add_argument( "--platform", type=str, - default=None, - help="Platform token for asset names, e.g. linux-x86_64 " - "(default: auto-detect from the current host)", + required=True, + help="Platform token for asset names, e.g. linux-x86_64. Explicit so a " + "cross-platform CI runner can never mislabel an asset.", ) args = parser.parse_args() @@ -275,8 +249,7 @@ def main(): sys.exit(1) output_dir = args.output or args.input.parent - plat = args.platform or platform_tag() - package(args.input, output_dir, args.rocm_libs_version, args.torch_compat, plat) + package(args.input, output_dir, args.rocm_libs_version, args.torch_compat, args.platform) if __name__ == "__main__": From 79037358640e79201028c297685fc17531552ac5 Mon Sep 17 00:00:00 2001 From: Mike Key Date: Fri, 3 Jul 2026 13:03:02 -0600 Subject: [PATCH 3/4] chore: prune stale react-qr-code entries from bun.lock --- bun.lock | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bun.lock b/bun.lock index c8b3dfdad..74d751ec1 100644 --- a/bun.lock +++ b/bun.lock @@ -57,7 +57,6 @@ "react-dom": "^18.3.0", "react-hook-form": "^7.53.0", "react-i18next": "^17.0.4", - "react-qr-code": "^2.0.18", "react-sound-visualizer": "^1.4.0", "tailwind-merge": "^2.5.4", "wavesurfer.js": "^7.0.0", @@ -1006,8 +1005,6 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], @@ -1022,8 +1019,6 @@ "react-loaders": ["react-loaders@3.0.1", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="], - "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], From 9f0376f643e5a78c13968f8214a4ec2ec719ac3d Mon Sep 17 00:00:00 2001 From: Mike Key Date: Fri, 3 Jul 2026 13:31:29 -0600 Subject: [PATCH 4/4] fix: harden GPU release pipeline with platform guard and credential scoping - Add SUPPORTED_GPU_ASSET_PLATFORMS guard in CUDA download flow to reject unsupported platforms before constructing asset URLs - Disable persisted git credentials in build-gpu-linux checkout step - Remove unused pip cache from build-gpu-linux setup-python step Both changes reduce the attack surface of write-permission release jobs and prevent silent 404s on unsupported platforms. --- .github/workflows/release.yml | 3 ++- backend/services/cuda.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 157b645d4..88b4fa0c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -366,6 +366,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Free up disk space uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be @@ -377,7 +379,6 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" - cache: "pip" - name: Install GPU PyTorch (Linux) first so requirements see it satisfied # The Linux GPU runtime ships inside the torch wheels (for ROCm: diff --git a/backend/services/cuda.py b/backend/services/cuda.py index 53591ca1f..4caf761af 100644 --- a/backend/services/cuda.py +++ b/backend/services/cuda.py @@ -22,7 +22,7 @@ from typing import Optional from ..config import get_data_dir -from ..utils.platform_detect import server_asset_platform +from ..utils.platform_detect import SUPPORTED_GPU_ASSET_PLATFORMS, server_asset_platform from ..utils.progress import get_progress_manager from .. import __version__ @@ -288,6 +288,11 @@ async def _download_cuda_binary_locked(version: Optional[str] = None): base_url = f"{GITHUB_RELEASES_URL}/{version}" plat = server_asset_platform() + if plat not in SUPPORTED_GPU_ASSET_PLATFORMS: + raise RuntimeError( + f"CUDA backend not available for platform '{plat}'. " + f"Supported platforms: {', '.join(SUPPORTED_GPU_ASSET_PLATFORMS)}" + ) server_archive = f"voicebox-server-cuda-{plat}.tar.gz" libs_archive = f"cuda-libs-{plat}-{CUDA_LIBS_VERSION}.tar.gz"