diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml new file mode 100644 index 0000000..460fca1 --- /dev/null +++ b/.github/workflows/python-release.yml @@ -0,0 +1,484 @@ +name: Python release + +# Build release assets and publish the four distributions through PyPI OIDC. +# Configure a trusted publisher for each project using environment "pypi". +# GPU wheels initially target Linux x86_64. CPU also covers Linux aarch64, +# macOS arm64 and Windows amd64. These are regular CPython builds, not cp313t. +on: + release: + types: [published] + +permissions: + contents: read + +# Match release.yml's shared remote-cache configuration for EVERY native build. +env: + SCCACHE_BUCKET: ${{ vars.SCCACHE_BUCKET }} + SCCACHE_ENDPOINT: ${{ vars.SCCACHE_ENDPOINT }} + SCCACHE_REGION: ${{ vars.SCCACHE_REGION }} + + SCCACHE_GHA_ENABLED: "false" + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + CMAKE_GENERATOR: Ninja + CMAKE_BUILD_PARALLEL_LEVEL: "2" + CMAKE_ARGS: >- + -DCMAKE_C_COMPILER_LAUNCHER=sccache + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + -DCMAKE_CUDA_COMPILER_LAUNCHER=sccache + -DCMAKE_HIP_COMPILER_LAUNCHER=sccache + -DBUILD_TESTING=OFF + -DGGML_CCACHE=OFF + -DGGML_NATIVE=OFF + -DGGML_OPENMP=OFF + -DGGML_METAL=OFF + +jobs: + build-cpu: + env: + SCCACHE_S3_KEY_PREFIX: clap.cpp/python/cpu/${{ matrix.os }}/${{ matrix.python }}/ + strategy: + fail-fast: false + matrix: + python: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + os: [ubuntu-22.04, ubuntu-22.04-arm, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ startsWith(matrix.os, 'windows-') && 'pwsh' || 'bash' }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Set up Windows compiler for Ninja and sccache + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + version: v0.18.0 + - name: Install build frontend + run: python -m pip install build ninja + - name: Build CPU wheel + run: python -m build --wheel python --outdir raw -Ccmake.define.CLAP_PYTHON_BACKEND=CPU + - name: Repair Linux wheel (Ubuntu 22.04 glibc floor) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + python -m pip install 'auditwheel>=6.4,<7' patchelf + case "$(uname -m)" in + x86_64) platform=manylinux_2_35_x86_64 ;; + aarch64) platform=manylinux_2_35_aarch64 ;; + *) exit 1 ;; + esac + auditwheel repair --plat "$platform" --only-plat --wheel-dir wheelhouse raw/*.whl + - name: Stage non-Linux wheel + if: runner.os != 'Linux' + shell: bash + run: | + set -euo pipefail + mkdir -p wheelhouse + cp raw/*.whl wheelhouse/ + - name: Upload repaired wheels + uses: actions/upload-artifact@v4 + with: + name: python-cpu-${{ matrix.os }}-${{ matrix.python }} + path: wheelhouse/*.whl + if-no-files-found: error + + build-cuda: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04] + python: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + runs-on: ${{ matrix.os }} + env: + SCCACHE_S3_KEY_PREFIX: clap.cpp/python/cuda/${{ matrix.os }}/${{ matrix.python }}/ + # Preserve release.yml's serial CUDA compilation and portable arch list. + CMAKE_BUILD_PARALLEL_LEVEL: "1" + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install CUDA 12.9 build dependencies (same as release.yml) + uses: Jimver/cuda-toolkit@v0.2.36 + with: + cuda: '12.9.2' + method: network + sub-packages: '["nvcc"]' + non-cuda-sub-packages: '["libcublas-dev"]' + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + version: v0.18.0 + - name: Prepare standalone CUDA project + run: | + set -euo pipefail + python -m pip install build ninja 'auditwheel>=6.4,<7' patchelf + python python/prepare_backend.py cuda "$RUNNER_TEMP/clap-cuda" + - name: Build CUDA wheel + run: | + set -euo pipefail + python -m build --wheel "$RUNNER_TEMP/clap-cuda" --outdir raw \ + -Ccmake.define.CLAP_PYTHON_BACKEND=CUDA \ + -Ccmake.define.GGML_STATIC=ON \ + '-Ccmake.define.CMAKE_CUDA_ARCHITECTURES=50-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-real;89-real;90;120a-real;121a-real' + - name: Repair CUDA wheel + # cudart/cuBLAS are static (as in release.yml); libcuda is driver-owned. + run: | + set -euo pipefail + auditwheel repair --plat manylinux_2_35_x86_64 --only-plat \ + --exclude libcuda.so.1 --wheel-dir wheelhouse raw/*.whl + - uses: actions/upload-artifact@v4 + with: + name: python-cuda-${{ matrix.os }}-${{ matrix.python }} + path: wheelhouse/*.whl + if-no-files-found: error + + build-vulkan: + env: + SCCACHE_S3_KEY_PREFIX: clap.cpp/python/vulkan/${{ matrix.os }}/${{ matrix.python }}/ + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04] + python: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install Vulkan build dependencies (same as release.yml) + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 + with: + packages: libvulkan-dev glslc spirv-headers + version: vulkan-${{ matrix.os }}-${{ runner.arch }}-v1 + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + version: v0.18.0 + - name: Prepare standalone Vulkan project + run: | + set -euo pipefail + python -m pip install build ninja 'auditwheel>=6.4,<7' patchelf + python python/prepare_backend.py vulkan "$RUNNER_TEMP/clap-vulkan" + - name: Build Vulkan wheel + run: | + set -euo pipefail + python -m build --wheel "$RUNNER_TEMP/clap-vulkan" --outdir raw \ + -Ccmake.define.CLAP_PYTHON_BACKEND=VULKAN + - name: Repair Vulkan wheel (Ubuntu 24.04 glibc floor) + # Deliberately use the host loader/ICD; never bundle a vendor GPU driver. + run: | + set -euo pipefail + auditwheel repair --plat manylinux_2_39_x86_64 --only-plat \ + --exclude libvulkan.so.1 --wheel-dir wheelhouse raw/*.whl + - uses: actions/upload-artifact@v4 + with: + name: python-vulkan-${{ matrix.os }}-${{ matrix.python }} + path: wheelhouse/*.whl + if-no-files-found: error + + build-rocm: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04] + python: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + runs-on: ${{ matrix.os }} + env: + SCCACHE_S3_KEY_PREFIX: clap.cpp/python/rocm/${{ matrix.os }}/${{ matrix.python }}/ + CMAKE_BUILD_PARALLEL_LEVEL: "1" + CMAKE_PREFIX_PATH: /opt/rocm + LD_LIBRARY_PATH: /opt/rocm/lib:/opt/rocm/lib64 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Add ROCm 6.4.3 package repository (same as release.yml) + run: | + set -euo pipefail + curl -fsSL --retry 3 https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /usr/share/keyrings/rocm.gpg > /dev/null + echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/6.4.3 jammy main' | sudo tee /etc/apt/sources.list.d/rocm.list + printf 'Package: *\nPin: origin repo.radeon.com\nPin-Priority: 600\n' | sudo tee /etc/apt/preferences.d/rocm + sudo apt-get update + - name: Install ROCm build dependencies + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 + with: + packages: hip-dev hipblas-dev rocblas-dev rocm-device-libs + version: rocm-6.4.3-ubuntu-22.04-v2 + - name: Restore ROCm installation prefix + run: | + set -euo pipefail + sudo ln -sfn /opt/rocm-6.4.3 /opt/rocm + test -x /opt/rocm/llvm/bin/clang++ + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + version: v0.18.0 + - name: Prepare standalone ROCm project + run: | + set -euo pipefail + python -m pip install build ninja 'auditwheel>=6.4,<7' patchelf + python python/prepare_backend.py rocm "$RUNNER_TEMP/clap-rocm" + - name: Build ROCm wheel + run: | + set -euo pipefail + python -m build --wheel "$RUNNER_TEMP/clap-rocm" --outdir raw \ + -Ccmake.define.CLAP_PYTHON_BACKEND=HIP \ + -Ccmake.define.CMAKE_HIP_COMPILER=/opt/rocm/llvm/bin/clang++ \ + '-Ccmake.define.CMAKE_HIP_ARCHITECTURES=gfx908;gfx90a;gfx942;gfx1030;gfx1100;gfx1101;gfx1200;gfx1201' \ + -Ccmake.define.CMAKE_INSTALL_RPATH=/opt/rocm/lib \ + -Ccmake.define.GGML_HIP_RCCL=OFF -Ccmake.define.GGML_STATIC=OFF + - name: Repair ROCm wheel, retaining system ROCm runtime + # Only vendor libraries under the pinned ROCm prefix are excluded. + # Record the exact exclusion SONAMEs for the release runtime manifest. + run: | + set -euo pipefail + mkdir -p wheelhouse + python - <<'PY' + import pathlib + import re + import subprocess + import sys + names = set() + root = pathlib.Path('/opt/rocm-6.4.3') + for library in root.rglob('*.so*'): + if not library.is_file(): + continue + result = subprocess.run(['readelf', '-d', str(library)], capture_output=True, text=True) + if result.returncode == 0: + names.update(re.findall(r'\(SONAME\).*?\[(.*?)\]', result.stdout)) + if not {'libamdhip64.so.6', 'libhipblas.so.2', 'librocblas.so.4'} <= names: + sys.exit('Expected ROCm runtime SONAMEs missing; inspect SDK installation') + pathlib.Path('wheelhouse/ROCM-6.4.3-EXCLUDED-SONAMES.txt').write_text('\n'.join(sorted(names)) + '\n') + command = ['auditwheel', 'repair', '--plat', 'manylinux_2_35_x86_64', '--only-plat', '--wheel-dir', 'wheelhouse'] + for name in sorted(names): + command.extend(['--exclude', name]) + wheels = list(pathlib.Path('raw').glob('*.whl')) + if len(wheels) != 1: + sys.exit(f'Expected one raw wheel, got {len(wheels)}') + subprocess.run(command + [str(wheels[0])], check=True) + PY + - uses: actions/upload-artifact@v4 + with: + name: python-rocm-${{ matrix.os }}-${{ matrix.python }} + path: | + wheelhouse/*.whl + wheelhouse/ROCM-6.4.3-EXCLUDED-SONAMES.txt + if-no-files-found: error + + sdist: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build source distributions (no native compilation) + shell: bash + run: | + set -euo pipefail + python -m pip install build + python -m build --sdist python --outdir sdists + for backend in cuda rocm vulkan; do + destination="$RUNNER_TEMP/clap-$backend" + python python/prepare_backend.py "$backend" "$destination" + python -m build --sdist "$destination" --outdir sdists + done + - uses: actions/upload-artifact@v4 + with: + name: python-sdists + path: sdists/*.tar.gz + if-no-files-found: error + + attach-release: + needs: [build-cpu, build-cuda, build-vulkan, build-rocm, sdist] + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: python-* + path: artifacts + # Keep directories separate so duplicate asset names cannot overwrite + # one another silently; the manifest explicitly verifies uniqueness. + merge-multiple: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate complete asset set and write runtime manifest + shell: bash + run: | + set -euo pipefail + python -m pip install packaging + python - <<'PY' + from collections import Counter + import hashlib + import pathlib + import shutil + import tarfile + import zipfile + from packaging.utils import parse_wheel_filename, parse_sdist_filename + root = pathlib.Path('artifacts') + dist = pathlib.Path('dist') + dist.mkdir() + expected = Counter() + for minor in range(9, 15): + interpreter = f'cp3{minor}' + for platform in ('linux-x86_64', 'linux-aarch64', 'macosx-arm64', 'win-amd64'): + expected[('clap-cpp', interpreter, platform)] += 1 + for backend in ('cuda', 'rocm', 'vulkan'): + expected[(f'clap-cpp-{backend}', interpreter, 'linux-x86_64')] += 1 + actual = Counter() + versions = set() + for wheel in root.rglob('*.whl'): + name, version, _, tags = parse_wheel_filename(wheel.name) + versions.add(version) + keys = set() + for tag in tags: + if tag.platform.startswith('linux_'): + raise SystemExit(f'Unrepaired Linux wheel: {wheel.name}') + if tag.platform.startswith('manylinux_'): + platform = 'linux-aarch64' if tag.platform.endswith('_aarch64') else 'linux-x86_64' + elif tag.platform.startswith('macosx_') and tag.platform.endswith('_arm64'): + platform = 'macosx-arm64' + elif tag.platform == 'win_amd64': + platform = 'win-amd64' + else: + raise SystemExit(f'Unexpected platform: {tag}') + keys.add((name, tag.interpreter, platform)) + if len(keys) != 1: + raise SystemExit(f'Ambiguous wheel: {wheel.name}') + actual.update(keys) + with zipfile.ZipFile(wheel) as archive: + if not any('_clap_cpp' in entry and entry.endswith(('.so', '.pyd')) for entry in archive.namelist()): + raise SystemExit(f'Missing native extension: {wheel.name}') + if (dist / wheel.name).exists(): + raise SystemExit(f'Duplicate release asset: {wheel.name}') + shutil.copy2(wheel, dist) + if actual != expected: + raise SystemExit(f'Wheel matrix mismatch: missing={expected-actual}, extra={actual-expected}') + sdists = list(root.rglob('*.tar.gz')) + names = Counter() + for source in sdists: + name, version = parse_sdist_filename(source.name) + names[name] += 1 + versions.add(version) + with tarfile.open(source) as archive: + if not any('/native/ggml/CMakeLists.txt' in entry.name for entry in archive): + raise SystemExit(f'Sdist missing pinned native sources: {source.name}') + if (dist / source.name).exists(): + raise SystemExit(f'Duplicate release asset: {source.name}') + shutil.copy2(source, dist) + if names != Counter({'clap-cpp': 1, 'clap-cpp-cuda': 1, 'clap-cpp-rocm': 1, 'clap-cpp-vulkan': 1}) or len(versions) != 1: + raise SystemExit(f'Sdist names/version mismatch: {names}, {versions}') + manifests = list(root.rglob('ROCM-6.4.3-EXCLUDED-SONAMES.txt')) + if len(manifests) != 6 or len({p.read_bytes() for p in manifests}) != 1: + raise SystemExit('ROCm exclusion manifests are missing or disagree') + shutil.copy2(manifests[0], dist) + (dist / 'PYTHON-WHEEL-RUNTIME.txt').write_text('''Python wheels are GitHub Release assets; stable releases also publish to PyPI. + CPU: CPython 3.9-3.14, Linux x86_64/aarch64 (glibc >= 2.35), + macOS arm64 (wheel filename records deployment minimum), Windows amd64. + GPU: CPython 3.9-3.14, Linux x86_64 only. No Windows GPU wheels yet. + Install clap-cpp plus the matching clap-cpp-cuda/rocm/vulkan wheel, + with identical versions and matching CPython/platform tags. + CUDA: toolkit 12.9.2, glibc >= 2.35; static cudart/cuBLAS; + compatible NVIDIA driver required, libcuda.so.1 is NOT bundled. + Vulkan: glibc >= 2.39; system libvulkan.so.1 loader and a working + vendor Vulkan ICD/driver are required and NOT bundled. + ROCm: glibc >= 2.35; install AMD ROCm 6.4.3 HIP, hipBLAS and rocBLAS, + including rocBLAS library data and a compatible AMD GPU/kernel driver. + Make /opt/rocm/lib:/opt/rocm/lib64 visible via LD_LIBRARY_PATH or ldconfig. + ROCM-6.4.3-EXCLUDED-SONAMES.txt lists every vendor SONAME deliberately + excluded from auditwheel bundling; these are system-runtime wheels, + not self-contained ROCm distributions. Do not relocate rocBLAS data. + ROCm GPU targets: gfx908,gfx90a,gfx942,gfx1030,gfx1100,gfx1101,gfx1200,gfx1201. + Linux wheels are auditwheel-repaired manylinux wheels; the external + GPU runtimes above remain deliberate exceptions, not portability promises. + Source archives include pinned native sources; GPU source builds need + the corresponding vendor toolkit. No tests or GPU execution are run here. + ''') + (dist / 'PYTHON-SHA256SUMS.txt').write_text(''.join( + f'{hashlib.sha256(p.read_bytes()).hexdigest()} {p.name}\n' + for p in sorted(dist.iterdir()) + )) + print(f'Validated {sum(actual.values())} wheels and {len(sdists)} sdists; version {next(iter(versions))}') + PY + - name: Attach verified assets to the triggering GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" dist/* --clobber + # Read back this exact release and verify asset size AND bytes; an + # upload command succeeding is not itself evidence of a complete set. + gh release view "$RELEASE_TAG" --json assets > release-assets.json + python - <<'PY' + import json + import pathlib + assets = {a['name']: a['size'] for a in json.loads(pathlib.Path('release-assets.json').read_text())['assets']} + for path in pathlib.Path('dist').iterdir(): + if assets.get(path.name) != path.stat().st_size: + raise SystemExit(f'Release asset missing or wrong size: {path.name}') + PY + mkdir downloaded + for asset in dist/*; do + gh release download "$RELEASE_TAG" --pattern "$(basename "$asset")" --dir downloaded + done + cd downloaded + sha256sum -c PYTHON-SHA256SUMS.txt + - uses: actions/upload-artifact@v4 + with: + name: python-pypi + path: | + dist/*.whl + dist/*.tar.gz + if-no-files-found: error + + publish-pypi: + needs: attach-release + if: github.event.release.prerelease == false + runs-on: ubuntu-22.04 + environment: + name: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-pypi + path: dist + - name: Publish base package and GPU extras + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 5b0ec50..a98fa20 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -12,8 +12,21 @@ foreach(source ggml/CMakeLists.txt thirdparty/json/CMakeLists.txt thirdparty/min endif() endforeach() -# Build a self-contained CPU extension, never CLI executables, GPU backends, -# external OpenMP runtimes, or shared libraries requiring a loader search path. +# Each wheel contains one statically linked runtime. GPU wheels install into +# separate packages so extras never overwrite the base package's CPU extension. +set(CLAP_PYTHON_BACKEND "CPU" CACHE STRING "Python wheel backend: CPU, CUDA, HIP or VULKAN") +set_property(CACHE CLAP_PYTHON_BACKEND PROPERTY STRINGS CPU CUDA HIP VULKAN) +if(NOT CLAP_PYTHON_BACKEND MATCHES "^(CPU|CUDA|HIP|VULKAN)$") + message(FATAL_ERROR "Unknown Python backend: ${CLAP_PYTHON_BACKEND}") +endif() +set(CLAP_PYTHON_PACKAGE "clap_cpp") +if(NOT CLAP_PYTHON_BACKEND STREQUAL "CPU") + string(TOLOWER "${CLAP_PYTHON_BACKEND}" backend_name) + if(backend_name STREQUAL "hip") + set(backend_name "rocm") + endif() + set(CLAP_PYTHON_PACKAGE "clap_cpp_${backend_name}") +endif() set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(GGML_NATIVE OFF CACHE BOOL "" FORCE) @@ -22,6 +35,9 @@ foreach(backend METAL CUDA HIP VULKAN SYCL OPENCL OPENVINO RPC MUSA WEBGPU ZDNN VIRTGPU VIRTGPU_BACKEND ET HEXAGON ZENDNN BLAS ACCELERATE) set(GGML_${backend} OFF CACHE BOOL "" FORCE) endforeach() +if(NOT CLAP_PYTHON_BACKEND STREQUAL "CPU") + set(GGML_${CLAP_PYTHON_BACKEND} ON CACHE BOOL "" FORCE) +endif() foreach(feature BACKEND_DL OPENMP BUILD_TESTS BUILD_EXAMPLES) set(GGML_${feature} OFF CACHE BOOL "" FORCE) endforeach() @@ -42,9 +58,9 @@ pybind11_add_module(_clap_cpp MODULE bindings.cpp) target_link_libraries(_clap_cpp PRIVATE clap) target_compile_features(_clap_cpp PRIVATE cxx_std_17) set_target_properties(_clap_cpp PROPERTIES CXX_EXTENSIONS OFF) -install(TARGETS _clap_cpp LIBRARY DESTINATION clap_cpp COMPONENT python - RUNTIME DESTINATION clap_cpp COMPONENT python) -install(FILES "${CLAP_ROOT}/ggml/LICENSE" DESTINATION clap_cpp/licenses RENAME ggml.txt COMPONENT python) -install(FILES "${CLAP_ROOT}/thirdparty/miniaudio/LICENSE" DESTINATION clap_cpp/licenses RENAME miniaudio.txt COMPONENT python) -install(FILES "${CLAP_ROOT}/thirdparty/icu/LICENSE" DESTINATION clap_cpp/licenses RENAME icu.txt COMPONENT python) -install(FILES "${CLAP_ROOT}/thirdparty/json/LICENSE.MIT" DESTINATION clap_cpp/licenses RENAME json.txt COMPONENT python) +install(TARGETS _clap_cpp LIBRARY DESTINATION ${CLAP_PYTHON_PACKAGE} COMPONENT python + RUNTIME DESTINATION ${CLAP_PYTHON_PACKAGE} COMPONENT python) +install(FILES "${CLAP_ROOT}/ggml/LICENSE" DESTINATION ${CLAP_PYTHON_PACKAGE}/licenses RENAME ggml.txt COMPONENT python) +install(FILES "${CLAP_ROOT}/thirdparty/miniaudio/LICENSE" DESTINATION ${CLAP_PYTHON_PACKAGE}/licenses RENAME miniaudio.txt COMPONENT python) +install(FILES "${CLAP_ROOT}/thirdparty/icu/LICENSE" DESTINATION ${CLAP_PYTHON_PACKAGE}/licenses RENAME icu.txt COMPONENT python) +install(FILES "${CLAP_ROOT}/thirdparty/json/LICENSE.MIT" DESTINATION ${CLAP_PYTHON_PACKAGE}/licenses RENAME json.txt COMPONENT python) diff --git a/python/README.md b/python/README.md index abdb957..5d40cc4 100644 --- a/python/README.md +++ b/python/README.md @@ -1,4 +1,51 @@ -# clap.cpp Python bindings (CPU) +# clap.cpp Python bindings + +## Installation + +```sh +pip install clap-cpp # CPU +pip install "clap-cpp[cuda]" # NVIDIA CUDA +pip install "clap-cpp[rocm]" # AMD ROCm +pip install "clap-cpp[vulkan]" # Vulkan +``` + +The extras install version-matched `clap-cpp-cuda`, `clap-cpp-rocm` or +`clap-cpp-vulkan` wheels. They do not change compiler flags during pip install. +The public import remains `from clap_cpp import Model`. With one GPU backend +installed it is selected automatically; otherwise the CPU extension is used. +Set `CLAP_CPP_BACKEND=cpu|cuda|rocm|vulkan` before importing to select explicitly. +If multiple GPU packages are installed, explicit selection is required. +`clap_cpp.backend` reports the selected compiled backend, not device availability. +A GPU extension that cannot load raises an error rather than silently loading +CPU; `CLAP_CPP_BACKEND=cpu` explicitly uses the bundled CPU extension. + +Release wheels cover CPython 3.9–3.14 (not free-threaded builds): + +| Backend | Platforms | Required system runtime | +| --- | --- | --- | +| CPU | Linux x86_64/ARM64 (glibc 2.35+), macOS ARM64, Windows x86_64 | None beyond platform runtimes | +| CUDA | Linux x86_64 (glibc 2.35+) | CUDA 12.9-compatible NVIDIA driver; cudart/cuBLAS are statically linked | +| ROCm | Linux x86_64 (glibc 2.35+) | ROCm 6.4.3 HIP, hipBLAS, rocBLAS (including library data), compatible AMD driver | +| Vulkan | Linux x86_64 (glibc 2.39+) | System Vulkan loader and vendor ICD/driver | + +GPU wheels do not bundle drivers. ROCm libraries must be discoverable through +`ldconfig` or `LD_LIBRARY_PATH=/opt/rocm/lib:/opt/rocm/lib64`; keep rocBLAS's data +installed in the matching ROCm prefix. No Windows GPU or macOS GPU wheels are +built by this workflow. On other targets pip can build from a source archive, +but this requires the corresponding GPU SDK and compiler. + +GPU acceleration currently uses the native runtime's cached dense matrix +multiplication path for CLAP. Architectures requiring storage-only precision +(MERT, MuQ, MuQ-MuLan and CLaMP3) and other operations retain their CPU paths. +Device initialization failures and unsupported operations retain the native +CPU fallback; installing an extra does not guarantee every operation runs on GPU. + +The commands above require these projects to have been published to PyPI. +Before the first publication, download matching wheels from a GitHub Release +into a local directory and use, for example, +`pip install --find-links ./wheels "clap-cpp[cuda]"`. + +## Build from source Build and install from a recursive checkout: @@ -9,11 +56,26 @@ python -m pip install ./python ``` Requires Python 3.9+, a C++17 compiler and CMake 3.20+. The build backend installs -CMake/Ninja if necessary. NumPy is the only runtime Python dependency; PyTorch, -FFmpeg, a separate `clap` executable and GPU drivers are not required. -The extension statically links the CPU runtime and bundled ICU. GPU backends and -OpenMP are disabled. x86 wheels use baseline instructions rather than the build -machine's instruction set. No pretrained weights are downloaded automatically. +CMake/Ninja if necessary. For the default CPU package NumPy is the only runtime +Python dependency; PyTorch, FFmpeg, a separate `clap` executable and GPU drivers +are not required. The extension statically links the CPU runtime and bundled +ICU. The base package disables GPU backends and OpenMP. x86 wheels use baseline +instructions rather than the build machine's instruction set. No pretrained +weights are downloaded automatically. + +Build an optional backend project separately, with its GPU SDK installed: + +```sh +python python/prepare_backend.py cuda ./build-python-cuda +python -m pip install ./build-python-cuda +python -m pip install ./python +``` + +Replace `cuda` with `rocm` or `vulkan` as needed. The helper also works inside +the unpacked base source distribution. Extra CMake options can be passed with +pip's `-Ccmake.define.NAME=VALUE`, for example CUDA/HIP architecture lists when +building on a machine without a GPU. GPU backend selection and wheel package +names must match: do not enable a GPU backend directly on the base project. ## Usage @@ -81,5 +143,24 @@ python -m build python The source distribution includes the pinned native sources and third-party licenses; a wheel can be rebuilt from that archive without a Git checkout or CMake-time downloads. Build dependencies must still be available from the Python -package index or a local wheelhouse. Wheel publishing and GPU support are out of -scope for this initial binding. +package index or a local wheelhouse. + +## Releases + +`.github/workflows/python-release.yml` runs when a GitHub Release is published. +It builds CPU, CUDA, ROCm and Vulkan distributions without running tests, using +sccache for C, C++, CUDA and HIP compilation. Cache storage reuses the repository's +`SCCACHE_BUCKET`, `SCCACHE_ENDPOINT`, `SCCACHE_REGION` variables and +`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` secrets. Linux wheels are repaired +with auditwheel; system GPU libraries remain external as listed above. All +wheels and source distributions are attached to that release. + +Stable releases also publish the four distributions to PyPI using OIDC. Before +releasing, configure a PyPI trusted publisher (owner `gorse-io`, repository +`clap.cpp`, workflow `python-release.yml`, environment `pypi`) for **each** of +`clap-cpp`, `clap-cpp-cuda`, `clap-cpp-rocm` and `clap-cpp-vulkan`, and configure +any required GitHub `pypi` environment approvals. Prereleases only attach assets. +The version in `python/pyproject.toml` and its three exact extra dependencies +must be updated together before publishing a new version. The backend helper +copies that version into each backend distribution; existing PyPI versions +cannot be overwritten. diff --git a/python/bindings.cpp b/python/bindings.cpp index 148b595..c535d25 100644 --- a/python/bindings.cpp +++ b/python/bindings.cpp @@ -81,7 +81,7 @@ class Model { } // namespace PYBIND11_MODULE(_clap_cpp, module) { - module.doc() = "Native CPU inference for clap.cpp"; + module.doc() = "Native inference for clap.cpp"; py::class_(module, "Model") .def(py::init(), py::arg("path"), py::arg("threads") = 0) .def_property_readonly("projection_dimension", [](Model &self) { diff --git a/python/clap_cpp/__init__.py b/python/clap_cpp/__init__.py index c856d55..d03bba3 100644 --- a/python/clap_cpp/__init__.py +++ b/python/clap_cpp/__init__.py @@ -1,5 +1,43 @@ -"""CPU audio/text embeddings backed by the native clap.cpp runtime.""" +"""Audio/text embeddings backed by the selected native clap.cpp runtime.""" -from ._clap_cpp import Model, decode_audio, resample_audio +from importlib import import_module +from importlib.metadata import version +from importlib.util import find_spec +import os -__all__ = ["Model", "decode_audio", "resample_audio"] + +def _load_backend(): + requested = os.environ.get("CLAP_CPP_BACKEND", "auto").lower() + choices = ("cuda", "rocm", "vulkan") + if requested not in ("auto", "cpu", *choices): + raise ImportError("CLAP_CPP_BACKEND must be auto, cpu, cuda, rocm or vulkan") + if requested == "auto": + installed = [name for name in choices if find_spec(f"clap_cpp_{name}") is not None] + if len(installed) > 1: + raise ImportError( + "Multiple clap-cpp GPU backends are installed; select one with " + "CLAP_CPP_BACKEND=cpu|cuda|rocm|vulkan" + ) + requested = installed[0] if installed else "cpu" + if requested == "cpu": + return requested, import_module("._clap_cpp", __name__) + package = f"clap_cpp_{requested}" + if find_spec(package) is None: + raise ImportError(f'Install the backend with pip install "clap-cpp[{requested}]"') + if version(f"clap-cpp-{requested}") != version("clap-cpp"): + raise ImportError(f"clap-cpp and clap-cpp-{requested} must have the same version") + try: + return requested, import_module(f"{package}._clap_cpp") + except (ImportError, OSError) as error: + raise ImportError( + f"Cannot load clap-cpp {requested} backend. Install its GPU runtime/driver " + "or set CLAP_CPP_BACKEND=cpu." + ) from error + + +backend, _native = _load_backend() +Model = _native.Model +decode_audio = _native.decode_audio +resample_audio = _native.resample_audio + +__all__ = ["Model", "decode_audio", "resample_audio", "backend"] diff --git a/python/prepare_backend.py b/python/prepare_backend.py new file mode 100644 index 0000000..1293525 --- /dev/null +++ b/python/prepare_backend.py @@ -0,0 +1,73 @@ +"""Create a standalone GPU wheel project from a checkout or the base sdist.""" + +import argparse +from pathlib import Path +import re +import shutil + + +def prepare(backend, destination): + source = Path(__file__).resolve().parent + metadata = (source / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'^version = "([^"]+)"$', metadata, re.MULTILINE) + if match is None: + raise ValueError("Missing package version") + version = match.group(1) + package = f"clap_cpp_{backend}" + cmake_backend = "HIP" if backend == "rocm" else backend.upper() + # Reuse the base sdist's exact native-source manifest, including licenses. + manifest = metadata.split("[tool.scikit-build.sdist.force-include]", 1)[1].split("\n[", 1)[0] + entries = re.findall(r'^"([^"]+)" = "([^"]+)"$', manifest, re.MULTILINE) + if not entries: + raise ValueError("Missing native source manifest") + destination.mkdir(parents=True, exist_ok=False) + ignore = shutil.ignore_patterns(".git", "__pycache__", "build", "dist") + for original, target in entries: + # Inside a base sdist the manifest's ../ paths have already been moved. + origin = source / target if (source / "native").is_dir() else source / original + output = destination / target + output.parent.mkdir(parents=True, exist_ok=True) + if origin.is_dir(): + shutil.copytree(origin, output, ignore=ignore) + else: + shutil.copy2(origin, output) + for name in ("CMakeLists.txt", "bindings.cpp", "README.md"): + shutil.copy2(source / name, destination / name) + (destination / package).mkdir() + (destination / package / "__init__.py").write_text( + f'"""Native {backend} runtime; import clap_cpp for the public API."""\n', encoding="utf-8" + ) + (destination / "pyproject.toml").write_text( + f'''[build-system] +requires = ["scikit-build-core>=1.0", "pybind11>=2.13"] +build-backend = "scikit_build_core.build" + +[project] +name = "clap-cpp-{backend}" +version = "{version}" +description = "{backend.upper()} backend for clap-cpp" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +dependencies = ["numpy>=1.23"] + +[tool.scikit-build] +minimum-version = "build-system.requires" +wheel.packages = ["{package}"] +install.components = ["python"] +build.targets = ["_clap_cpp"] +sdist.exclude = ["**/.git", "**/__pycache__/**", "dist/**", "build/**"] + +[tool.scikit-build.cmake.define] +CLAP_PYTHON_BACKEND = "{cmake_backend}" +''', encoding="utf-8" + ) + print(f"Prepared clap-cpp-{backend} {version} in {destination}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("backend", choices=("cuda", "rocm", "vulkan")) + parser.add_argument("destination", type=Path) + args = parser.parse_args() + prepare(args.backend, args.destination.resolve()) diff --git a/python/pyproject.toml b/python/pyproject.toml index ec818b7..d01a0b7 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "scikit_build_core.build" [project] name = "clap-cpp" version = "0.1.0" -description = "CPU Python bindings for clap.cpp audio and text embeddings" +description = "Python bindings for clap.cpp audio and text embeddings" readme = "README.md" requires-python = ">=3.9" license = "MIT" @@ -13,6 +13,9 @@ dependencies = ["numpy>=1.23"] [project.optional-dependencies] test = ["pytest>=7"] +cuda = ["clap-cpp-cuda==0.1.0"] +rocm = ["clap-cpp-rocm==0.1.0"] +vulkan = ["clap-cpp-vulkan==0.1.0"] [tool.scikit-build] minimum-version = "build-system.requires" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index dd5d60a..9efbbc6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,16 @@ target_include_directories(clap SYSTEM PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../thirdparty/miniaudio") target_link_libraries(clap PUBLIC ggml PRIVATE ggml-cpu nlohmann_json::nlohmann_json ICU::uc ICU::i18n) +if(GGML_CUDA OR GGML_HIP OR GGML_VULKAN) + target_compile_definitions(clap PRIVATE CLAP_USE_GPU) +endif() +if(GGML_CUDA) + target_link_libraries(clap PRIVATE ggml-cuda) + target_compile_definitions(clap PRIVATE CLAP_USE_CUDA) +elseif(GGML_HIP) + target_link_libraries(clap PRIVATE ggml-hip) + target_compile_definitions(clap PRIVATE CLAP_USE_HIP) +endif() if(GGML_VULKAN) target_link_libraries(clap PRIVATE ggml-vulkan) target_compile_definitions(clap PRIVATE CLAP_USE_VULKAN) diff --git a/src/model-ops.cpp b/src/model-ops.cpp index 710eb5e..c388c71 100644 --- a/src/model-ops.cpp +++ b/src/model-ops.cpp @@ -6,9 +6,13 @@ #include #include #include -#ifdef CLAP_USE_VULKAN +#ifdef CLAP_USE_GPU #include +#if defined(CLAP_USE_CUDA) || defined(CLAP_USE_HIP) +#include +#else #include +#endif #include #include #include @@ -38,7 +42,7 @@ struct MatmulWeight { } // namespace struct TensorOps::BackendState { -#ifdef CLAP_USE_VULKAN +#ifdef CLAP_USE_GPU using Context = std::unique_ptr; using Buffer = std::unique_ptr; std::unique_ptr gpu{nullptr, ggml_backend_free}; @@ -50,15 +54,25 @@ struct TensorOps::BackendState { // Declared after gpu: device buffers must be destroyed before the backend. std::unordered_map> weights; mutable std::mutex mutex; - bool last_vulkan = false; + bool last_gpu = false; bool enabled = true; size_t uploads = 0; BackendState() { +#if defined(CLAP_USE_CUDA) + const auto *setting = std::getenv("CLAP_CUDA"); +#elif defined(CLAP_USE_HIP) + const auto *setting = std::getenv("CLAP_ROCM"); +#else const auto *setting = std::getenv("CLAP_VULKAN"); +#endif if (setting && std::strcmp(setting, "0") == 0) return; try { +#if defined(CLAP_USE_CUDA) || defined(CLAP_USE_HIP) + if (ggml_backend_cuda_get_device_count() > 0) gpu.reset(ggml_backend_cuda_init(0)); +#else if (ggml_backend_vk_get_device_count() > 0) gpu.reset(ggml_backend_vk_init(0)); +#endif } catch (const std::exception &) { // An optional backend must not prevent loading on a CPU-only host. } @@ -131,7 +145,7 @@ struct TensorOps::BackendState { bool TensorOps::last_linear_used_vulkan() const { #ifdef CLAP_USE_VULKAN std::lock_guard lock(backend->mutex); - return backend->last_vulkan; + return backend->last_gpu; #else return false; #endif @@ -186,7 +200,7 @@ void tensor_row(const ggml_tensor *t, int64_t row, float *output) { } TensorOps::TensorOps(const ModelLoader &source, int threads) : loader(source), n_threads(threads > 0 ? threads : static_cast(std::max(1u, std::min(8u, std::thread::hardware_concurrency())))) { -#ifdef CLAP_USE_VULKAN +#ifdef CLAP_USE_GPU backend = std::make_shared(); #endif } @@ -198,12 +212,12 @@ Matrix TensorOps::linear(const Matrix &x, const std::string &name, bool bias) co MatmulWeight operand(w, loader.f32_activation_compute()); Matrix result(x.rows, static_cast(w->ne[1])); bool computed = false; -#ifdef CLAP_USE_VULKAN +#ifdef CLAP_USE_GPU std::lock_guard lock(backend->mutex); - backend->last_vulkan = false; + backend->last_gpu = false; try { if (!loader.f32_activation_compute()) computed = backend->linear(x, w, result); - backend->last_vulkan = computed; + backend->last_gpu = computed; } catch (const std::exception &) { // Allocation/driver failures are recoverable: use the original CPU path. backend->enabled = false;