diff --git a/.github/workflows/hip-wheel.yml b/.github/workflows/hip-wheel.yml new file mode 100644 index 0000000..ca715db --- /dev/null +++ b/.github/workflows/hip-wheel.yml @@ -0,0 +1,96 @@ +name: Linux HIP build and wheel validation + +"on": + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + hip: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + QWENTTS_REF: 6fae92914045cd83364d2845ceaa0f7969727319 + ROCM_IMAGE: rocm/dev-ubuntu-24.04:7.2.4-complete@sha256:92f309c51b52cef8762867848f1529dee821624f23cd5df38455e819538f762f + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Free space for the ROCm development image + run: | + sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/dotnet + sudo apt-get clean + df -h / + - name: Fetch pinned native source + run: | + git init third_party/qwentts.cpp + git -C third_party/qwentts.cpp remote add origin https://github.com/ServeurpersoCom/qwentts.cpp + git -C third_party/qwentts.cpp fetch --depth 1 origin "$QWENTTS_REF" + git -C third_party/qwentts.cpp checkout --detach FETCH_HEAD + git -C third_party/qwentts.cpp submodule update --init --recursive --depth 1 + - name: Start the pinned ROCm build container + run: | + # No GPU devices are passed through: gfx942 is an explicit compile target. + docker run --detach --name qwentts-hip \ + -v "$GITHUB_WORKSPACE:/workspace" -w /workspace \ + "$ROCM_IMAGE" sleep infinity + docker exec qwentts-hip bash -euxo pipefail -c ' + apt-get update + apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build patchelf python3 python3-venv git ca-certificates + git config --global --add safe.directory /workspace/third_party/qwentts.cpp + git config --global --add safe.directory /workspace/third_party/qwentts.cpp/ggml + python3 -m venv /opt/hip-test + /opt/hip-test/bin/python -m pip install "setuptools>=77" wheel build twine pytest numpy huggingface-hub + # These remain system dependencies; no native build path is added. + printf "/opt/rocm/lib\n/opt/rocm/lib64\n" > /etc/ld.so.conf.d/qwentts-rocm.conf + ldconfig + /opt/rocm/bin/hipconfig --version + /opt/rocm/llvm/bin/clang++ --version + ' + - name: Build the real HIP runtime and local-version wheel + run: | + docker exec qwentts-hip bash -euxo pipefail -c ' + /opt/hip-test/bin/python scripts/build_native.py \ + --source third_party/qwentts.cpp --build-dir build/hip --backend hip \ + --hip-compiler /opt/rocm/llvm/bin/clang++ \ + --hip-architectures gfx942 --rocm-path /opt/rocm --jobs 2 --clean \ + --cmake-arg=-G --cmake-arg=Ninja + test -f src/qwentts_cpp/lib/libggml-hip.so.0 + /opt/hip-test/bin/python scripts/set_local_version.py rocm724 + /opt/hip-test/bin/python -m build --wheel + /opt/hip-test/bin/python -m twine check --strict dist/*.whl + PYTHONPATH=src QWENTTS_CPP_SOURCE=third_party/qwentts.cpp \ + /opt/hip-test/bin/python -m pytest -q tests/test_native_abi.py tests/test_build_native.py + ' + - name: Validate the installed HIP wheel without native source or build trees + run: | + docker exec qwentts-hip bash -euxo pipefail -c ' + /opt/hip-test/bin/python -m pip install --no-deps dist/*.whl + rm -rf build third_party src/qwentts_cpp/lib + unset QWENTTS_CPP_LIBRARY QWEN_LIBRARY_PATH LD_LIBRARY_PATH PYTHONPATH + /opt/hip-test/bin/python -I -c "from pathlib import Path; from qwentts_cpp import QwenLibrary; lib = QwenLibrary(); print(lib.path, lib.version()); assert lib.path.is_relative_to(Path(\"/opt/hip-test\")); assert any(Path(dep._name).name == \"libggml-hip.so.0\" for dep in lib._dependency_handles)" + # Inspect actual ELF dependencies after removing the build trees. + for library in /opt/hip-test/lib/python*/site-packages/qwentts_cpp/lib/*.so*; do + test "$(patchelf --print-rpath "$library")" = '\''$ORIGIN'\'' + dependencies="$(ldd "$library")" + printf "%s\n" "$dependencies" + if printf "%s\n" "$dependencies" | grep -E "not found|/workspace/"; then + exit 1 + fi + done + # Native integration tests use the installed path, after the default + # loader above has independently succeeded without any override. + export QWENTTS_CPP_LIBRARY="$(/opt/hip-test/bin/python -I -c "from qwentts_cpp import QwenLibrary; print(QwenLibrary().path)")" + /opt/hip-test/bin/python -m pytest -q tests + ' + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: hip-wheel-rocm724-gfx942-linux-x86_64 + path: dist/*.whl + retention-days: 7 + - name: Remove the build container + if: always() + run: docker rm --force qwentts-hip || true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ab21265 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Python and Linux packaging tests + +"on": + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install test and ELF relocation tools + run: | + sudo apt-get update + sudo apt-get install -y patchelf + python -m pip install 'setuptools>=77' wheel pytest numpy huggingface-hub + - name: Test wrapper and installed ELF fixture wheel + env: + PYTHONPATH: src + run: python -m pytest -q tests diff --git a/AGENTS.md b/AGENTS.md index f8288fe..b328d25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,12 @@ Pull requests do not run the Linux wheel matrix. Do not run the validation workflow solely as a publishing prerequisite, because the publishing workflows perform fresh builds. +The `.github/workflows/hip-wheel.yml` PR check builds one real Linux ROCm 7.2.4 +HIP runtime for gfx942 on a standard Ubuntu worker. It validates a local-version +candidate wheel and clean installed loading without an AMD GPU. This is separate +from the dispatch-only Linux matrix and does not publish wheels. AMD synthesis +and device registration still require an AMD GPU environment. + ## Updating the qwentts.cpp Pin To rebuild against a newer qwentts.cpp revision: @@ -48,7 +54,8 @@ To rebuild against a newer qwentts.cpp revision: 1. Resolve the latest upstream `master` commit to its full SHA. 2. Update every default and event fallback for `QWENTTS_REF` in `.github/workflows/wheels.yml`, `.github/workflows/publish.yml`, - `.github/workflows/publish-hf-wheels.yml`, and `.github/workflows/metal-wheel.yml`. + `.github/workflows/publish-hf-wheels.yml`, `.github/workflows/metal-wheel.yml`, + and `.github/workflows/hip-wheel.yml`. Update `QWENTTS_NATIVE_REVISION` in `src/qwentts_cpp/_binding.py` and verify ctypes layouts with `QWENTTS_CPP_SOURCE=/path/to/source python -m pytest tests/test_native_abi.py`. 3. Update the pinned revision and its summary in `README.md`. diff --git a/README.md b/README.md index bc1e46b..383d7e6 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,136 @@ delocate-wheel --require-archs arm64 -w wheelhouse -v dist/*.whl python -m twine check --strict wheelhouse/*.whl ``` +### AMD ROCm / HIP (Linux source builds) + +The build helper supports `--backend hip` or `QWENTTS_CPP_BACKEND=hip` on +Linux. It enables `GGML_HIP` and HIP graphs, builds shared GGML libraries, +and disables CUDA, Metal, BLAS, and host-specific CPU optimizations. +The pinned GGML requires ROCm/HIP **6.1 or newer** at configure time; this is +a build requirement, not a guarantee that every ROCm version or GPU works. +[Issue #35](https://github.com/andimarafioti/qwentts-cpp-python/issues/35) +records successful manual-build synthesis on **ROCm 7.2.4, AMD Instinct +MI300X VF (`gfx942`)**, in +`rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.9.1`. +Other versions and architectures have not been validated. The **Linux HIP +build and wheel validation** PR check compiles the real pinned source for +`gfx942` in AMD's ROCm 7.2.4 development container on an Ubuntu GitHub worker. +It checks ABI layouts and wheel metadata, deletes the native source/build +trees, checks installed ELF dependencies, and runs the wrapper tests against +the installed wheel. The candidate `0.5.0+rocm724` wheel is saved as a CI +artifact. These workers have no AMD GPU; synthesis and device registration +still require validation on AMD hardware. A manual build with an explicit +library path does not establish wheel portability. + +Use a Linux ROCm development environment with a C/C++ compiler, CMake 3.21+, +HIP clang++, hipBLAS, rocBLAS, and `patchelf` (for example, install `patchelf` +with your system package manager). The matching AMD driver and GPU access +must be available; containers need the host's ROCm device access as well. +Build from this repository with the pinned native source: + +```bash +python -m pip install cmake ninja build numpy huggingface-hub pytest +git clone https://github.com/ServeurpersoCom/qwentts.cpp third_party/qwentts.cpp +git -C third_party/qwentts.cpp checkout 6fae92914045cd83364d2845ceaa0f7969727319 +git -C third_party/qwentts.cpp submodule update --init --recursive + +python scripts/build_native.py \ + --source third_party/qwentts.cpp --build-dir build/hip --backend hip \ + --hip-compiler /opt/rocm/llvm/bin/clang++ \ + --hip-architectures gfx942 --rocm-path /opt/rocm --jobs 8 --clean +python -m pip install -e . +QWENTTS_CPP_SOURCE="$PWD/third_party/qwentts.cpp" python -m pytest -q tests +python -c "from qwentts_cpp import QwenLibrary; lib = QwenLibrary(); print(lib.path, lib.version())" +``` + +No `QWENTTS_CPP_LIBRARY` or build-directory `LD_LIBRARY_PATH` is needed: +the helper copies `libqwen.so`, the GGML core/base/CPU libraries, and the +versioned HIP backend into `qwentts_cpp/lib`. It resolves the HIP symlink +to a regular `libggml-hip.so.0` file and requires successful `patchelf` +relocation to `$ORIGIN`, so these libraries resolve their packaged siblings. +The Python loader preloads the HIP backend before GGML and libqwen. +Building native libraries is an explicit step; `pip install -e .` alone +does not compile them. + +`--hip-compiler`, `--hip-architectures`, and `--rocm-path` default to +`CMAKE_HIP_COMPILER`, `CMAKE_HIP_ARCHITECTURES`, and `ROCM_PATH`, respectively. +When omitted, CMake/upstream discovers the toolkit and GPU targets. Use the +target for your GPU; quote multiple targets such as `"gfx942;gfx950"`. +The ROCm prefix sets CMake's package search prefix and HIP compiler ROCm root. +For additional options, use `QWENTTS_CPP_CMAKE_ARGS` or repeat +`--cmake-arg=...`; these are applied last, so +`--cmake-arg=-DGGML_HIP_GRAPHS=OFF` disables graphs. Use a clean build when +changing backends or toolkits. + +HIP packaging supports linked, versioned backends with `GGML_BACKEND_DL=OFF` +(set by the helper). Dynamic module backends with `GGML_BACKEND_DL=ON` are +rejected after configuration, before compilation. Copy-only `--skip-build` +runs also reject an unversioned HIP module before replacing packaged libraries. +Rebuild with `--clean` and `GGML_BACKEND_DL=OFF` if using an existing dynamic +backend build directory. + +**System dependencies remain external.** This helper bundles qwentts/GGML +libraries only. It does not bundle `libamdhip64`, `libhipblas`, `libhipblaslt`, +`librocblas`, their transitive ROCm dependencies, or rocBLAS/Tensile data files. +Install a matching ROCm runtime (the tested container supplies these), along +with the usual Linux C/C++ and OpenMP runtime dependencies. The dynamic +loader must find the system libraries through its configured search paths; +if needed, set `LD_LIBRARY_PATH` to the system ROCm `lib`/`lib64` directories. +Do not add the native source/build directory to that path for an installed +package test. Inspect `ldd` on the packaged `.so` files and resolve any +`not found` entries in the system environment. + +Run a GPU smoke test with local weights: + +```bash +python scripts/smoke_stream.py \ + --talker /path/to/qwen-talker-1.7b-voicedesign-BF16.gguf \ + --codec /path/to/qwen-tokenizer-12hz-BF16.gguf \ + --instruct "A warm, calm narrator." \ + --text "Hello. This speech was generated on an AMD GPU." \ + --require-rocm --full --output amd-smoke.wav +``` + +`--require-rocm` selects `GGML_BACKEND=ROCm0`, checks that the installed GGML +registry contains that device with an AMD description, and fails before model +loading if it is unavailable. It prints the library path, native revision, +and device description, with native backend logs at info level. `--full` +also runs buffered synthesis and writes `amd-smoke-full.wav`; streaming +writes `amd-smoke.wav`. Both must be finite, nonsilent, mono 24 kHz audio; +streaming must produce multiple chunks. Listen to both files to assess speech +quality. Repeat with BF16/Q4_K_M and the Base/CustomVoice models described in +the issue, using `--ref-spk` or `--speaker` for their conditioning. Device +availability and GPU backend selection do not exclude CPU execution of +individual unsupported operators. + +For a local wheel, use a separate local version in a disposable checkout: + +```bash +python scripts/set_local_version.py rocm724 # changes local version metadata +python -m build --wheel +``` + +This names the wheel `0.5.0+rocm724`; it is not a published download. +The same local version is used for the HIP PR-check artifact; that artifact +targets the ROCm 7.2.4 / Ubuntu 24.04 / gfx942 environment, without a +manylinux portability claim. ROCm publishing is not configured. Future ROCm +distributions must use a +separately selectable backend index/local version, such as a Hugging Face +`rocm724` flavor. Do not publish ROCm and CUDA wheels with identical public +package/version/platform tags to PyPI. + +Before distributing a HIP wheel, copy only the wheel, smoke script, and local +weights to a fresh matching ROCm environment without the repository or native +build tree. Install the wheel, unset `QWENTTS_CPP_LIBRARY` and +`QWEN_LIBRARY_PATH`, and keep only declared system paths in `LD_LIBRARY_PATH`. +Verify `QwenLibrary()` loads the installed package and run the AMD smoke test +there. Run the full wrapper tests against the installed package (without a +source `PYTHONPATH`), inspect wheel contents/dependency paths, and record +GPU memory/backend logs and audio quality as described in issue #35. +The Linux packaging regression uses small ELF fixtures to test relocation +and clean installed loading; it does not validate real ROCm execution or +establish that a wheel is self-contained. + ### Native ABI compatibility The CI wheel build defaults to qwentts.cpp diff --git a/scripts/build_native.py b/scripts/build_native.py index d04e1df..bf826c5 100644 --- a/scripts/build_native.py +++ b/scripts/build_native.py @@ -143,7 +143,37 @@ def relocate_macos_libraries(copied: list[Path], build_dir: Path) -> None: run(["codesign", "--force", "--sign", "-", str(path)]) +def validate_hip_build_mode(build_dir: Path) -> None: + """Reject module backends; the package supports linked HIP backends only.""" + error = ( + "Dynamic HIP backends (GGML_BACKEND_DL=ON / libggml-hip.so modules) " + "are not supported by this package. Rebuild with GGML_BACKEND_DL=OFF " + "and --clean to produce the linked, versioned HIP runtime." + ) + cache = build_dir / "CMakeCache.txt" + if cache.is_file(): + for line in cache.read_text().splitlines(): + name, separator, value = line.partition("=") + if separator and name.split(":", 1)[0] == "GGML_BACKEND_DL": + value = value.upper() + disabled = ("", "0", "OFF", "NO", "FALSE", "N", "IGNORE", "NOTFOUND") + if value not in disabled and not value.endswith("-NOTFOUND"): + raise SystemExit(error) + module = find_first(build_dir, ["libggml-hip.so"]) + versioned = find_first(build_dir, ["libggml-hip.so.0", "libggml-hip.so.*"]) + # A normal shared build also has an unversioned linker symlink. Accept + # that alias only when it resolves to the versioned runtime we package. + if module and (versioned is None or module.resolve() != versioned.resolve()): + raise SystemExit(error) + + def copy_shared_libraries(build_dir: Path, package_lib_dir: Path) -> None: + hip_library = find_first(build_dir, ["libggml-hip.so.0", "libggml-hip.so.*"]) + if sys.platform.startswith("linux") and find_first(build_dir, ["libggml-hip.so", "libggml-hip.so.*"]): + validate_hip_build_mode(build_dir) + patchelf = shutil.which("patchelf") + if sys.platform.startswith("linux") and hip_library and not patchelf: + raise SystemExit("Packaging HIP libraries requires patchelf to set sibling-library RPATHs") package_lib_dir.mkdir(parents=True, exist_ok=True) for path in package_lib_dir.iterdir(): if path.name == ".gitkeep": @@ -157,6 +187,7 @@ def copy_shared_libraries(build_dir: Path, package_lib_dir: Path) -> None: ("libggml-base.so.0", ["libggml-base.so.0", "libggml-base.so.*"]), ("libggml-cpu.so.0", ["libggml-cpu.so.0", "libggml-cpu.so.*"]), ("libggml-cuda.so.0", ["libggml-cuda.so.0", "libggml-cuda.so.*"]), + ("libggml-hip.so.0", ["libggml-hip.so.0", "libggml-hip.so.*"]), ("libggml-vulkan.so.0", ["libggml-vulkan.so.0", "libggml-vulkan.so.*"]), ("libggml-sycl.so.0", ["libggml-sycl.so.0", "libggml-sycl.so.*"]), ("libggml.so.0", ["libggml.so.0", "libggml.so.*"]), @@ -206,13 +237,14 @@ def copy_shared_libraries(build_dir: Path, package_lib_dir: Path) -> None: if sys.platform == "darwin": relocate_macos_libraries(copied, build_dir) - patchelf = shutil.which("patchelf") if patchelf and sys.platform.startswith("linux"): for path in copied: if path.is_file() and ".so" in path.name: try: run([patchelf, "--set-rpath", "$ORIGIN", str(path)]) except subprocess.CalledProcessError: + if hip_library: + raise pass if os.environ.get("QWENTTS_CPP_NO_STRIP") != "1": @@ -230,10 +262,16 @@ def main() -> int: parser.add_argument("--build-dir", default=os.environ.get("QWENTTS_CPP_BUILD_DIR", "build/qwentts-cpp")) parser.add_argument( "--backend", - choices=["cpu", "cuda", "metal", "vulkan", "sycl"], + choices=["cpu", "cuda", "hip", "metal", "vulkan", "sycl"], default=os.environ.get("QWENTTS_CPP_BACKEND", "metal" if sys.platform == "darwin" else "cuda"), ) parser.add_argument("--cuda-compiler", default=os.environ.get("CMAKE_CUDA_COMPILER", "/usr/local/cuda/bin/nvcc")) + parser.add_argument("--hip-compiler", default=os.environ.get("CMAKE_HIP_COMPILER"), + help="HIP clang++ compiler (otherwise CMake discovers it)") + parser.add_argument("--hip-architectures", default=os.environ.get("CMAKE_HIP_ARCHITECTURES"), + help="GPU targets, e.g. gfx942 or a quoted semicolon-separated list") + parser.add_argument("--rocm-path", default=os.environ.get("ROCM_PATH"), + help="ROCm installation prefix (otherwise upstream discovers it)") parser.add_argument("--cmake-arg", action="append", default=[], help="Extra CMake configure argument; repeatable") parser.add_argument("--target", default=os.environ.get("QWENTTS_CPP_CMAKE_TARGET", "qwen")) parser.add_argument("--jobs", type=int, default=int(os.environ.get("QWENTTS_CPP_BUILD_JOBS", os.cpu_count() or 2))) @@ -242,6 +280,8 @@ def main() -> int: args = parser.parse_args() if args.backend == "metal" and sys.platform != "darwin": parser.error("The Metal backend requires macOS") + if args.backend == "hip" and not sys.platform.startswith("linux"): + parser.error("The HIP backend is supported on Linux") root = Path(__file__).resolve().parents[1] source = Path(args.source).resolve() @@ -274,6 +314,20 @@ def main() -> int: cmake_args.extend(["-DGGML_BLAS=OFF", "-DGGML_METAL=OFF"]) elif args.backend == "cuda": cmake_args.extend(["-DGGML_CUDA=ON", f"-DCMAKE_CUDA_COMPILER={args.cuda_compiler}"]) + elif args.backend == "hip": + cmake_args.extend([ + "-DBUILD_SHARED_LIBS=ON", "-DGGML_HIP=ON", "-DGGML_HIP_GRAPHS=ON", + "-DGGML_BACKEND_DL=OFF", + "-DGGML_CUDA=OFF", "-DGGML_METAL=OFF", "-DGGML_BLAS=OFF", + "-DGGML_NATIVE=OFF", "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON", + ]) + if args.hip_compiler: + cmake_args.append(f"-DCMAKE_HIP_COMPILER={args.hip_compiler}") + if args.hip_architectures: + cmake_args.append(f"-DCMAKE_HIP_ARCHITECTURES={args.hip_architectures}") + if args.rocm_path: + cmake_args.append(f"-DCMAKE_PREFIX_PATH={args.rocm_path}") + cmake_args.append(f"-DCMAKE_HIP_COMPILER_ROCM_ROOT={args.rocm_path}") elif args.backend == "metal": cmake_args.extend([ "-DGGML_METAL=ON", "-DGGML_METAL_EMBED_LIBRARY=ON", @@ -294,6 +348,8 @@ def main() -> int: native_diagnostic_compatibility(source), metal_shader_compatibility(source, args.backend == "metal")): run(cmake_args) + if args.backend == "hip": + validate_hip_build_mode(build_dir) run(["cmake", "--build", str(build_dir), "--target", args.target, "-j", str(args.jobs)]) copy_shared_libraries(build_dir, package_lib_dir) return 0 diff --git a/scripts/smoke_stream.py b/scripts/smoke_stream.py index ef7a9b5..efab7e7 100644 --- a/scripts/smoke_stream.py +++ b/scripts/smoke_stream.py @@ -2,13 +2,50 @@ from __future__ import annotations import argparse +import ctypes import os from pathlib import Path import wave import numpy as np -from qwentts_cpp import QwenTTS, load_speaker_embedding +from qwentts_cpp import QwenLibrary, QwenTTS, load_speaker_embedding + + +def require_rocm(library: QwenLibrary) -> str: + """Check the installed GGML registry before loading any model weights.""" + ggml = ctypes.CDLL(str(library.path.parent / "libggml.so.0")) + ggml.ggml_backend_load_all.argtypes = [] + ggml.ggml_backend_load_all.restype = None + ggml.ggml_backend_dev_by_name.argtypes = [ctypes.c_char_p] + ggml.ggml_backend_dev_by_name.restype = ctypes.c_void_p + ggml.ggml_backend_dev_description.argtypes = [ctypes.c_void_p] + ggml.ggml_backend_dev_description.restype = ctypes.c_char_p + ggml.ggml_backend_load_all() + device = ggml.ggml_backend_dev_by_name(b"ROCm0") + if not device: + raise RuntimeError("ROCm0 was not registered; an AMD ROCm device is required") + raw_description = ggml.ggml_backend_dev_description(device) + description = raw_description.decode("utf-8", errors="replace") if raw_description else "" + if "AMD" not in description: + raise RuntimeError(f"ROCm0 is not an AMD device: {description!r}") + return description + + +def check_audio(audio: np.ndarray, rate: int) -> None: + assert rate == 24000 and audio.ndim == 1 and audio.size, "Expected nonempty mono 24 kHz audio" + assert np.isfinite(audio).all(), "Invalid audio samples" + + +def write_wav(path: Path, audio: np.ndarray, rate: int) -> None: + check_audio(audio, rate) + assert np.max(np.abs(audio)) > 1e-5, "Synthesized audio is silent" + pcm = (np.clip(audio, -1, 1) * 32767).astype(" None: @@ -20,35 +57,44 @@ def main() -> None: parser.add_argument("--speaker", help="Speaker name for a CustomVoice model") parser.add_argument("--ref-spk", type=Path, help="Local .spk embedding for a Base model") parser.add_argument("--instruct", help="Style instruction for a VoiceDesign model") - parser.add_argument("--require-metal", action="store_true") + device = parser.add_mutually_exclusive_group() + device.add_argument("--require-metal", action="store_true") + device.add_argument("--require-rocm", action="store_true") + parser.add_argument("--full", action="store_true", help="Also validate buffered synthesis and write a *-full.wav") args = parser.parse_args() if args.require_metal: # Explicit selection fails if Metal is unavailable, instead of silently # falling back to CPU. This is the native ggml device name. os.environ["GGML_BACKEND"] = "MTL0" + if args.require_rocm: + os.environ["GGML_BACKEND"] = "ROCm0" + library = QwenLibrary(log_level="info") + print(f"Native library: {library.path}") + print(f"Native version: {library.version()}") + print(f"AMD device: {require_rocm(library)}") chunks = [] rate = None with QwenTTS(args.talker, args.codec) as tts: print(f"Native version: {tts.library.version()}") - for chunk, sample_rate in tts.stream( + request = dict( text=args.text, speaker=args.speaker, instruct=args.instruct, ref_spk_emb=load_speaker_embedding(args.ref_spk) if args.ref_spk else None, - seed=42, max_new_tokens=128, codec_chunk_sec=0.5, - ): - assert chunk.size and np.isfinite(chunk).all(), "Invalid streaming audio" + seed=42, max_new_tokens=128, + ) + if args.full: + audio, sample_rate = tts.synthesize(**request) + full_output = args.output.with_name(args.output.stem + "-full.wav") + write_wav(full_output, audio, sample_rate) + print(f"Wrote {full_output}: {audio.size / sample_rate:.2f} seconds") + for chunk, sample_rate in tts.stream(**request, codec_chunk_sec=0.5): + check_audio(chunk, sample_rate) assert rate in (None, sample_rate), "Sample rate changed during streaming" rate = sample_rate chunks.append(chunk) print(f"Chunk {len(chunks)}: {chunk.size} samples at {rate} Hz", flush=True) assert len(chunks) >= 2, "Expected multiple streaming callbacks" audio = np.concatenate(chunks) - assert np.max(np.abs(audio)) > 1e-5, "Synthesized audio is silent" - pcm = (np.clip(audio, -1, 1) * 32767).astype(" Sequence[str]: "libggml-cpu.so.0", "libggml-cuda.so", "libggml-cuda.so.0", + "libggml-hip.so", + "libggml-hip.so.0", "libggml-vulkan.so", "libggml-vulkan.so.0", "libggml-sycl.so", diff --git a/tests/test_binding.py b/tests/test_binding.py index a0d4813..209e424 100644 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -6,6 +6,7 @@ import threading import weakref from types import SimpleNamespace +from pathlib import Path from unittest.mock import Mock import numpy as np @@ -30,6 +31,36 @@ ) +def test_installed_hip_library_is_preloaded_before_ggml_and_qwen(monkeypatch, tmp_path): + import qwentts_cpp._binding as binding + + monkeypatch.setattr(binding.sys, "platform", "linux") + monkeypatch.setattr(binding, "__file__", str(tmp_path / "qwentts_cpp" / "_binding.py")) + monkeypatch.delenv("QWENTTS_CPP_LIBRARY", raising=False) + monkeypatch.delenv("QWEN_LIBRARY_PATH", raising=False) + installed = tmp_path / "qwentts_cpp" / "lib" + installed.mkdir(parents=True) + names = ["libggml-base.so.0", "libggml-cpu.so.0", "libggml-hip.so.0", "libggml.so.0", "libqwen.so"] + for name in names: + (installed / name).touch() + loaded = [] + + def load(path, mode): + assert Path(path).parent == installed + loaded.append(Path(path).name) + if Path(path).name == "libggml.so.0": + assert "libggml-hip.so.0" in loaded + return Mock() + + monkeypatch.setattr(binding.ctypes, "CDLL", load) + monkeypatch.setattr(QwenLibrary, "_validate_native_revision", Mock()) + monkeypatch.setattr(QwenLibrary, "_bind", Mock()) + library = QwenLibrary() + assert library.path == installed / "libqwen.so" + assert loaded == names + assert len(library._dependency_handles) == 4 + + @pytest.mark.parametrize("version", [b"unknown", b"abcdef0 (2026-01-01)", b"", None, b"7df", b"7df559a (2026-07-17)"]) def test_unverified_library_rejected_before_binding(monkeypatch, tmp_path, version): from unittest.mock import Mock diff --git a/tests/test_build_native.py b/tests/test_build_native.py index ef93f0d..aa2ccfb 100644 --- a/tests/test_build_native.py +++ b/tests/test_build_native.py @@ -1,14 +1,164 @@ -"""Check diagnostics in the pinned native source after wheel-build patching.""" +"""Check backend configuration, packaging, and native build compatibility.""" +from contextlib import nullcontext import os from pathlib import Path import shutil +import subprocess +import sys +from unittest.mock import Mock import pytest +from scripts import build_native from scripts.build_native import native_diagnostic_compatibility +@pytest.mark.parametrize("from_env", [False, True]) +def test_hip_configuration(monkeypatch, tmp_path, from_env): + monkeypatch.setattr(sys, "platform", "linux") + for name in ("QWENTTS_CPP_CMAKE_ARGS", "CMAKE_HIP_COMPILER", "CMAKE_HIP_ARCHITECTURES", "ROCM_PATH"): + monkeypatch.delenv(name, raising=False) + argv = ["build_native.py", "--source", str(tmp_path), "--build-dir", str(tmp_path / "build")] + if from_env: + monkeypatch.setenv("QWENTTS_CPP_BACKEND", "hip") + monkeypatch.setenv("CMAKE_HIP_COMPILER", "/custom/rocm/llvm/bin/clang++") + monkeypatch.setenv("CMAKE_HIP_ARCHITECTURES", "gfx942;gfx950") + monkeypatch.setenv("ROCM_PATH", "/custom/rocm") + monkeypatch.setenv("QWENTTS_CPP_CMAKE_ARGS", "-DGGML_HIP_GRAPHS=OFF") + else: + argv += ["--backend", "hip", "--hip-compiler", "/custom/rocm/llvm/bin/clang++", + "--hip-architectures", "gfx942;gfx950", "--rocm-path", "/custom/rocm", + "--cmake-arg=-DGGML_HIP_GRAPHS=OFF"] + monkeypatch.setattr(sys, "argv", argv) + for name in ("native_logging_compatibility", "native_diagnostic_compatibility", "metal_shader_compatibility"): + monkeypatch.setattr(build_native, name, lambda *args: nullcontext()) + run = Mock() + monkeypatch.setattr(build_native, "run", run) + copy = Mock() + monkeypatch.setattr(build_native, "copy_shared_libraries", copy) + assert build_native.main() == 0 + configure, build = [call.args[0] for call in run.call_args_list] + for flag in ("-DBUILD_SHARED_LIBS=ON", "-DGGML_HIP=ON", "-DGGML_CUDA=OFF", + "-DGGML_BACKEND_DL=OFF", + "-DGGML_METAL=OFF", "-DGGML_BLAS=OFF", "-DGGML_NATIVE=OFF", + "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON", "-DCMAKE_INSTALL_RPATH=$ORIGIN", + "-DCMAKE_HIP_COMPILER=/custom/rocm/llvm/bin/clang++", + "-DCMAKE_HIP_ARCHITECTURES=gfx942;gfx950", "-DCMAKE_PREFIX_PATH=/custom/rocm", + "-DCMAKE_HIP_COMPILER_ROCM_ROOT=/custom/rocm"): + assert flag in configure + assert configure.index("-DGGML_HIP_GRAPHS=ON") < configure.index("-DGGML_HIP_GRAPHS=OFF") + assert build[:2] == ["cmake", "--build"] + copy.assert_called_once() + + +@pytest.mark.parametrize("platform", ["darwin", "win32"]) +def test_hip_rejects_unsupported_platforms(monkeypatch, platform): + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr(sys, "argv", ["build_native.py", "--backend", "hip"]) + with pytest.raises(SystemExit) as exc: + build_native.main() + assert exc.value.code == 2 + + +def test_copy_versioned_hip_library_and_relocate_siblings(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("QWENTTS_CPP_NO_STRIP", "1") + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/patchelf" if name == "patchelf" else None) + build = tmp_path / "build" + build.mkdir() + (build / "libqwen.so").write_bytes(b"qwen") + versioned = build / "libggml-hip.so.0.25.3" + versioned.write_bytes(b"hip") + (build / "libggml-hip.so.0").symlink_to(versioned.name) + (build / "libggml-hip.so").symlink_to("libggml-hip.so.0") + run = Mock() + monkeypatch.setattr(build_native, "run", run) + package = tmp_path / "installed" / "lib" + build_native.copy_shared_libraries(build, package) + shutil.rmtree(build) + assert (package / "libggml-hip.so.0").read_bytes() == b"hip" + assert not (package / "libggml-hip.so.0").is_symlink() + assert sorted(p.name for p in package.iterdir()) == ["libggml-hip.so.0", "libqwen.so"] + for path in package.iterdir(): + assert any(call.args[0] == ["/usr/bin/patchelf", "--set-rpath", "$ORIGIN", str(path)] + for call in run.call_args_list) + + +@pytest.mark.parametrize("missing_tool", [False, True]) +def test_hip_packaging_requires_successful_relocation(monkeypatch, tmp_path, missing_tool): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(shutil, "which", lambda name: None if missing_tool else "/usr/bin/patchelf") + (tmp_path / "libqwen.so").touch() + (tmp_path / "libggml-hip.so.0.25.3").touch() + monkeypatch.setattr(build_native, "run", Mock(side_effect=subprocess.CalledProcessError(1, "patchelf"))) + with pytest.raises(SystemExit if missing_tool else subprocess.CalledProcessError): + build_native.copy_shared_libraries(tmp_path, tmp_path / "package") + + +@pytest.mark.parametrize("from_env", [False, True]) +def test_dynamic_hip_rejected_after_configure_before_build(monkeypatch, tmp_path, from_env): + monkeypatch.setattr(sys, "platform", "linux") + build = tmp_path / "build" + argv = ["build_native.py", "--backend", "hip", "--source", str(tmp_path), "--build-dir", str(build)] + monkeypatch.delenv("QWENTTS_CPP_CMAKE_ARGS", raising=False) + if from_env: + monkeypatch.setenv("QWENTTS_CPP_CMAKE_ARGS", "-DGGML_BACKEND_DL:BOOL=ON") + else: + argv.append("--cmake-arg=-DGGML_BACKEND_DL:BOOL=ON") + monkeypatch.setattr(sys, "argv", argv) + for name in ("native_logging_compatibility", "native_diagnostic_compatibility", "metal_shader_compatibility"): + monkeypatch.setattr(build_native, name, lambda *args: nullcontext()) + + def configure(command): + assert command.index("-DGGML_BACKEND_DL=OFF") < command.index("-DGGML_BACKEND_DL:BOOL=ON") + (build / "CMakeCache.txt").write_text("GGML_BACKEND_DL:BOOL=ON\n") + + run = Mock(side_effect=configure) + copy = Mock() + monkeypatch.setattr(build_native, "run", run) + monkeypatch.setattr(build_native, "copy_shared_libraries", copy) + with pytest.raises(SystemExit, match="Dynamic HIP backends.*not supported"): + build_native.main() + run.assert_called_once() + copy.assert_not_called() + + +@pytest.mark.parametrize("stale_versioned", [False, True]) +def test_unversioned_hip_module_rejected_without_replacing_package(monkeypatch, tmp_path, stale_versioned): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(build_native, "__file__", str(tmp_path / "project" / "scripts" / "build_native.py")) + build = tmp_path / "build" + build.mkdir() + (build / "libqwen.so").write_bytes(b"new qwen") + (build / "libggml-hip.so").write_bytes(b"HIP module") + if stale_versioned: + (build / "libggml-hip.so.0.25.3").write_bytes(b"old linked HIP") + package = tmp_path / "project" / "src" / "qwentts_cpp" / "lib" + package.mkdir(parents=True) + (package / "libqwen.so").write_bytes(b"existing qwen") + monkeypatch.setattr(sys, "argv", [ + "build_native.py", "--backend", "hip", "--skip-build", "--build-dir", str(build), + ]) + run = Mock() + monkeypatch.setattr(build_native, "run", run) + with pytest.raises(SystemExit, match="Dynamic HIP backends.*not supported"): + build_native.main() + run.assert_not_called() + assert (package / "libqwen.so").read_bytes() == b"existing qwen" + assert [path.name for path in package.iterdir()] == ["libqwen.so"] + + +@pytest.mark.parametrize("value", ["ON", "YES", "1", "TRUE"]) +def test_dynamic_hip_cache_rejected_even_with_versioned_library(monkeypatch, tmp_path, value): + monkeypatch.setattr(sys, "platform", "linux") + (tmp_path / "CMakeCache.txt").write_text(f"GGML_BACKEND_DL:BOOL={value}\n") + (tmp_path / "libggml-hip.so.0.25.3").touch() + with pytest.raises(SystemExit, match="Dynamic HIP backends.*not supported"): + build_native.copy_shared_libraries(tmp_path, tmp_path / "package") + assert not (tmp_path / "package").exists() + + def test_missing_tokenizer_remains_an_error(tmp_path): source = os.environ.get("QWENTTS_CPP_SOURCE") if not source: diff --git a/tests/test_hip_packaging.py b/tests/test_hip_packaging.py new file mode 100644 index 0000000..0919739 --- /dev/null +++ b/tests/test_hip_packaging.py @@ -0,0 +1,88 @@ +"""Exercise real ELF relocation and installed wheel loading without a GPU. + +The tiny native fixtures model the GGML dependency graph, not HIP execution. +Hardware synthesis still needs the ROCm smoke test and system ROCm libraries. +""" +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from qwentts_cpp._binding import QWENTTS_NATIVE_REVISION +from scripts.build_native import copy_shared_libraries + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="ELF relocation requires Linux") +def test_hip_wheel_loads_without_source_or_build_directories(tmp_path, monkeypatch): + compiler = shutil.which("cc") + if not compiler or not shutil.which("patchelf"): + pytest.skip("A C compiler and patchelf are required") + root = Path(__file__).resolve().parents[1] + project = tmp_path / "project" + project.mkdir() + for name in ("setup.py", "pyproject.toml", "README.md", "LICENSE"): + shutil.copy2(root / name, project / name) + shutil.copytree(root / "src" / "qwentts_cpp", project / "src" / "qwentts_cpp", + ignore=shutil.ignore_patterns("lib", "__pycache__")) + build = tmp_path / "native-build" + build.mkdir() + + def compile_library(name, source, dependencies=()): + c_file = build / f"{name}.c" + c_file.write_text(source) + soname = f"lib{name}.so.0" if name != "qwen" else "libqwen.so" + versioned = soname + ".25.3" if name != "qwen" else soname + subprocess.run([compiler, "-shared", "-fPIC", str(c_file), "-o", str(build / versioned), + f"-Wl,-soname,{soname}", f"-Wl,-rpath,{build}", "-L", str(build), + *[f"-l{dep}" for dep in dependencies]], check=True) + if versioned != soname: + (build / soname).symlink_to(versioned) + (build / f"lib{name}.so").symlink_to(soname) + + compile_library("ggml-base", "int base_marker(void) { return 20; }") + compile_library("ggml-cpu", "extern int base_marker(void); int cpu_marker(void) { return base_marker(); }", + ("ggml-base",)) + compile_library("ggml-hip", "extern int base_marker(void); int hip_marker(void) { return base_marker() + 2; }", + ("ggml-base",)) + compile_library("ggml", "extern int hip_marker(void), cpu_marker(void); " + "int backend_marker(void) { return hip_marker() + cpu_marker(); }", ("ggml-hip", "ggml-cpu")) + # Required symbols are bound but never called. Only qt_version and the + # dependency marker run; this deliberately makes no synthesis claim. + exports = ("qt_init_default_params", "qt_tts_default_params", "qt_init", "qt_free", + "qt_synthesize", "qt_audio_free", "qt_log_set", "qt_duration_sec_to_tokens", + "qt_n_languages", "qt_language_name", "qt_model_type") + compile_library("qwen", f'const char *qt_version(void) {{ return "{QWENTTS_NATIVE_REVISION} (fixture)"; }}\n' + 'const char *qt_last_error(void) { return ""; }\n' + "extern int backend_marker(void); int qt_test_marker(void) { return backend_marker(); }\n" + + "\n".join(f"void {symbol}(void) {{}}" for symbol in exports), ("ggml",)) + monkeypatch.setenv("QWENTTS_CPP_NO_STRIP", "1") + package_lib = project / "src" / "qwentts_cpp" / "lib" + copy_shared_libraries(build, package_lib) + for library in package_lib.iterdir(): + assert subprocess.check_output(["patchelf", "--print-rpath", str(library)], text=True).strip() == "$ORIGIN" + wheelhouse = tmp_path / "wheelhouse" + env = os.environ.copy() + for name in ("QWENTTS_CPP_LIBRARY", "QWEN_LIBRARY_PATH", "LD_LIBRARY_PATH", "PYTHONPATH", + "QWENTTS_CPP_WHEEL_BUILD_TAG"): + env.pop(name, None) + subprocess.run([sys.executable, "setup.py", "bdist_wheel", "--dist-dir", str(wheelhouse)], + cwd=project, env=env, check=True, text=True) + wheel, = wheelhouse.glob("*.whl") + installed = tmp_path / "installed" + subprocess.run([sys.executable, "-m", "pip", "install", "--no-deps", "--target", str(installed), str(wheel)], + env=env, check=True, text=True) + shutil.rmtree(project) + shutil.rmtree(build) + probe = ( + "import sys; from pathlib import Path; " + f"sys.path.insert(0, {str(installed)!r}); " + "from qwentts_cpp import QwenLibrary; lib = QwenLibrary(); " + f"assert lib.path.parent == Path({str(installed / 'qwentts_cpp' / 'lib')!r}); " + "assert lib._lib.qt_test_marker() == 42; " + "assert any(Path(dep._name).name == 'libggml-hip.so.0' for dep in lib._dependency_handles); " + "print(lib.path, lib.version())" + ) + subprocess.run([sys.executable, "-I", "-c", probe], cwd=tmp_path, env=env, check=True) diff --git a/tests/test_smoke_stream.py b/tests/test_smoke_stream.py new file mode 100644 index 0000000..525ffac --- /dev/null +++ b/tests/test_smoke_stream.py @@ -0,0 +1,89 @@ +"""The ROCm smoke gate must fail before synthesis if AMD is unavailable.""" +from types import SimpleNamespace +import sys +from unittest.mock import MagicMock, Mock +import wave + +import numpy as np +import pytest + +from scripts import smoke_stream + + +@pytest.mark.parametrize("device,description,error", [ + (None, b"AMD Instinct MI300X", "ROCm0 was not registered"), + (123, b"Other GPU", "not an AMD device"), + (123, None, "not an AMD device"), + (123, b"AMD Instinct MI300X", None), +]) +def test_require_rocm_checks_registry(monkeypatch, tmp_path, device, description, error): + ggml = SimpleNamespace( + ggml_backend_load_all=Mock(), + ggml_backend_dev_by_name=Mock(return_value=device), + ggml_backend_dev_description=Mock(return_value=description), + ) + load = Mock(return_value=ggml) + monkeypatch.setattr(smoke_stream.ctypes, "CDLL", load) + library = SimpleNamespace(path=tmp_path / "libqwen.so") + if error: + with pytest.raises(RuntimeError, match=error): + smoke_stream.require_rocm(library) + else: + assert smoke_stream.require_rocm(library) == "AMD Instinct MI300X" + load.assert_called_once_with(str(tmp_path / "libggml.so.0")) + ggml.ggml_backend_load_all.assert_called_once_with() + ggml.ggml_backend_dev_by_name.assert_called_once_with(b"ROCm0") + + +def test_rocm_missing_device_fails_before_model_load(monkeypatch): + monkeypatch.setenv("GGML_BACKEND", "CPU") + monkeypatch.setattr(sys, "argv", [ + "smoke_stream.py", "--talker", "talker.gguf", "--codec", "codec.gguf", "--require-rocm", + ]) + library = Mock() + monkeypatch.setattr(smoke_stream, "QwenLibrary", Mock(return_value=library)) + model = Mock() + monkeypatch.setattr(smoke_stream, "QwenTTS", model) + monkeypatch.setattr(smoke_stream, "require_rocm", Mock(side_effect=RuntimeError("missing AMD"))) + with pytest.raises(RuntimeError, match="missing AMD"): + smoke_stream.main() + assert smoke_stream.os.environ["GGML_BACKEND"] == "ROCm0" + model.assert_not_called() + + +@pytest.mark.parametrize("audio,rate", [ + (np.zeros((2, 2)), 24000), (np.array([]), 24000), + (np.array([np.nan]), 24000), (np.ones(10), 22050), +]) +def test_smoke_rejects_invalid_audio(audio, rate): + with pytest.raises(AssertionError): + smoke_stream.check_audio(audio, rate) + + +def test_full_and_streaming_smoke_write_valid_wavs(monkeypatch, tmp_path): + output = tmp_path / "amd.wav" + monkeypatch.setenv("GGML_BACKEND", "CPU") + monkeypatch.setattr(sys, "argv", [ + "smoke_stream.py", "--talker", "talker.gguf", "--codec", "codec.gguf", + "--speaker", "Vivian", "--require-rocm", "--full", "--output", str(output), + ]) + monkeypatch.setattr(smoke_stream, "QwenLibrary", Mock()) + gate = Mock(return_value="AMD Instinct MI300X") + monkeypatch.setattr(smoke_stream, "require_rocm", gate) + tts = MagicMock() + tts.__enter__.return_value = tts + audio = np.array([0.0, 0.25, -0.25], dtype=np.float32) + tts.synthesize.return_value = (audio, 24000) + tts.stream.return_value = iter([(audio, 24000), (audio, 24000)]) + monkeypatch.setattr(smoke_stream, "QwenTTS", Mock(return_value=tts)) + smoke_stream.main() + gate.assert_called_once() + assert smoke_stream.os.environ["GGML_BACKEND"] == "ROCm0" + assert tts.synthesize.call_args.kwargs["speaker"] == "Vivian" + assert tts.stream.call_args.kwargs["speaker"] == "Vivian" + for path, frames in [(output, 6), (tmp_path / "amd-full.wav", 3)]: + with wave.open(str(path)) as wav: + assert wav.getframerate() == 24000 + assert wav.getnchannels() == 1 + assert wav.getnframes() == frames + assert wav.getsampwidth() == 2