diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e594137..eadc9bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,14 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + # The tests build wheels (build, wheel) and lint install.sh (shellcheck). + - run: | + python3 -m pip install --quiet build==1.6.1 wheel==0.48.0 + command -v shellcheck || { sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck; } # publish.yml runs these scripts only after a release; their engine - # staging, link check and npm launcher are tested here on synthetic - # bundles. + # staging, link check, wheel tags, npm and Homebrew packages, and + # install.sh's platform checks are tested here on synthetic bundles. - run: python3 tests/scripts/test_packaging.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53dc76e..26a840e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,11 +7,14 @@ name: Publish packages # the engine. The scripts are in packaging/. # # It runs when a release is published (the engine's release pipeline creates -# it as a draft at the commit that holds the release's source, attaches both +# it as a draft at the commit that holds the release's source, attaches the # engine bundles, then publishes it) and by hand for a tag. Every run builds # every package and installs each on macOS and, on the GB10 the CUDA engine -# needs, on Linux arm64. A run uploads only when all of these hold, and is -# otherwise a dry run: +# needs, on Linux arm64. The Linux x86-64 (AMD ROCm) bundle is optional: the +# release pipeline leaves it out when its AMD runner is off. When the release +# carries it, its packages are built, installed on a Strix Halo and uploaded +# with the rest; when not, there are none, and nothing waits for that machine. +# A run uploads only when all of these hold, and is otherwise a dry run: # - the repository variable PUBLISH_ENABLED is "true" and dry_run is not ticked; # - the release is published: not a draft, not a pre-release; # - this run's commit is the one the tag names, so the packages hold that @@ -23,8 +26,8 @@ name: Publish packages # v* tags; PyPI trusted publisher for basert (this repository, publish.yml, # environment pypi); secrets NPM_TOKEN (npm), CARGO_REGISTRY_TOKEN (crates) # and HOMEBREW_TAP_TOKEN (homebrew: Contents and Pull requests write on the -# tap); the self-hosted GB10 runner made available to this repository; then -# PUBLISH_ENABLED. +# tap); the self-hosted GB10 and Strix Halo (rocm-gfx1151) runners made +# available to this repository; then PUBLISH_ENABLED. # # Actions are pinned to commits and tools to exact versions: the upload jobs # hold publishing rights. @@ -66,6 +69,8 @@ jobs: outputs: version: ${{ steps.plan.outputs.version }} upload: ${{ steps.plan.outputs.upload }} + rocm: ${{ steps.plan.outputs.rocm }} + engines: ${{ steps.registries.outputs.engines }} pypi: ${{ steps.registries.outputs.pypi }} npm: ${{ steps.registries.outputs.npm }} crates: ${{ steps.registries.outputs.crates }} @@ -122,6 +127,21 @@ jobs: # A missing release stops the run here, with gh's message. state=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease \ --jq 'if .isDraft then "a draft" elif .isPrerelease then "a pre-release" else "published" end') + # The ROCm bundle is optional (see the top of this file); everything + # below that builds, tests or uploads a ROCm package follows this. + rocm_bundle="basert-engine-linux-x64-rocm-$V.tar.gz" + assets=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name') + rocm=false + for asset in $assets; do + if [ "$asset" = "$rocm_bundle" ]; then rocm=true; fi + done + if [ "$rocm" = true ]; then + echo "rocm=true" >> "$GITHUB_OUTPUT" + echo "$TAG carries $rocm_bundle: the Linux x86-64 (ROCm) packages are part of this run." + else + echo "rocm=false" >> "$GITHUB_OUTPUT" + echo "::notice::$TAG has no $rocm_bundle: no Linux x86-64 (ROCm) packages this run." + fi # The commit the tag names: the peeled line for an annotated tag, else the tag's own. tagged=$(git ls-remote "https://github.com/$GITHUB_REPOSITORY" "refs/tags/$TAG" "refs/tags/$TAG^{}" \ | sort -k2 | tail -n 1 | cut -f1) @@ -144,6 +164,7 @@ jobs: env: V: ${{ steps.plan.outputs.version }} UPLOAD: ${{ steps.plan.outputs.upload }} + ROCM: ${{ steps.plan.outputs.rocm }} run: | set -euo pipefail set -f # the file name patterns below are matched, not expanded @@ -166,6 +187,11 @@ jobs: # The platform tags come from the build, so they're matched. wheels="basert-$V-py3-none-macosx_*_arm64.whl basert-$V-py3-none-manylinux_*_aarch64.whl" engines="basert-darwin-arm64 basert-linux-arm64-cuda" + if [ "$ROCM" = true ]; then + wheels="$wheels basert-$V-py3-none-manylinux_*_x86_64.whl" + engines="$engines basert-linux-x64-rocm" + fi + echo "engines=$engines" >> "$GITHUB_OUTPUT" # the npm job publishes these pypi_has() { files=$(curl -sf -A "$agent" "https://pypi.org/pypi/basert/$V/json" | jq -r '.urls[].filename') || return 1 for want in $wheels; do @@ -207,6 +233,10 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} + # Before the script tests: they build wheels too. + - name: Wheel tooling + run: python3 -m pip install --quiet build==1.6.1 wheel==0.48.0 twine==7.0.0 + - name: Test the packaging scripts run: python3 tests/scripts/test_packaging.py @@ -223,7 +253,6 @@ jobs: - name: PyPI wheels run: | - python3 -m pip install --quiet build==1.6.1 wheel==0.48.0 twine==7.0.0 for b in bundles/*.tar.gz; do packaging/python/build-wheel.sh "$b" out/wheels; done python3 -m twine check --strict out/wheels/*.whl @@ -342,7 +371,7 @@ jobs: working-directory: ${{ runner.temp }} run: | python3 -m venv venv - venv/bin/pip install --quiet out/wheels/*manylinux*.whl + venv/bin/pip install --quiet out/wheels/*manylinux*_aarch64.whl venv/bin/basert --version venv/bin/basert chat --help | head -n 3 # the launcher finds basert-chat venv/bin/basert serve --help | head -n 3 @@ -385,6 +414,78 @@ jobs: ./consumer done + # The same checks again for the Linux x86-64 engine, on a Strix Halo + # (gfx1151) with the ROCm 10 runtime it needs: no GitHub-hosted runner has + # an AMD GPU. Only for a release that carries the ROCm bundle. The runner is + # self-hosted like the GB10 (this happens under RUNNER_TEMP), and is offline + # while the machine is booked for other work, so a run may wait for it. Its + # label must stay out of workflows that pull requests run: + # tests/scripts/test_packaging.py checks that. + test-rocm: + name: Test on Linux x86-64 (ROCm) + needs: [plan, build] + if: needs.plan.outputs.rocm == 'true' + runs-on: [self-hosted, Linux, X64, rocm-gfx1151] + env: + # `serve --help` must not install superfluid on the shared machine. + BASERT_SUPERFLUID_AUTO_INSTALL: "0" + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: packages + path: ${{ runner.temp }}/out + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: pip install basert + working-directory: ${{ runner.temp }} + run: | + python3 -m venv venv + venv/bin/pip install --quiet out/wheels/*manylinux*_x86_64.whl + venv/bin/basert --version + venv/bin/basert chat --help | head -n 3 # the launcher finds basert-chat + venv/bin/basert serve --help | head -n 3 + # The wheel's own engine, not one installed elsewhere on the machine. + venv/bin/python -c "import baseRT; p = baseRT._find_library(); assert '/_engine/' in p, p; print(p)" + venv/bin/python -c "import ctypes, baseRT; f = baseRT._get_lib().baseRT_version_string; f.restype = ctypes.c_char_p; print('engine', f().decode())" + + - name: npm install @basecompute/basert + working-directory: ${{ runner.temp }} + run: | + mkdir npm-test && cd npm-test && npm init -y > /dev/null + npm install --no-audit --no-fund ../out/npm/basecompute-basert-linux-x64-rocm-*.tgz ../out/npm/basecompute-basert-[0-9]*.tgz + npx basert --version + npx basert serve --help | head -n 3 + # Only the engine's own refusal of a missing model proves it loaded. + # A library or symbol koffi cannot load, or anything else failing, + # throws some other error; a model that loads is wrong too. + node -e ' + const { BaseRTModel } = require("@basecompute/basert"); + const expected = "Failed to load model: Failed to open model: /nonexistent.base"; + try { new BaseRTModel("/nonexistent.base"); } catch (e) { + if (!e.message.startsWith(expected)) throw e; + console.log("engine loaded:", e.message); + process.exit(0); + } + throw new Error("a model that does not exist loaded");' + + # As on the GB10: the SONAME resolves in the package, and loading the + # engine pulls in the HIP runtime and hipBLAS(Lt) from ROCm 10. + - name: Link a program against the installed engines + working-directory: ${{ runner.temp }} + run: | + printf '#include \nconst char *baseRT_version_string(void);\nint main(void) { return puts(baseRT_version_string()) < 0; }\n' > consumer.c + for engine in "$(venv/bin/python -c 'import baseRT, os; print(os.path.dirname(baseRT._find_library()))')" \ + "$PWD/npm-test/node_modules/@basecompute/basert-linux-x64-rocm/engine"; do + cc consumer.c -o consumer -L"$engine" -lbaseRT -Wl,-rpath,"$engine" + ldd ./consumer | grep -q "libbaseRT.so.0 => $engine/libbaseRT.so.0" + ./consumer + done + crates-check: name: Verify crates needs: plan @@ -407,10 +508,15 @@ jobs: # --- uploads: only when the plan says so ----------------------------------- + # test-rocm is skipped for a release without the ROCm bundle, and a skipped + # need would skip this job too, so the test results are checked here. pypi: name: Publish to PyPI - needs: [plan, test-macos, test-linux] - if: needs.plan.outputs.upload == 'true' && needs.plan.outputs.pypi == 'true' + needs: [plan, test-macos, test-linux, test-rocm] + if: >- + ${{ !cancelled() && needs.plan.outputs.upload == 'true' && needs.plan.outputs.pypi == 'true' + && needs.test-macos.result == 'success' && needs.test-linux.result == 'success' + && (needs.test-rocm.result == 'success' || (needs.plan.outputs.rocm == 'false' && needs.test-rocm.result == 'skipped')) }} runs-on: ubuntu-24.04 environment: name: pypi @@ -429,10 +535,15 @@ jobs: # upload would stop at the first wheel already there. Step over it. skip-existing: true + # test-rocm is skipped for a release without the ROCm bundle, and a skipped + # need would skip this job too, so the test results are checked here. npm: name: Publish to npm - needs: [plan, test-macos, test-linux] - if: needs.plan.outputs.upload == 'true' && needs.plan.outputs.npm == 'true' + needs: [plan, test-macos, test-linux, test-rocm] + if: >- + ${{ !cancelled() && needs.plan.outputs.upload == 'true' && needs.plan.outputs.npm == 'true' + && needs.test-macos.result == 'success' && needs.test-linux.result == 'success' + && (needs.test-rocm.result == 'success' || (needs.plan.outputs.rocm == 'false' && needs.test-rocm.result == 'skipped')) }} runs-on: ubuntu-24.04 environment: name: npm @@ -452,11 +563,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} V: ${{ needs.plan.outputs.version }} + ENGINES: ${{ needs.plan.outputs.engines }} run: | set -euo pipefail # Engine packages first, so the main package's optional dependencies # already exist when it appears. Skip any a previous run published. - for pkg in basert-darwin-arm64 basert-linux-arm64-cuda basert; do + # shellcheck disable=SC2086 # ENGINES is a word list + for pkg in $ENGINES basert; do if npm view "@basecompute/$pkg@$V" version > /dev/null 2>&1; then echo "@basecompute/$pkg@$V is on npm already" else @@ -464,10 +577,16 @@ jobs: fi done + # test-rocm is skipped for a release without the ROCm bundle, and a skipped + # need would skip this job too, so the test results are checked here. crates: name: Publish to crates.io - needs: [plan, crates-check, test-macos, test-linux] - if: needs.plan.outputs.upload == 'true' && needs.plan.outputs.crates == 'true' + needs: [plan, crates-check, test-macos, test-linux, test-rocm] + if: >- + ${{ !cancelled() && needs.plan.outputs.upload == 'true' && needs.plan.outputs.crates == 'true' + && needs.test-macos.result == 'success' && needs.test-linux.result == 'success' + && (needs.test-rocm.result == 'success' || (needs.plan.outputs.rocm == 'false' && needs.test-rocm.result == 'skipped')) + && needs.crates-check.result == 'success' }} runs-on: ubuntu-24.04-arm environment: name: crates diff --git a/README.md b/README.md index f51ce41..9c4ac4b 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,24 @@ # BaseRT -**Fastest LLM inference on Apple Silicon — now on NVIDIA GB10 (DGX Spark), too.** +**Fastest LLM inference on Apple Silicon — now on NVIDIA GB10 (DGX Spark) and AMD Strix Halo / Strix Point, too.** Pull a model from HuggingFace, chat with it, or serve an OpenAI-compatible API — all from one CLI. [![Docs](https://img.shields.io/badge/docs-docs.basecompute.co-5b8fa8)](https://docs.basecompute.co) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-Apple%20Silicon%20%C2%B7%20NVIDIA%20GB10-black)](#requirements) +[![Platform](https://img.shields.io/badge/platform-Apple%20Silicon%20%C2%B7%20NVIDIA%20GB10%20%C2%B7%20AMD%20Strix-black)](#requirements) [![Bindings](https://img.shields.io/badge/bindings-Python%20%C2%B7%20Node%20%C2%B7%20Rust%20%C2%B7%20Swift-444)](docs/bindings/index.md) --- -BaseRT runs large language models locally on Apple Silicon (Metal) and NVIDIA -GB10 / DGX Spark (CUDA), accelerated by hand-written GPU kernels. This repository -is the open ecosystem around the engine: the `basert` CLI (model hub + -converter), the `.base` model format, the public C API, and language bindings for -Python, Node, Rust, and Swift. +BaseRT runs large language models locally on Apple Silicon (Metal), NVIDIA +GB10 / DGX Spark (CUDA) and AMD Strix Halo / Strix Point (ROCm), accelerated by +hand-written GPU kernels. This repository is the open ecosystem around the +engine: the `basert` CLI (model hub + converter), the `.base` model format, the +public C API, and language bindings for Python, Node, Rust, and Swift. - **One CLI for everything** — `basert pull`, `chat`, `serve`, `convert`. - **OpenAI-compatible server** — chat, completions, embeddings, transcription, @@ -38,6 +38,27 @@ One of: - Apple Silicon (M1 or later), macOS 14+. - NVIDIA GB10 (DGX Spark), Linux / arm64 with a CUDA driver. +- AMD Strix Halo (Ryzen AI Max, gfx1151) or Strix Point (Ryzen AI 300, + gfx1150), Linux / x86-64 on Ubuntu 24.04 or newer, with the ROCm 10 runtime + from AMD's apt repository (below). The kernel must be one AMD supports for + RDNA3.5: on Ubuntu 24.04 the HWE kernel `6.17.0-19.19~24.04.2` or later or + the OEM kernel `6.14.0-1018` or later, Ubuntu 26.04's own kernel, and + elsewhere Linux 6.18.4 or later + ([AMD's list](https://rocm.docs.amd.com/en/latest/reference/system-optimization/rdna3-5.html)). + +On AMD, add AMD's ROCm 10 repository, then install the runtime with the BLAS +kernels for your chip: `amdrocm-blas10.0-gfx1151` on Strix Halo, +`amdrocm-blas10.0-gfx1150` on Strix Point. AMD serves the repository for +Ubuntu 24.04 (`ubuntu2404`, as below) and 26.04 (`ubuntu2604`). The installer +checks for all of this and prints the commands for your machine. + +```sh +sudo curl -fsSL https://stable.repo.amd.com/rocm/gpg/packages.gpg -o /etc/apt/keyrings/amdrocm.asc +echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/amdrocm.asc] https://stable.repo.amd.com/rocm/core/packages/ubuntu2404 stable main' | sudo tee /etc/apt/sources.list.d/amdrocm.list +sudo apt update +sudo apt install amdrocm-blas10.0-gfx1151 +sudo usermod -aG render "$USER" # access to the GPU; log in again afterwards +``` ## Quickstart diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index 80986ee..d289b6b 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -47,6 +47,7 @@ function libNames(): string[] { const ENGINE_PACKAGES: Record = { "darwin-arm64": "@basecompute/basert-darwin-arm64", "linux-arm64": "@basecompute/basert-linux-arm64-cuda", + "linux-x64": "@basecompute/basert-linux-x64-rocm", }; function enginePackageDir(): string | undefined { diff --git a/install.sh b/install.sh index b9d6400..41ee2da 100755 --- a/install.sh +++ b/install.sh @@ -4,8 +4,9 @@ # curl -LsSf https://basecompute.co/install.sh | sh # # Downloads the latest prebuilt engine bundle for this platform — macOS/arm64 -# (Metal) or Linux/arm64 (CUDA) — the engine library + the basert CLI and -# runtime tools, installs it to ~/.basert, then adds it to your PATH. Each +# (Metal), Linux/arm64 (NVIDIA CUDA) or Linux/x86_64 (AMD ROCm, for Strix Halo +# and Strix Point) — the engine library + the basert CLI and runtime tools, +# installs it to ~/.basert, then adds it to your PATH. Each # release goes to ~/.basert/versions// (from 0.4.3 on), and ~/.basert holds # links to the one in use, so `basert self update` updates it later and # `basert self use ` goes back. Re-running installs the target release the @@ -21,6 +22,9 @@ # BASERT_VERSION release tag, e.g. v0.5.0 (default: latest) # BASERT_FORCE set to 1 to reinstall even if already up to date # BASERT_NO_MODIFY_PATH set to 1 to skip editing shell profiles +# +# For the installer's tests only: BASERT_INSTALL_TEST_ROOT, a folder that +# stands in for / where the AMD GPU and ROCm checks look. set -eu REPO="basecompute/baseRT" @@ -37,9 +41,148 @@ need tar need uname # --- platform detection + asset selection ------------------------------------ -# BaseRT ships two prebuilt engine bundles: macOS/arm64 (Metal) and Linux/arm64 -# (CUDA). Pick the one matching this host; the installed CLI is built for that -# backend and `basert pull` resolves the matching catalog variant. +# BaseRT ships three prebuilt engine bundles: macOS/arm64 (Metal), Linux/arm64 +# (NVIDIA CUDA) and Linux/x86_64 (AMD ROCm). Pick the one matching this host; +# the installed CLI is built for that backend and `basert pull` resolves the +# matching catalog variant. +# +# The Linux/x86_64 engine runs on the integrated GPU of AMD Strix Halo +# (gfx1151) and Strix Point (gfx1150), through the ROCm 10 runtime that AMD's +# apt repository installs in /opt/rocm/core-10.0. This installer checks for it +# and says what to install; it does not run apt itself. +sysroot="${BASERT_INSTALL_TEST_ROOT:-}" +ROCM_LIB="/opt/rocm/core-10.0/lib" +AMD_REPO="https://stable.repo.amd.com/rocm" +rocm_missing="" + +# A field of /etc/os-release ($1), unquoted. +os_release() { sed -n "s/^$1=//p" "$sysroot/etc/os-release" 2>/dev/null | tr -d "\"'"; } + +# The commands that install ROCm 10 for a chip ($1), one per line: AMD's apt +# repository first when apt does not have it yet. AMD serves it for Ubuntu +# 24.04 and 26.04. +rocm_steps() { + if ! grep -rqs "stable.repo.amd.com/rocm/core" "$sysroot/etc/apt/sources.list" "$sysroot/etc/apt/sources.list.d"; then + case "$(os_release ID) $(os_release VERSION_ID)" in + "ubuntu 24.04") dist=ubuntu2404 ;; + "ubuntu 26.04") dist=ubuntu2604 ;; + *) dist="" ;; + esac + if [ -n "$dist" ]; then + echo "sudo curl -fsSL $AMD_REPO/gpg/packages.gpg -o /etc/apt/keyrings/amdrocm.asc" + echo "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/amdrocm.asc] $AMD_REPO/core/packages/$dist stable main' | sudo tee /etc/apt/sources.list.d/amdrocm.list" + echo "sudo apt update" + else + echo "# from AMD's ROCm 10 repository, $AMD_REPO/core/packages, which serves Ubuntu 24.04 and 26.04:" + fi + fi + echo "sudo apt install amdrocm-blas10.0-$1" +} + +device_hint="" + +# The AMD GPUs' LLVM targets (gfx1151, ...), one per line, from the amdgpu +# driver's topology in node-number order: the order HIP numbers them in. +# Nothing without the driver. +kfd_gpus() { + [ -e "$sysroot/dev/kfd" ] || return 0 + nodes="$sysroot/sys/class/kfd/kfd/topology/nodes" + for dir in "$nodes"/*; do + [ -f "$dir/properties" ] && printf '%s\n' "${dir##*/}" + done | sort -n | while read -r node; do + v="$(sed -n 's/^gfx_target_version \([0-9][0-9]*\)$/\1/p' "$nodes/$node/properties")" + case "$v" in + "" | 0) ;; # a CPU node + *) printf 'gfx%d%x%x\n' $((v / 10000)) $((v / 100 % 100)) $((v % 100)) ;; + esac + done +} + +# A comma list's entries ($1), one per line, as C reads them (ROCr's getline, +# HIP's do { } while (pos < size)): no entry after a final comma. +entries() { + printf '%s\n' "$1" | sed 's/,$//' | tr ',' '\n' +} + +# C's strtol($1, &end, 0) when it reads all of $1: the value (hex after 0x, +# octal after 0), or failure. +strtol0() { + s=$1 sign="" + case "$s" in -*) sign=- s=${s#-} ;; +*) s=${s#+} ;; esac + case "$s" in + 0[xX]*) body=${s#??} base=16; case "$body" in "" | *[!0-9a-fA-F]*) return 1 ;; esac ;; + 0?*) body=${s#0} base=8; case "$body" in *[!0-7]*) return 1 ;; esac ;; + "" | *[!0-9]*) return 1 ;; + *) body=$s base=10 ;; + esac + # Past any GPU count, and safe from the shell's integer width. + if [ "${#body}" -gt 9 ]; then echo "${sign}999999999"; return 0; fi + case $base in 16) v=$((0x$body)) ;; 8) v=$((0$body)) ;; *) v=$((body)) ;; esac + echo "$sign$v" +} + +# The GPUs (stdin, one per line) that ROCR_VISIBLE_DEVICES leaves, in its order, +# as ROCr reads it (amd_filter_device.cpp): set but empty leaves none; entries +# are trimmed and read up to the number of GPUs, each a strtol index, and one +# that does not parse, is out of range or repeats ends the list. A single "?" +# when it reaches an entry that names a GPU by UUID (or an empty one). +rocr_visible() { + lines="$(cat)" + if [ "${ROCR_VISIBLE_DEVICES+set}" != set ]; then + [ -z "$lines" ] || printf '%s\n' "$lines" + return 0 + fi + [ -n "$ROCR_VISIBLE_DEVICES" ] || return 0 + count="$(printf '%s\n' "$lines" | grep -c . || true)" + entries "$ROCR_VISIBLE_DEVICES" | { + out="" seen="," n=0 + while IFS= read -r entry; do + [ "$n" -lt "$count" ] || break + n=$((n + 1)) + entry="$(printf '%s' "$entry" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + case "$entry" in "" | [Gg]*) echo "?"; exit 0 ;; esac + i="$(strtol0 "$entry")" || break + { [ "$i" -ge 0 ] && [ "$i" -lt "$count" ]; } || break + case "$seen" in *",$i,"*) break ;; esac + seen="$seen$i," + out="$out$(printf '%s\n' "$lines" | sed -n "$((i + 1))p") +" + done + printf '%s' "$out" + } +} + +# The GPUs (stdin, as rocr_visible leaves them) that HIP's own list leaves, as +# "index gpu" lines in HIP's order, where index is the number HIP_VISIBLE_DEVICES +# names that GPU by. HIP reads HIP_VISIBLE_DEVICES, or CUDA_VISIBLE_DEVICES when +# that is empty (rocdevice.cpp): only plain decimal entries count, a repeat is +# skipped, and any other entry ends the list. A single "?" when the order is +# unknown: ROCr's list was, or this one reaches an entry naming a GPU by UUID. +hip_visible() { + lines="$(cat)" + if [ "$lines" = "?" ]; then echo "?"; return 0; fi + list="${HIP_VISIBLE_DEVICES:-${CUDA_VISIBLE_DEVICES:-}}" + if [ -z "$list" ]; then + [ -z "$lines" ] || printf '%s\n' "$lines" | awk '{ print NR - 1, $0 }' + return 0 + fi + count="$(printf '%s\n' "$lines" | grep -c . || true)" + entries "$list" | { + out="" seen="," + while IFS= read -r entry; do + case "$entry" in *GPU-*) echo "?"; exit 0 ;; esac + case "$entry" in 0 | [1-9] | [1-9]*[0-9]) ;; *) break ;; esac + case "$entry" in *[!0-9]*) break ;; esac + { [ "${#entry}" -le 9 ] && [ "$entry" -lt "$count" ]; } || break + case "$seen" in *",$entry,"*) continue ;; esac + seen="$seen$entry," + out="$out$entry $(printf '%s\n' "$lines" | sed -n "$((entry + 1))p") +" + done + printf '%s' "$out" + } +} + os="$(uname -s)" arch="$(uname -m)" case "$os $arch" in @@ -52,8 +195,64 @@ case "$os $arch" in else err "BaseRT on Linux/arm64 needs an NVIDIA CUDA GPU (none detected)." fi ;; + "Linux x86_64") + # The x86_64 bundle is ROCm-only, built for these two chips. The engine + # runs on HIP device 0, so that is the GPU that decides; a Strix GPU behind + # another one is installed for, with how to make it device 0. + raw="$(kfd_gpus)" + seen="$(printf '%s\n' "$raw" | rocr_visible | hip_visible)" + gpu="" device=0 selector=0 first="" + if [ "$seen" = "?" ]; then + # A UUID selector: neither runtime's UUID matching is reproduced here, so + # which GPU is device 0 is left to the engine's own check at start. + first="$(printf '%s\n' "$raw" | sed -n '1p')" + for g in $raw; do + case "$g" in gfx1151 | gfx1150) gpu="$g"; break ;; esac + done + if [ -z "$gpu" ]; then + [ -n "$first" ] || err "BaseRT on Linux/x86_64 needs an AMD Strix Halo (gfx1151) or Strix Point (gfx1150) GPU with the amdgpu driver (none detected)." + err "BaseRT on Linux/x86_64 runs on AMD Strix Halo (gfx1151) and Strix Point (gfx1150) GPUs, not $first." + fi + warn "ROCR_VISIBLE_DEVICES or HIP_VISIBLE_DEVICES names a GPU by UUID, so which GPU is HIP device 0" + warn "is not checked here; the engine checks that it is a Strix GPU when it starts." + else + first="$(printf '%s\n' "$seen" | sed -n '1s/^[0-9]* //p')" + count="$(printf '%s\n' "$seen" | grep -c . || true)" + i=0 + while [ "$i" -lt "$count" ]; do + line="$(printf '%s\n' "$seen" | sed -n "$((i + 1))p")" + case "${line#* }" in gfx1151 | gfx1150) gpu="${line#* }" device=$i selector="${line%% *}"; break ;; esac + i=$((i + 1)) + done + if [ -z "$gpu" ]; then + [ -n "$raw" ] || err "BaseRT on Linux/x86_64 needs an AMD Strix Halo (gfx1151) or Strix Point (gfx1150) GPU with the amdgpu driver (none detected)." + [ -n "$first" ] || err "BaseRT on Linux/x86_64 needs an AMD Strix Halo (gfx1151) or Strix Point (gfx1150) GPU, and ROCR_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES leave no AMD GPU visible." + err "BaseRT on Linux/x86_64 runs on AMD Strix Halo (gfx1151) and Strix Point (gfx1150) GPUs, not $first." + fi + if [ "$device" -ne 0 ]; then + # HIP_VISIBLE_DEVICES counts the GPUs ROCR_VISIBLE_DEVICES leaves, not + # the order an earlier HIP list made: offer that number. + device_hint="export HIP_VISIBLE_DEVICES=$selector" + warn "the engine runs on HIP device 0, which is $first here; the $gpu is HIP device $device." + warn "make it device 0 wherever you run basert: $device_hint" + fi + fi + if [ ! -r "$sysroot/dev/kfd" ] || [ ! -w "$sysroot/dev/kfd" ]; then + warn "you cannot open /dev/kfd, so the engine cannot use the GPU: add yourself to the render" + warn "group (sudo usermod -aG render \"\$USER\") and log in again." + fi + # One package brings the runtime and the BLAS kernels for this chip. + hip="" + for f in "$sysroot$ROCM_LIB"/libamdhip64.so*; do [ -e "$f" ] && hip="$f"; done + if [ -z "$hip" ] || [ ! -d "$sysroot$ROCM_LIB/rocblas/library/$gpu" ]; then + rocm_missing="$(rocm_steps "$gpu")" + warn "the engine needs the ROCm 10 runtime and the $gpu BLAS kernels in /opt/rocm/core-10.0, which" + warn "are not there. Install them from AMD's ROCm 10 apt repository:" + printf '%s\n' "$rocm_missing" | while IFS= read -r line; do warn " $line"; done + fi + ASSET_PREFIX="basert-engine-linux-x64-rocm" ;; *) - err "unsupported platform: $os/$arch. BaseRT ships macOS/arm64 (Metal) and Linux/arm64 (CUDA)." ;; + err "unsupported platform: $os/$arch. BaseRT ships macOS/arm64 (Metal), Linux/arm64 (NVIDIA CUDA) and Linux/x86_64 (AMD ROCm)." ;; esac say "platform $os/$arch → $ASSET_PREFIX" @@ -78,7 +277,7 @@ if [ "${BASERT_FORCE:-0}" != "1" ] && [ -x "$INSTALL_DIR/basert" ] \ exit 0 fi -# Find the browser_download_url for the macOS arm64 tarball. +# Find the browser_download_url for this platform's tarball. url="$(printf '%s' "$json" \ | grep -o '"browser_download_url"[^,]*' \ | grep -o 'https://[^"]*'"$ASSET_PREFIX"'[^"]*\.tar\.gz' \ @@ -164,6 +363,7 @@ if [ -x "$INSTALL_DIR/basert" ] && "$INSTALL_DIR/basert" superfluid --help >/dev fi # --- PATH wiring ------------------------------------------------------------- +# shellcheck disable=SC2016 # $PATH is written to the profile unexpanded add_path_line='export PATH="'"$INSTALL_DIR"':$PATH"' already_on_path=0 case ":$PATH:" in *":$INSTALL_DIR:"*) already_on_path=1 ;; esac @@ -184,6 +384,16 @@ if [ "$already_on_path" -eq 0 ]; then echo echo " Restart your shell, or run: export PATH=\"$INSTALL_DIR:\$PATH\"" fi +if [ -n "$rocm_missing" ]; then + echo + echo " First install the ROCm 10 runtime the engine needs (from AMD's apt repository):" + printf '%s\n' "$rocm_missing" | sed 's/^/ /' +fi +if [ -n "$device_hint" ]; then + echo + echo " The engine runs on HIP device 0, which is not your $gpu; make the $gpu device 0:" + echo " $device_hint" +fi echo echo " Try it:" echo " basert pull basecompute/Qwen3-0.6B" diff --git a/packaging/npm/README.md b/packaging/npm/README.md index 86dc79e..a3ca062 100644 --- a/packaging/npm/README.md +++ b/packaging/npm/README.md @@ -1,18 +1,25 @@ # @basecompute/basert -Fast local LLM inference on Apple Silicon (Metal) and NVIDIA GB10 / DGX Spark -(CUDA). This package installs the `basert` CLI, the prebuilt engine, and the -Node.js / TypeScript bindings. +Fast local LLM inference on Apple Silicon (Metal), NVIDIA GB10 / DGX Spark +(CUDA) and AMD Strix Halo / Strix Point (ROCm). This package installs the +`basert` CLI, the prebuilt engine, and the Node.js / TypeScript bindings. ```sh npm install -g @basecompute/basert # the CLI npm install @basecompute/basert # the bindings, in a project ``` -Requires Node 18+, and Apple Silicon with macOS 15+ or Linux/arm64 with an -NVIDIA GPU and the CUDA 13 runtime (cuBLAS). npm installs only the engine for -your platform (`@basecompute/basert-darwin-arm64` or -`@basecompute/basert-linux-arm64-cuda`), so don't install with +Requires Node 18+, and one of: + +- Apple Silicon with macOS 15+; +- Linux/arm64 with an NVIDIA GPU and the CUDA 13 runtime (cuBLAS); +- Linux/x64 with an AMD Strix Halo (gfx1151) or Strix Point (gfx1150) GPU and + the ROCm 10 runtime in `/opt/rocm/core-10.0` + (`sudo apt install amdrocm-blas10.0-gfx1151`, or `-gfx1150`). + +npm installs only the engine for your platform +(`@basecompute/basert-darwin-arm64`, `@basecompute/basert-linux-arm64-cuda` or +`@basecompute/basert-linux-x64-rocm`), so don't install with `--omit=optional`. ## CLI diff --git a/packaging/npm/basert.js b/packaging/npm/basert.js index afcbbea..54fc509 100644 --- a/packaging/npm/basert.js +++ b/packaging/npm/basert.js @@ -14,13 +14,14 @@ const path = require("path"); const ENGINE_PACKAGES = { "darwin-arm64": "@basecompute/basert-darwin-arm64", "linux-arm64": "@basecompute/basert-linux-arm64-cuda", + "linux-x64": "@basecompute/basert-linux-x64-rocm", }; const platform = `${process.platform}-${process.arch}`; const pkg = ENGINE_PACKAGES[platform]; if (!pkg) { console.error( - `basert: no prebuilt engine for ${platform}. BaseRT ships macOS/arm64 (Apple Silicon) and Linux/arm64 (NVIDIA CUDA).` + `basert: no prebuilt engine for ${platform}. BaseRT ships macOS/arm64 (Apple Silicon), Linux/arm64 (NVIDIA CUDA) and Linux/x64 (AMD ROCm).` ); process.exit(1); } diff --git a/packaging/npm/build-packages.mjs b/packaging/npm/build-packages.mjs index f4c1692..1cabf37 100644 --- a/packaging/npm/build-packages.mjs +++ b/packaging/npm/build-packages.mjs @@ -3,23 +3,28 @@ // // node packaging/npm/build-packages.mjs [--engines ] // -// holds both release engine bundles for the version in -// bindings/node/package.json. Writes three tarballs to , ready for -// `npm publish `: +// holds the release's engine bundles for the version in +// bindings/node/package.json. Writes a tarball per engine and the main one to +// , ready for `npm publish `: // // basecompute-basert-darwin-arm64-.tgz engine for Apple Silicon // basecompute-basert-linux-arm64-cuda-.tgz engine for Linux/arm64 CUDA +// basecompute-basert-linux-x64-rocm-.tgz engine for Linux/x86-64 ROCm // basecompute-basert-.tgz bindings + `basert` command // // The engine is split out so each machine downloads only its own (esbuild and -// biome ship the same way): the main package lists both engine packages as -// optionalDependencies, and npm skips the one whose os/cpu don't match. +// biome ship the same way): the main package lists the engine packages as +// optionalDependencies, and npm skips those whose os/cpu don't match. +// +// The macOS and CUDA bundles are in every release. The ROCm one is optional: +// the engine's release pipeline ships a release without it when its AMD +// runner is off, and then there is no ROCm package to build or depend on. // // --engines names the engine packages this release publishes, comma-separated -// (default: all, `basert-darwin-arm64,basert-linux-arm64-cuda`). Every engine -// tarball is still built, for testing, but the main package depends only on -// the named ones: one that never reaches the registry must not be among its -// optionalDependencies. publish.yml publishes all of them. +// (default: every one built). Every engine tarball is still built, for +// testing, but the main package depends only on the named ones: one that +// never reaches the registry must not be among its optionalDependencies. +// publish.yml publishes all of them. // // bindings/node is compiled in a staging copy and its package.json rewritten // there, so the published package gets its own name and metadata while the @@ -70,21 +75,38 @@ const PLATFORMS = [ cpu: ["arm64"], libc: ["glibc"], }, + { + bundle: "basert-engine-linux-x64-rocm", + name: `${SCOPE}/basert-linux-x64-rocm`, + description: "BaseRT engine for Linux/x86-64 with AMD ROCm (Strix Halo / Strix Point)", + os: ["linux"], + cpu: ["x64"], + libc: ["glibc"], + optional: true, // not in a release built without its AMD runner + }, ]; -const published = PLATFORMS.filter((p) => !engines || engines.includes(p.name.split("/")[1])); for (const name of engines || []) { if (!PLATFORMS.some((p) => p.name === `${SCOPE}/${name}`)) { console.error(`--engines: no engine package ${name}`); process.exit(2); } } +const named = (p) => !engines || engines.includes(p.name.split("/")[1]); const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "inherit"] }).toString(); const writeJson = (file, obj) => fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n"); const source = JSON.parse(fs.readFileSync(path.join(root, "bindings/node/package.json"), "utf8")); const version = source.version; +const bundleFile = (p) => path.join(bundlesDir, `${p.bundle}-${version}.tar.gz`); +// An optional bundle the release lacks is left out, unless --engines names it. +const built = PLATFORMS.filter((p) => { + if (fs.existsSync(bundleFile(p)) || !p.optional || (engines && named(p))) return true; + console.log(`no ${path.basename(bundleFile(p))} in this release: ${p.name} is not built`); + return false; +}); +const published = built.filter(named); const stage = fs.mkdtempSync(path.join(os.tmpdir(), "basert-npm-")); fs.mkdirSync(outDir, { recursive: true }); @@ -95,8 +117,8 @@ function pack(dir) { try { // --- engine packages ------------------------------------------------------- - for (const p of PLATFORMS) { - const bundle = path.join(bundlesDir, `${p.bundle}-${version}.tar.gz`); + for (const p of built) { + const bundle = bundleFile(p); if (!fs.existsSync(bundle)) { throw new Error(`missing ${bundle} (bindings/node is at ${version}; has release v${version} been published?)`); } @@ -160,17 +182,19 @@ try { writeJson(path.join(main, "package.json"), { name: `${SCOPE}/basert`, version, - description: "Fast local LLM inference on Apple Silicon and NVIDIA GB10: the basert CLI and Node.js bindings", + description: "Fast local LLM inference on Apple Silicon, NVIDIA GB10 and AMD Strix: the basert CLI and Node.js bindings", license: "Apache-2.0", repository: { ...REPOSITORY, directory: "bindings/node" }, homepage: HOMEPAGE, - keywords: ["llm", "inference", "apple-silicon", "metal", "cuda", "openai"], + keywords: ["llm", "inference", "apple-silicon", "metal", "cuda", "rocm", "amd", "openai"], main: source.main, types: source.types, bin: { basert: "bin/basert.js" }, engines: source.engines, - os: ["darwin", "linux"], - cpu: ["arm64"], + // Where this release has an engine. One left out of --engines still + // reaches users through install.sh or Homebrew, and the bindings find it. + os: [...new Set(built.flatMap((p) => p.os))], + cpu: [...new Set(built.flatMap((p) => p.cpu))], dependencies: source.dependencies, optionalDependencies: Object.fromEntries(published.map((p) => [p.name, version])), files: ["dist/**/*.js", "dist/**/*.d.ts", "bin/", "NOTICE"], diff --git a/packaging/python/README.md b/packaging/python/README.md index eac03f9..f76878b 100644 --- a/packaging/python/README.md +++ b/packaging/python/README.md @@ -1,15 +1,20 @@ # BaseRT -Fast local LLM inference on Apple Silicon (Metal) and NVIDIA GB10 / DGX Spark -(CUDA). This package installs the `basert` CLI, the prebuilt engine, and the -Python bindings in one step. +Fast local LLM inference on Apple Silicon (Metal), NVIDIA GB10 / DGX Spark +(CUDA) and AMD Strix Halo / Strix Point (ROCm). This package installs the +`basert` CLI, the prebuilt engine, and the Python bindings in one step. ```sh pip install basert ``` -Requires Apple Silicon with macOS 15+, or Linux/arm64 with an NVIDIA GPU and -the CUDA 13 runtime (cuBLAS). +Requires one of: + +- Apple Silicon with macOS 15+; +- Linux/arm64 with an NVIDIA GPU and the CUDA 13 runtime (cuBLAS); +- Linux/x86-64 with an AMD Strix Halo (gfx1151) or Strix Point (gfx1150) GPU + and the ROCm 10 runtime in `/opt/rocm/core-10.0` + (`sudo apt install amdrocm-blas10.0-gfx1151`, or `-gfx1150`). ## CLI diff --git a/packaging/python/platform_tag.py b/packaging/python/platform_tag.py index 754f248..db3c043 100644 --- a/packaging/python/platform_tag.py +++ b/packaging/python/platform_tag.py @@ -4,10 +4,11 @@ The tag is read from the binaries, not hard-coded, so it follows the engine build: on macOS the highest minimum-OS version any Mach-O file declares -(0.2.6 needs macOS 15, so macosx_15_0_arm64); on Linux the highest glibc symbol -version any ELF file needs (0.2.6 needs 2.38, so manylinux_2_38_aarch64). pip -then refuses the wheel on a machine the engine could not run on, instead of -installing it and failing at load time. +(0.2.6 needs macOS 15, so macosx_15_0_arm64); on Linux the CPU the ELF files +are built for and the highest glibc symbol version any of them needs (0.2.6's +CUDA engine needs 2.38 on arm64, so manylinux_2_38_aarch64; the ROCm engine is +x86-64, so manylinux_2__x86_64). pip then refuses the wheel on a machine the +engine could not run on, instead of installing it and failing at load time. """ from __future__ import annotations @@ -22,6 +23,11 @@ LC_VERSION_MIN_MACOSX = 0x24 ELF_MAGIC = b"\x7fELF" GLIBC_RE = re.compile(rb"GLIBC_2\.(\d+)(?:\.\d+)?\x00") +# e_machine of the CPUs an engine is built for, by wheel architecture name. +ELF_CPUS = {0xB7: "aarch64", 0x3E: "x86_64"} +# GPU code objects are ELF files too (an AMDGPU code object, a CUDA cubin). +# They run on the GPU: they say nothing about the CPU and need no glibc. +ELF_GPUS = {0xE0: "AMDGPU", 0xBE: "CUDA"} def macho_min_os(data: bytes) -> tuple[int, int] | None: @@ -41,28 +47,51 @@ def macho_min_os(data: bytes) -> tuple[int, int] | None: return None -def main() -> None: - if len(sys.argv) != 2: - sys.exit(__doc__) - engine = Path(sys.argv[1]) +def elf_machine(data: bytes) -> int: + """e_machine from an ELF header, in the byte order the header declares.""" + return struct.unpack_from("H", data, 18)[0] + + +def platform_tag(engine: Path) -> str: macos: list[tuple[int, int]] = [] glibc: list[int] = [] + cpus: dict[str, str] = {} # CPU -> the first file built for it for f in sorted(engine.iterdir()): if not f.is_file(): continue data = f.read_bytes() if (v := macho_min_os(data)) is not None: macos.append(v) - elif data[:4] == ELF_MAGIC: + elif data[:4] == ELF_MAGIC and len(data) >= 20: + machine = elf_machine(data) + if machine in ELF_GPUS: + continue + if machine not in ELF_CPUS: + raise ValueError(f"{f}: an ELF file for machine {machine:#x}, not {' or '.join(ELF_CPUS.values())}") + cpus.setdefault(ELF_CPUS[machine], f.name) glibc.extend(int(m) for m in GLIBC_RE.findall(data)) - if macos and not glibc: + if macos and not cpus: major, minor = max(macos) - print(f"macosx_{major}_{minor}_arm64") - elif glibc and not macos: - print(f"manylinux_2_{max(glibc)}_aarch64") - else: - sys.exit(f"{engine}: expected only Mach-O or only ELF binaries") + return f"macosx_{major}_{minor}_arm64" + if cpus and not macos: + if len(cpus) > 1: + built = ", ".join(f"{name} for {cpu}" for cpu, name in sorted(cpus.items())) + raise ValueError(f"{engine}: ELF files for more than one CPU ({built})") + if not glibc: + raise ValueError(f"{engine}: no ELF file names a glibc version") + (cpu,) = cpus + return f"manylinux_2_{max(glibc)}_{cpu}" + raise ValueError(f"{engine}: expected only Mach-O or only ELF binaries") + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(__doc__) + try: + print(platform_tag(Path(sys.argv[1]))) + except ValueError as e: + sys.exit(str(e)) if __name__ == "__main__": diff --git a/packaging/python/pyproject.toml b/packaging/python/pyproject.toml index 8a188b3..11dd235 100644 --- a/packaging/python/pyproject.toml +++ b/packaging/python/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta" [project] name = "basert" dynamic = ["version"] -description = "Fast local LLM inference on Apple Silicon and NVIDIA GB10: the basert CLI, engine, and Python bindings" +description = "Fast local LLM inference on Apple Silicon, NVIDIA GB10 and AMD Strix: the basert CLI, engine, and Python bindings" readme = "README.md" # The bindings and CLI launcher are Apache-2.0; the bundled engine binaries in # baseRT/_engine/ are under the BaseRT Engine license (_engine/LICENSE). @@ -21,7 +21,7 @@ license = "Apache-2.0 AND LicenseRef-BaseRT-Engine" license-files = ["LICENSE", "NOTICE", "baseRT/_engine/LICENSE", "baseRT/_engine/NOTICE"] requires-python = ">=3.9" authors = [{ name = "BaseCompute" }] -keywords = ["llm", "inference", "apple-silicon", "metal", "cuda", "openai"] +keywords = ["llm", "inference", "apple-silicon", "metal", "cuda", "rocm", "amd", "openai"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: GPU", diff --git a/packaging/stage-engine.sh b/packaging/stage-engine.sh index ef7d309..93847b1 100755 --- a/packaging/stage-engine.sh +++ b/packaging/stage-engine.sh @@ -11,7 +11,8 @@ # # Keeping the release layout matters: the `basert` launcher finds the # `basert-*` runtime tools next to itself, and those find their kernel files -# (baseRT*.metallib / baseRT_cuda_kernels.fatbin) the same way. +# (baseRT*.metallib / baseRT_cuda_kernels.fatbin / baseRT_rocm_kernels.hsaco) +# the same way. set -eu [ $# -eq 2 ] || [ $# -eq 3 ] || { echo "usage: $0 [': ']" >&2; exit 2; } diff --git a/packaging/update-formula.sh b/packaging/update-formula.sh index 8e8a62a..f037de8 100755 --- a/packaging/update-formula.sh +++ b/packaging/update-formula.sh @@ -5,9 +5,13 @@ # # holds the release's .sha256 files (the tarballs themselves are # not needed). The formula is written whole from the template below rather -# than patched, so a release only ever changes the version and the two -# checksums, and the diff in the tap repo says exactly that. publish.yml opens -# that diff as a PR on the tap (basecompute/homebrew-tap, Formula/basert.rb). +# than patched, so a release only ever changes the version and the checksums, +# and the diff in the tap repo says exactly that. publish.yml opens that diff +# as a PR on the tap (basecompute/homebrew-tap, Formula/basert.rb). +# +# The macOS and Linux arm64 (CUDA) bundles are in every release. The Linux +# x86-64 (ROCm) one is optional: the engine's release pipeline ships a release +# without it when its AMD runner is off, and then the formula stays arm64-only. set -eu [ $# -eq 3 ] || { echo "usage: $0 " >&2; exit 2; } @@ -24,26 +28,64 @@ sha() { } mac_sha=$(sha basert-engine-macos-arm64) linux_sha=$(sha basert-engine-linux-arm64-cuda) +rocm_sha="" +if [ -f "$dir/basert-engine-linux-x64-rocm-$version.tar.gz.sha256" ]; then + rocm_sha=$(sha basert-engine-linux-x64-rocm) +fi base="https://github.com/basecompute/baseRT/releases/download/v$version" +if [ -n "$rocm_sha" ]; then + desc="Fast local LLM inference on Apple Silicon, NVIDIA GB10 and AMD Strix" + layout=" # One prebuilt bundle per platform: Apple Silicon on macOS, and on Linux + # arm64 (NVIDIA CUDA) and x86-64 (AMD ROCm)." + linux_arch="" + rocm_url=" + if Hardware::CPU.intel? + url \"$base/basert-engine-linux-x64-rocm-$version.tar.gz\" + sha256 \"$rocm_sha\" + end" + caveats="if Hardware::CPU.arm? + <<~EOS + The Linux arm64 engine runs on NVIDIA GPUs (GB10 / DGX Spark) and needs + the NVIDIA driver and the CUDA 13 runtime (libcublas) on this machine. + EOS + else + <<~EOS + The Linux x86-64 engine runs on AMD Strix Halo (gfx1151) and Strix Point + (gfx1150) GPUs and needs the ROCm 10 runtime in /opt/rocm/core-10.0: + sudo apt install amdrocm-blas10.0-gfx1151 # Strix Halo + sudo apt install amdrocm-blas10.0-gfx1150 # Strix Point + EOS + end" +else + desc="Fast local LLM inference on Apple Silicon and NVIDIA GB10" + layout=" # One prebuilt bundle per OS, both arm64 only." + linux_arch=" + depends_on arch: :arm64" + rocm_url="" + caveats="<<~EOS + The Linux engine runs on NVIDIA GPUs (GB10 / DGX Spark) and needs the + NVIDIA driver and the CUDA 13 runtime (libcublas) on this machine. + EOS" +fi + mkdir -p "$out" cat > "$out/basert.rb" < $version" +echo "$out/basert.rb -> $version${rocm_sha:+ (with the Linux x86-64 ROCm bundle)}" diff --git a/tests/scripts/test_packaging.py b/tests/scripts/test_packaging.py index 0b83ccc..9ee0873 100644 --- a/tests/scripts/test_packaging.py +++ b/tests/scripts/test_packaging.py @@ -1,16 +1,24 @@ #!/usr/bin/env python3 -"""Unit tests for packaging/stage-engine.sh, packaging/check_engine_links.py and -the npm launcher and package build (packaging/npm/). +"""Unit tests for packaging/stage-engine.sh, packaging/check_engine_links.py, +the wheel platform tag and build (packaging/python/), the npm launcher and +package build (packaging/npm/) and the Homebrew formula +(packaging/update-formula.sh); in the open repo also for install.sh and the +publish workflow's lists of what a release builds. The bundles here are synthetic: a library is a few bytes of text, and the -Mach-O and ELF programs carry only the load commands the link check reads. +Mach-O and ELF programs carry only the load commands, CPU and version markers +the scripts read. """ from __future__ import annotations +import fnmatch +import hashlib +import importlib.util import io import json import os +import re import shutil import struct import subprocess @@ -18,31 +26,54 @@ import tarfile import tempfile import unittest +import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[2] STAGE = ROOT / "packaging" / "stage-engine.sh" BASERT_JS = ROOT / "packaging" / "npm" / "basert.js" +PLATFORM_TAG = ROOT / "packaging" / "python" / "platform_tag.py" +BUILD_WHEEL = ROOT / "packaging" / "python" / "build-wheel.sh" +FORMULA = ROOT / "packaging" / "update-formula.sh" +# The open repo's own files: install.sh and the workflows are not in the +# engine repository's copy of this tree. +INSTALL_SH = ROOT / "install.sh" +WORKFLOWS = ROOT / ".github" / "workflows" +IN_ENGINE_REPO = (ROOT / "tools" / "sync_public.py").exists() sys.path.insert(0, str(ROOT / "packaging")) from check_engine_links import missing_links, missing_own_names # noqa: E402 -def macho(deps: list[str], own_id: str | None = None) -> bytes: - """A 64-bit Mach-O with an LC_LOAD_DYLIB per dep (and LC_ID_DYLIB when given).""" +AARCH64, X86_64, AMDGPU = 0xB7, 0x3E, 0xE0 # ELF e_machine +# A ROCm kernel file for two GPUs: a clang offload bundle, not an ELF file. +OFFLOAD_BUNDLE = b"__CLANG_OFFLOAD_BUNDLE__" + struct.pack(" bytes: + """The symbol version string an ELF file needing glibc 2. carries.""" + return f"GLIBC_2.{minor}".encode() + b"\0" + + +def macho(deps: list[str], own_id: str | None = None, minos: tuple[int, int] | None = None) -> bytes: + """A 64-bit Mach-O with an LC_LOAD_DYLIB per dep (and LC_ID_DYLIB, LC_BUILD_VERSION when given).""" + body = b"" + if minos: + body += struct.pack("<6I", 0x32, 24, 1, minos[0] << 16 | minos[1] << 8, 0, 0) cmds = [(0xD, own_id)] if own_id else [] cmds += [(0xC, d) for d in deps] - body = b"" for cmd, name in cmds: s = name.encode() + b"\0" size = (24 + len(s) + 7) // 8 * 8 body += struct.pack("<6I", cmd, size, 24, 2, 0x10000, 0x10000) + s.ljust(size - 24, b"\0") - header = struct.pack(" bytes: - """A 64-bit little-endian ELF with one PT_LOAD and a PT_DYNAMIC naming `needed` (and DT_SONAME when given).""" +def elf(needed: list[str], soname: str | None = None, machine: int = AARCH64, tail: bytes = b"") -> bytes: + """A 64-bit little-endian ELF for `machine` with one PT_LOAD and a PT_DYNAMIC + naming `needed` (and DT_SONAME when given), followed by `tail`.""" strtab, offsets = b"\0", [] for n in needed + ([soname] if soname else []): offsets.append(len(strtab)) @@ -55,10 +86,10 @@ def elf(needed: list[str], soname: str | None = None) -> bytes: dyn += struct.pack(" None: @@ -76,6 +107,54 @@ def bundle(path: Path, files: dict[str, bytes], links: dict[str, str]) -> None: tar.addfile(info) +# What each release bundle is called and which wheel its engine makes. +BUNDLES = { + "basert-engine-macos-arm64": "macosx_15_0_arm64", + "basert-engine-linux-arm64-cuda": "manylinux_2_38_aarch64", + "basert-engine-linux-x64-rocm": "manylinux_2_39_x86_64", +} + + +def release(folder: Path, v: str, rocm: bool = True) -> None: + """Write a release's engine bundles (and their .sha256 files) to `folder`, + shaped like the real ones: the versioned library chain, the launcher, a + tool, the kernel file, the headers, LICENSE and NOTICE.""" + folder.mkdir(parents=True, exist_ok=True) + common = {"LICENSE": b"engine licence", "NOTICE": b"engine notice", "include/baseRT/baseRT.h": b"/* api */"} + mac = { + f"libbaseRT.{v}.dylib": macho([], own_id="@rpath/libbaseRT.0.dylib", minos=(15, 0)), + "basert": macho([], minos=(14, 0)), + "basert-chat": macho(["@rpath/libbaseRT.0.dylib", "/usr/lib/libc++.1.dylib"], minos=(15, 0)), + "baseRT.metallib": b"MTLB", + } + so = {"libbaseRT.so.0": f"libbaseRT.so.{v}", "libbaseRT.so": "libbaseRT.so.0"} + cuda = { + f"libbaseRT.so.{v}": elf(["libcublas.so.13", "libcuda.so.1"], soname="libbaseRT.so.0", tail=glibc(38)), + "basert": elf(["libc.so.6"], tail=glibc(34)), + "basert-chat": elf(["libbaseRT.so.0", "libc.so.6"], tail=glibc(38)), + "baseRT_cuda_kernels.fatbin": FATBIN, + } + hip = ["libamdhip64.so.7", "libhipblas.so.3", "libhipblaslt.so.1"] + rocm_files = { + f"libbaseRT.so.{v}": elf(hip, soname="libbaseRT.so.0", machine=X86_64, tail=glibc(38)), + "basert": elf(["libc.so.6"], machine=X86_64, tail=glibc(39)), + "basert-chat": elf(["libbaseRT.so.0", "libc.so.6"], machine=X86_64, tail=glibc(38)), + "baseRT_rocm_kernels.hsaco": OFFLOAD_BUNDLE, + } + contents = { + "basert-engine-macos-arm64": ({**mac, **common}, {"libbaseRT.0.dylib": f"libbaseRT.{v}.dylib", "libbaseRT.dylib": "libbaseRT.0.dylib"}), + "basert-engine-linux-arm64-cuda": ({**cuda, **common}, so), + "basert-engine-linux-x64-rocm": ({**rocm_files, **common}, so), + } + for name, (files, links) in contents.items(): + if name.endswith("rocm") and not rocm: + continue + tarball = folder / f"{name}-{v}.tar.gz" + bundle(tarball, files, links) + sha = hashlib.sha256(tarball.read_bytes()).hexdigest() + (folder / f"{tarball.name}.sha256").write_text(f"{sha} {tarball.name}\n") + + class StageEngineTests(unittest.TestCase): def setUp(self) -> None: self.tmp = Path(tempfile.mkdtemp()) @@ -237,13 +316,18 @@ def test_elf_system_libraries_are_not_ours_to_ship(self) -> None: class NpmLauncherTests(unittest.TestCase): """packaging/npm/basert.js, run by node over a node_modules laid out as npm would.""" - ENGINES = {"darwin-arm64": "basert-darwin-arm64", "linux-arm64": "basert-linux-arm64-cuda"} + ENGINES = { + "darwin-arm64": "basert-darwin-arm64", + "linux-arm64": "basert-linux-arm64-cuda", + "linux-x64": "basert-linux-x64-rocm", + } def setUp(self) -> None: node = shutil.which("node") - engine = self.ENGINES.get(f"{sys.platform}-{os.uname().machine.replace('aarch64', 'arm64')}") + machine = {"aarch64": "arm64", "x86_64": "x64"}.get(os.uname().machine, os.uname().machine) + engine = self.ENGINES.get(f"{sys.platform}-{machine}") if not node or not engine: - self.skipTest("needs node on macOS/arm64 or Linux/arm64") + self.skipTest("needs node on macOS/arm64, Linux/arm64 or Linux/x64") self.node = node self.tmp = Path(tempfile.mkdtemp()) self.modules = self.tmp / "node_modules" / "@basecompute" @@ -348,17 +432,7 @@ def setUp(self) -> None: ) os.chmod(self.bin / "npx", 0o755) self.bundles = self.tmp / "bundles" - self.bundles.mkdir() - bundle( - self.bundles / f"basert-engine-macos-arm64-{v}.tar.gz", - {f"libbaseRT.{v}.dylib": macho([], own_id="@rpath/libbaseRT.0.dylib"), "basert": macho([]), "LICENSE": b"engine"}, - {"libbaseRT.0.dylib": f"libbaseRT.{v}.dylib", "libbaseRT.dylib": "libbaseRT.0.dylib"}, - ) - bundle( - self.bundles / f"basert-engine-linux-arm64-cuda-{v}.tar.gz", - {f"libbaseRT.so.{v}": elf([], soname="libbaseRT.so.0"), "basert": elf([]), "LICENSE": b"engine"}, - {"libbaseRT.so.0": f"libbaseRT.so.{v}", "libbaseRT.so": "libbaseRT.so.0"}, - ) + release(self.bundles, v) def build(self, *args: str) -> tuple[subprocess.CompletedProcess, Path]: out = self.tmp / "out" @@ -374,17 +448,27 @@ def build(self, *args: str) -> tuple[subprocess.CompletedProcess, Path]: ) return run, out + def manifest(self, tgz: Path) -> dict: + with tarfile.open(tgz) as tar: + return json.load(tar.extractfile("package/package.json")) + def test_the_bindings_compile_on_a_machine_they_do_not_install_on(self) -> None: run, out = self.build("--engines", "basert-darwin-arm64") self.assertEqual(run.returncode, 0, run.stderr) v = self.VERSION self.assertEqual( sorted(p.name for p in out.iterdir()), - [f"basecompute-basert-{v}.tgz", f"basecompute-basert-darwin-arm64-{v}.tgz", f"basecompute-basert-linux-arm64-cuda-{v}.tgz"], + [ + f"basecompute-basert-{v}.tgz", + f"basecompute-basert-darwin-arm64-{v}.tgz", + f"basecompute-basert-linux-arm64-cuda-{v}.tgz", + f"basecompute-basert-linux-x64-rocm-{v}.tgz", + ], ) - with tarfile.open(out / f"basecompute-basert-{v}.tgz") as tar: - main = json.load(tar.extractfile("package/package.json")) - self.assertEqual((main["os"], main["cpu"]), (["darwin", "linux"], ["arm64"])) + main = self.manifest(out / f"basecompute-basert-{v}.tgz") + # Every platform this release has an engine for: one left out of + # --engines is still there through install.sh or Homebrew. + self.assertEqual((main["os"], main["cpu"]), (["darwin", "linux"], ["arm64", "x64"])) # Only the engine package this release publishes. self.assertEqual(main["optionalDependencies"], {"@basecompute/basert-darwin-arm64": v}) # The engine names npm as what updates it, for `basert self update`. @@ -395,6 +479,588 @@ def test_the_bindings_compile_on_a_machine_they_do_not_install_on(self) -> None: source = json.loads((self.root / "bindings" / "node" / "package.json").read_text()) self.assertEqual(source["os"], self.FOREIGN["os"]) + def test_the_rocm_engine_is_an_x64_linux_package(self) -> None: + run, out = self.build() + self.assertEqual(run.returncode, 0, run.stderr) + v = self.VERSION + tgz = out / f"basecompute-basert-linux-x64-rocm-{v}.tgz" + rocm = self.manifest(tgz) + self.assertEqual( + (rocm["name"], rocm["os"], rocm["cpu"], rocm["libc"]), + ("@basecompute/basert-linux-x64-rocm", ["linux"], ["x64"], ["glibc"]), + ) + with tarfile.open(tgz) as tar: + engine = {m.name.split("/", 2)[2] for m in tar.getmembers() if m.name.startswith("package/engine/")} + self.assertTrue({"baseRT_rocm_kernels.hsaco", "libbaseRT.so", "libbaseRT.so.0", "basert"} <= engine, engine) + main = self.manifest(out / f"basecompute-basert-{v}.tgz") + self.assertEqual(main["cpu"], ["arm64", "x64"]) + self.assertEqual( + main["optionalDependencies"], + {f"@basecompute/{name}": v for name in ("basert-darwin-arm64", "basert-linux-arm64-cuda", "basert-linux-x64-rocm")}, + ) + + def test_a_release_without_the_rocm_bundle_has_no_x64_package(self) -> None: + # release.yml ships a release without it when its AMD runner is off. + (self.bundles / f"basert-engine-linux-x64-rocm-{self.VERSION}.tar.gz").unlink() + run, out = self.build() + self.assertEqual(run.returncode, 0, run.stderr) + v = self.VERSION + self.assertIn("basert-linux-x64-rocm is not built", run.stdout) + self.assertEqual( + sorted(p.name for p in out.iterdir()), + [f"basecompute-basert-{v}.tgz", f"basecompute-basert-darwin-arm64-{v}.tgz", f"basecompute-basert-linux-arm64-cuda-{v}.tgz"], + ) + main = self.manifest(out / f"basecompute-basert-{v}.tgz") + self.assertEqual((main["os"], main["cpu"]), (["darwin", "linux"], ["arm64"])) + self.assertNotIn("@basecompute/basert-linux-x64-rocm", main["optionalDependencies"]) + + def test_the_cuda_bundle_is_never_optional(self) -> None: + (self.bundles / f"basert-engine-linux-arm64-cuda-{self.VERSION}.tar.gz").unlink() + run, _ = self.build() + self.assertNotEqual(run.returncode, 0) + self.assertIn("missing", run.stderr) + + def test_naming_the_rocm_package_needs_its_bundle(self) -> None: + (self.bundles / f"basert-engine-linux-x64-rocm-{self.VERSION}.tar.gz").unlink() + run, _ = self.build("--engines", "basert-darwin-arm64,basert-linux-x64-rocm") + self.assertNotEqual(run.returncode, 0) + self.assertIn(f"basert-engine-linux-x64-rocm-{self.VERSION}.tar.gz", run.stderr) + + + +class PlatformTagTests(unittest.TestCase): + """packaging/python/platform_tag.py: the wheel tag a staged engine folder gets.""" + + def tag(self, files: dict[str, bytes]) -> subprocess.CompletedProcess: + engine = Path(tempfile.mkdtemp()) + for name, data in files.items(): + (engine / name).write_bytes(data) + return subprocess.run([sys.executable, str(PLATFORM_TAG), str(engine)], capture_output=True, text=True) + + def test_the_cuda_engine_is_aarch64(self) -> None: + run = self.tag({ + "libbaseRT.so": elf(["libcublas.so.13"], soname="libbaseRT.so.0", tail=glibc(38)), + "basert": elf([], tail=glibc(34)), + "baseRT_cuda_kernels.fatbin": FATBIN, + }) + self.assertEqual((run.returncode, run.stdout), (0, "manylinux_2_38_aarch64\n"), run.stderr) + + def test_the_rocm_engine_is_x86_64(self) -> None: + # Until 0.5.0 every ELF engine was tagged aarch64, so this one made a + # second aarch64 wheel, and publish.yml's Linux test installed both. + run = self.tag({ + "libbaseRT.so": elf(["libamdhip64.so.7"], soname="libbaseRT.so.0", machine=X86_64, tail=glibc(38)), + "basert": elf([], machine=X86_64, tail=glibc(39)), + "baseRT_rocm_kernels.hsaco": OFFLOAD_BUNDLE, + }) + self.assertEqual((run.returncode, run.stdout), (0, "manylinux_2_39_x86_64\n"), run.stderr) + + def test_a_gpu_code_object_says_nothing_about_the_cpu(self) -> None: + # A kernel file built for one GPU is an AMDGPU ELF code object. + run = self.tag({ + "libbaseRT.so": elf([], machine=X86_64, tail=glibc(39)), + "baseRT_rocm_kernels.hsaco": elf([], machine=AMDGPU), + }) + self.assertEqual((run.returncode, run.stdout), (0, "manylinux_2_39_x86_64\n"), run.stderr) + + def test_binaries_for_two_cpus_are_refused(self) -> None: + run = self.tag({"libbaseRT.so": elf([], tail=glibc(38)), "basert": elf([], machine=X86_64, tail=glibc(39))}) + self.assertEqual(run.returncode, 1) + self.assertIn("more than one CPU (libbaseRT.so for aarch64, basert for x86_64)", run.stderr) + + def test_an_elf_file_for_another_cpu_is_refused(self) -> None: + run = self.tag({"libbaseRT.so": elf([], machine=0x28, tail=glibc(38))}) # 32-bit Arm + self.assertEqual(run.returncode, 1) + self.assertIn("machine 0x28", run.stderr) + + def test_the_macos_engine_takes_the_highest_minimum_os(self) -> None: + run = self.tag({"libbaseRT.dylib": macho([], minos=(15, 0)), "basert": macho([], minos=(14, 0))}) + self.assertEqual((run.returncode, run.stdout), (0, "macosx_15_0_arm64\n"), run.stderr) + + +class FormulaTests(unittest.TestCase): + """packaging/update-formula.sh, from a release's .sha256 files.""" + + V = "1.2.3" + + def formula(self, rocm: bool) -> tuple[subprocess.CompletedProcess, str]: + tmp = Path(tempfile.mkdtemp()) + release(tmp / "bundles", self.V, rocm=rocm) + for tarball in (tmp / "bundles").glob("*.tar.gz"): + tarball.unlink() # the formula needs only the checksums + run = subprocess.run(["sh", str(FORMULA), self.V, str(tmp / "bundles"), str(tmp / "out")], capture_output=True, text=True) + rb = tmp / "out" / "basert.rb" + return run, rb.read_text() if rb.exists() else "" + + @staticmethod + def block(rb: str, opening: str) -> str: + """The lines of a top-level `opening do` block of the class.""" + start = rb.index(f"\n {opening} do\n") + return rb[start : rb.index("\n end\n", start)] + + def url(self, bundle_name: str) -> str: + return f'url "https://github.com/basecompute/baseRT/releases/download/v{self.V}/{bundle_name}-{self.V}.tar.gz"' + + def test_linux_on_intel_gets_the_rocm_bundle(self) -> None: + run, rb = self.formula(rocm=True) + self.assertEqual(run.returncode, 0, run.stderr) + linux = self.block(rb, "on_linux") + self.assertIn(f" if Hardware::CPU.intel?\n {self.url('basert-engine-linux-x64-rocm')}\n", linux) + self.assertIn(f" if Hardware::CPU.arm?\n {self.url('basert-engine-linux-arm64-cuda')}\n", linux) + self.assertNotIn("depends_on arch:", linux) + # macOS stays Apple Silicon only. + self.assertIn("depends_on arch: :arm64", self.block(rb, "on_macos")) + self.assertNotIn("\n depends_on arch:", rb) + self.assertIn("AMD Strix", re.search(r'^ desc "(.*)"$', rb, re.M).group(1)) + for gfx in ("gfx1151", "gfx1150"): + self.assertIn(f"sudo apt install amdrocm-blas10.0-{gfx}", rb) + self.assertIn("CUDA 13", rb) + + def test_without_the_rocm_bundle_it_stays_arm64_only(self) -> None: + run, rb = self.formula(rocm=False) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("depends_on arch: :arm64", self.block(rb, "on_linux")) + self.assertIn("depends_on arch: :arm64", self.block(rb, "on_macos")) + self.assertNotIn("Hardware::CPU.intel?", rb) + self.assertNotIn("rocm", rb.lower()) + self.assertNotIn("AMD", rb) + + def test_each_checksum_is_the_one_its_sha256_file_names(self) -> None: + run, rb = self.formula(rocm=True) + self.assertEqual(run.returncode, 0, run.stderr) + urls = re.findall(r'url "[^"]*/([^/"]+)"\n\s+sha256 "([0-9a-f]{64})"', rb) + tmp_shas = {} + tmp = Path(tempfile.mkdtemp()) + release(tmp, self.V) + for f in tmp.glob("*.sha256"): + sha, name = f.read_text().split() + tmp_shas[name] = sha + self.assertEqual(len(urls), 3) + self.assertEqual(dict(urls), tmp_shas) + + def test_a_bad_rocm_checksum_is_refused(self) -> None: + tmp = Path(tempfile.mkdtemp()) + release(tmp, self.V) + (tmp / f"basert-engine-linux-x64-rocm-{self.V}.tar.gz.sha256").write_text("not-a-checksum x\n") + run = subprocess.run(["sh", str(FORMULA), self.V, str(tmp), str(tmp / "out")], capture_output=True, text=True) + self.assertNotEqual(run.returncode, 0) + self.assertIn("bad checksum", run.stderr) + + +@unittest.skipIf(IN_ENGINE_REPO, "wheels are built from the public tree (packaging/public-tree.sh)") +class WheelBuildTests(unittest.TestCase): + """packaging/python/build-wheel.sh over a whole release: one wheel per + bundle, each tagged for the platform its engine runs on. Needs `build` and + `wheel` (ci.yml and publish.yml install them) and the network: the build + fetches its setuptools.""" + + def setUp(self) -> None: + missing = [m for m in ("build", "wheel") if importlib.util.find_spec(m) is None] + if missing: + self.skipTest(f"needs {' and '.join(missing)} (pip install build wheel)") + self.tmp = Path(tempfile.mkdtemp()) + init = (ROOT / "bindings" / "python" / "baseRT" / "__init__.py").read_text() + self.v = re.search(r'^__version__ = "(.*)"$', init, re.M).group(1) + + def test_each_bundle_makes_the_wheel_for_its_platform(self) -> None: + release(self.tmp / "bundles", self.v) + out = self.tmp / "wheels" + # build-wheel.sh runs python3: this one, which has build and wheel. + env = dict(os.environ, PATH=f"{Path(sys.executable).parent}{os.pathsep}{os.environ['PATH']}") + for name in BUNDLES: + run = subprocess.run( + ["sh", str(BUILD_WHEEL), str(self.tmp / "bundles" / f"{name}-{self.v}.tar.gz"), str(out)], + capture_output=True, text=True, env=env, + ) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + self.assertEqual( + sorted(p.name for p in out.iterdir()), + sorted(f"basert-{self.v}-py3-none-{tag}.whl" for tag in BUNDLES.values()), + ) + with zipfile.ZipFile(out / f"basert-{self.v}-py3-none-manylinux_2_39_x86_64.whl") as whl: + engine = {n.split("/", 2)[2] for n in whl.namelist() if n.startswith("baseRT/_engine/")} + self.assertTrue({"baseRT_rocm_kernels.hsaco", "libbaseRT.so", "libbaseRT.so.0", "basert", "installed-by"} <= engine, engine) + + +@unittest.skipUnless(INSTALL_SH.is_file(), "install.sh is in the open repo only") +class InstallScriptTests(unittest.TestCase): + """install.sh, run with stand-ins for uname and curl and a folder standing + in for / (BASERT_INSTALL_TEST_ROOT) where the AMD GPU and ROCm checks look.""" + + V = "1.2.3" + UNAME = '#!/bin/sh\ncase "$1" in -s) echo "$STUB_OS" ;; -m) echo "$STUB_ARCH" ;; *) exit 1 ;; esac\n' + # The release API answers from a file; a download copies the asset. + CURL = """#!/bin/sh +out="" url="" +while [ $# -gt 0 ]; do + case "$1" in -o) out=$2; shift 2 ;; -*) shift ;; *) url=$1; shift ;; esac +done +echo "$url" >> "$STUB_LOG" +case "$url" in + https://api.github.com/*) cat "$STUB_RELEASE" ;; + *) cp "$STUB_ASSETS/${url##*/}" "$out" ;; +esac +""" + + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp()) + self.root = self.tmp / "root" + self.root.mkdir() + self.bin = self.tmp / "bin" + self.bin.mkdir() + for name, text in (("uname", self.UNAME), ("curl", self.CURL)): + (self.bin / name).write_text(text) + os.chmod(self.bin / name, 0o755) + # Bundles whose programs are scripts, so the installer can run them + # here: `self install` and `superfluid` are refused, as by a launcher + # older than 0.4.3, and the bundle is unpacked as it is. + self.assets = self.tmp / "assets" + self.assets.mkdir() + refuse = b"#!/bin/sh\nexit 1\n" + urls = [] + for name in BUNDLES: + tarball = self.assets / f"{name}-{self.V}.tar.gz" + bundle(tarball, {"basert": refuse, "basert-chat": refuse, f"lib-{name}": b"engine", "LICENSE": b"licence"}, {}) + for f in (tarball.name, f"{tarball.name}.sha256"): + urls.append(f"https://github.com/basecompute/baseRT/releases/download/v{self.V}/{f}") + self.api = self.tmp / "release.json" + self.api.write_text(json.dumps({"tag_name": f"v{self.V}", "assets": [{"browser_download_url": u} for u in urls]})) + self.log = self.tmp / "curl.log" + self.log.write_text("") + self.dest = self.tmp / "basert" + + def amd(self, *gfx_target_versions: int, kfd: bool = True) -> None: + """An amdgpu driver's view: /dev/kfd and one topology node per version (0 = a CPU).""" + if kfd: + (self.root / "dev").mkdir(exist_ok=True) + (self.root / "dev" / "kfd").write_text("") + for i, v in enumerate(gfx_target_versions): + node = self.root / "sys/class/kfd/kfd/topology/nodes" / str(i) + node.mkdir(parents=True) + (node / "properties").write_text(f"cpu_cores_count 0\ngfx_target_version {v}\nvendor_id 4098\n") + + def rocm(self, gfx: str) -> None: + """What `apt install amdrocm-blas10.0-` puts in /opt/rocm/core-10.0.""" + lib = self.root / "opt/rocm/core-10.0/lib" + (lib / "rocblas/library" / gfx).mkdir(parents=True) + (lib / "libamdhip64.so.7.15.26333-0000000").write_text("hip") + os.symlink("libamdhip64.so.7.15.26333-0000000", lib / "libamdhip64.so.7") + + def install(self, os_name: str = "Linux", arch: str = "x86_64", **extra: str) -> subprocess.CompletedProcess: + env = { + "PATH": f"{self.bin}{os.pathsep}/usr/bin{os.pathsep}/bin", + "HOME": str(self.tmp / "home"), + "BASERT_INSTALL_DIR": str(self.dest), + "BASERT_NO_MODIFY_PATH": "1", + "BASERT_INSTALL_TEST_ROOT": str(self.root), + "STUB_OS": os_name, + "STUB_ARCH": arch, + "STUB_RELEASE": str(self.api), + "STUB_ASSETS": str(self.assets), + "STUB_LOG": str(self.log), + **extra, + } + return subprocess.run(["sh", str(INSTALL_SH)], capture_output=True, text=True, env=env) + + def downloaded(self) -> list[str]: + return [u.rsplit("/", 1)[-1] for u in self.log.read_text().split() if not u.startswith("https://api.")] + + def test_strix_halo_with_rocm_gets_the_rocm_bundle(self) -> None: + self.amd(0, 110501) + self.rocm("gfx1151") + run = self.install() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("Linux/x86_64 → basert-engine-linux-x64-rocm", run.stdout) + self.assertEqual(self.downloaded(), [f"basert-engine-linux-x64-rocm-{self.V}.tar.gz"]) + self.assertTrue((self.dest / "lib-basert-engine-linux-x64-rocm").is_file()) + self.assertNotIn("apt install", run.stdout + run.stderr) + + def test_strix_point_without_rocm_is_told_its_apt_line(self) -> None: + self.amd(0, 110500) + run = self.install() + # Installed anyway: the CLI works, and the engine once ROCm is there. + self.assertEqual(run.returncode, 0, run.stderr) + self.assertTrue((self.dest / "lib-basert-engine-linux-x64-rocm").is_file()) + self.assertIn("sudo apt install amdrocm-blas10.0-gfx1150", run.stderr) + self.assertIn("sudo apt install amdrocm-blas10.0-gfx1150", run.stdout) # again at the end + + def test_blas_kernels_for_another_chip_are_not_enough(self) -> None: + self.amd(0, 110500) + self.rocm("gfx1151") + run = self.install() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("sudo apt install amdrocm-blas10.0-gfx1150", run.stderr) + + def os_release(self, os_id: str, version: str) -> None: + (self.root / "etc").mkdir(exist_ok=True) + (self.root / "etc/os-release").write_text(f'NAME="Some Linux"\nID={os_id}\nVERSION_ID="{version}"\n') + + def test_without_amds_repository_its_setup_comes_first(self) -> None: + for version, dist in (("24.04", "ubuntu2404"), ("26.04", "ubuntu2604")): + with self.subTest(version=version): + shutil.rmtree(self.root) + self.root.mkdir() + self.amd(0, 110501) + self.os_release("ubuntu", version) + run = self.install(BASERT_FORCE="1") + self.assertEqual(run.returncode, 0, run.stderr) + steps = [ + "sudo curl -fsSL https://stable.repo.amd.com/rocm/gpg/packages.gpg -o /etc/apt/keyrings/amdrocm.asc", + f"echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/amdrocm.asc] https://stable.repo.amd.com/rocm/core/packages/{dist} stable main' | sudo tee /etc/apt/sources.list.d/amdrocm.list", + "sudo apt update", + "sudo apt install amdrocm-blas10.0-gfx1151", + ] + for out in (run.stderr, run.stdout): # warned, and again at the end + lines = [line.rsplit("basert\x1b[0m", 1)[-1].strip() for line in out.splitlines()] + at = [lines.index(s) if s in lines else -1 for s in steps] + self.assertEqual(at, list(range(at[0], at[0] + 4)), out) # whole lines, in order + + def test_with_amds_repository_only_the_apt_line(self) -> None: + self.amd(0, 110500) + self.os_release("ubuntu", "24.04") + sources = self.root / "etc/apt/sources.list.d" + sources.mkdir(parents=True) + (sources / "amdrocm.sources").write_text( + "Types: deb\nURIs: https://stable.repo.amd.com/rocm/core/packages/ubuntu2404\nSuites: stable\nComponents: main\n" + ) + run = self.install() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("sudo apt install amdrocm-blas10.0-gfx1150", run.stdout) + self.assertNotIn("keyrings", run.stdout + run.stderr) + self.assertNotIn("apt update", run.stdout + run.stderr) + + def test_another_release_is_told_which_ones_amd_serves(self) -> None: + for release in (("ubuntu", "25.10"), ("debian", "13"), None): + with self.subTest(release=release): + shutil.rmtree(self.root) + self.root.mkdir() + self.amd(0, 110501) + if release: + self.os_release(*release) + run = self.install(BASERT_FORCE="1") + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("which serves Ubuntu 24.04 and 26.04", run.stdout) + self.assertIn("sudo apt install amdrocm-blas10.0-gfx1151", run.stdout) + self.assertNotIn("keyrings", run.stdout + run.stderr) + + def test_a_machine_without_an_amd_gpu_is_refused(self) -> None: + for kfd, nodes in ((False, (0, 110501)), (True, (0,))): + with self.subTest(kfd=kfd, nodes=nodes): + shutil.rmtree(self.root) + self.root.mkdir() + self.amd(*nodes, kfd=kfd) + run = self.install() + self.assertEqual(run.returncode, 1) + self.assertIn("needs an AMD Strix Halo (gfx1151) or Strix Point (gfx1150) GPU", run.stderr) + self.assertEqual(self.downloaded(), []) + + def test_another_amd_gpu_is_named_and_refused(self) -> None: + for nodes, name in (((0, 110000), "gfx1100"), ((90010,), "gfx90a")): + with self.subTest(name=name): + shutil.rmtree(self.root) + self.root.mkdir() + self.amd(*nodes) + run = self.install() + self.assertEqual(run.returncode, 1) + self.assertIn(f"Strix Point (gfx1150) GPUs, not {name}.", run.stderr) + + def test_a_strix_gpu_behind_another_is_named_with_its_device_number(self) -> None: + # The engine opens HIP device 0: a Strix GPU that is device 1 needs + # HIP_VISIBLE_DEVICES, and the installer says so (and installs anyway). + self.amd(0, 110000, 110501) + self.rocm("gfx1151") + run = self.install() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("→ basert-engine-linux-x64-rocm", run.stdout) + self.assertIn("HIP device 0, which is gfx1100 here; the gfx1151 is HIP device 1", run.stderr) + self.assertIn("export HIP_VISIBLE_DEVICES=1", run.stdout) # again at the end + + def test_device_0_decides(self) -> None: + self.amd(0, 110501, 110000) + self.rocm("gfx1151") + run = self.install() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertNotIn("HIP_VISIBLE_DEVICES", run.stdout + run.stderr) + + def test_the_visibility_variables_choose_device_0(self) -> None: + self.amd(0, 110000, 110501) + self.rocm("gfx1151") + for env in ({"HIP_VISIBLE_DEVICES": "1"}, {"ROCR_VISIBLE_DEVICES": "1"}, {"CUDA_VISIBLE_DEVICES": "1,0"}): + with self.subTest(env=env): + run = self.install(**env) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertNotIn("HIP device", run.stderr) + run = self.install(HIP_VISIBLE_DEVICES="0") # only the gfx1100 is left + self.assertEqual(run.returncode, 1) + self.assertIn("Strix Point (gfx1150) GPUs, not gfx1100.", run.stderr) + + def fresh(self, *nodes: int) -> None: + """A new machine: these AMD GPUs (after a CPU node), ROCm for the gfx1151, nothing installed yet.""" + shutil.rmtree(self.root) + self.root.mkdir() + shutil.rmtree(self.dest, ignore_errors=True) + self.log.write_text("") + self.amd(0, *nodes) + self.rocm("gfx1151") + + def test_a_uuid_selector_leaves_device_0_to_the_engine(self) -> None: + # ROCr and HIP match UUIDs differently and the installer does not + # reproduce either: it neither refuses nor offers a selector. + uuid = "GPU-1a2b3c4d5e6f7a8b" + for env in ({"ROCR_VISIBLE_DEVICES": uuid, "HIP_VISIBLE_DEVICES": "0"}, + {"ROCR_VISIBLE_DEVICES": uuid}, {"HIP_VISIBLE_DEVICES": uuid}): + with self.subTest(env=env): + self.fresh(110000, 110501) + run = self.install(**env) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertNotIn("HIP_VISIBLE_DEVICES=", run.stdout + run.stderr) + self.assertIn("by UUID", run.stderr) + + def test_lists_that_leave_no_gpu_are_refused(self) -> None: + # ROCr: set but empty, or an index out of range from the first entry; + # HIP: an entry that is not a plain decimal ends the list. + for env in ({"ROCR_VISIBLE_DEVICES": ""}, {"ROCR_VISIBLE_DEVICES": "5"}, + {"HIP_VISIBLE_DEVICES": "-1"}, {"HIP_VISIBLE_DEVICES": "01"}): + with self.subTest(env=env): + self.fresh(110501) + run = self.install(**env) + self.assertEqual(run.returncode, 1, run.stderr) + self.assertIn("leave no AMD GPU", run.stderr) + self.assertEqual(self.downloaded(), []) + + def test_the_offered_selector_makes_the_strix_gpu_device_0(self) -> None: + # HIP_VISIBLE_DEVICES counts the GPUs ROCR_VISIBLE_DEVICES leaves, not + # the order an earlier HIP list made: run the installer again with the + # value it offered and the Strix GPU is device 0. + cases = ( + ((110000, 110501), {}, "1"), + ((110501, 110000), {"HIP_VISIBLE_DEVICES": "1,0"}, "0"), + ((110501, 110000, 110500), {"ROCR_VISIBLE_DEVICES": "1,0,2", "HIP_VISIBLE_DEVICES": "0,1"}, "1"), + ) + for nodes, env, want in cases: + with self.subTest(env=env): + self.fresh(*nodes) + run = self.install(**env) + self.assertEqual(run.returncode, 0, run.stderr) + offered = re.findall(r"export HIP_VISIBLE_DEVICES=(\d+)", run.stdout) + self.assertEqual(offered, [want], run.stdout) + again = self.install(**{**env, "HIP_VISIBLE_DEVICES": want}) + self.assertEqual(again.returncode, 0, again.stderr) + self.assertNotIn("HIP device", again.stderr) + + def test_topology_nodes_are_read_in_number_order(self) -> None: + # Node 10 sorts before node 2 as text; HIP numbers the GPUs by node number. + self.amd(0, 0, 110501, 0, 0, 0, 0, 0, 0, 0, 110000) + self.rocm("gfx1151") + run = self.install() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertNotIn("HIP device", run.stderr) + + @unittest.skipIf(os.geteuid() == 0, "root opens any file") + def test_a_kfd_this_user_cannot_open_is_pointed_out(self) -> None: + self.amd(0, 110501) + self.rocm("gfx1151") + os.chmod(self.root / "dev" / "kfd", 0) + run = self.install() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("render", run.stderr) + + def test_the_other_platforms_are_unchanged(self) -> None: + run = self.install("Darwin", "arm64") + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(self.downloaded(), [f"basert-engine-macos-arm64-{self.V}.tar.gz"]) + for os_name, arch in (("Darwin", "x86_64"), ("Linux", "riscv64")): + with self.subTest(platform=f"{os_name}/{arch}"): + run = self.install(os_name, arch) + self.assertEqual(run.returncode, 1) + self.assertIn( + f"unsupported platform: {os_name}/{arch}. BaseRT ships macOS/arm64 (Metal), " + "Linux/arm64 (NVIDIA CUDA) and Linux/x86_64 (AMD ROCm).", + run.stderr, + ) + + def test_it_parses_and_passes_shellcheck(self) -> None: + for sh in ("sh", "dash", "bash"): + if shutil.which(sh): + run = subprocess.run([sh, "-n", str(INSTALL_SH)], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, f"{sh}: {run.stderr}") + if not shutil.which("shellcheck"): + self.skipTest("shellcheck is not installed") + run = subprocess.run(["shellcheck", str(INSTALL_SH)], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout) + + +@unittest.skipUnless((WORKFLOWS / "publish.yml").is_file() and not IN_ENGINE_REPO, "the open repo's workflows") +class WorkflowTests(unittest.TestCase): + """What .github/workflows/publish.yml expects a release to build, against + what the packaging scripts build; and where the lab's runners may be used.""" + + V = "1.2.3" + # Self-hosted runners: the GB10 and the Strix Halo. + LAB_LABELS = ("gb10", "rocm-gfx1151") + # Events a pull request from a fork can start a workflow with. + PR_EVENTS = {"pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment", + "issue_comment", "workflow_run", "merge_group"} + + def setUp(self) -> None: + self.publish = (WORKFLOWS / "publish.yml").read_text() + + @staticmethod + def triggers(workflow: str) -> set[str]: + block = re.search(r"^on:\n((?:[ \t]+.*\n|\n)*)", workflow, re.M) + if block is None: + inline = re.search(r"^on:\s*(.+)$", workflow, re.M) + return set(re.findall(r"[\w-]+", inline.group(1))) if inline else set() + return set(re.findall(r"^ (\w+):", block.group(1), re.M)) + + def test_lab_runners_never_run_pull_requests(self) -> None: + # A fork's pull request runs the workflow as the fork wrote it, so + # only a workflow pull requests cannot start may name these runners. + for path in sorted(WORKFLOWS.glob("*.y*ml")): + text = path.read_text() + labels = [label for label in self.LAB_LABELS if re.search(rf"\b{re.escape(label)}\b", text)] + if labels: + with self.subTest(workflow=path.name): + self.assertEqual(self.triggers(text) & self.PR_EVENTS, set(), f"{path.name} names {labels}") + self.assertEqual(self.triggers(self.publish), {"release", "workflow_dispatch"}) + self.assertIn("pull_request", self.triggers((WORKFLOWS / "ci.yml").read_text())) + + def test_publish_names_every_engine_package_the_build_makes(self) -> None: + mjs = (ROOT / "packaging" / "npm" / "build-packages.mjs").read_text() + built = set(re.findall(r"name: `\$\{SCOPE\}/(basert-[\w-]+)`", mjs)) + listed = set() + for value in re.findall(r'^\s*engines="([^"]*)"$', self.publish, re.M): + listed |= set(value.replace("$engines", "").split()) + self.assertEqual(listed, built) + self.assertEqual(built, {"basert-darwin-arm64", "basert-linux-arm64-cuda", "basert-linux-x64-rocm"}) + + def test_each_wheel_pattern_matches_exactly_one_wheel(self) -> None: + wheels = [f"basert-{self.V}-py3-none-{tag}.whl" for tag in BUNDLES.values()] + # The plan's list of the wheels PyPI must have. + patterns = [] + for value in re.findall(r'^\s*wheels="([^"]*)"$', self.publish, re.M): + patterns += value.replace("$wheels", "").replace("$V", self.V).split() + # The wheels each test job installs. + patterns += re.findall(r"pip install --quiet out/wheels/(\S+\.whl)", self.publish) + self.assertEqual(len(patterns), 6, patterns) + for pattern in patterns: + with self.subTest(pattern=pattern): + self.assertEqual(len(fnmatch.filter(wheels, pattern)), 1, fnmatch.filter(wheels, pattern)) + + def test_each_npm_install_takes_one_engine_and_the_main_package(self) -> None: + tarballs = [f"basecompute-basert-{name}-{self.V}.tgz" for name in ("darwin-arm64", "linux-arm64-cuda", "linux-x64-rocm")] + tarballs.append(f"basecompute-basert-{self.V}.tgz") + installs = re.findall(r"npm install --no-audit --no-fund (.+)$", self.publish, re.M) + self.assertEqual(len(installs), 3, installs) + engines = set() + for line in installs: + with self.subTest(install=line): + matched = [fnmatch.filter(tarballs, arg.rsplit("/", 1)[-1]) for arg in line.split()] + self.assertEqual([len(m) for m in matched], [1, 1]) + self.assertEqual(matched[1], [f"basecompute-basert-{self.V}.tgz"]) + engines.add(matched[0][0]) + self.assertEqual(len(engines), 3) + if __name__ == "__main__": unittest.main()