mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
feat: split CUDA backend into independently versioned server + libs archives
Switch CUDA builds from PyInstaller --onefile to --onedir and split the output into two separately versioned archives: 1. Server core (~200-400MB) — versioned with the app, redownloaded on every app update 2. CUDA libs (~2GB) — versioned independently (cu126-v1), only redownloaded when the CUDA toolkit or torch version changes This eliminates the ~2.4GB full redownload on every version bump. After initial setup, most app updates only need ~200-400MB. Closes #297
This commit is contained in:
@@ -200,33 +200,37 @@ jobs:
|
||||
run: |
|
||||
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
|
||||
|
||||
- name: Build CUDA server binary
|
||||
- name: Build CUDA server binary (onedir)
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
run: python build_binary.py --cuda
|
||||
|
||||
- name: Split binary for GitHub Releases
|
||||
- name: Package into server core + CUDA libs archives
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/split_binary.py \
|
||||
backend/dist/voicebox-server-cuda.exe \
|
||||
--output release-assets/
|
||||
python scripts/package_cuda.py \
|
||||
backend/dist/voicebox-server-cuda/ \
|
||||
--output release-assets/ \
|
||||
--cuda-libs-version cu126-v1 \
|
||||
--torch-compat ">=2.6.0,<2.8.0"
|
||||
|
||||
- name: Upload split parts to GitHub Release
|
||||
- name: Upload archives to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
release-assets/voicebox-server-cuda.part*.exe
|
||||
release-assets/voicebox-server-cuda.sha256
|
||||
release-assets/voicebox-server-cuda.manifest
|
||||
release-assets/voicebox-server-cuda.tar.gz
|
||||
release-assets/voicebox-server-cuda.tar.gz.sha256
|
||||
release-assets/cuda-libs-cu126-v1.tar.gz
|
||||
release-assets/cuda-libs-cu126-v1.tar.gz.sha256
|
||||
release-assets/cuda-libs.json
|
||||
draft: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload binary as workflow artifact
|
||||
- name: Upload onedir as workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: voicebox-server-cuda-windows
|
||||
path: backend/dist/voicebox-server-cuda.exe
|
||||
path: backend/dist/voicebox-server-cuda/
|
||||
retention-days: 7
|
||||
|
||||
@@ -34,9 +34,15 @@ def build_server(cuda=False):
|
||||
binary_name = "voicebox-server-cuda" if cuda else "voicebox-server"
|
||||
|
||||
# PyInstaller arguments
|
||||
# CUDA builds use --onedir so we can split the output into two archives:
|
||||
# 1. Server core (~200-400MB) — versioned with the app
|
||||
# 2. CUDA libs (~2GB) — versioned independently (only redownloaded on
|
||||
# CUDA toolkit / torch major version changes)
|
||||
# CPU builds remain --onefile for simplicity.
|
||||
pack_mode = "--onedir" if cuda else "--onefile"
|
||||
args = [
|
||||
"server.py", # Use server.py as entry point instead of main.py
|
||||
"--onefile",
|
||||
pack_mode,
|
||||
"--name",
|
||||
binary_name,
|
||||
]
|
||||
|
||||
+262
-119
@@ -1,16 +1,22 @@
|
||||
"""
|
||||
CUDA backend binary download, assembly, and verification.
|
||||
CUDA backend download, assembly, and verification.
|
||||
|
||||
Downloads split parts of the CUDA-enabled voicebox-server binary from
|
||||
GitHub Releases, reassembles them, verifies integrity via SHA-256,
|
||||
and places the binary in the app's data directory for use on next
|
||||
backend restart.
|
||||
Downloads two archives from GitHub Releases:
|
||||
1. Server core (voicebox-server-cuda.tar.gz) — the exe + non-NVIDIA deps,
|
||||
versioned with the app.
|
||||
2. CUDA libs (cuda-libs-{version}.tar.gz) — NVIDIA runtime libraries,
|
||||
versioned independently (only redownloaded on CUDA toolkit bump).
|
||||
|
||||
Both archives are extracted into {data_dir}/backends/cuda/ which forms the
|
||||
complete PyInstaller --onedir directory structure that torch expects.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -24,6 +30,10 @@ GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
|
||||
|
||||
PROGRESS_KEY = "cuda-backend"
|
||||
|
||||
# The current expected CUDA libs version. Bump this when we change the
|
||||
# CUDA toolkit version or torch's CUDA dependency changes (e.g. cu126 -> cu128).
|
||||
CUDA_LIBS_VERSION = "cu126-v1"
|
||||
|
||||
|
||||
def get_backends_dir() -> Path:
|
||||
"""Directory where downloaded backend binaries are stored."""
|
||||
@@ -32,21 +42,46 @@ def get_backends_dir() -> Path:
|
||||
return d
|
||||
|
||||
|
||||
def get_cuda_binary_name() -> str:
|
||||
"""Platform-specific CUDA binary filename."""
|
||||
def get_cuda_dir() -> Path:
|
||||
"""Directory where the CUDA backend (onedir) is extracted."""
|
||||
d = get_backends_dir() / "cuda"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def get_cuda_exe_name() -> str:
|
||||
"""Platform-specific CUDA executable filename."""
|
||||
if sys.platform == "win32":
|
||||
return "voicebox-server-cuda.exe"
|
||||
return "voicebox-server-cuda"
|
||||
|
||||
|
||||
def get_cuda_binary_path() -> Optional[Path]:
|
||||
"""Return path to CUDA binary if it exists."""
|
||||
p = get_backends_dir() / get_cuda_binary_name()
|
||||
"""Return path to the CUDA executable if it exists inside the onedir."""
|
||||
p = get_cuda_dir() / get_cuda_exe_name()
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def get_cuda_libs_manifest_path() -> Path:
|
||||
"""Path to the cuda-libs.json manifest inside the CUDA dir."""
|
||||
return get_cuda_dir() / "cuda-libs.json"
|
||||
|
||||
|
||||
def get_installed_cuda_libs_version() -> Optional[str]:
|
||||
"""Read the installed CUDA libs version from cuda-libs.json, or None."""
|
||||
manifest_path = get_cuda_libs_manifest_path()
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(manifest_path.read_text())
|
||||
return data.get("version")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read cuda-libs.json: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_cuda_active() -> bool:
|
||||
"""Check if the current process is the CUDA binary.
|
||||
|
||||
@@ -60,25 +95,147 @@ def get_cuda_status() -> dict:
|
||||
progress_manager = get_progress_manager()
|
||||
cuda_path = get_cuda_binary_path()
|
||||
progress = progress_manager.get_progress(PROGRESS_KEY)
|
||||
cuda_libs_version = get_installed_cuda_libs_version()
|
||||
|
||||
return {
|
||||
"available": cuda_path is not None,
|
||||
"active": is_cuda_active(),
|
||||
"binary_path": str(cuda_path) if cuda_path else None,
|
||||
"cuda_libs_version": cuda_libs_version,
|
||||
"downloading": progress is not None and progress.get("status") == "downloading",
|
||||
"download_progress": progress,
|
||||
}
|
||||
|
||||
|
||||
async def download_cuda_binary(version: Optional[str] = None):
|
||||
"""Download the CUDA backend binary from GitHub Releases.
|
||||
def _needs_server_download(version: Optional[str] = None) -> bool:
|
||||
"""Check if the server core archive needs to be (re)downloaded."""
|
||||
cuda_path = get_cuda_binary_path()
|
||||
if not cuda_path:
|
||||
return True
|
||||
# Check if the binary version matches the expected app version
|
||||
installed = get_cuda_binary_version()
|
||||
expected = version or __version__
|
||||
if expected.startswith("v"):
|
||||
expected = expected[1:]
|
||||
return installed != expected
|
||||
|
||||
Downloads split parts listed in a manifest file, concatenates them,
|
||||
and verifies the SHA-256 checksum for integrity. Atomic write
|
||||
(temp file -> rename).
|
||||
|
||||
def _needs_cuda_libs_download() -> bool:
|
||||
"""Check if the CUDA libs archive needs to be (re)downloaded."""
|
||||
installed = get_installed_cuda_libs_version()
|
||||
if installed is None:
|
||||
return True
|
||||
return installed != CUDA_LIBS_VERSION
|
||||
|
||||
|
||||
async def _download_and_extract_archive(
|
||||
client,
|
||||
url: str,
|
||||
sha256_url: Optional[str],
|
||||
dest_dir: Path,
|
||||
label: str,
|
||||
progress_offset: int,
|
||||
total_size: int,
|
||||
):
|
||||
"""Download a .tar.gz archive and extract it into dest_dir.
|
||||
|
||||
Args:
|
||||
version: Version tag (e.g. "v0.2.0"). Defaults to current app version.
|
||||
client: httpx.AsyncClient
|
||||
url: URL of the .tar.gz archive
|
||||
sha256_url: URL of the .sha256 checksum file (optional)
|
||||
dest_dir: Directory to extract into
|
||||
label: Human-readable label for progress updates
|
||||
progress_offset: Byte offset for progress reporting (when downloading
|
||||
multiple archives sequentially)
|
||||
total_size: Total bytes across all downloads (for progress bar)
|
||||
"""
|
||||
progress = get_progress_manager()
|
||||
temp_path = dest_dir / f".download-{label.replace(' ', '-')}.tmp"
|
||||
|
||||
# Clean up leftover partial download
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
# Fetch expected checksum
|
||||
expected_sha = None
|
||||
if sha256_url:
|
||||
try:
|
||||
sha_resp = await client.get(sha256_url)
|
||||
if sha_resp.status_code == 200:
|
||||
expected_sha = sha_resp.text.strip().split()[0]
|
||||
logger.info(f"{label}: expected SHA-256: {expected_sha[:16]}...")
|
||||
except Exception as e:
|
||||
logger.warning(f"{label}: could not fetch checksum — skipping verification: {e}")
|
||||
|
||||
# Stream download
|
||||
downloaded = 0
|
||||
async with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
with open(temp_path, "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY,
|
||||
current=progress_offset + downloaded,
|
||||
total=total_size,
|
||||
filename=f"Downloading {label}",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Verify integrity
|
||||
if expected_sha:
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY,
|
||||
current=progress_offset + downloaded,
|
||||
total=total_size,
|
||||
filename=f"Verifying {label}...",
|
||||
status="downloading",
|
||||
)
|
||||
sha256 = hashlib.sha256()
|
||||
with open(temp_path, "rb") as f:
|
||||
while True:
|
||||
data = f.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
sha256.update(data)
|
||||
actual = sha256.hexdigest()
|
||||
if actual != expected_sha:
|
||||
temp_path.unlink()
|
||||
raise ValueError(f"{label} integrity check failed: expected {expected_sha[:16]}..., got {actual[:16]}...")
|
||||
logger.info(f"{label}: integrity verified")
|
||||
|
||||
# Extract (use data filter for path traversal protection on Python 3.12+)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY,
|
||||
current=progress_offset + downloaded,
|
||||
total=total_size,
|
||||
filename=f"Extracting {label}...",
|
||||
status="downloading",
|
||||
)
|
||||
with tarfile.open(temp_path, "r:gz") as tar:
|
||||
if sys.version_info >= (3, 12):
|
||||
tar.extractall(path=dest_dir, filter="data")
|
||||
else:
|
||||
tar.extractall(path=dest_dir)
|
||||
|
||||
temp_path.unlink()
|
||||
logger.info(f"{label}: extracted to {dest_dir}")
|
||||
return downloaded
|
||||
|
||||
|
||||
async def download_cuda_binary(version: Optional[str] = None):
|
||||
"""Download the CUDA backend (server core + CUDA libs if needed).
|
||||
|
||||
Downloads both archives from GitHub Releases, extracts them into
|
||||
{data_dir}/backends/cuda/, and writes the cuda-libs.json manifest.
|
||||
|
||||
Only downloads what's needed:
|
||||
- Server core: always redownloaded (versioned with app)
|
||||
- CUDA libs: only if missing or version mismatch
|
||||
|
||||
Args:
|
||||
version: Version tag (e.g. "v0.3.0"). Defaults to current app version.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
@@ -86,114 +243,91 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
version = f"v{__version__}"
|
||||
|
||||
progress = get_progress_manager()
|
||||
binary_name = get_cuda_binary_name()
|
||||
dest_dir = get_backends_dir()
|
||||
final_path = dest_dir / binary_name
|
||||
temp_path = dest_dir / f"{binary_name}.download"
|
||||
cuda_dir = get_cuda_dir()
|
||||
|
||||
# Clean up any leftover partial download
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
need_server = _needs_server_download(version)
|
||||
need_libs = _needs_cuda_libs_download()
|
||||
|
||||
logger.info(f"Starting CUDA backend download for {version}")
|
||||
if not need_server and not need_libs:
|
||||
logger.info("CUDA backend is up to date, nothing to download")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting CUDA backend download for {version} "
|
||||
f"(server={'yes' if need_server else 'cached'}, "
|
||||
f"libs={'yes' if need_libs else 'cached'})"
|
||||
)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY, current=0, total=0,
|
||||
filename="Fetching manifest...", status="downloading",
|
||||
PROGRESS_KEY,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Preparing download...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
base_url = f"{GITHUB_RELEASES_URL}/{version}"
|
||||
stem = Path(binary_name).stem # voicebox-server-cuda
|
||||
server_archive = "voicebox-server-cuda.tar.gz"
|
||||
libs_archive = f"cuda-libs-{CUDA_LIBS_VERSION}.tar.gz"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
|
||||
# Fetch the manifest (list of split part filenames)
|
||||
manifest_url = f"{base_url}/{stem}.manifest"
|
||||
manifest_resp = await client.get(manifest_url)
|
||||
manifest_resp.raise_for_status()
|
||||
parts = [p.strip() for p in manifest_resp.text.strip().splitlines() if p.strip()]
|
||||
|
||||
if not parts:
|
||||
raise ValueError("Empty manifest — no split parts found")
|
||||
|
||||
logger.info(f"Found {len(parts)} split parts to download")
|
||||
|
||||
# Fetch expected checksum (optional — for integrity verification)
|
||||
expected_sha = None
|
||||
try:
|
||||
sha_url = f"{base_url}/{stem}.sha256"
|
||||
sha_resp = await client.get(sha_url)
|
||||
if sha_resp.status_code == 200:
|
||||
# Format: "sha256hex filename\n"
|
||||
expected_sha = sha_resp.text.strip().split()[0]
|
||||
logger.info(f"Expected SHA-256: {expected_sha[:16]}...")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch checksum file — skipping verification: {e}")
|
||||
|
||||
# Get total size across all parts by issuing HEAD requests
|
||||
# Estimate total download size
|
||||
total_size = 0
|
||||
for part_name in parts:
|
||||
if need_server:
|
||||
try:
|
||||
head_resp = await client.head(f"{base_url}/{part_name}")
|
||||
content_length = int(head_resp.headers.get("content-length", 0))
|
||||
total_size += content_length
|
||||
head = await client.head(f"{base_url}/{server_archive}")
|
||||
total_size += int(head.headers.get("content-length", 0))
|
||||
except Exception:
|
||||
pass
|
||||
if need_libs:
|
||||
try:
|
||||
head = await client.head(f"{base_url}/{libs_archive}")
|
||||
total_size += int(head.headers.get("content-length", 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Total download size: {total_size / 1024 / 1024:.1f} MB")
|
||||
|
||||
# Download and concatenate parts
|
||||
total_downloaded = 0
|
||||
with open(temp_path, "wb") as f:
|
||||
for i, part_name in enumerate(parts):
|
||||
part_url = f"{base_url}/{part_name}"
|
||||
logger.info(f"Downloading part {i + 1}/{len(parts)}: {part_name}")
|
||||
offset = 0
|
||||
|
||||
async with client.stream("GET", part_url) as response:
|
||||
response.raise_for_status()
|
||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||
f.write(chunk)
|
||||
total_downloaded += len(chunk)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY, current=total_downloaded, total=total_size,
|
||||
filename=f"Downloading CUDA backend ({i + 1}/{len(parts)})",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Verify integrity if checksum was available
|
||||
if expected_sha:
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY, current=total_downloaded, total=total_downloaded,
|
||||
filename="Verifying integrity...", status="downloading",
|
||||
)
|
||||
sha256 = hashlib.sha256()
|
||||
with open(temp_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
sha256.update(chunk)
|
||||
|
||||
actual = sha256.hexdigest()
|
||||
if actual != expected_sha:
|
||||
raise ValueError(
|
||||
f"Integrity check failed: expected {expected_sha[:16]}..., "
|
||||
f"got {actual[:16]}..."
|
||||
# Download server core
|
||||
if need_server:
|
||||
server_downloaded = await _download_and_extract_archive(
|
||||
client,
|
||||
url=f"{base_url}/{server_archive}",
|
||||
sha256_url=f"{base_url}/{server_archive}.sha256",
|
||||
dest_dir=cuda_dir,
|
||||
label="CUDA server",
|
||||
progress_offset=offset,
|
||||
total_size=total_size,
|
||||
)
|
||||
logger.info(f"Integrity verified: {actual[:16]}...")
|
||||
offset += server_downloaded
|
||||
|
||||
# Atomic move into place (replace handles existing target on all platforms)
|
||||
temp_path.replace(final_path)
|
||||
# Make executable on Unix
|
||||
exe_path = cuda_dir / get_cuda_exe_name()
|
||||
if sys.platform != "win32" and exe_path.exists():
|
||||
exe_path.chmod(0o755)
|
||||
|
||||
# Make executable on Unix
|
||||
if sys.platform != "win32":
|
||||
final_path.chmod(0o755)
|
||||
# Download CUDA libs
|
||||
if need_libs:
|
||||
await _download_and_extract_archive(
|
||||
client,
|
||||
url=f"{base_url}/{libs_archive}",
|
||||
sha256_url=f"{base_url}/{libs_archive}.sha256",
|
||||
dest_dir=cuda_dir,
|
||||
label="CUDA libraries",
|
||||
progress_offset=offset,
|
||||
total_size=total_size,
|
||||
)
|
||||
|
||||
logger.info(f"CUDA backend downloaded to {final_path}")
|
||||
# Write local cuda-libs.json manifest
|
||||
manifest = {"version": CUDA_LIBS_VERSION}
|
||||
get_cuda_libs_manifest_path().write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
|
||||
logger.info(f"CUDA backend ready at {cuda_dir}")
|
||||
progress.mark_complete(PROGRESS_KEY)
|
||||
|
||||
except Exception as e:
|
||||
# Clean up on failure
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
logger.error(f"CUDA backend download failed: {e}")
|
||||
progress.mark_error(PROGRESS_KEY, str(e))
|
||||
raise
|
||||
@@ -202,15 +336,19 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
def get_cuda_binary_version() -> Optional[str]:
|
||||
"""Get the version of the installed CUDA binary, or None if not installed."""
|
||||
import subprocess
|
||||
|
||||
cuda_path = get_cuda_binary_path()
|
||||
if not cuda_path:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(cuda_path), "--version"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=str(cuda_path.parent), # Run from the onedir directory
|
||||
)
|
||||
# Output format: "voicebox-server 0.2.0"
|
||||
# Output format: "voicebox-server 0.3.0"
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if "voicebox-server" in line:
|
||||
return line.split()[-1]
|
||||
@@ -222,26 +360,29 @@ def get_cuda_binary_version() -> Optional[str]:
|
||||
async def check_and_update_cuda_binary():
|
||||
"""Check if the CUDA binary is outdated and auto-download if so.
|
||||
|
||||
Called on server startup. If a CUDA binary exists but its version
|
||||
doesn't match the current app version, triggers a background download
|
||||
of the updated CUDA binary. The download progress is visible to the
|
||||
frontend via the existing SSE progress endpoint.
|
||||
Called on server startup. Checks both server version and CUDA libs
|
||||
version. Downloads only what's needed.
|
||||
"""
|
||||
cuda_path = get_cuda_binary_path()
|
||||
if not cuda_path:
|
||||
return # No CUDA binary installed, nothing to update
|
||||
|
||||
cuda_version = get_cuda_binary_version()
|
||||
current_version = __version__
|
||||
need_server = _needs_server_download()
|
||||
need_libs = _needs_cuda_libs_download()
|
||||
|
||||
if cuda_version == current_version:
|
||||
logger.info(f"CUDA binary is up to date (v{current_version})")
|
||||
if not need_server and not need_libs:
|
||||
logger.info(f"CUDA binary is up to date (server=v{__version__}, libs={get_installed_cuda_libs_version()})")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"CUDA binary version mismatch: binary=v{cuda_version}, app=v{current_version}. "
|
||||
f"Auto-downloading updated CUDA backend..."
|
||||
)
|
||||
reasons = []
|
||||
if need_server:
|
||||
cuda_version = get_cuda_binary_version()
|
||||
reasons.append(f"server v{cuda_version} != v{__version__}")
|
||||
if need_libs:
|
||||
installed_libs = get_installed_cuda_libs_version()
|
||||
reasons.append(f"libs {installed_libs} != {CUDA_LIBS_VERSION}")
|
||||
|
||||
logger.info(f"CUDA backend needs update ({', '.join(reasons)}). Auto-downloading...")
|
||||
|
||||
try:
|
||||
await download_cuda_binary()
|
||||
@@ -250,10 +391,12 @@ async def check_and_update_cuda_binary():
|
||||
|
||||
|
||||
async def delete_cuda_binary() -> bool:
|
||||
"""Delete the downloaded CUDA binary. Returns True if deleted."""
|
||||
path = get_cuda_binary_path()
|
||||
if path and path.exists():
|
||||
path.unlink()
|
||||
logger.info(f"Deleted CUDA binary: {path}")
|
||||
"""Delete the downloaded CUDA backend directory. Returns True if deleted."""
|
||||
import shutil
|
||||
|
||||
cuda_dir = get_cuda_dir()
|
||||
if cuda_dir.exists() and any(cuda_dir.iterdir()):
|
||||
shutil.rmtree(cuda_dir)
|
||||
logger.info(f"Deleted CUDA backend directory: {cuda_dir}")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
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-cu126.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/
|
||||
python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ --output release-assets/
|
||||
python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ --cuda-libs-version cu126-v1
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
# Directories / prefixes that belong in the CUDA libs archive.
|
||||
# PyInstaller --onedir puts NVIDIA packages in nvidia/ subdirectories
|
||||
# (e.g. nvidia/cublas/lib/, nvidia/cudnn/lib/, etc.)
|
||||
NVIDIA_PREFIXES = (
|
||||
"nvidia/",
|
||||
"nvidia\\",
|
||||
)
|
||||
|
||||
# Individual DLL patterns that may end up at the top level on Windows
|
||||
NVIDIA_DLL_PREFIXES = (
|
||||
"cublas",
|
||||
"cudart",
|
||||
"cudnn",
|
||||
"cufft",
|
||||
"curand",
|
||||
"cusolver",
|
||||
"cusparse",
|
||||
"nvjitlink",
|
||||
"nvrtc",
|
||||
)
|
||||
|
||||
|
||||
def is_nvidia_file(rel_path: str) -> bool:
|
||||
"""Check if a relative path belongs to the NVIDIA CUDA libs."""
|
||||
rel_lower = rel_path.lower().replace("\\", "/")
|
||||
|
||||
# Files under nvidia/ subdirectory tree
|
||||
if rel_lower.startswith("nvidia/"):
|
||||
return True
|
||||
|
||||
# Top-level NVIDIA DLLs (Windows) — e.g. cublas64_12.dll
|
||||
name = rel_lower.rsplit("/", 1)[-1]
|
||||
for prefix in NVIDIA_DLL_PREFIXES:
|
||||
if name.startswith(prefix) and (name.endswith(".dll") or name.endswith(".so")):
|
||||
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 package(
|
||||
onedir_path: Path,
|
||||
output_dir: Path,
|
||||
cuda_libs_version: str,
|
||||
torch_compat: str,
|
||||
):
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Collect all files in the onedir output, split into core vs nvidia
|
||||
core_files = []
|
||||
nvidia_files = []
|
||||
|
||||
for item in sorted(onedir_path.rglob("*")):
|
||||
if item.is_dir():
|
||||
continue
|
||||
rel = item.relative_to(onedir_path)
|
||||
rel_str = str(rel)
|
||||
if is_nvidia_file(rel_str):
|
||||
nvidia_files.append((rel_str, item))
|
||||
else:
|
||||
core_files.append((rel_str, item))
|
||||
|
||||
core_size = sum(f.stat().st_size for _, f in core_files)
|
||||
nvidia_size = sum(f.stat().st_size for _, f in nvidia_files)
|
||||
|
||||
print(f"Input directory: {onedir_path}")
|
||||
print(f"Core files: {len(core_files)} ({core_size / (1024**2):.1f} MB)")
|
||||
print(f"NVIDIA files: {len(nvidia_files)} ({nvidia_size / (1024**2):.1f} MB)")
|
||||
|
||||
if not nvidia_files:
|
||||
print(
|
||||
"WARNING: No NVIDIA files found! The CUDA libs archive will be empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Make sure you built with --cuda and the NVIDIA packages are present.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# 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"
|
||||
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"
|
||||
)
|
||||
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"
|
||||
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"
|
||||
)
|
||||
print(f" Size: {cuda_libs_archive.stat().st_size / (1024**2):.1f} MB")
|
||||
print(f" SHA-256: {cuda_sha[:16]}...")
|
||||
|
||||
# Write cuda-libs.json manifest
|
||||
manifest = {
|
||||
"version": cuda_libs_version,
|
||||
"torch_compat": torch_compat,
|
||||
"archive": cuda_libs_archive.name,
|
||||
"sha256": cuda_sha,
|
||||
}
|
||||
manifest_path = output_dir / "cuda-libs.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(f"\nManifest: {manifest_path.name}")
|
||||
print(json.dumps(manifest, indent=2))
|
||||
|
||||
# Summary
|
||||
total_input = core_size + nvidia_size
|
||||
total_output = server_archive.stat().st_size + cuda_libs_archive.stat().st_size
|
||||
print(f"\nTotal input: {total_input / (1024**3):.2f} GB")
|
||||
print(f"Total output: {total_output / (1024**3):.2f} GB (compressed)")
|
||||
print(
|
||||
f"Server core: {server_archive.stat().st_size / (1024**2):.1f} MB (redownloaded on app update)"
|
||||
)
|
||||
print(
|
||||
f"CUDA libs: {cuda_libs_archive.stat().st_size / (1024**2):.1f} MB (cached until CUDA toolkit bump)"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Package PyInstaller --onedir CUDA build into server + CUDA libs archives"
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
type=Path,
|
||||
help="Path to PyInstaller --onedir output directory (e.g. backend/dist/voicebox-server-cuda/)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Output directory for archives (default: same as input parent)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cuda-libs-version",
|
||||
type=str,
|
||||
default="cu126-v1",
|
||||
help="Version string for the CUDA libs archive (default: cu126-v1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--torch-compat",
|
||||
type=str,
|
||||
default=">=2.6.0,<2.8.0",
|
||||
help="Torch version compatibility range (default: >=2.6.0,<2.8.0)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.input.is_dir():
|
||||
print(f"Error: {args.input} is not a directory", file=sys.stderr)
|
||||
print("Expected a PyInstaller --onedir output directory.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = args.output or args.input.parent
|
||||
package(args.input, output_dir, args.cuda_libs_version, args.torch_compat)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,12 @@
|
||||
"""
|
||||
Split a large binary into chunks for GitHub Releases (<2 GB each).
|
||||
|
||||
DEPRECATED: For CUDA builds, use scripts/package_cuda.py instead.
|
||||
This script was used when the CUDA binary was built with --onefile and
|
||||
needed to be split into parts for the 2GB GitHub Release asset limit.
|
||||
With the switch to --onedir + dual archives (server core + CUDA libs),
|
||||
package_cuda.py handles the packaging.
|
||||
|
||||
Usage:
|
||||
python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe
|
||||
python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe --chunk-size 1900000000
|
||||
@@ -34,7 +40,7 @@ def split(input_path: Path, chunk_size: int, output_dir: Path):
|
||||
part_index = len(parts)
|
||||
part_name = f"{input_path.stem}.part{part_index:02d}{input_path.suffix}"
|
||||
part_path = output_dir / part_name
|
||||
part_path.write_bytes(data[i:i + chunk_size])
|
||||
part_path.write_bytes(data[i : i + chunk_size])
|
||||
parts.append(part_name)
|
||||
|
||||
# Write manifest (ordered list of part filenames)
|
||||
|
||||
+16
-10
@@ -197,22 +197,24 @@ async fn start_server(
|
||||
println!("Data directory: {:?}", data_dir);
|
||||
println!("Remote mode: {}", remote.unwrap_or(false));
|
||||
|
||||
// Check for CUDA backend binary in data directory
|
||||
// Check for CUDA backend in data directory (onedir layout: backends/cuda/)
|
||||
let cuda_binary = {
|
||||
let backends_dir = data_dir.join("backends");
|
||||
let cuda_dir = data_dir.join("backends").join("cuda");
|
||||
let cuda_name = if cfg!(windows) {
|
||||
"voicebox-server-cuda.exe"
|
||||
} else {
|
||||
"voicebox-server-cuda"
|
||||
};
|
||||
let path = backends_dir.join(cuda_name);
|
||||
if path.exists() {
|
||||
println!("Found CUDA backend binary at {:?}", path);
|
||||
let exe_path = cuda_dir.join(cuda_name);
|
||||
if exe_path.exists() {
|
||||
println!("Found CUDA backend at {:?}", cuda_dir);
|
||||
|
||||
// Version check: run --version and compare to app version
|
||||
// Version check: run --version from the onedir directory so
|
||||
// PyInstaller can find its support files for the fast --version path
|
||||
let app_version = app.config().version.clone().unwrap_or_default();
|
||||
let version_ok = match std::process::Command::new(&path)
|
||||
let version_ok = match std::process::Command::new(&exe_path)
|
||||
.arg("--version")
|
||||
.current_dir(&cuda_dir)
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
@@ -237,7 +239,7 @@ async fn start_server(
|
||||
};
|
||||
|
||||
if version_ok {
|
||||
Some(path)
|
||||
Some(exe_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -300,10 +302,14 @@ async fn start_server(
|
||||
println!("Custom models directory: {}", dir);
|
||||
}
|
||||
|
||||
// If CUDA binary exists, launch it directly instead of the bundled sidecar
|
||||
// If CUDA binary exists, launch it from the onedir directory.
|
||||
// .current_dir() is critical: PyInstaller onedir expects all DLLs and
|
||||
// support files (nvidia/, _internal/, etc.) relative to the exe.
|
||||
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
|
||||
println!("Launching CUDA backend: {:?}", cuda_path);
|
||||
let cuda_dir = cuda_path.parent().unwrap();
|
||||
println!("Launching CUDA backend: {:?} (cwd: {:?})", cuda_path, cuda_dir);
|
||||
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
|
||||
cmd = cmd.current_dir(cuda_dir);
|
||||
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote {
|
||||
cmd = cmd.args(["--host", "0.0.0.0"]);
|
||||
|
||||
Reference in New Issue
Block a user