diff --git a/.github/scripts/free-disk.sh b/.github/scripts/free-disk.sh new file mode 100755 index 0000000..a0cf150 --- /dev/null +++ b/.github/scripts/free-disk.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Remove toolchains the hosted runner image carries and these jobs never use. +# The CUDA image and a staged package tree need about 25 GB. +set -euo pipefail +df -h / | tail -1 +sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup \ + /opt/hostedtoolcache/CodeQL /usr/local/share/boost /usr/share/swift +sudo docker image prune -af >/dev/null 2>&1 || true +df -h / | tail -1 diff --git a/.github/scripts/linux-installed-check.sh b/.github/scripts/linux-installed-check.sh new file mode 100755 index 0000000..57314ce --- /dev/null +++ b/.github/scripts/linux-installed-check.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Run where the deb or the rpm was just installed: every library resolves and +# the server starts. /dist/stub/libcuda.so.1 stands in for the driver. +set -euo pipefail +export LD_LIBRARY_PATH=/dist/stub +if ldd /opt/stemd/bin/stemd-server | grep "not found"; then + echo "ERROR: stemd-server has unresolved libraries" >&2 + exit 1 +fi +stemd-server --version +stemd-cli --version +echo "installed package passed" diff --git a/.github/scripts/windows-cuda.ps1 b/.github/scripts/windows-cuda.ps1 new file mode 100644 index 0000000..8eb1d1e --- /dev/null +++ b/.github/scripts/windows-cuda.ps1 @@ -0,0 +1,53 @@ +# Install the parts of the CUDA toolkit and cuDNN that the Windows build needs, +# from NVIDIA's redistributable archives, into one toolkit-shaped tree. +# +# windows-cuda.ps1 [-Dest C:\cuda] +# +# Sets CUDA_PATH and CUDNN_PATH (both the tree) and puts nvcc on PATH for the +# later steps of the job. The versions match the ones stemd fetches at run +# time (crates/stemd-server/src/cuda.rs). +param([string]$Dest = 'C:\cuda') +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$cudaVersion = '13.3.1' +$cudnnVersion = '9.14.0' +$components = 'cccl', 'cuda_crt', 'cuda_cudart', 'cuda_nvcc', 'cuda_nvrtc', + 'libcublas', 'libcufft', 'libnvvm' + +$cudaRoot = 'https://developer.download.nvidia.com/compute/cuda/redist/' +$cudnnRoot = 'https://developer.download.nvidia.com/compute/cudnn/redist/' +$cuda = Invoke-RestMethod "${cudaRoot}redistrib_$cudaVersion.json" +$cudnn = Invoke-RestMethod "${cudnnRoot}redistrib_$cudnnVersion.json" + +$archives = @(foreach ($name in $components) { + $entry = $cuda.$name.'windows-x86_64' + if (-not $entry) { throw "no Windows archive for $name in CUDA $cudaVersion" } + [pscustomobject]@{ Url = $cudaRoot + $entry.relative_path; Sha256 = $entry.sha256 } +}) +$entry = $cudnn.cudnn.'windows-x86_64'.cuda13 +if (-not $entry) { throw "no Windows CUDA 13 archive for cuDNN $cudnnVersion" } +$archives += [pscustomobject]@{ Url = $cudnnRoot + $entry.relative_path; Sha256 = $entry.sha256 } + +$work = Join-Path $env:RUNNER_TEMP 'cuda-archives' +New-Item -ItemType Directory -Force -Path $work, $Dest | Out-Null +foreach ($a in $archives) { + $zip = Join-Path $work (Split-Path $a.Url -Leaf) + Invoke-WebRequest $a.Url -OutFile $zip + $got = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() + if ($got -ne $a.Sha256) { throw "$zip is $got, expected $($a.Sha256)" } + & 7z x -y "-o$work" $zip | Out-Null + if ($LASTEXITCODE -ne 0) { throw "7z failed on $zip" } + $dir = Join-Path $work ([IO.Path]::GetFileNameWithoutExtension($zip)) + Get-ChildItem $dir -Exclude LICENSE | Copy-Item -Destination $Dest -Recurse -Force + Remove-Item $zip, $dir -Recurse -Force +} + +# cuDNN keeps its DLLs in bin\; mlx-sys and MLX's FindCUDNN expect bin\x64. +New-Item -ItemType Directory -Force -Path (Join-Path $Dest 'bin\x64') | Out-Null +Get-ChildItem (Join-Path $Dest 'bin\cudnn*.dll') | Move-Item -Destination (Join-Path $Dest 'bin\x64') -Force + +"CUDA_PATH=$Dest" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 +"CUDNN_PATH=$Dest" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 +(Join-Path $Dest 'bin') | Out-File $env:GITHUB_PATH -Append -Encoding utf8 +& (Join-Path $Dest 'bin\nvcc.exe') --version | Select-Object -Last 2 diff --git a/.github/scripts/windows-tools.ps1 b/.github/scripts/windows-tools.ps1 new file mode 100644 index 0000000..09534af --- /dev/null +++ b/.github/scripts/windows-tools.ps1 @@ -0,0 +1,13 @@ +# Put Ninja and ccache on PATH for the later steps of the job. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +python -m pip install --quiet ninja==1.13.2 +if ($LASTEXITCODE -ne 0) { throw 'pip install ninja failed' } + +$ccache = 'ccache-4.14.2-windows-x86_64' +$zip = Join-Path $env:RUNNER_TEMP "$ccache.zip" +Invoke-WebRequest "https://github.com/ccache/ccache/releases/download/v4.14.2/$ccache.zip" -OutFile $zip +& 7z x -y "-o$env:RUNNER_TEMP" $zip | Out-Null +if ($LASTEXITCODE -ne 0) { throw "7z failed on $zip" } +(Join-Path $env:RUNNER_TEMP $ccache) | Out-File $env:GITHUB_PATH -Append -Encoding utf8 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..ebea58c --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,308 @@ +# Build every release artefact, check that each installs and starts, and, for +# a release tag, attach them all to a draft release. +# +# Linux .deb, .rpm, .AppImage x86_64 and aarch64 +# macOS .dmg arm64, signed and notarized +# Windows installer and .zip x86_64 +# +# push of a v tag a release: the tag must be on main and match +# Cargo.toml's version; the artefacts go into a +# draft GitHub release, published by hand +# pull request into main the artefacts, when it touches the packaging +# workflow_dispatch the artefacts +# +# Linux and Windows link a prebuilt MLX (packaging/mlx-prebuilt.env), so a run +# takes minutes rather than hours. Until that file pins a SHA-256 per platform +# those jobs are skipped, and a release tag fails. +# +# macOS signing and notarization use the secrets MACOS_CERT_P12 (base64 of the +# Developer ID Application certificate), MACOS_CERT_PASSWORD, and +# NOTARY_KEY_P8 (base64 of an App Store Connect API key), NOTARY_KEY_ID, and +# NOTARY_ISSUER for a team key. Without them the app is signed ad hoc and no .dmg is made; a +# release tag fails. +name: CD + +on: + push: + tags: ["v*"] + pull_request: + branches: [main] + paths: + - .github/scripts/** + - .github/workflows/cd.yml + - packaging/** + - scripts/** + - vendor/** + workflow_dispatch: + +concurrency: + group: cd-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +permissions: + contents: read + +jobs: + plan: + name: Plan + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.plan.outputs.version }} + release: ${{ steps.plan.outputs.release }} + prebuilt: ${{ steps.plan.outputs.prebuilt }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: plan + run: | + version=$(awk '/^\[workspace.package\]/{f=1} f && /^version *=/{gsub(/"/,"");print $3;exit}' Cargo.toml) + release=false + if [[ "$GITHUB_REF_TYPE" == tag ]]; then + if [[ "${GITHUB_REF_NAME#v}" != "$version" ]]; then + echo "::error::tag $GITHUB_REF_NAME does not match Cargo.toml's version $version" + exit 1 + fi + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::tag $GITHUB_REF_NAME is not on main" + exit 1 + fi + release=true + fi + . packaging/mlx-prebuilt.env + prebuilt=false + if [[ -n "$SHA256_linux_x86_64" && -n "$SHA256_linux_aarch64" && -n "$SHA256_windows_x86_64" ]]; then + prebuilt=true + elif [[ "$release" == true ]]; then + echo "::error::packaging/mlx-prebuilt.env pins no prebuilt MLX; build $MLX_PREBUILT_TAG first" + exit 1 + else + echo "::warning::packaging/mlx-prebuilt.env pins no prebuilt MLX yet, so the Linux and Windows artefacts are not built" + fi + { + echo "version=$version" + echo "release=$release" + echo "prebuilt=$prebuilt" + } >> "$GITHUB_OUTPUT" + echo "version $version, release $release, prebuilt MLX $prebuilt" >> "$GITHUB_STEP_SUMMARY" + + linux: + name: Linux (${{ matrix.arch }}) + needs: plan + if: needs.plan.outputs.prebuilt == 'true' + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04, platform: linux/amd64 } + - { arch: aarch64, runner: ubuntu-24.04-arm, platform: linux/arm64 } + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - name: Free disk space + run: .github/scripts/free-disk.sh + - name: Build image + run: docker build --platform ${{ matrix.platform }} -t stemd-linux packaging/linux + - uses: actions/cache@v4 + with: + path: | + .cargo-registry + key: cargo-linux-${{ matrix.arch }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-linux-${{ matrix.arch }}- + - name: Build, stage and package + env: + VERSION: ${{ needs.plan.outputs.version }} + run: | + mkdir -p .cargo-registry + docker run --rm -v "$PWD:/work" -v "$PWD/.cargo-registry:/root/.cargo/registry" \ + -e VERSION stemd-linux ' + set -e + packaging/prebuilt.sh fetch /tmp/mlx + export MLX_PREBUILT_DIR=/tmp/mlx + export MLX_CUDA_ARCHITECTURES="$(packaging/prebuilt.sh archs)" + cargo build --release --locked -p stemd-server -p stemd-cli + packaging/linux/stage.sh + packaging/linux/build.sh --version "$VERSION" + # The toolkit stub, for the install checks below; not packaged. + mkdir -p dist/stub + cp -L /usr/local/cuda/lib64/stubs/libcuda.so dist/stub/libcuda.so.1' + sudo chown -R "$(id -u):$(id -g)" dist target .cargo-registry + # Install the deb and the rpm on clean systems, which also checks the + # declared dependencies, and start the server from each and from the + # AppImage. Starting needs a libcuda.so.1, so the stub stands in for the + # driver. + - name: Install checks + run: | + check=/ci/linux-installed-check.sh + for image in ubuntu:22.04 debian:12 ubuntu:24.04; do + docker run --rm -v "$PWD/dist:/dist:ro" -v "$PWD/.github/scripts:/ci:ro" "$image" \ + bash -ec "apt-get update -qq && apt-get install -y -qq /dist/*.deb >/dev/null && $check" + done + docker run --rm -v "$PWD/dist:/dist:ro" -v "$PWD/.github/scripts:/ci:ro" fedora:latest \ + bash -ec "dnf install -y -q /dist/*.rpm >/dev/null && $check" + appimage=$(ls dist/*.AppImage) + (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$appimage" --appimage-extract >/dev/null) + LD_LIBRARY_PATH="$PWD/dist/stub" "$RUNNER_TEMP/squashfs-root/AppRun" --version + - uses: actions/upload-artifact@v4 + with: + name: linux-${{ matrix.arch }} + path: | + dist/*.deb + dist/*.rpm + dist/*.AppImage + compression-level: 0 + + macos: + name: macOS (arm64) + needs: plan + runs-on: macos-26 + env: + SIGNED: ${{ secrets.MACOS_CERT_P12 != '' && secrets.NOTARY_KEY_P8 != '' }} + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - name: Check signing + if: needs.plan.outputs.release == 'true' && env.SIGNED != 'true' + run: | + echo "::error::a release needs the MACOS_CERT_P12 and NOTARY_KEY_P8 secrets" + exit 1 + - name: Metal compiler + run: xcrun --find metal || xcodebuild -downloadComponent MetalToolchain + - uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: cargo-macos-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-macos- + # A keychain of its own, first in the search list, so bundle-app.sh + # finds the Developer ID identity there. + - name: Signing keychain + if: env.SIGNED == 'true' + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }} + run: | + keychain="$RUNNER_TEMP/signing.keychain-db" + password="$(uuidgen)" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + base64 -D <<<"$MACOS_CERT_P12" > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$MACOS_CERT_PASSWORD" \ + -T /usr/bin/codesign + rm "$RUNNER_TEMP/cert.p12" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" >/dev/null + # shellcheck disable=SC2046 + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + base64 -D <<<"$NOTARY_KEY_P8" > "$RUNNER_TEMP/notary.p8" + - name: Build, sign and notarize + if: env.SIGNED == 'true' + env: + STEMD_NOTARY_KEY: ${{ runner.temp }}/notary.p8 + STEMD_NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + STEMD_NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }} + run: scripts/bundle.sh + - name: Build + if: env.SIGNED != 'true' + run: scripts/bundle-app.sh + - name: Start check + run: dist/stemd.app/Contents/MacOS/stemd --version + - name: Remove credentials + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP/notary.p8" + - uses: actions/upload-artifact@v4 + if: env.SIGNED == 'true' + with: + name: macos-arm64 + path: dist/*.dmg + compression-level: 0 + + windows: + name: Windows (x86_64) + needs: plan + if: needs.plan.outputs.prebuilt == 'true' + runs-on: windows-2022 + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - uses: ilammy/msvc-dev-cmd@v1 + - name: Install CUDA + run: .github/scripts/windows-cuda.ps1 + - uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: cargo-windows-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-windows- + - name: Fetch the prebuilt MLX + shell: bash + run: | + dir="$(cygpath -u "$RUNNER_TEMP")/mlx" + packaging/prebuilt.sh fetch "$dir" + { + echo "MLX_PREBUILT_DIR=$(cygpath -w "$dir")" + echo "MLX_CUDA_ARCHITECTURES=$(packaging/prebuilt.sh archs)" + } >> "$GITHUB_ENV" + - name: Build and package + run: scripts/package-windows.ps1 -Out dist + # The runner has no NVIDIA driver, so this also checks that the server + # starts without CUDA, and that the installer leaves the GPU component + # out. + - name: Install check + run: | + # The server is a GUI-subsystem executable, which `&` does not wait for. + function Test-Version([string]$exe) { + $out = Join-Path $env:RUNNER_TEMP 'version.txt' + $p = Start-Process $exe -ArgumentList '--version' -Wait -PassThru -NoNewWindow -RedirectStandardOutput $out + Get-Content $out + if ($p.ExitCode -ne 0) { throw ('{0} --version exited with 0x{1:X8}' -f $exe, $p.ExitCode) } + } + Test-Version dist\stemd-windows\stemd-server.exe + $setup = Get-Item dist\stemd-*-setup.exe + Start-Process $setup.FullName -ArgumentList '/S' -Wait + $installed = Join-Path $env:LOCALAPPDATA 'Programs\stemd' + Test-Version (Join-Path $installed 'stemd-server.exe') + Test-Version (Join-Path $installed 'stemd-cli.exe') + - uses: actions/upload-artifact@v4 + with: + name: windows-x86_64 + path: | + dist/*-setup.exe + dist/*-windows-x64.zip + compression-level: 0 + + release: + name: Release + needs: [plan, linux, macos, windows] + if: needs.plan.outputs.release == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + # A draft, so nothing is public until published by hand. A rerun + # replaces the files. + - name: Draft release + run: | + tag="$GITHUB_REF_NAME" + draft=$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null || true) + if [[ "$draft" == false ]]; then + echo "::error::$tag is published; its files are no longer changed" + exit 1 + fi + if [[ -z "$draft" ]]; then + gh release create "$tag" --draft --verify-tag --title "stemd ${tag#v}" --notes "" + fi + gh release upload "$tag" --clobber dist/* + (cd dist && sha256sum -- *) | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/mlx-prebuilt.yml b/.github/workflows/mlx-prebuilt.yml new file mode 100644 index 0000000..dfaa83e --- /dev/null +++ b/.github/workflows/mlx-prebuilt.yml @@ -0,0 +1,186 @@ +# Build the prebuilt MLX that the Linux and Windows packages link, and attach +# it to the release named in packaging/mlx-prebuilt.env. Start it by pushing +# that tag (mlx-cuda-), or by hand. +# +# Compiling the CUDA kernels can outlast a runner's 6 h job limit. The compile +# goes through ccache, and the cache is saved even when the build is stopped, +# so re-running the failed jobs continues where they stopped. Three caches of +# 3 GB stay inside the repository's 10 GB cache quota. +# +# Afterwards, copy the SHA-256 lines from the run summary into +# packaging/mlx-prebuilt.env. +name: MLX prebuilt + +on: + push: + tags: ["mlx-cuda-*"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + linux: + name: MLX (linux ${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04, platform: linux/amd64 } + - { arch: aarch64, runner: ubuntu-24.04-arm, platform: linux/arm64 } + runs-on: ${{ matrix.runner }} + timeout-minutes: 355 + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - name: Free disk space + run: .github/scripts/free-disk.sh + - name: Build image + run: docker build --platform ${{ matrix.platform }} -t stemd-linux packaging/linux + - id: key + run: | + . packaging/mlx-prebuilt.env + echo "key=ccache-linux-${{ matrix.arch }}-$MLX_PREBUILT_TAG-$(sha256sum packaging/linux/Dockerfile packaging/mlx-prebuilt.env vendor/mlx-rs-stemd/mlx-sys/build.rs | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + - uses: actions/cache/restore@v4 + with: + path: .ccache + key: ${{ steps.key.outputs.key }}-${{ github.run_id }}-${{ github.run_attempt }} + # ccache checks every hit itself, so any cache for this arch is safe. + restore-keys: | + ${{ steps.key.outputs.key }}- + ccache-linux-${{ matrix.arch }}- + # Stopped by `timeout` with time left to save the cache. + - name: Build MLX + id: build + continue-on-error: true + run: | + mkdir -p .ccache dist + timeout --signal=TERM -k 2m 300m docker run --rm --init --name mlx -v "$PWD:/work" \ + -e CCACHE_DIR=/work/.ccache -e CCACHE_MAXSIZE=3G \ + -e CMAKE_CUDA_COMPILER_LAUNCHER=ccache -e CMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -e CMAKE_BUILD_PARALLEL_LEVEL=3 -e CARGO_BUILD_JOBS=3 \ + stemd-linux "packaging/prebuilt.sh build /work/dist && ccache -s" + - name: Own the cache + if: always() + run: | + docker rm -f mlx >/dev/null 2>&1 || true + sudo chown -R "$(id -u):$(id -g)" .ccache dist + - uses: actions/cache/save@v4 + if: always() + with: + path: .ccache + key: ${{ steps.key.outputs.key }}-${{ github.run_id }}-${{ github.run_attempt }} + - name: Stop if unfinished + if: steps.build.outcome != 'success' + run: | + echo "::error::The build stopped before it finished. Re-run the failed jobs to continue from the cache." + exit 1 + - uses: actions/upload-artifact@v4 + with: + name: mlx-linux-${{ matrix.arch }} + path: dist/mlx-cuda-linux-*.tar.zst + compression-level: 0 + + windows: + name: MLX (windows x86_64) + runs-on: windows-2022 + timeout-minutes: 355 + env: + CCACHE_DIR: ${{ github.workspace }}\.ccache + CCACHE_MAXSIZE: 3G + # Short, because MLX's dependencies nest deep under OUT_DIR. + CARGO_TARGET_DIR: C:\t + # MSVC rejects the Hopper quantized matmul kernels (error C2719 on an + # over-aligned parameter). Hopper GPUs use the sm80 kernels instead. + MLX_DISABLE_SM90A_KERNELS: 1 + steps: + - uses: actions/checkout@v5 + with: + submodules: true + # No developer prompt: cmake-rs finds MSVC itself, and Git Bash's own + # link.exe would shadow MSVC's on PATH. + - name: Install CUDA + run: .github/scripts/windows-cuda.ps1 + - name: Install Ninja and ccache + run: .github/scripts/windows-tools.ps1 + - id: key + shell: bash + run: | + . packaging/mlx-prebuilt.env + echo "key=ccache-windows-x86_64-$MLX_PREBUILT_TAG-$(sha256sum .github/scripts/windows-cuda.ps1 packaging/mlx-prebuilt.env vendor/mlx-rs-stemd/mlx-sys/build.rs | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + - uses: actions/cache/restore@v4 + with: + path: .ccache + key: ${{ steps.key.outputs.key }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + ${{ steps.key.outputs.key }}- + ccache-windows-x86_64- + # Stopped by the step timeout with time left to save the cache. + - name: Build MLX + id: build + continue-on-error: true + timeout-minutes: 300 + shell: bash + env: + CMAKE_CUDA_COMPILER_LAUNCHER: ccache + CMAKE_CXX_COMPILER_LAUNCHER: ccache + CMAKE_BUILD_PARALLEL_LEVEL: 3 + CARGO_BUILD_JOBS: 3 + run: packaging/prebuilt.sh build dist + - name: Cache statistics + if: always() + run: ccache -s + - uses: actions/cache/save@v4 + if: always() + with: + path: .ccache + key: ${{ steps.key.outputs.key }}-${{ github.run_id }}-${{ github.run_attempt }} + - name: Stop if unfinished + if: steps.build.outcome != 'success' + run: | + echo "::error::The build stopped before it finished. Re-run the failed jobs to continue from the cache." + exit 1 + - uses: actions/upload-artifact@v4 + with: + name: mlx-windows-x86_64 + path: dist/mlx-cuda-windows-*.zip + compression-level: 0 + + publish: + name: Attach to the release + needs: [linux, windows] + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: packaging/mlx-prebuilt.env + sparse-checkout-cone-mode: false + - uses: actions/download-artifact@v4 + with: + pattern: mlx-* + path: dist + merge-multiple: true + - run: | + . packaging/mlx-prebuilt.env + if [[ "$GITHUB_REF_TYPE" == tag && "$GITHUB_REF_NAME" != "$MLX_PREBUILT_TAG" ]]; then + echo "::error::tag $GITHUB_REF_NAME does not match MLX_PREBUILT_TAG=$MLX_PREBUILT_TAG" + exit 1 + fi + if ! gh release view "$MLX_PREBUILT_TAG" >/dev/null 2>&1; then + gh release create "$MLX_PREBUILT_TAG" --prerelease --target "$GITHUB_SHA" \ + --title "Prebuilt MLX ($MLX_PREBUILT_TAG)" \ + --notes "MLX built with CUDA for the Linux and Windows packages. Not for end users. See packaging/mlx-prebuilt.env." + fi + gh release upload "$MLX_PREBUILT_TAG" --clobber dist/mlx-cuda-* + { + echo '```' + for f in dist/mlx-cuda-*; do + platform="${f#dist/mlx-cuda-}"; platform="${platform%%.*}" + echo "SHA256_${platform//-/_}=$(sha256sum "$f" | cut -d' ' -f1)" + done + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index f8820f0..4d5749d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ __pycache__/ # Agent tooling state, including worktrees that are their own repositories .claude/ + +# CI caches +/.ccache/ +/.cargo-registry/ diff --git a/README.md b/README.md index 31a3e86..7472a27 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ track bit for bit. | --- | --- | | macOS | 14 or later, Apple silicon | | Windows | NVIDIA card and driver 580 or later, else CPU | -| Linux | Debian 13 or Ubuntu 24.04, NVIDIA card, driver 580 or later | +| Linux | x86_64 or aarch64, glibc 2.35 or later, NVIDIA card, driver 580 or later | | disk | 168 MB to 942 MB of model weights, downloaded on first run | ## Install @@ -57,27 +57,22 @@ On a machine with an NVIDIA card, tick the box at the end of the installer or ru once and pinned by digest. No toolkit and no repository. Without it stemd runs on the CPU, correctly and far slower. -**Linux.** Take the `amd64.deb` from the -[latest release](https://github.com/nsaintot/stemd/releases/latest). It wants -the CUDA 13 runtime and cuDNN 9.5 or later, both from -[NVIDIA's own repositories](https://developer.download.nvidia.com/compute/cuda/repos/), -so add those first. +**Linux.** Take the package for your distribution and architecture +(`x86_64` or `aarch64`) from the +[latest release](https://github.com/nsaintot/stemd/releases/latest). Each one +carries the CUDA runtime and cuDNN, so the NVIDIA driver is the only thing the +machine needs. -```bash -sudo apt install ./stemd_*_amd64.deb -``` - -Debian 13 takes one more step. NVIDIA publish no cuDNN package for it yet, so -that dependency cannot resolve from any repository: install cuDNN 9.5 or later by -hand, point the linker at it, and install without that one dependency. Miss the -linker line and the window opens, reports `device: gpu`, and fails every -separation. - -```bash -echo /opt/cudnn13/lib | sudo tee /etc/ld.so.conf.d/cudnn.conf -sudo ldconfig -sudo dpkg -i --force-depends ./stemd_*_amd64.deb -``` +| | | +| --- | --- | +| Debian, Ubuntu | `sudo apt install ./stemd-*-linux-x86_64.deb` | +| Fedora, RHEL 10 | `sudo dnf install ./stemd-*-linux-x86_64.rpm` | +| anything else | `chmod +x stemd-*.AppImage && ./stemd-*.AppImage` | + +Built on Ubuntu 22.04, so any distribution with glibc 2.35 or later: Ubuntu +22.04, Debian 12, Fedora 36, RHEL 10 and later. The aarch64 build is for +NVIDIA's Arm servers and workstations, such as DGX Spark and Grace Hopper; +on aarch64 without an NVIDIA card it has nothing to run on. ## Use it diff --git a/docs/building.md b/docs/building.md index 71a2833..2686216 100644 --- a/docs/building.md +++ b/docs/building.md @@ -64,16 +64,26 @@ correctly, and takes 610 seconds over a clip the card does in three. Keep those variables identical between builds. cargo re-runs the MLX build script when any of them moves, and that is forty minutes rather than four. -**Linux**, x86-64, CUDA. `libavahi-compat-libdnssd-dev` for the mDNS -advertisement, and the CUDA toolkit plus cuDNN 9.5 or later. Use -`scripts/bundle-linux.sh`, which checks all of it before compiling anything, -because the failures are otherwise sixty lines into a CMake trace naming -something that is not what is missing. +**Linux**, x86_64 or aarch64, CUDA. Build in the image from +`packaging/linux/Dockerfile`: CUDA 13.3 with cuDNN on Ubuntu 22.04, libclang +18, CMake 3.31, and the BLAS and avahi-compat headers. It needs no GPU. -cuDNN must be 9.5 or later: the cudnn-frontend MLX builds against calls -`cudnnBackendPopulateCudaGraph`, which was added there. An older one compiles -and then finds no execution plan for any graph at run time. Debian's own -`nvidia-cudnn` is 9.0.0 and built for CUDA 12, so it does not qualify. +```bash +docker build --platform linux/arm64 -t stemd-linux packaging/linux +docker run --rm -v "$PWD:/work" -e MLX_CUDA_ARCHITECTURES=121 \ + -e CMAKE_BUILD_PARALLEL_LEVEL=4 -e CARGO_BUILD_JOBS=4 stemd-linux \ + 'cargo build --release -p stemd-server -p stemd-cli' +``` + +nvcc wants several GB per kernel, so the job count is capped: unbounded, it +runs a 32 GB machine out of memory. cmake-rs passes cargo's job count to the +build, so both are set. + +On a machine of your own instead: `libavahi-compat-libdnssd-dev`, +`liblapacke-dev`, the CUDA toolkit, cuDNN 9.5 or later in `CUDNN_PATH`, and +`MLX_BUILD_CUDA=1`. cuDNN must be 9.5 or later: the cudnn-frontend MLX builds +against calls `cudnnBackendPopulateCudaGraph`, which was added there. An older +one compiles and then finds no execution plan for any graph at run time. ## Packaging @@ -82,16 +92,15 @@ and then finds no execution plan for any graph at run time. Debian's own ./scripts/bundle.sh # macOS: a signed, notarized, stapled .dmg ./scripts/bundle-windows.ps1 # Windows: dist/stemd-windows ./scripts/package-windows.ps1 # Windows: an installer and a zip -./scripts/package-deb.sh # Linux: a .deb -./scripts/bundle-linux.sh # Linux: a self-contained directory, over 1 GB +packaging/linux/stage.sh # Linux: dist/linux, in the image +packaging/linux/build.sh # Linux: .deb, .rpm and .AppImage from it ``` `MLX_CUDA_ARCHITECTURES` decides which cards the result runs on, and it is the single largest thing in the size and the build time. Unset, MLX builds for the card in the machine, which is a package that will not start anywhere else. The -released artefacts are built with `80;86;89;90;120`, Ampere through Blackwell, -which is five compilations of every kernel: about two and a half hours on -sixteen cores, and a .deb of 199 MB rather than 55. +released artefacts use the lists in `packaging/mlx-prebuilt.env`; five +architectures take about two and a half hours on sixteen cores. The Windows installer offers both modes. Just for me, into `%LOCALAPPDATA%\Programs\stemd`, is the default; for all users goes to Program @@ -110,16 +119,92 @@ highest token available, which on an administrator account means one consent prompt even for a per-user install. The installer needs NSIS, which `winget install NSIS.NSIS` provides. -Two Linux artefacts on purpose. The `.deb` takes CUDA and cuDNN from the -distribution, which means one copy of each on the machine rather than two that -can disagree about which engines to load. The directory carries its own copy of -everything and exists for a machine with no package manager, such as a live USB. +### Release builds + +`.github/workflows/cd.yml` builds every artefact on GitHub's runners and checks +that each installs and starts. A `v` tag on main uploads them all to a +draft release, which is published by hand. -Which distributions can install the `.deb` is decided by the machine that built -it, not by the packaging. Built on Debian 13 it requires glibc 2.39 and -libstdc++ 14, which Debian 13 and Ubuntu 24.04 have and Ubuntu 22.04 and Debian -12 do not. Every other dependency resolves on all four. Build on the oldest -target you mean to support. +| | runner | check | +| --- | --- | --- | +| Linux `.deb`, `.rpm`, `.AppImage`, x86_64 and aarch64 | `ubuntu-24.04`, `ubuntu-24.04-arm` | installed on Ubuntu 22.04, Debian 12, Ubuntu 24.04 and Fedora | +| macOS `.dmg`, arm64 | `macos-26` | signed, notarized, `--version` | +| Windows installer and `.zip`, x86_64 | `windows-2022` | silent install, `--version` without CUDA | + +The runners have no GPU, so a separation on a card is still checked by hand +before publishing. + +macOS signing reads these repository secrets: + +| secret | | +| --- | --- | +| `MACOS_CERT_P12` | base64 of the Developer ID Application certificate and key, as `.p12` | +| `MACOS_CERT_PASSWORD` | its password | +| `NOTARY_KEY_P8` | base64 of an App Store Connect API key (`.p8`), Developer role | +| `NOTARY_KEY_ID`, `NOTARY_ISSUER` | that key's ID, and for a team key its issuer ID (a UUID) | + +Without them a pull request builds an ad hoc signed app and no `.dmg`, and a +release tag fails. + +Windows installs CUDA 13.3.1 and cuDNN 9.14 from NVIDIA's redistributable +archives with `.github/scripts/windows-cuda.ps1`, the versions stemd fetches +at run time. + +### Linux packages + +| | | +| --- | --- | +| `stage.sh` | `dist/linux/opt/stemd`: binaries, vendored libraries, the run-time headers | +| `build.sh` | nfpm writes the `.deb` and `.rpm`, appimagetool the `.AppImage`, from that one tree | +| `nfpm.yaml.in` | dependencies: the display stack and D-Bus only | +| `THIRD-PARTY` | notices for the vendored NVIDIA, BLAS and avahi libraries | +| `jit-headers.sh` | writes `jit-headers.txt`, the toolkit headers MLX's run-time compiler reads; rerun after an MLX or CUDA update | + +Every package carries CUDA, cuDNN, BLAS and avahi-compat in `/opt/stemd/lib`, +about 2 GB unpacked. The driver (`libcuda.so.1`), the display stack and the C++ +runtime come from the machine. NVIDIA's libraries are copied unmodified, as +their licences require; the executables find them through `DT_RPATH` +(`$ORIGIN/../lib`), which also covers what cuDNN opens at run time. + +`/usr/bin/stemd-server` is a symlink to `/opt/stemd/stemd`, a launcher that sets +`CUDA_HOME` to the bundled headers and execs `bin/stemd-server` by absolute +path. MLX locates its CCCL headers from argv[0] without resolving symlinks, so +the server must never be started through a symlink or by name. + +The glibc floor is the image's: 2.35. + +### Prebuilt MLX + +Compiling MLX's CUDA kernels is most of a Linux or Windows build: about two and +a half hours on sixteen cores for five GPU architectures. The packages link a +prebuilt copy instead. + +| | | +| --- | --- | +| `packaging/mlx-prebuilt.env` | release tag, GPU architectures per platform, SHA-256 per asset | +| `packaging/prebuilt.sh` | `build OUT` packs one, `fetch DIR` downloads and checks one; Linux in the image, Windows in Git Bash | +| `.github/workflows/mlx-prebuilt.yml` | builds all three on push of the `mlx-cuda-` tag | +| `MLX_PREBUILT_OUT` | `mlx-sys` copies the MLX libraries and a manifest there after a build | +| `MLX_PREBUILT_DIR` | `mlx-sys` links from there and skips CMake | + +On Windows the prebuilt also carries OpenBLAS (`libopenblas.lib` and `.dll`) +and `dl.lib`. + +The manifest records the `mlx-sys` version, the MLX tag, the target, the C++ +compiler (only `msvc` on Windows, whose toolsets link older ones), nvcc, +cuDNN, the GPU architectures and the RMSNorm patch, and `mlx-sys` refuses a +prebuilt that does not match its own build. To make a new one: bump `MLX_PREBUILT_TAG`, push that +tag, and copy the SHA-256 lines from the run summary into +`mlx-prebuilt.env`. A runner can stop at its 6 h limit before the kernels are +done; the compile goes through ccache, so re-running the failed job continues. + +GPU architectures: Linux and Windows x86_64 `80;86;89;90a;120` (Ampere +through Blackwell), Linux aarch64 `90a;100;121` (Grace Hopper, Grace +Blackwell, DGX Spark). `90a` rather +than `90`, because MLX builds its Hopper-only kernels for `90a` alone. +Windows skips the Hopper quantized matmul kernels +(`MLX_DISABLE_SM90A_KERNELS`), which MSVC cannot compile; Hopper uses the +sm80 ones there. On the macOS bundle: the icon is built from `resources/` with `sips` and `iconutil`, both part of macOS, so a clone needs nothing installed to produce a @@ -148,7 +233,9 @@ xcrun notarytool store-credentials stemd-notary \ --apple-id --team-id ``` -`STEMD_NOTARY_PROFILE` names a different one. Without a profile `bundle.sh` +`STEMD_NOTARY_PROFILE` names a different one. CI uses an App Store Connect API +key instead: `STEMD_NOTARY_KEY` (the `.p8` file), `STEMD_NOTARY_KEY_ID` and +`STEMD_NOTARY_ISSUER`. Without a profile `bundle.sh` still produces a signed image and stops with what is missing, so an unnotarized release takes a deliberate act. diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun new file mode 100755 index 0000000..733c8ce --- /dev/null +++ b/packaging/linux/AppRun @@ -0,0 +1,5 @@ +#!/bin/sh +# The AppImage's entry point. The image mounts at a new path on every launch; +# the launcher finds everything relative to itself. +here="$(dirname "$(readlink -f "$0")")" +exec "$here/opt/stemd/stemd" "$@" diff --git a/packaging/linux/Dockerfile b/packaging/linux/Dockerfile new file mode 100644 index 0000000..0ead553 --- /dev/null +++ b/packaging/linux/Dockerfile @@ -0,0 +1,70 @@ +# The Linux build and packaging image, for amd64 and arm64. +# +# Ubuntu 22.04 sets the glibc floor at 2.35: the packages install on Ubuntu +# 22.04 and later, Debian 12 and later, and Fedora 36 and later. The CUDA and +# cuDNN versions here are the ones the packages bundle. +# +# docker build --platform linux/arm64 -t stemd-linux packaging/linux +FROM nvidia/cuda:13.3.1-cudnn-devel-ubuntu22.04 + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential pkg-config ca-certificates curl file git gnupg \ + python3-pip patchelf zstd gettext-base desktop-file-utils ccache \ + liblapacke-dev libopenblas-dev liblapack-dev \ + libavahi-compat-libdnssd-dev \ + && rm -rf /var/lib/apt/lists/* + +# bindgen needs libclang 16 or later: clang 14 rejects the _Float16 and __bf16 +# in mlx-c's half.h. Jammy stops at 15. +RUN curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/llvm.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/llvm.gpg] http://apt.llvm.org/jammy/ llvm-toolchain-jammy-18 main" \ + > /etc/apt/sources.list.d/llvm.list \ + && apt-get update && apt-get install -y --no-install-recommends libclang-18-dev clang-18 \ + && rm -rf /var/lib/apt/lists/* +ENV LIBCLANG_PATH=/usr/lib/llvm-18/lib + +# CMake 3.25 fails find_package on CUDA::nvToolsExt, which CUDA 12 removed. +RUN pip3 install --no-cache-dir cmake==3.31.6 + +RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal +ENV PATH="/root/.cargo/bin:${PATH}" + +# nfpm writes the deb and the rpm from one recipe; appimagetool writes the +# AppImage. appimagetool is itself an AppImage and a container has no FUSE, so +# it is unpacked once here. +ARG NFPM_VERSION=2.41.1 +ARG APPIMAGETOOL_VERSION=1.9.0 +RUN set -eux; \ + case "$(dpkg --print-architecture)" in \ + amd64) nfpm_arch=x86_64; ai_arch=x86_64 ;; \ + arm64) nfpm_arch=arm64; ai_arch=aarch64 ;; \ + *) echo "unsupported architecture" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_${nfpm_arch}.tar.gz" \ + | tar -xz -C /usr/local/bin nfpm; \ + curl -fsSL -o /tmp/appimagetool \ + "https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${ai_arch}.AppImage"; \ + chmod +x /tmp/appimagetool; \ + cd /opt && /tmp/appimagetool --appimage-extract >/dev/null && mv squashfs-root appimagetool.d; \ + rm /tmp/appimagetool; \ + printf '#!/bin/sh\nexec /opt/appimagetool.d/AppRun "$@"\n' > /usr/local/bin/appimagetool; \ + chmod +x /usr/local/bin/appimagetool + +# MLX's FindCUDNN wants include/ and lib/ under one root, and the cuDNN packages +# put both in the multiarch directories. +RUN multiarch="$(gcc -print-multiarch)" \ + && mkdir -p /opt/cudnn \ + && ln -s "/usr/include/$multiarch" /opt/cudnn/include \ + && ln -s "/usr/lib/$multiarch" /opt/cudnn/lib + +# The build links -lcuda against the toolkit's stub; the driver comes from the +# machine that runs the result. +ENV CUDA_PATH=/usr/local/cuda \ + LIBRARY_PATH=/usr/local/cuda/lib64/stubs \ + MLX_BUILD_CUDA=1 \ + CUDNN_PATH=/opt/cudnn + +WORKDIR /work +ENTRYPOINT ["/bin/bash", "-lc"] diff --git a/packaging/linux/THIRD-PARTY b/packaging/linux/THIRD-PARTY new file mode 100644 index 0000000..7969b1c --- /dev/null +++ b/packaging/linux/THIRD-PARTY @@ -0,0 +1,38 @@ +Third-party components in this package +======================================= + +stemd itself is under MIT or Apache-2.0, at your option: LICENSE-MIT and +LICENSE-APACHE beside this file. + +NVIDIA CUDA runtime libraries (lib/libcudart*, libcublas*, libcufft*, +libnvrtc*, libnvJitLink*) and CUDA headers (cuda/include) + Copyright NVIDIA Corporation. Redistributed unmodified under the CUDA + Toolkit End User License Agreement: + https://docs.nvidia.com/cuda/eula/index.html + +NVIDIA cuDNN (lib/libcudnn*) + Copyright NVIDIA Corporation. Redistributed unmodified under the NVIDIA cuDNN + Software License Agreement: + https://docs.nvidia.com/deeplearning/cudnn/latest/reference/eula.html + For use on systems with NVIDIA GPUs only. + +NVIDIA CCCL (include/cccl) + Apache License 2.0 with LLVM exceptions: + https://github.com/NVIDIA/cccl/blob/main/LICENSE + +OpenBLAS, LAPACK and the GNU Fortran runtime (lib/libblas*, liblapack*, +libopenblas*, libgfortran*, libquadmath*) + OpenBLAS: BSD-3-Clause. LAPACK: BSD-3-Clause (modified). libgfortran and + libquadmath: GPL-3.0 with the GCC Runtime Library Exception. Taken from + Ubuntu 22.04. + +Avahi compatibility library (lib/libdns_sd*, libavahi-*) + LGPL-2.1-or-later. Taken from Ubuntu 22.04. + +LAME 3.100, statically linked into bin/stemd-server + LGPL-2.0 (GNU Library General Public License version 2). + Source: https://lame.sourceforge.io/ + +crates/stemd-core/data/mode1_*.bin, compiled into bin/stemd-server + Filter coefficients recovered by measurement of third-party hardware rather + than authored, so the stemd licence does not purport to cover them. diff --git a/packaging/linux/build.sh b/packaging/linux/build.sh new file mode 100755 index 0000000..863de97 --- /dev/null +++ b/packaging/linux/build.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Turn the staged tree into the three Linux packages for this machine's +# architecture. +# +# build.sh [--version V] [--stage DIR] [--out DIR] [--only deb|rpm|appimage] +# +# Output: stemd--linux-.{deb,rpm,AppImage} +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" + +version="$(awk '/^\[workspace.package\]/{f=1} f && /^version *=/{gsub(/"/,"");print $3;exit}' "$root/Cargo.toml")" +stage="$root/dist/linux" +out="$root/dist" +only="" +while [[ $# -gt 0 ]]; do + case "$1" in + --version) version="$2"; shift 2 ;; + --stage) stage="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + --only) only="$2"; shift 2 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done +[[ -x "$stage/opt/stemd/stemd" ]] || { + echo "ERROR: $stage is not a staged tree. Run packaging/linux/stage.sh." >&2 + exit 1 +} +mkdir -p "$out" +stage="$(cd "$stage" && pwd)" +out="$(cd "$out" && pwd)" + +case "$(uname -m)" in + x86_64) arch=amd64; uname_arch=x86_64 ;; + aarch64) arch=arm64; uname_arch=aarch64 ;; + *) echo "ERROR: unsupported architecture $(uname -m)" >&2; exit 1 ;; +esac +base="stemd-${version}-linux-${uname_arch}" + +want() { [[ -z "$only" || "$only" == "$1" ]]; } + +if want deb || want rpm; then + recipe="$(mktemp --suffix=.yaml)" + trap 'rm -f "$recipe"' EXIT + VERSION="$version" ARCH="$arch" envsubst '${VERSION} ${ARCH}' \ + < "$root/packaging/linux/nfpm.yaml.in" > "$recipe" + for format in deb rpm; do + want "$format" || continue + echo "==> $format" + (cd "$stage" && nfpm package --config "$recipe" --packager "$format" \ + --target "$out/$base.$format") + done +fi + +if want appimage; then + echo "==> AppImage" + appdir="$out/stemd.AppDir" + rm -rf "$appdir" + mkdir -p "$appdir" + # Hard links: the tree is about 2 GB and appimagetool only reads it. + cp -al "$stage/opt" "$stage/usr" "$appdir/" 2>/dev/null || + cp -a "$stage/opt" "$stage/usr" "$appdir/" + install -m 0755 "$root/packaging/linux/AppRun" "$appdir/AppRun" + install -m 0644 "$root/packaging/linux/stemd.desktop" "$appdir/stemd.desktop" + cp "$appdir/usr/share/icons/hicolor/256x256/apps/stemd.png" "$appdir/stemd.png" + ARCH="$uname_arch" appimagetool --no-appstream --comp zstd "$appdir" "$out/$base.AppImage" + rm -rf "$appdir" +fi + +for f in "$out/$base".{deb,rpm,AppImage}; do [[ -f "$f" ]] && du -h "$f"; done; true diff --git a/packaging/linux/jit-headers.sh b/packaging/linux/jit-headers.sh new file mode 100755 index 0000000..174dae2 --- /dev/null +++ b/packaging/linux/jit-headers.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# List the CUDA toolkit headers MLX's run-time kernel compiler reads, into +# packaging/linux/jit-headers.txt. stage.sh ships those and CCCL, and nothing +# else from the toolkit's include directory. +# +# jit-headers.sh +# +# Runs in the image from packaging/linux/Dockerfile. Compiles every header MLX +# embeds for NVRTC, with the options MLX passes, for every compute capability +# CUDA 13 supports, and records the files NVRTC opens. Fails if one is a CCCL +# file outside the cuda/ and nv/ trees stage.sh ships. Run it again after an +# MLX or CUDA update. +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +mlx_tag="$(sed -n 's/^const MLX_TAG: &str = "\(.*\)";/\1/p' "$root/vendor/mlx-rs-stemd/mlx-sys/build.rs")" +cuda="${CUDA_PATH:-/usr/local/cuda}" +case "$(uname -m)" in + x86_64) inc="$cuda/targets/x86_64-linux/include" ;; + aarch64) inc="$cuda/targets/sbsa-linux/include" ;; +esac +command -v strace >/dev/null || { apt-get update -qq && apt-get install -y -qq strace >/dev/null; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +git clone -q --depth 1 --branch "$mlx_tag" https://github.com/ml-explore/mlx.git "$work/mlx" + +# Compile argv[1] with NVRTC and the remaining arguments as options. +cat > "$work/nvrtc.c" <<'EOF' +#include +#include +#include +int main(int argc, char **argv) { + FILE *f = fopen(argv[1], "rb"); + fseek(f, 0, SEEK_END); + long n = ftell(f); + rewind(f); + char *src = malloc(n + 1); + src[fread(src, 1, n, f)] = 0; + nvrtcProgram p; + nvrtcCreateProgram(&p, src, "headers.cu", 0, NULL, NULL); + nvrtcCompileProgram(p, argc - 2, (const char **)argv + 2); + return 0; +} +EOF +gcc "$work/nvrtc.c" -I"$cuda/include" -L"$cuda/lib64" -lnvrtc -o "$work/nvrtc" + +# One translation unit per header, as MLX's kernels include them. A header +# compiled on its own can fail; only the files opened matter. +# MLX compiles for the device it finds, as sm_ with an `a` suffix from +# compute capability 9.0 on, and passes no other option but include paths. +dev="$work/mlx/mlx/backend/cuda/device" +for h in "$dev"/*.cuh "$dev"/*.h; do + printf '#include "mlx/backend/cuda/device/%s"\n#include \n' \ + "$(basename "$h")" > "$work/tu.cu" + for cc in 75 80 86 87 89 90a 100a 103a 110a 120a 121a; do + LD_LIBRARY_PATH="$cuda/lib64" strace -f -e trace=openat -o "$work/trace.$cc.$(basename "$h")" \ + "$work/nvrtc" "$work/tu.cu" "--gpu-architecture=sm_$cc" --include-path="$work/mlx" \ + --include-path="$inc/cccl" --include-path="$inc" >/dev/null 2>&1 + done +done + +opened="$(cat "$work"/trace.* | grep -v ENOENT | grep -oE "\"$inc/[^\"]+\"" | tr -d '"' \ + | sed "s#^$inc/##" | sort -u)" +outside="$(grep '^cccl/' <<<"$opened" | grep -vE '^cccl/(cuda|nv)/' || true)" +if [[ -n "$outside" ]]; then + echo "ERROR: NVRTC opens CCCL files outside cuda/ and nv/; ship them in stage.sh:" >&2 + echo "$outside" >&2 + exit 1 +fi +grep -v '^cccl/' <<<"$opened" > "$root/packaging/linux/jit-headers.txt" +wc -l < "$root/packaging/linux/jit-headers.txt" diff --git a/packaging/linux/jit-headers.txt b/packaging/linux/jit-headers.txt new file mode 100644 index 0000000..5ec33a3 --- /dev/null +++ b/packaging/linux/jit-headers.txt @@ -0,0 +1,18 @@ +cooperative_groups.h +cooperative_groups/details/driver_abi.h +cooperative_groups/details/helpers.h +cooperative_groups/details/info.h +cooperative_groups/details/invoke.h +cooperative_groups/details/memory.h +cooperative_groups/details/partitioning.h +cooperative_groups/details/sync.h +cuda_bf16.h +cuda_fp16.h +cuda_fp4.h +cuda_fp4.hpp +cuda_fp6.h +cuda_fp6.hpp +cuda_fp8.h +cuda_fp8.hpp +cuda_runtime_api.h +math_constants.h diff --git a/packaging/linux/nfpm.yaml.in b/packaging/linux/nfpm.yaml.in new file mode 100644 index 0000000..abc0aa5 --- /dev/null +++ b/packaging/linux/nfpm.yaml.in @@ -0,0 +1,89 @@ +# Recipe for the .deb and the .rpm. Both ship the tree stage.sh assembles; +# only the dependency names differ by family. +# +# build.sh runs this through envsubst for VERSION and ARCH (amd64 or arm64), +# and runs nfpm from inside the staged tree, so `src` paths are relative. +name: stemd +version: ${VERSION} +arch: ${ARCH} +platform: linux +maintainer: nsaintot +description: | + Stem separation service with a drop window. + stemd separates a track into stems on an NVIDIA GPU and serves the result + over HTTP, announcing itself on the local network with mDNS. It bundles the + CUDA runtime and cuDNN; it needs the NVIDIA driver, 580 or later. +vendor: stemd +homepage: https://github.com/nsaintot/stemd +license: MIT OR Apache-2.0 +section: sound + +contents: + - src: opt/stemd + dst: /opt/stemd + - src: usr/share/applications/stemd.desktop + dst: /usr/share/applications/stemd.desktop + - src: usr/share/icons + dst: /usr/share/icons + - src: /opt/stemd/stemd + dst: /usr/bin/stemd-server + type: symlink + - src: /opt/stemd/bin/stemd-cli + dst: /usr/bin/stemd-cli + type: symlink + +# zstd is supported by dpkg on Ubuntu 22.04 and Debian 12, and by rpm on every +# current RPM family. +deb: + compression: zstd +rpm: + compression: zstd + +# The libraries stage.sh does not vendor: the display stack, which winit and +# glutin open at run time, and D-Bus, which avahi-client talks through. The +# driver (libcuda.so.1) is not a dependency: Debian names it libcuda1, Ubuntu +# libnvidia-compute-, and a .run install provides it outside any +# package. The launcher warns when it is missing. +overrides: + deb: + depends: + - libc6 (>= 2.35) + - libstdc++6 (>= 12) + - libgcc-s1 + - libdbus-1-3 + - libgl1 + - libegl1 + - libx11-6 + - libx11-xcb1 + - libxcursor1 + - libxi6 + - libxrandr2 + - libxkbcommon0 + - libxkbcommon-x11-0 + - libwayland-client0 + - libwayland-cursor0 + - libwayland-egl1 + recommends: + - avahi-daemon + rpm: + # Sonames rather than package names: RPM provides them automatically and + # every RPM family satisfies them under its own package names. + depends: + - libc.so.6(GLIBC_2.35)(64bit) + - libstdc++.so.6(GLIBCXX_3.4.30)(64bit) + - libgcc_s.so.1()(64bit) + - libdbus-1.so.3()(64bit) + - libGL.so.1()(64bit) + - libEGL.so.1()(64bit) + - libX11.so.6()(64bit) + - libX11-xcb.so.1()(64bit) + - libXcursor.so.1()(64bit) + - libXi.so.6()(64bit) + - libXrandr.so.2()(64bit) + - libxkbcommon.so.0()(64bit) + - libxkbcommon-x11.so.0()(64bit) + - libwayland-client.so.0()(64bit) + - libwayland-cursor.so.0()(64bit) + - libwayland-egl.so.1()(64bit) + recommends: + - avahi diff --git a/packaging/linux/stage.sh b/packaging/linux/stage.sh new file mode 100755 index 0000000..a6ab755 --- /dev/null +++ b/packaging/linux/stage.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Assemble the tree that every Linux package ships. +# +# stage.sh [--out DIR] [--bin DIR] +# +# The deb and the rpm install it at /opt/stemd; the AppImage mounts it. +# +# opt/stemd/stemd launcher: sets CUDA_HOME, execs bin/stemd-server +# opt/stemd/bin/ stemd-server, stemd-cli +# opt/stemd/lib/ CUDA, cuDNN, BLAS and avahi-compat, vendored +# opt/stemd/include/cccl/ CCCL headers for MLX's run-time kernel compiler +# opt/stemd/cuda/include/ CUDA runtime headers for the same +# +# Runs in the image built from packaging/linux/Dockerfile, after +# `cargo build --release -p stemd-server -p stemd-cli`. +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" + +out="$root/dist/linux" +bin="${CARGO_TARGET_DIR:-$root/target}/release" +while [[ $# -gt 0 ]]; do + case "$1" in + --out) out="$2"; shift 2 ;; + --bin) bin="$2"; shift 2 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +say() { printf ' %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +cuda="${CUDA_PATH:-/usr/local/cuda}" +cudnn="${CUDNN_PATH:-/opt/cudnn}" +case "$(uname -m)" in + x86_64) cuda_target=x86_64-linux ;; + aarch64) cuda_target=sbsa-linux ;; + *) die "unsupported architecture $(uname -m)" ;; +esac + +exe="$bin/stemd-server" +[[ -f "$exe" ]] || die "no $exe. Run cargo build --release -p stemd-server -p stemd-cli first." +[[ -f "$bin/stemd-cli" ]] || die "no $bin/stemd-cli." + +# A CPU-only MLX links, starts and runs about 190x slower, so the binary is +# checked rather than the environment that built it. +needed="$(readelf -d "$exe" | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p')" +for lib in libcudart.so.13 libcublasLt.so.13 libnvrtc.so.13 libcudnn.so.9; do + grep -qx "$lib" <<<"$needed" || die "$exe does not link $lib: MLX was built without CUDA." +done + +prefix="$out/opt/stemd" +rm -rf "$out" +mkdir -p "$prefix/bin" "$prefix/lib" "$prefix/include/cccl" "$prefix/cuda/include" \ + "$prefix/share/doc/stemd" +install -m 0755 "$exe" "$bin/stemd-cli" "$prefix/bin/" + +# --- libraries ----------------------------------------------------------- +# +# The driver, the display stack and the C++ runtime come from the machine; +# everything else is vendored. The host-owned list matches nfpm.yaml.in. +host_owned() { + case "$1" in + libcuda.so*|libnvidia-*.so*) return 0 ;; + libGL.so*|libGLX.so*|libGLdispatch.so*|libEGL.so*|libOpenGL.so*|libdrm.so*|\ + libX11*.so*|libxcb*.so*|libXau.so*|libXdmcp.so*|libX[a-z]*.so*|\ + libwayland-*.so*|libxkbcommon*.so*) return 0 ;; + libdbus-1.so*|libsystemd.so*) return 0 ;; + ld-linux*.so*|libc.so*|libm.so*|libdl.so*|libpthread.so*|librt.so*|\ + libresolv.so*|libutil.so*|libgcc_s.so*|libstdc++.so*|libatomic.so*) return 0 ;; + esac + return 1 +} + +# ldd has to find a libcuda.so.1 to walk past it; the toolkit's stub serves. +stubs="$(mktemp -d)" +trap 'rm -rf "$stubs"' EXIT +ln -s "$cuda/lib64/stubs/libcuda.so" "$stubs/libcuda.so.1" +export LD_LIBRARY_PATH="$stubs:$cuda/lib64:$cudnn/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + +soname() { readelf -d "$1" | sed -n 's/.*(SONAME).*\[\(.*\)\]/\1/p'; } + +# cuDNN opens its engine libraries itself and NVRTC opens its builtins, so no +# NEEDED entry names them. They go into the walk so their own dependencies come +# too. Copied under their soname, which is the name they are opened by. +queue=("$exe") +for f in "$cudnn"/lib/libcudnn*.so.9 "$cuda"/lib64/libnvrtc-builtins.so; do + [[ -f "$f" ]] || continue + name="$(soname "$f")" + [[ -f "$prefix/lib/$name" ]] && continue + cp -L "$f" "$prefix/lib/$name" + queue+=("$prefix/lib/$name") +done + +# Direct dependencies only. ldd prints the whole closure, which would also +# vendor the dependencies of host-owned libraries such as libsystemd, and those +# must stay the host's. +missing=() +seen=" " +while [[ ${#queue[@]} -gt 0 ]]; do + target="${queue[0]}" + queue=("${queue[@]:1}") + resolved="$(ldd "$target" | awk '$2 == "=>" {print $1, ($3 == "not" ? "" : $3)}')" + for name in $(readelf -d "$target" | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p'); do + host_owned "$name" && continue + [[ "$seen" == *" $name "* ]] && continue + seen="$seen$name " + path="$(awk -v n="$name" '$1 == n {print $2}' <<<"$resolved")" + if [[ -z "$path" || ! -f "$path" ]]; then + missing+=("$name") + continue + fi + [[ -f "$prefix/lib/$name" ]] || cp -L "$path" "$prefix/lib/$name" + queue+=("$prefix/lib/$name") + done +done +[[ ${#missing[@]} -eq 0 ]] || die "not found, so not vendored: $(printf '%s ' "${missing[@]}" | tr ' ' '\n' | sort -u | xargs)" + +# The executables carry DT_RPATH rather than DT_RUNPATH: it also covers the +# libraries that cuDNN and BLAS load. NVIDIA's libraries already look beside +# themselves ($ORIGIN) and the EULA forbids modifying them; the rest get the +# same RUNPATH here. +for f in "$prefix"/bin/*; do + patchelf --force-rpath --set-rpath '$ORIGIN/../lib' "$f" +done +for f in "$prefix"/lib/*; do + case "$(basename "$f")" in + libcu*|libnv*) ;; + *) chmod u+w "$f"; patchelf --set-rpath '$ORIGIN' "$f" ;; + esac +done +chmod 0644 "$prefix"/lib/* +say "vendored $(ls "$prefix/lib" | wc -l) libraries, $(du -sh "$prefix/lib" | cut -f1)" + +# --- headers ------------------------------------------------------------- +# +# MLX compiles kernels while it runs. It finds CCCL at /../include/cccl +# and the runtime headers under CUDA_HOME, which the launcher sets. Only the +# toolkit headers in jit-headers.txt are shipped; jit-headers.sh lists them. +inc="$cuda/targets/$cuda_target/include" +for tree in cuda nv; do + cp -r "$inc/cccl/$tree" "$prefix/include/cccl/" +done +while read -r h; do + [[ -n "$h" ]] || continue + [[ -f "$inc/$h" ]] || die "$inc/$h, listed in jit-headers.txt, does not exist" + install -D -m 0644 "$inc/$h" "$prefix/cuda/include/$h" +done < "$root/packaging/linux/jit-headers.txt" +say "headers: $(find "$prefix/include" "$prefix/cuda" -type f | wc -l) files" + +# --- launcher ------------------------------------------------------------ +# +# MLX derives its CCCL path from argv[0] without resolving it, so the server is +# exec'd by absolute path, never through a symlink or by name. +cat > "$prefix/stemd" <<'EOF' +#!/bin/sh +here="$(dirname "$(readlink -f "$0")")" +export CUDA_HOME="$here/cuda" +if ! /sbin/ldconfig -p 2>/dev/null | grep -q 'libcuda\.so\.1 '; then + echo "stemd: libcuda.so.1 not found. Install the NVIDIA driver, 580 or later." >&2 +fi +exec "$here/bin/stemd-server" "$@" +EOF +chmod 0755 "$prefix/stemd" + +# --- desktop integration and notices ------------------------------------- +mkdir -p "$out/usr/share/applications" "$out/usr/share/icons/hicolor/256x256/apps" +install -m 0644 "$root/packaging/linux/stemd.desktop" "$out/usr/share/applications/" +install -m 0644 "$root/resources/stemd-icon-win-256.png" \ + "$out/usr/share/icons/hicolor/256x256/apps/stemd.png" +install -m 0644 "$root/LICENSE-MIT" "$root/LICENSE-APACHE" \ + "$root/packaging/linux/THIRD-PARTY" "$prefix/share/doc/stemd/" + +# --- check --------------------------------------------------------------- +unresolved="$(LD_LIBRARY_PATH="$stubs" ldd "$prefix/bin/stemd-server" | grep 'not found' || true)" +[[ -z "$unresolved" ]] || die "unresolved after staging: $unresolved" +LD_LIBRARY_PATH="$stubs" "$prefix/bin/stemd-server" --version >/dev/null || + die "the staged stemd-server does not start" +say "staged $(du -sh "$out" | cut -f1) at $out" diff --git a/packaging/linux/stemd.desktop b/packaging/linux/stemd.desktop new file mode 100644 index 0000000..f307110 --- /dev/null +++ b/packaging/linux/stemd.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=stemd +GenericName=Stem separation +Comment=Separate a track into stems on the GPU +Exec=stemd-server +Icon=stemd +Terminal=false +Categories=AudioVideo;Audio; +Keywords=stems;separation;demucs;audio; +StartupNotify=true diff --git a/packaging/mlx-prebuilt.env b/packaging/mlx-prebuilt.env new file mode 100644 index 0000000..baeaff0 --- /dev/null +++ b/packaging/mlx-prebuilt.env @@ -0,0 +1,14 @@ +# The prebuilt MLX that the Linux and Windows packages link, one per platform. +# .github/workflows/mlx-prebuilt.yml builds it and attaches it to the release +# named by MLX_PREBUILT_TAG; packaging/prebuilt.sh fetches and checks it. +# +# Changing an architecture list, the toolchain or the MLX pin means a new tag +# and a new build. 90a rather than 90: MLX builds its Hopper-only kernels for +# 90a alone. The SHA-256 lines are filled in from that workflow's summary. +MLX_PREBUILT_TAG=mlx-cuda-2 +CUDA_ARCHS_linux_x86_64="80;86;89;90a;120" +CUDA_ARCHS_linux_aarch64="90a;100;121" +CUDA_ARCHS_windows_x86_64="80;86;89;90a;120" +SHA256_linux_x86_64=2837bfdeecfa441c0c7d3bf9bd4974a3b04269e79bfbebecdb53806278d2722d +SHA256_linux_aarch64=703eacbef4b6d54532417dcb455a743a2199b901c67b3630c3deeeb249b1068e +SHA256_windows_x86_64=51cb5388c5abf5c128717a294d12fdf8b0530ac4cec4d19bbbce233642fae57f diff --git a/packaging/prebuilt.sh b/packaging/prebuilt.sh new file mode 100755 index 0000000..a7da5b1 --- /dev/null +++ b/packaging/prebuilt.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build, or fetch and check, the prebuilt MLX for this platform. +# +# prebuilt.sh archs print MLX_CUDA_ARCHITECTURES for this platform +# prebuilt.sh build OUT build MLX from source, pack it as OUT/ +# prebuilt.sh fetch DIR download the pinned asset, check it, unpack into DIR +# +# On Linux it runs in the image built from packaging/linux/Dockerfile; on +# Windows in Git Bash, after .github/scripts/windows-cuda.ps1. The asset is +# mlx-cuda--.tar.zst on Linux and .zip on Windows, attached to the +# release MLX_PREBUILT_TAG. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=SCRIPTDIR/mlx-prebuilt.env +. "$root/packaging/mlx-prebuilt.env" + +case "$(uname -s)" in + Linux) os=linux; arch="$(uname -m)"; ext=tar.zst ;; + MINGW*|MSYS*) os=windows; arch=x86_64; ext=zip ;; + *) echo "ERROR: no prebuilt MLX for $(uname -s)" >&2; exit 1 ;; +esac +# Paths handed to cargo and 7-Zip, which are Windows programs on Windows. +native() { if [[ "$os" == windows ]]; then cygpath -w "$1"; else printf '%s' "$1"; fi; } + +platform="${os}_$arch" +archs_var="CUDA_ARCHS_$platform" +sha_var="SHA256_$platform" +archs="${!archs_var:?no CUDA_ARCHS_$platform in mlx-prebuilt.env}" +asset="mlx-cuda-$os-$arch.$ext" +repo="${GITHUB_REPOSITORY:-nsaintot/stemd}" + +case "${1:-}" in + archs) + printf '%s\n' "$archs" + ;; + build) + out="${2:?usage: prebuilt.sh build OUT}" + mkdir -p "$out" + out="$(cd "$out" && pwd)" + tree="$(mktemp -d)" + MLX_BUILD_CUDA=1 MLX_CUDA_ARCHITECTURES="$archs" MLX_PREBUILT_OUT="$(native "$tree")" \ + cargo build --release --manifest-path "$(native "$root/Cargo.toml")" -p mlx-sys + rm -f "$out/$asset" + if [[ "$os" == windows ]]; then + (cd "$tree" && 7z a -tzip -mx=9 "$(native "$out/$asset")" . >/dev/null) + else + tar -C "$tree" -c . | zstd -19 -T0 -q -o "$out/$asset" + fi + rm -rf "$tree" + sha256sum "$out/$asset" + ;; + fetch) + dir="${2:?usage: prebuilt.sh fetch DIR}" + want="${!sha_var}" + [[ -n "$want" ]] || { echo "ERROR: no $sha_var in mlx-prebuilt.env" >&2; exit 1; } + mkdir -p "$dir" + tmp="$(mktemp)" + curl -fsSL -o "$tmp" "https://github.com/$repo/releases/download/$MLX_PREBUILT_TAG/$asset" + got="$(sha256sum "$tmp" | cut -d' ' -f1)" + [[ "$got" == "$want" ]] || { echo "ERROR: $asset is $got, expected $want" >&2; exit 1; } + if [[ "$os" == windows ]]; then + 7z x -y -o"$(native "$dir")" "$(native "$tmp")" >/dev/null + else + zstd -dc "$tmp" | tar -C "$dir" -x + fi + rm -f "$tmp" + cat "$dir/mlx-prebuilt.txt" + ;; + *) + echo "usage: prebuilt.sh archs | build OUT | fetch DIR" >&2 + exit 2 + ;; +esac diff --git a/scripts/bundle-linux.sh b/scripts/bundle-linux.sh deleted file mode 100755 index cce1cda..0000000 --- a/scripts/bundle-linux.sh +++ /dev/null @@ -1,403 +0,0 @@ -#!/bin/bash -# Build and package stemd for Linux, with CUDA if the machine has it. -# -# The counterpart to bundle-windows.ps1, and it exists for the same reason that -# one now sets its own environment. MLX builds its CUDA backend only when -# MLX_BUILD_CUDA says so, and says nothing at all when it does not: the result -# links, starts, reports `device: cpu`, separates correctly, and runs about 190x -# slower. That was measured, not guessed. A build that depends on the variable -# already being exported is a build that works in one shell. -# -# Everything it needs from the machine is checked before anything is compiled, -# because the failures are otherwise sixty lines into a CMake trace and name -# things that are not what is missing. -set -euo pipefail - -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -out="${1:-$root/dist/stemd-linux}" - -# The architecture of the card this is for. One keeps the compile short; a -# release wants a wider list and a much longer build. sm_86 is the RTX 3090 Ti -# this was developed against. -export MLX_CUDA_ARCHITECTURES="${MLX_CUDA_ARCHITECTURES:-86}" -# nvcc's front end wants several GB per qmm_impl_* kernel and there are 107 of -# them. Unbounded, it took a 32 GB Windows machine and a 16 GB WSL out of memory -# at about two thirds of the way through. Raise it if the machine is large. -export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-4}" - -say() { printf ' %s\n' "$*"; } - -# The cuDNN version cudnn-frontend needs. See the search below. -CUDNN_MIN=9.5 - -# Read a cuDNN version out of a directory holding cudnn.h. The numbers moved -# into cudnn_version.h at 8.0, and Debian puts that one in the multiarch -# directory rather than beside the header that includes it, so both are tried. -cudnn_version() { - local inc="$1" f v - for f in "$inc/cudnn_version.h" "$inc"/*/cudnn_version.h "$inc/cudnn.h"; do - [ -f "$f" ] || continue - v="$(awk '/define CUDNN_MAJOR/{a=$3} /define CUDNN_MINOR/{b=$3} \ - /define CUDNN_PATCHLEVEL/{c=$3} \ - END{if (a != "") print a"."b"."c}' "$f")" - [ -n "$v" ] && { printf '%s' "$v"; return; } - done -} -die() { printf '\nstopped: %s\n' "$*" >&2; exit 1; } - -# --- what the machine has ------------------------------------------------ -# -# Seven things had to be true for this to build the first time and not one of -# them is in any README, so they are checked here by name rather than left to -# fail as something else. See docs in the port notes for what each one costs. - -# cargo, which is the one thing here that is not about CUDA and the one this -# script kept assuming. rustup installs it into ~/.cargo/bin and adds that to -# PATH from a shell profile, which a non-interactive ssh session never reads. -# So this ran fine by hand and died with `cargo: command not found` when run -# over ssh, after passing every other check and printing all of them. -if ! command -v cargo >/dev/null 2>&1; then - for c in "${CARGO_HOME:-$HOME/.cargo}/bin" /usr/local/cargo/bin; do - [ -x "$c/cargo" ] && export PATH="$c:$PATH" && break - done -fi -command -v cargo >/dev/null 2>&1 || - die "no cargo on PATH, and none under ~/.cargo/bin. If rustup installed it - somewhere else, put that directory on PATH; a login shell finding it is - not enough, because this may be running without one." -say "cargo: $(command -v cargo) ($(cargo --version | awk '{print $2}'))" - -# Two layouts in the wild and they agree on almost nothing. NVIDIA's run-file -# and repository packages put everything under /usr/local/cuda-, nvcc -# included. Debian packages the toolkit as /usr/lib/cuda and puts nvcc in -# /usr/bin, so testing for bin/nvcc under the root finds nothing on a machine -# that has a perfectly good toolkit. -cuda_home="${CUDA_PATH:-${CUDA_HOME:-}}" -if [ -z "$cuda_home" ]; then - cuda_home="$(ls -d /usr/local/cuda-* /usr/local/cuda /usr/lib/cuda 2>/dev/null | sort -Vr | head -1 || true)" -fi -# The chosen toolkit's own nvcc wins over whatever is on PATH. A machine with -# both Debian's 12.4 in /usr/bin and NVIDIA's 13.3 in /usr/local resolves -# `command -v nvcc` to the 12.4 one, and then reports building against 13.3 -# while compiling with 12.4: the binary comes out linked to libcudart.so.12 and -# gets packaged beside 13.3's libraries and headers. -nvcc="" -[ -x "${cuda_home:-/nonexistent}/bin/nvcc" ] && nvcc="$cuda_home/bin/nvcc" -[ -z "$nvcc" ] && nvcc="$(command -v nvcc || true)" - -if [ -n "$cuda_home" ] && [ -n "$nvcc" ]; then - export CUDA_PATH="$cuda_home" - export CUDA_HOME="$cuda_home" - export MLX_BUILD_CUDA=1 - [ -d "$cuda_home/bin" ] && export PATH="$cuda_home/bin:$PATH" - export CUDACXX="$nvcc" - say "CUDA: $cuda_home, nvcc $("$nvcc" --version | sed -n 's/.*release \([0-9.]*\).*/\1/p' | head -1)" - say "building for sm_$MLX_CUDA_ARCHITECTURES, $CMAKE_BUILD_PARALLEL_LEVEL at a time" - cuda=yes -else - say "no CUDA toolkit found, so this will be a CPU-only build" - say "set CUDA_PATH if there is one somewhere unusual" - cuda=no -fi - -# CMake 3.25, which is bookworm's, fails find_package on CUDA::nvToolsExt, a -# target CUDA 12 removed. Only matters for a CUDA build. -if [ "$cuda" = yes ]; then - have="$(cmake --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+' | head -1 || true)" - [ -n "$have" ] || die "no cmake on PATH" - if [ "$(printf '%s\n3.31\n' "$have" | sort -V | head -1)" != "3.31" ]; then - die "cmake $have is too old: 3.25 fails find_package on CUDA::nvToolsExt, - a target CUDA 12 removed. 3.31 or newer, and a build of it into /opt is - the usual answer on Debian." - fi - say "cmake: $have" -fi - -# MLX find_path()s for lapacke.h, so the headers have to be there and not just -# the libraries. liblapacke-dev on Debian. -[ -f /usr/include/lapacke.h ] || ls /usr/include/*/lapacke.h >/dev/null 2>&1 || - die "no lapacke.h: MLX looks for it by path, not just for the library. - Install liblapacke-dev (and libopenblas-dev, liblapack-dev)." - -# bindgen needs libclang, and Debian's clang 14 knows neither _Float16 nor -# __bf16 on x86, which is what mlx-c's half.h is made of. -if [ -z "${LIBCLANG_PATH:-}" ]; then - for v in 19 18 17 16; do - if [ -d "/usr/lib/llvm-$v/lib" ]; then export LIBCLANG_PATH="/usr/lib/llvm-$v/lib"; break; fi - done -fi -[ -n "${LIBCLANG_PATH:-}" ] || - die "no libclang 16 or newer found for bindgen. Debian's clang 14 rejects - _Float16 and __bf16 on x86, which mlx-c's half.h uses. Install - libclang-16-dev or newer, or set LIBCLANG_PATH." -say "libclang: $LIBCLANG_PATH" - -# The linker cache, read once. dpkg rewrites /etc/ld.so.cache from a trigger at -# the end of an install, and `ldconfig -p` run at that moment lists nothing, so -# a check that shells out separately each time occasionally reports a library -# missing that is sitting on the disk. That happened twice today, both times -# minutes after an apt run, and both times the library was there. Falling back -# to looking for the file makes the answer not depend on the timing. -libcache="$(/sbin/ldconfig -p 2>/dev/null || true)" -have_lib() { - case "$libcache" in *"$1"*) return 0 ;; esac - ls /usr/lib/*/"$1"* /lib/*/"$1"* /usr/lib/"$1"* 2>/dev/null | grep -q . -} - -# `-ldns_sd` is avahi-compat on Linux, and discovery.rs links it unconditionally. -have_lib libdns_sd || - die "no libdns_sd: mDNS advertisement links it. Install - libavahi-compat-libdnssd-dev." - -# `-lcuda` is the driver API. On a machine with the driver it is in the usual -# place; on one with only the toolkit it is the stub, and MLX links it either -# way. -if [ "$cuda" = yes ]; then - have_lib libcuda.so || - [ -e "$cuda_home/lib64/stubs/libcuda.so" ] || - die "no libcuda.so to link against. Either install the NVIDIA driver, or - symlink the toolkit's lib64/stubs/libcuda.so somewhere on the - linker path." -fi - -# MLX's CMakeLists does find_package(CUDNN REQUIRED) for the CUDA backend, and -# its FindCUDNN.cmake looks only in the toolkit and one or two NVIDIA paths. -# Absent, the build dies sixty lines into a CMake trace asking to be passed -# CUDNN_INCLUDE_PATH, which is not a variable anything here sets and not the one -# that would fix it. This cost a build on both platforms today. -# -# It is not part of the CUDA toolkit on either. Debian ships `nvidia-cudnn`, -# which is a downloader that shows NVIDIA's licence and asks you to accept it. -if [ "$cuda" = yes ]; then - # Not first-match, and that matters. Debian ships cuDNN 9.0.0, while the - # cudnn-frontend MLX pins calls cudnnBackendPopulateCudaGraph, which cuDNN - # added in 9.5. Taking the first cudnn.h on a machine that has both gives a - # build that compiles cleanly and then fails at run time with "No execution - # plans support the graph", on a server that started and said device: gpu. - # So every candidate is read for its version and the newest usable one wins. - # - # /opt is in the list because that is where a manual install lands, and on - # Debian 13 a manual install is the only kind there is: NVIDIA publish cuDNN - # for ubuntu2204, ubuntu2404 and debian12, and not for trixie. - cudnn_root="" - cudnn_ver="" - cudnn_seen="" - for cand in "${CUDNN_PATH:-}" /opt/cudnn* /opt/*/cudnn "$cuda_home" /usr /usr/local; do - [ -n "$cand" ] && [ -f "$cand/include/cudnn.h" ] || continue - v="$(cudnn_version "$cand/include")" - [ -n "$v" ] || continue - cudnn_seen="$cudnn_seen $cand ($v)" - # Below the floor never wins, however new it is relative to the others. - [ "$(printf '%s\n%s\n' "$v" "$CUDNN_MIN" | sort -V | head -1)" = "$CUDNN_MIN" ] || continue - if [ -z "$cudnn_ver" ] || - [ "$(printf '%s\n%s\n' "$v" "$cudnn_ver" | sort -Vr | head -1)" = "$v" ]; then - cudnn_root="$cand" - cudnn_ver="$v" - fi - done - if [ -z "$cudnn_root" ] && [ -n "$cudnn_seen" ]; then - die "cuDNN is installed but too old:$cudnn_seen. MLX builds against a - cudnn-frontend that calls cudnnBackendPopulateCudaGraph, which cuDNN - added in $CUDNN_MIN. An older one links, starts, loads a model, and - then finds no execution plan for any graph. Install $CUDNN_MIN or - newer and set CUDNN_PATH to the root holding include/cudnn.h." - fi - [ -n "$cudnn_root" ] || - die "no cudnn.h anywhere this looked: CUDNN_PATH, /opt, $cuda_home, /usr. - MLX needs cuDNN for the CUDA backend and it is not part of the CUDA - toolkit. Where it comes from depends on the distribution: on Ubuntu - and on Debian 12 it is apt install libcudnn9-cuda-13, and on Debian - 13 there is no package yet, so it has to be unpacked by hand and - named with CUDNN_PATH." - export CUDNN_PATH="$cudnn_root" - say "cudnn: $cudnn_root ($cudnn_ver)" -fi - -# MLX compiles some kernels at run time with NVRTC and they include -# . cuda-toolkit does not pull CCCL in, and without it every -# separation dies on the first gather, at run time, on a machine that started -# fine and reported device: gpu. -cccl="" -if [ "$cuda" = yes ]; then - # Whatever directory holds cuda/std/, since that is the include root MLX - # hands NVRTC. The run-file groups cuda/, thrust/ and cub/ under a cccl - # directory; Debian puts all three straight into /usr/include. - for c in "$cuda_home/targets/x86_64-linux/include/cccl" "$cuda_home/include/cccl" \ - "$cuda_home/include" /usr/include; do - [ -f "$c/cuda/std/tuple" ] && cccl="$c" && break - done - [ -n "$cccl" ] || - die "no CCCL headers under $cuda_home. MLX builds kernels at run time - and they include , so a machine that never compiles - anything still needs them on disk. Install cuda-cccl-." - say "cccl: $cccl" -fi - -# MLX compiles kernels at run time and needs the CUDA runtime headers as well -# as CCCL: jit_module.cpp looks for them under CUDA_HOME/include, and without -# them every separation fails with "Can not find locations of CUDA headers". -# The toolkit layout has them beside the toolkit; Debian puts them in -# /usr/include with everything else. -cuda_inc="" -if [ "$cuda" = yes ]; then - for c in "$cuda_home/include" /usr/include; do - [ -f "$c/cuda_runtime.h" ] && cuda_inc="$c" && break - done - [ -n "$cuda_inc" ] || die "no cuda_runtime.h under $cuda_home or /usr/include." - say "cuda headers: $cuda_inc" -fi - -# --- build --------------------------------------------------------------- - -# Source paths go into the binary, in every panic message and in the debug -# info, as the absolute paths of this machine. Rewritten to names that say -# what the file is and nothing about whose disk it was on. -# The source trees rather than the checkout: a prefix covering target/ makes -# rustc unable to find the proc-macro crates it has just built there. -remap="--remap-path-prefix=$root/crates=stemd/crates" -remap="$remap --remap-path-prefix=$root/vendor=stemd/vendor" -remap="$remap --remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=cargo" -remap="$remap --remap-path-prefix=$(rustc --print sysroot)=rustc" - -printf '\nbuilding...\n' -RUSTFLAGS="${RUSTFLAGS:-} $remap" \ - cargo build --release --manifest-path "$root/Cargo.toml" -p stemd-server -p stemd-cli - -exe="$root/target/release/stemd-server" - -# Ask the binary, not the environment that made it. On Linux these are ordinary -# link-time dependencies rather than Windows' delay-loads, so ldd is the whole -# answer and needs no tooling that might be absent. -if [ "$cuda" = yes ]; then - linked="$(ldd "$exe" | grep -cE 'libcublas|libcufft|libcudnn|libnvrtc' || true)" - [ "$linked" -ge 3 ] || - die "$exe links no CUDA libraries, so MLX_BUILD_CUDA did not take. It - would ship as a server that starts, reports device: cpu, separates - correctly and runs about 190x slower. Remove - target/release/build/mlx-sys-* and build again." - say "links $linked CUDA libraries" -fi - -# --- package ------------------------------------------------------------- -# -# bin/ rather than the top level, because MLX resolves its NVRTC include path -# as /../include/cccl. Put the executable at the root and the -# headers are looked for one directory above the package. - -rm -rf "$out" -mkdir -p "$out/bin" -cp "$root/target/release/stemd-server" "$root/target/release/stemd-cli" "$out/bin/" - -if [ "$cuda" = yes ]; then - mkdir -p "$out/lib" "$out/include" - # What it actually links, resolved through ldd, plus the two families that - # are loaded at run time and so never appear there: cuDNN opens its engine - # libraries itself, and nvrtc wants its builtins the same way. - # cudnn is deliberately not in this list. ldd resolves libcudnn.so.9 through - # the system linker, which finds the distribution's copy, and cuDNN 9 splits - # into a small dispatcher plus large engine libraries that it opens itself. - # Taking the dispatcher from here and the engines from the cuDNN actually - # built against gives a package with a 9.0 dispatcher driving 9.14 engines, - # which loads, reports its version as 9.0, and then finds no execution plan - # for any graph. All of cuDNN comes from one place, below. - ldd "$exe" | awk '{print $1, $3}' | - grep -E '^lib(cudart|cublas|cublasLt|cufft|nvrtc|nvJitLink|cusparse|curand)' | - while read -r name path; do - [ -f "$path" ] && cp -Lu "$path" "$out/lib/" && say "bundled $name" - done - # cuDNN opens its engine libraries itself and nvrtc wants its builtins the - # same way, so ldd cannot see either. Taken from the cuDNN actually built - # against rather than from a fixed path, which on a machine with two - # installed would otherwise bundle the wrong one. - # - # The builtins come from beside the nvrtc that ldd resolved, for the same - # reason and by the same rule. Globbing a system directory by name does not - # keep the pair together: this machine has Debian's CUDA 12.4 in - # /usr/lib/x86_64-linux-gnu and NVIDIA's 13.3 under /usr/local, and the glob - # bundled 10.7 MB of 12.4 builtins next to the 13.3 nvrtc the binary links. - # Harmless there, because nvrtc loads its own version by name, but it is - # dead weight that says the sweep is picking files by what they are called - # rather than by what the program will open. - nvrtc_dir="" - nvrtc_lib="$(ldd "$exe" | awk '$1 ~ /^libnvrtc\.so/ {print $3; exit}')" - [ -n "$nvrtc_lib" ] && [ -f "$nvrtc_lib" ] && nvrtc_dir="$(dirname "$nvrtc_lib")" - for extra in "$cudnn_root"/lib/libcudnn*.so.9* /usr/lib/x86_64-linux-gnu/libcudnn*.so.9* \ - "${nvrtc_dir:-/nonexistent}"/libnvrtc-builtins*.so*; do - [ -f "$extra" ] || continue - [ -f "$out/lib/$(basename "$extra")" ] && continue - cp -Lu "$extra" "$out/lib/" 2>/dev/null && say "bundled $(basename "$extra")" - done - # OpenBLAS is a hard link-time dependency and is not part of CUDA; the - # Windows bundle carries it for the same reason. - for blas in $(ldd "$exe" | awk '/libopenblas|libblas|liblapack/{print $3}'); do - [ -f "$blas" ] && cp -Lu "$blas" "$out/lib/" && say "bundled $(basename "$blas")" - done - mkdir -p "$out/cuda/include" - if [ "$cuda_inc" = /usr/include ] && command -v dpkg >/dev/null 2>&1; then - # /usr/include is shared with the system, so take only what the toolkit - # package owns rather than three hundred megabytes of everything. - pkg="$(dpkg -S "$cuda_inc/cuda_runtime.h" 2>/dev/null | cut -d: -f1 | head -1)" - dpkg -L "$pkg" 2>/dev/null | grep -E '^/usr/include/.*\.(h|hpp)$' | - while read -r h; do cp -u "$h" "$out/cuda/include/" 2>/dev/null || true; done - else - cp -r "$cuda_inc/." "$out/cuda/include/" - fi - say "cuda headers: $(find "$out/cuda/include" -type f | wc -l) files" - mkdir -p "$out/include/cccl" - for tree in cuda thrust cub libcudacxx; do - [ -d "$cccl/$tree" ] && cp -r "$cccl/$tree" "$out/include/cccl/" - done - say "headers: $(find "$out/include/cccl" -type f | wc -l) files from $cccl" -fi - -cat > "$out/stemd" <<'LAUNCH' -#!/bin/bash -# Run stemd with its bundled CUDA, without installing anything. -here="$(cd "$(dirname "$0")" && pwd)" -[ -d "$here/lib" ] && export LD_LIBRARY_PATH="$here/lib:${LD_LIBRARY_PATH:-}" -# MLX builds kernels at run time and looks for the CUDA runtime headers under -# CUDA_HOME. Unset, every separation fails with "Can not find locations of CUDA -# headers" on a server that otherwise started perfectly and reported device: -# gpu. The CCCL headers beside these are found relative to the executable. -[ -d "$here/cuda/include" ] && export CUDA_HOME="$here/cuda" -# The driver is deliberately not bundled: it has to match the running kernel -# module, so shipping one machine's copy to another is a version mismatch -# rather than a GPU. -if [ -d "$here/lib" ] && ! /sbin/ldconfig -p 2>/dev/null | grep -q libcuda.so.1 && - [ ! -e /usr/lib/x86_64-linux-gnu/libcuda.so.1 ]; then - echo "warning: libcuda.so.1 not found. That is the NVIDIA driver, and it is" - echo " not part of this bundle: it has to match the running kernel." - echo " With nouveau this will not start." -fi -exec "$here/bin/stemd-server" "$@" -LAUNCH -chmod +x "$out/stemd" - -# Say what this is, in the directory itself. The most likely thing to happen to -# it is being found on a stick a year from now by someone who has to guess -# whether it is the thing to install. -cat > "$out/README" <<'DOC' -stemd, bundled for a machine you cannot install packages on -=========================================================== - -This directory carries its own CUDA runtime, its own cuDNN, and the headers MLX -needs to compile kernels while it runs. That is why it is well over a gigabyte. -It exists for one case: a live USB, or a machine where apt is not an option. - - ./stemd - -On Debian or Ubuntu, use the .deb instead. It is about 55 MB, because it takes -those same libraries from the distribution, which means one copy of cuDNN on -the machine rather than two that can disagree about which engines to load. See -scripts/package-deb.sh. - -Either way the NVIDIA driver comes from the machine, not from here: it has to -match the running kernel, so a copy of one machine's driver is a version -mismatch rather than a GPU. CUDA 13 needs 580 or later. -DOC - -printf '\n' -say "packaged into $out" -du -sh "$out" | sed 's/^/ /' -say "this is the no-package-manager build; on Debian or Ubuntu prefer the .deb" -say " scripts/package-deb.sh" diff --git a/scripts/bundle-windows.ps1 b/scripts/bundle-windows.ps1 index 419cad2..bafd4ca 100644 --- a/scripts/bundle-windows.ps1 +++ b/scripts/bundle-windows.ps1 @@ -119,7 +119,9 @@ if ($WithCli) { # to ask whether a card is present, and it is therefore in the CPU-only build # too. Picking it would have been a check that passes on exactly the binary it # exists to reject. -$exe = Join-Path $root 'target\release\stemd-server.exe' +$target = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $root 'target' } +$release = Join-Path $target 'release' +$exe = Join-Path $release 'stemd-server.exe' $text = [System.Text.Encoding]::ASCII.GetString([System.IO.File]::ReadAllBytes($exe)) $wanted = 'cublasLt64', 'cufft64', 'nvrtc64' $missing = $wanted | Where-Object { $text -notmatch $_ } @@ -132,7 +134,6 @@ if ($missing) { } Write-Host " CUDA imports present ($($wanted -join ', '))" -$release = Join-Path $root 'target\release' Remove-Item $Out -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Out | Out-Null @@ -140,10 +141,16 @@ Copy-Item (Join-Path $release 'stemd-server.exe') $Out if ($WithCli) { Copy-Item (Join-Path $release 'stemd-cli.exe') $Out } # MLX builds OpenBLAS as a DLL inside mlx-sys's OUT_DIR and nothing installs it. -$blas = Get-ChildItem (Join-Path $release 'build') -Directory -Filter 'mlx-sys-*' | - ForEach-Object { Join-Path $_.FullName 'out\build\libopenblas.dll' } | - Where-Object { Test-Path $_ } | Select-Object -First 1 -if (-not $blas) { throw 'no libopenblas.dll under target\release\build\mlx-sys-*; build stemd-server first' } +# A prebuilt MLX carries it in its lib directory. +$blas = if ($env:MLX_PREBUILT_DIR) { + Join-Path $env:MLX_PREBUILT_DIR 'lib\libopenblas.dll' +} else { + Get-ChildItem (Join-Path $release 'build') -Directory -Filter 'mlx-sys-*' | + ForEach-Object { Join-Path $_.FullName 'out\build\libopenblas.dll' } | + Where-Object { Test-Path $_ } | Select-Object -First 1 +} +if ($blas -and -not (Test-Path $blas)) { $blas = $null } +if (-not $blas) { throw 'no libopenblas.dll in the prebuilt MLX or under target\release\build\mlx-sys-*; build stemd-server first' } Copy-Item $blas $Out # The MSVC runtime, from the redistributable directory the toolchain ships. diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 80bce89..ac8d68c 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -4,6 +4,8 @@ # Needs a keychain profile, made once: # xcrun notarytool store-credentials stemd-notary \ # --apple-id --team-id +# or an App Store Connect API key in STEMD_NOTARY_KEY (the .p8 file), +# STEMD_NOTARY_KEY_ID and, for a team key, STEMD_NOTARY_ISSUER, which CI uses. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -30,6 +32,13 @@ APP_Y=173 APPS_X=491 APPS_Y=173 PROFILE="${STEMD_NOTARY_PROFILE:-stemd-notary}" +if [ -n "${STEMD_NOTARY_KEY:-}" ]; then + NOTARY=(--key "$STEMD_NOTARY_KEY" --key-id "$STEMD_NOTARY_KEY_ID") + # Team keys have an issuer; individual keys have none. + if [ -n "${STEMD_NOTARY_ISSUER:-}" ]; then NOTARY+=(--issuer "$STEMD_NOTARY_ISSUER"); fi +else + NOTARY=(--keychain-profile "$PROFILE") +fi say() { printf ' %s\n' "$*"; } @@ -59,8 +68,12 @@ esac # `history` hits the network as well as the keychain, so only the error naming # the keychain item means the profile is absent. -if ! NOTARY_CHECK="$(xcrun notarytool history --keychain-profile "$PROFILE" 2>&1)"; then - if printf '%s' "$NOTARY_CHECK" | grep -q "No Keychain password item"; then +if ! NOTARY_CHECK="$(xcrun notarytool history "${NOTARY[@]}" 2>&1)"; then + if [ -n "${STEMD_NOTARY_KEY:-}" ]; then + echo "notarytool could not use the API key in STEMD_NOTARY_KEY. It said:" >&2 + echo >&2 + printf '%s\n' "$NOTARY_CHECK" | sed 's/^/ /' >&2 + elif printf '%s' "$NOTARY_CHECK" | grep -q "No Keychain password item"; then echo "no notarytool profile named '$PROFILE'. Make one with" >&2 echo >&2 echo " xcrun notarytool store-credentials $PROFILE \\" >&2 @@ -80,7 +93,7 @@ echo "notarizing the app, which takes a few minutes" APPZIP="$DIST/stemd-app-for-notarization.zip" rm -f "$APPZIP" ditto -c -k --keepParent "$APP" "$APPZIP" -xcrun notarytool submit "$APPZIP" --keychain-profile "$PROFILE" --wait +xcrun notarytool submit "$APPZIP" "${NOTARY[@]}" --wait rm -f "$APPZIP" xcrun stapler staple "$APP" say "app stapled" @@ -175,7 +188,7 @@ codesign --force --sign "$IDENTITY" --timestamp "$DMG" say "signed: $IDENTITY" echo "notarizing the image" -xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait +xcrun notarytool submit "$DMG" "${NOTARY[@]}" --wait xcrun stapler staple "$DMG" say "image stapled" diff --git a/scripts/package-deb.sh b/scripts/package-deb.sh deleted file mode 100755 index 22d23a9..0000000 --- a/scripts/package-deb.sh +++ /dev/null @@ -1,369 +0,0 @@ -#!/bin/bash -# Package stemd for Debian and Ubuntu, depending on the CUDA stack instead of -# carrying it. -# -# The counterpart to bundle-linux.sh and the opposite trade. That script makes a -# 1.6 GB directory that runs on any machine with a driver, because it brings its -# own CUDA runtime, its own cuDNN and 4248 headers. This one makes about 30 MB -# and asks apt for the rest, which is what a package manager is for: one copy of -# cuDNN on the machine, upgraded when the machine is, rather than a second copy -# that can disagree with the first. That disagreement has already cost this port -# a day, when a 9.0 dispatcher drove 9.14 engines, loaded cleanly, and then -# found no execution plan for any graph. -# -# The tarball is still the right shape for a live USB, where there is no package -# manager and nothing persists. It is the wrong shape for everything else. -# -# Usage: scripts/package-deb.sh [output-dir] [path-to-stemd-server] -# -# The binary has to exist already. Building it is bundle-linux.sh's job, or -# cargo's, and both need the CUDA environment that this script has no business -# guessing at a second time. -set -euo pipefail - -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -out="${1:-$root/dist}" -binary="${2:-$root/target/release/stemd-server}" - -say() { printf ' %s\n' "$*"; } -die() { printf '\nstopped: %s\n' "$*" >&2; exit 1; } - -# Where the CUDA packages put the two header trees MLX reads at run time. Both -# are under the `cuda` alternative rather than a versioned directory, so an -# upgrade from 13.3 to 13.4 moves the target and the package keeps working. -CUDA_PREFIX=/usr/local/cuda -CCCL_DIR="$CUDA_PREFIX/targets/x86_64-linux/include/cccl" - -# Where the real binary goes. Not /usr/bin, and the reason is not tidiness. -# -# MLX locates the CCCL headers it hands NVRTC at `/../include/cccl`, -# and it learns the binary directory from dladdr, which reports argv[0] and does -# not resolve it. Measured on this machine: invoked by absolute path it reports -# that path, invoked through a symlink it reports the symlink, and invoked as a -# bare name found on PATH it reports `stemd-server` with no directory at all. In -# that last case the lookup becomes the relative path `include/cccl` and lands -# wherever the working directory happens to be. -# -# So the install has to own a directory whose shape MLX expects, and the thing -# on PATH has to exec into it by absolute path. See the wrapper below. -LIBDIR=/usr/lib/stemd - -[ -f "$binary" ] || die "no binary at $binary. Build one first: - scripts/bundle-linux.sh - or, with the CUDA environment already set, - cargo build --release -p stemd-server" - -# Check it is the CUDA build and not a CPU-only one, by asking the binary rather -# than the environment that made it. A CPU-only MLX links, starts, reports -# `device: cpu`, separates correctly and runs about 190x slower, and nothing in -# the build says anything is wrong. Shipping one as a .deb would be shipping -# that silence to other people's machines. -if command -v objdump >/dev/null; then - needed="$(objdump -p "$binary" | awk '/NEEDED/ {print $2}')" - for lib in libcudart.so.13 libnvrtc.so.13 libcublasLt.so.13; do - grep -qx "$lib" <<<"$needed" || - die "$binary does not link $lib, so it was built against a CPU-only - MLX. Set MLX_BUILD_CUDA=1 and CUDNN_PATH, delete - target/release/build/mlx-sys-*, and build again." - done - say "cuda: linked (libcudart, libnvrtc, libcublasLt)" -else - say "cuda: unverified, no objdump on this machine" -fi - -version="$(awk '/^\[workspace.package\]/{f=1} f && /^version *=/{gsub(/[",]/,"");print $3;exit}' "$root/Cargo.toml")" -[ -n "$version" ] || die "no version in $root/Cargo.toml" -arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)" -pkgdir="$out/stemd_${version}_${arch}" - -rm -rf "$pkgdir" -mkdir -p "$pkgdir/DEBIAN" \ - "$pkgdir$LIBDIR/bin" \ - "$pkgdir$LIBDIR/include" \ - "$pkgdir/usr/bin" \ - "$pkgdir/usr/share/applications" \ - "$pkgdir/usr/share/icons/hicolor/256x256/apps" \ - "$pkgdir/usr/share/doc/stemd" - -install -m 0755 "$binary" "$pkgdir$LIBDIR/bin/stemd-server" -[ -f "$root/target/release/stemd-cli" ] && - install -m 0755 "$root/target/release/stemd-cli" "$pkgdir$LIBDIR/bin/stemd-cli" - -# A symlink rather than a copy of the headers: they belong to cccl-13-3, which -# is in Depends, and two copies of a header tree is the same mistake as two -# copies of cuDNN in a smaller size. -ln -s "$CCCL_DIR" "$pkgdir$LIBDIR/include/cccl" - -# The wrapper is load-bearing, not a convenience. See the note on LIBDIR: exec -# by absolute path is what makes MLX's argv[0]-derived header lookup land in -# this package instead of in the user's working directory. -cat > "$pkgdir/usr/bin/stemd-server" < "$pkgdir/usr/bin/stemd-cli" < "$pkgdir/usr/share/applications/stemd.desktop" </maps on a working machine and mapped back to -# packages with dpkg -S. -dlopened="libgl1, libglx0, libglvnd0, - libx11-6, libx11-xcb1, libxcb1, libxcb-dri3-0, libxcb-glx0, libxcb-present0, - libxcb-randr0, libxcb-sync1, libxcb-xfixes0, libxcb-xkb1, - libxcursor1, libxext6, libxfixes3, libxi6, libxrender1, - libxkbcommon0, libxkbcommon-x11-0, libdrm2" - -# The Bonjour compatibility layer, which discovery.rs calls through DNSServiceRegister. -service="libavahi-compat-libdnssd1, libavahi-client3, libavahi-common3, libdbus-1-3, libsystemd0" - -# CUDA, from NVIDIA's apt repository. -# -# cuda-cudart-dev-13-3 is here for its headers, not for development: MLX -# compiles kernels with NVRTC while it runs and includes the CUDA runtime -# headers from $CUDA_PREFIX/include as it goes. Without them every separation -# dies at the first gather, on a server that started fine and said device: gpu. -# -# cuDNN is pinned at 9.5 because the cudnn-frontend version MLX builds against -# calls cudnnBackendPopulateCudaGraph, which cuDNN added in 9.5. Debian's own -# nvidia-cudnn is 9.0.0 and declares nvidia-cuda-dev (<< 13~), so it is both too -# old and for the wrong CUDA. It is deliberately not offered as an alternative. -cuda="cuda-cudart-13-3, cuda-cudart-dev-13-3, cccl-13-3, - libcublas-13-3, libcufft-13-3, cuda-nvrtc-13-3, - libcudnn9-cuda-13 (>= 9.5)" - -base="libc6, libgcc-s1, libstdc++6, libgfortran5, zlib1g, libcap2, libcuda1" -blas="libopenblas0 | libblas3" - -if command -v dpkg-shlibdeps >/dev/null 2>&1; then - # Two different problems, and they need two different flags. - # - # --ignore-missing-info is for a library that is found but belongs to no - # package, which is cuDNN on Debian 13 today: NVIDIA publish none for trixie - # yet, so there is nothing for shlibdeps to name. - # - # -l is for a library that is not found at all, which is the same cuDNN when - # it was installed by hand outside the linker path. Without it shlibdeps - # stops at "cannot find library libcudnn.so.9" before it reports anything, - # and --ignore-missing-info does not cover that case. - search=() - [ -n "${CUDNN_PATH:-}" ] && [ -d "$CUDNN_PATH/lib" ] && search+=(-l"$CUDNN_PATH/lib") - IFS=: read -ra extra <<<"${LD_LIBRARY_PATH:-}" - for d in "${extra[@]}"; do [ -n "$d" ] && [ -d "$d" ] && search+=(-l"$d"); done - - pushd "$pkgdir" >/dev/null - mkdir -p debian && : > debian/control - if found="$(dpkg-shlibdeps -O --ignore-missing-info "${search[@]}" \ - "$pkgdir$LIBDIR/bin/stemd-server" 2>/dev/null)"; then - base="${found#shlibs:Depends=}" - say "shlibdeps: resolved from the ELF" - else - say "shlibdeps: failed, using the checked-in base list" - fi - rm -rf debian - popd >/dev/null -else - say "shlibdeps: not installed, using the checked-in base list" -fi - -# Merge the two sets, drop repeats, and relax one constraint that shlibdeps gets -# wrong for a package that is meant to travel. -# -# It reads the driver's symbols file and emits libcuda1 (>= ), which today is 610.57.04. That is a fact about this machine, not a -# requirement of stemd: the floor is CUDA 13's own, which is 580. Left alone the -# package would refuse to install on a working 590 desktop and say the driver -# was too old, which would not be true. -# -# Deduplication keys on the package name, so the versioned entry shlibdeps -# produced wins over the bare name in the lists above, and alternatives such as -# `libopenblas0 | libblas3` keep their first name as the key. -# libcuda1 is named here as well as in the base list: when shlibdeps resolves -# the rest from a machine whose only libcuda.so.1 is the toolkit's stub, it -# belongs to no package and drops out, and the base list has been replaced. -depends="$(printf '%s, libcuda1, %s, %s, %s, %s' "$base" "$blas" "$service" "$cuda" "$dlopened" \ - | tr ',' '\n' \ - | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/[[:space:]]+/ /g' \ - | grep -v '^$' \ - | sed -E 's/^libcuda1( \(.*\))?$/libcuda1 (>= 580)/' \ - | awk '!seen[$1]++' \ - | paste -sd, - \ - | sed 's/,/, /g')" - -# Say which glibc this build just committed to, because nothing in this script -# decides it: it is whatever the machine that ran cargo happens to have, and it -# is what decides who can install the result. -# -# Measured with apt on each target, using a control-only package carrying this -# exact Depends line. Built on Debian 13 it comes out as libc6 (>= 2.39) and -# libstdc++6 (>= 14), which Ubuntu 24.04 satisfies, and Ubuntu 22.04 (2.35, -# 12.3) and Debian 12 (2.36, 12.2) do not. Every other dependency here, CUDA -# and X11 included, resolves on all of them. So the way to reach the older -# targets is to build on the oldest one, not to edit this file. -floor="$(tr ',' '\n' <<<"$depends" | grep -E '^ *(libc6|libstdc\+\+6) ' | tr -s ' ' | sed 's/^ //' | paste -sd'; ' -)" -[ -n "$floor" ] && say "built against: $floor" - -installed_kb="$(du -sk "$pkgdir" | cut -f1)" - -cat > "$pkgdir/DEBIAN/control" < -Section: sound -Priority: optional -Homepage: https://github.com/nsaintot/stemd -Installed-Size: $installed_kb -Depends: $depends -Description: Stem separation service with a drop window - stemd separates a track into stems on the GPU and serves the result over HTTP, - announcing itself on the local network with mDNS so a player can find it. - . - It needs an NVIDIA card and a driver new enough for CUDA 13, which means 580 or - later. libcuda1 is listed because the binary links libcuda.so.1 and will not - start without it. On a machine that already runs a driver it is already - satisfied; on one with no driver at all, apt will offer to install one, and - that is a decision worth taking deliberately. See README.Debian. -EOF - -# README.Debian, because the two things that can go wrong at install time are -# both about the machine rather than the package, and neither is obvious from a -# dependency error. -cat > "$pkgdir/usr/share/doc/stemd/README.Debian" <<'EOF' -stemd for Debian -================ - -The CUDA dependencies come from NVIDIA's apt repository, not from Debian. Add -it before installing: - - https://developer.download.nvidia.com/compute/cuda/repos/ - -Debian 13 (trixie) is currently a special case. NVIDIA publish cuDNN for -ubuntu2204, ubuntu2404 and debian12, but not yet for debian13, so -libcudnn9-cuda-13 cannot be satisfied there from any repository. Debian's own -nvidia-cudnn package does not help: it is 9.0.0, and it declares -nvidia-cuda-dev (<< 13~), so it is both older than the 9.5 this needs and built -for the wrong CUDA. - -Until NVIDIA publish for trixie, install cuDNN 9.5 or later by hand, tell the -dynamic linker where it went, and install this package without that one -dependency: - - echo /opt/cudnn13/lib | sudo tee /etc/ld.so.conf.d/cudnn.conf - sudo ldconfig - sudo dpkg -i --force-depends stemd_*.deb - -The ld.so.conf.d entry is the part that is easy to miss. Without it the package -installs, the window opens, the server reports device: gpu, and then every -separation fails on libcudnn.so.9 at run time. Everything else in Depends -resolves normally on trixie; cuDNN is the only gap. - -Which distributions can install this depends on where it was built, not on -this package. A build made on Debian 13 requires glibc 2.39 and libstdc++ 14, -which Debian 13 and Ubuntu 24.04 have and Ubuntu 22.04 and Debian 12 do not. -Everything else in Depends resolves on all four. Build on the oldest target you -mean to support. - -The driver is not a dependency. CUDA 13 needs 580 or later; check with -nvidia-smi. A machine whose driver came from NVIDIA's .run installer has -libcuda.so.1 without the libcuda1 package, and apt will want to install the -package anyway. Use the distribution's driver packages, or the ones from -NVIDIA's repository, rather than the .run installer. -EOF - -# Debian wants the full text of a licence that is not in common-licenses, which -# is why MIT is inlined and the other two are references. LAME has a stanza of -# its own because it is compiled into the binary rather than depended on: the -# package ships it, so the package has to say so. -{ - cat <<'EOF' -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: stemd -Source: https://github.com/nsaintot/stemd - -Files: * -Copyright: 2026 Nicolas Saintot -License: MIT or Apache-2.0 - -Files: crates/stemd-core/data/mode1_*.bin -Copyright: measurements of third-party hardware, not authored code -License: MIT or Apache-2.0 - Filter coefficients recovered by measurement rather than written, so the grant - above does not purport to cover them. They run one conversion, 44.1 to 96 kHz, - and only when a request asks for it. - -Files: (statically linked) lame-3.100 -Copyright: 1998-2017 The LAME Project -License: LGPL-2 - The MP3 encoder is LAME 3.100, compiled into this binary by way of the - mp3lame-encoder crate rather than linked against a system library. Its terms - are the GNU Library General Public License version 2. Upstream source: - https://lame.sourceforge.io/ - -License: LGPL-2 - On Debian systems the full text is in /usr/share/common-licenses/LGPL-2. - -License: Apache-2.0 - On Debian systems the full text is in /usr/share/common-licenses/Apache-2.0. - -License: MIT -EOF - sed 's/^/ /; s/^ $/ ./' "$root/LICENSE-MIT" -} > "$pkgdir/usr/share/doc/stemd/copyright" - -printf 'stemd (%s) unstable; urgency=low\n\n * Packaged from the tree at %s.\n\n -- nsaintot %s\n' \ - "$version" "$version" "$(date -R)" \ - | gzip -9n > "$pkgdir/usr/share/doc/stemd/changelog.Debian.gz" - -# Normalise modes. The heredocs above inherit the builder's umask, and a umask -# of 002 puts group-writable files in the package, which is a policy violation -# that depends on who ran the script rather than on anything in the tree. -find "$pkgdir" -type d -exec chmod 0755 {} + -find "$pkgdir" -type f -perm -u+x -exec chmod 0755 {} + -find "$pkgdir" -type f ! -perm -u+x -exec chmod 0644 {} + -dpkg-deb --root-owner-group --build "$pkgdir" "$out/stemd_${version}_${arch}.deb" >/dev/null -rm -rf "$pkgdir" - -deb="$out/stemd_${version}_${arch}.deb" -printf '\n' -say "packaged: $deb" -say "$(du -h "$deb" | cut -f1), against $(tr -cd , <<<"$depends" | wc -c) dependencies" -say "install with: sudo apt install $deb" diff --git a/vendor/mlx-rs-stemd b/vendor/mlx-rs-stemd index 69da8be..824be78 160000 --- a/vendor/mlx-rs-stemd +++ b/vendor/mlx-rs-stemd @@ -1 +1 @@ -Subproject commit 69da8bef9cdf17bb47aa47e6f77f01e0f5ff6deb +Subproject commit 824be781f7a7d15c5d0a15e3dbda40177847df02