From a6b421883d06f5f83b395c6e6bc02987a1404b88 Mon Sep 17 00:00:00 2001 From: IsuraManchanayake Date: Thu, 12 Nov 2026 14:52:55 +1100 Subject: [PATCH 1/5] packaging: the Linux x86-64 ROCm engine on PyPI, npm, Homebrew and install.sh From v0.5.0 a release can carry basert-engine-linux-x64-rocm-.tar.gz, the engine for AMD Strix Halo (gfx1151) and Strix Point (gfx1150) on ROCm 10. Without this, publish.yml broke on it: platform_tag.py tagged every ELF engine aarch64, so the ROCm wheel became a second manylinux_2_43_aarch64 wheel, test-linux's `pip install *manylinux*.whl` installed both and failed, and PyPI, npm and crates.io stopped for the whole release. The ROCm bundle is optional (release.yml ships without it when its AMD runner is off), so every channel follows what the release carries: - platform_tag.py reads the CPU from each ELF header (aarch64 / x86_64), skips GPU code objects, and refuses a folder built for two CPUs. - npm: @basecompute/basert-linux-x64-rocm (linux, x64, glibc), built only when the bundle is there; the main package's os/cpu follow the engines the release has; linux-x64 in both ENGINE_PACKAGES tables. - Homebrew: an on_linux Intel branch with the ROCm bundle and an apt caveat per chip; macOS stays arm64-only, and a release without the bundle keeps Linux arm64-only too. - publish.yml: the plan reads whether the release has the bundle; a ROCm test job on strix-halo-1 (pip, npm, a linked program) runs only then; PyPI/npm/crates accept it skipped only for a release without the bundle; test-linux installs only the aarch64 wheel; the wheel and npm lists gain the x86_64 wheel and the ROCm package. - install.sh: Linux x86_64 requires an amdgpu Strix GPU (/dev/kfd and a topology node with gfx_target_version 110501 or 110500; another AMD GPU is named and refused), warns with the chip's apt line when ROCm 10 or its BLAS kernels are missing, then takes the ROCm bundle. - README and the PyPI/npm READMEs name the AMD platform. Tests: platform tags per CPU; npm and Homebrew with and without the ROCm bundle; a real wheel build of a three-bundle release; install.sh on a fake / with stub uname/curl, sh -n and shellcheck; publish.yml's wheel and npm globs each match one package, its engine list matches build-packages.mjs, and the lab runners' labels stay out of workflows pull requests can start. --- .github/workflows/ci.yml | 11 +- .github/workflows/publish.yml | 147 +++++++- README.md | 18 +- bindings/node/src/index.ts | 1 + install.sh | 74 +++- packaging/npm/README.md | 21 +- packaging/npm/basert.js | 3 +- packaging/npm/build-packages.mjs | 56 ++- packaging/python/README.md | 15 +- packaging/python/platform_tag.py | 59 +++- packaging/python/pyproject.toml | 4 +- packaging/stage-engine.sh | 3 +- packaging/update-formula.sh | 71 +++- tests/scripts/test_packaging.py | 586 +++++++++++++++++++++++++++++-- 14 files changed, 947 insertions(+), 122 deletions(-) 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..04bff0d 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,10 @@ 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 26.04 or newer, with the ROCm 10 runtime + from AMD's apt repository: `sudo apt install amdrocm-blas10.0-gfx1151` (or + `amdrocm-blas10.0-gfx1150`). ## 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..9dd93d0 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,37 @@ 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" +rocm_missing="" + +# The AMD GPU's LLVM target (gfx1151, ...), from the amdgpu driver's topology; +# nothing without one. A supported chip wins over any other AMD GPU present. +amd_gpu() { + [ -e "$sysroot/dev/kfd" ] || return 0 + other="" + for props in "$sysroot"/sys/class/kfd/kfd/topology/nodes/*/properties; do + [ -f "$props" ] || continue + v="$(sed -n 's/^gfx_target_version \([0-9][0-9]*\)$/\1/p' "$props")" + case "$v" in + "" | 0) ;; # a CPU node + 110501) echo gfx1151; return 0 ;; + 110500) echo gfx1150; return 0 ;; + *) [ -n "$other" ] || other="$(printf 'gfx%d%x%x' $((v / 10000)) $((v / 100 % 100)) $((v % 100)))" ;; + esac + done + printf '%s' "$other" +} + os="$(uname -s)" arch="$(uname -m)" case "$os $arch" in @@ -52,8 +84,30 @@ 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. + gpu="$(amd_gpu)" + case "$gpu" in + gfx1151 | gfx1150) ;; + "") 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 $gpu." ;; + esac + 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="sudo apt install amdrocm-blas10.0-$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 (stable.repo.amd.com/rocm/core):" + warn " $rocm_missing" + 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 +132,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 +218,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 +239,11 @@ 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):" + echo " $rocm_missing" +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..8930baf 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_43_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(43)), + "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(43)), + "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,450 @@ 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(43)), + "basert": elf([], machine=X86_64, tail=glibc(39)), + "baseRT_rocm_kernels.hsaco": OFFLOAD_BUNDLE, + }) + self.assertEqual((run.returncode, run.stdout), (0, "manylinux_2_43_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(43)), + "baseRT_rocm_kernels.hsaco": elf([], machine=AMDGPU), + }) + self.assertEqual((run.returncode, run.stdout), (0, "manylinux_2_43_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_43_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") -> 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), + } + 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 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_beside_another_is_used(self) -> None: + 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) + + @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() From 03f7754097cae6aa07a5da331f5f6fb6dc1a35ef Mon Sep 17 00:00:00 2001 From: IsuraManchanayake Date: Thu, 12 Nov 2026 15:59:04 +1100 Subject: [PATCH 2/5] packaging: the ROCm bundle needs Ubuntu 24.04 and glibc 2.39 The engine's release pipeline now builds the ROCm bundle in an Ubuntu 24.04 + ROCm 10 image: libbaseRT needs GLIBC_2.38 and basert GLIBC_2.39, so its wheel is manylinux_2_39_x86_64 (platform_tag.py reads that from the binaries; no code change). The README asks for Ubuntu 24.04 or newer and a kernel AMD supports for RDNA3.5 (Ubuntu 24.04 HWE 6.17.0-19.19~24.04.2+, OEM 6.14.0-1018+, Ubuntu 26.04's own, elsewhere 6.18.4+). The test fixtures carry the bundle's real glibc versions. --- README.md | 8 ++++++-- tests/scripts/test_packaging.py | 16 ++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 04bff0d..d5dddf1 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,13 @@ 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 26.04 or newer, with the ROCm 10 runtime + gfx1150), Linux / x86-64 on Ubuntu 24.04 or newer, with the ROCm 10 runtime from AMD's apt repository: `sudo apt install amdrocm-blas10.0-gfx1151` (or - `amdrocm-blas10.0-gfx1150`). + `amdrocm-blas10.0-gfx1150`). 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)). ## Quickstart diff --git a/tests/scripts/test_packaging.py b/tests/scripts/test_packaging.py index 8930baf..08a6a3b 100644 --- a/tests/scripts/test_packaging.py +++ b/tests/scripts/test_packaging.py @@ -111,7 +111,7 @@ def bundle(path: Path, files: dict[str, bytes], links: dict[str, str]) -> None: 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_43_x86_64", + "basert-engine-linux-x64-rocm": "manylinux_2_39_x86_64", } @@ -136,9 +136,9 @@ def release(folder: Path, v: str, rocm: bool = True) -> None: } 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(43)), + 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(43)), + "basert-chat": elf(["libbaseRT.so.0", "libc.so.6"], machine=X86_64, tail=glibc(38)), "baseRT_rocm_kernels.hsaco": OFFLOAD_BUNDLE, } contents = { @@ -549,19 +549,19 @@ 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(43)), + "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_43_x86_64\n"), run.stderr) + 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(43)), + "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_43_x86_64\n"), run.stderr) + 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))}) @@ -677,7 +677,7 @@ def test_each_bundle_makes_the_wheel_for_its_platform(self) -> None: 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_43_x86_64.whl") as whl: + 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) From c8eaf366940ee859e8f242add3cf583a1b28e627 Mon Sep 17 00:00:00 2001 From: IsuraManchanayake Date: Fri, 13 Nov 2026 02:10:09 +1100 Subject: [PATCH 3/5] install.sh: judge the AMD GPU by HIP device 0, and say how to select a Strix GPU behind another Review fix (Codex P2). The engine opens HIP device 0, but install.sh accepted a Strix GPU found anywhere in the KFD topology: with a gfx1100 as device 0 and a gfx1151 behind it, it installed and printed the usual instructions, and the engine then refused device 0. It now lists the AMD GPUs as HIP numbers them (topology nodes in number order, narrowed by ROCR_VISIBLE_DEVICES and then HIP_VISIBLE_DEVICES or CUDA_VISIBLE_DEVICES; a UUID list is left unread) and lets device 0 decide. A Strix GPU behind another one is still installed for, with a warning that names its device number, and the closing instructions repeat 'export HIP_VISIBLE_DEVICES='. Node directories are read in number order (node 10 sorts before node 2 as text). --- install.sh | 81 +++++++++++++++++++++++++-------- tests/scripts/test_packaging.py | 39 +++++++++++++++- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/install.sh b/install.sh index 9dd93d0..f5bb13b 100755 --- a/install.sh +++ b/install.sh @@ -54,22 +54,49 @@ sysroot="${BASERT_INSTALL_TEST_ROOT:-}" ROCM_LIB="/opt/rocm/core-10.0/lib" rocm_missing="" -# The AMD GPU's LLVM target (gfx1151, ...), from the amdgpu driver's topology; -# nothing without one. A supported chip wins over any other AMD GPU present. -amd_gpu() { +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 - other="" - for props in "$sysroot"/sys/class/kfd/kfd/topology/nodes/*/properties; do - [ -f "$props" ] || continue - v="$(sed -n 's/^gfx_target_version \([0-9][0-9]*\)$/\1/p' "$props")" + 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 - 110501) echo gfx1151; return 0 ;; - 110500) echo gfx1150; return 0 ;; - *) [ -n "$other" ] || other="$(printf 'gfx%d%x%x' $((v / 10000)) $((v / 100 % 100)) $((v % 100)))" ;; + *) printf 'gfx%d%x%x\n' $((v / 10000)) $((v / 100 % 100)) $((v % 100)) ;; esac done - printf '%s' "$other" +} + +# The lines of stdin that a ROCR_/HIP_VISIBLE_DEVICES list ($1) leaves, in its +# order, as the ROCm runtime reads it: indices into the lines, a repeat +# dropped, an index past the end ending the list. Unset or empty, or a list +# that names devices by UUID, leaves the lines as they are. +visible() { + lines="$(cat)" + list="$(printf '%s' "$1" | tr -d ' ')" + case "$list" in + "" | *[!0-9,]*) [ -z "$lines" ] || printf '%s\n' "$lines"; return 0 ;; + esac + count="$(printf '%s\n' "$lines" | grep -c . || true)" + kept="," + for i in $(printf '%s' "$list" | tr ',' ' '); do + [ "$i" -lt "$count" ] || break + case "$kept" in *",$i,"*) continue ;; esac + kept="$kept$i," + printf '%s\n' "$lines" | sed -n "$((i + 1))p" + done +} + +# The AMD GPUs as HIP numbers them, device 0 first: ROCR_VISIBLE_DEVICES +# narrows the driver's list, then HIP_VISIBLE_DEVICES (or CUDA_VISIBLE_DEVICES). +amd_gpus() { + kfd_gpus | visible "${ROCR_VISIBLE_DEVICES:-}" | visible "${HIP_VISIBLE_DEVICES:-${CUDA_VISIBLE_DEVICES:-}}" } os="$(uname -s)" @@ -85,13 +112,26 @@ case "$os $arch" in 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. - gpu="$(amd_gpu)" - case "$gpu" in - gfx1151 | gfx1150) ;; - "") 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 $gpu." ;; - esac + # 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. + gpus="$(amd_gpus)" + first="$(printf '%s\n' "$gpus" | sed -n '1p')" + gpu="" device=0 + for g in $gpus; do + case "$g" in gfx1151 | gfx1150) gpu="$g"; break ;; esac + device=$((device + 1)) + 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 + if [ "$device" -ne 0 ]; then + device_hint="export HIP_VISIBLE_DEVICES=$device" + 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" + warn "(where HIP_VISIBLE_DEVICES or ROCR_VISIBLE_DEVICES is set already, list the $gpu first there)" + 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." @@ -244,6 +284,11 @@ if [ -n "$rocm_missing" ]; then echo " First install the ROCm 10 runtime the engine needs (from AMD's apt repository):" echo " $rocm_missing" 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/tests/scripts/test_packaging.py b/tests/scripts/test_packaging.py index 08a6a3b..2699b9c 100644 --- a/tests/scripts/test_packaging.py +++ b/tests/scripts/test_packaging.py @@ -746,7 +746,7 @@ def rocm(self, gfx: str) -> None: (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") -> subprocess.CompletedProcess: + 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"), @@ -758,6 +758,7 @@ def install(self, os_name: str = "Linux", arch: str = "x86_64") -> subprocess.Co "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) @@ -811,12 +812,46 @@ def test_another_amd_gpu_is_named_and_refused(self) -> None: self.assertEqual(run.returncode, 1) self.assertIn(f"Strix Point (gfx1150) GPUs, not {name}.", run.stderr) - def test_a_strix_gpu_beside_another_is_used(self) -> None: + 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) + run = self.install(HIP_VISIBLE_DEVICES="GPU-0123456789abcdef") # a UUID: not read, node order stands + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("the gfx1151 is HIP device 1", run.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: From 778091ce905494588cd46cf33f93cdd030ef3015 Mon Sep 17 00:00:00 2001 From: IsuraManchanayake Date: Fri, 13 Nov 2026 02:51:42 +1100 Subject: [PATCH 4/5] install.sh: read the GPU visibility lists as ROCr and HIP do, and offer the selector HIP reads Review fixes (Codex P2 x2 on c8eaf36). Visibility: an empty, UUID or invalid list was treated as no filter, so ROCR_VISIBLE_DEVICES= with HIP_VISIBLE_DEVICES=0 was refused (it saw only the gfx1100), and ROCR_VISIBLE_DEVICES="" or HIP_VISIBLE_DEVICES=-1 installed although ROCm shows no GPU. rocr_visible and hip_visible now read each list as its runtime does (ROCr: set-but-empty shows no GPU, entries trimmed, strtol base 0, a bad, out-of-range or repeated entry ends the list; HIP: HIP_VISIBLE_DEVICES or else CUDA_VISIBLE_DEVICES, plain decimals only, a repeat skipped, a bad entry ends the list, counting what ROCr left). A list with no GPU left is refused as such. A UUID entry the runtime would reach makes the order unknown: the installer then installs for a Strix GPU and says device 0 is the engine's to check, instead of guessing. Selector: the export offered was the GPU's place after the HIP list, but HIP counts the list before it: raw [gfx1151, gfx1100] with HIP_VISIBLE_DEVICES=1,0 was offered 1, which selects the gfx1100. It now offers the GPU's index among the GPUs ROCr leaves, and the tests run the installer again with that value and check the Strix GPU is device 0. --- install.sh | 152 ++++++++++++++++++++++++-------- tests/scripts/test_packaging.py | 57 +++++++++++- 2 files changed, 170 insertions(+), 39 deletions(-) diff --git a/install.sh b/install.sh index f5bb13b..252fbb0 100755 --- a/install.sh +++ b/install.sh @@ -73,30 +73,89 @@ kfd_gpus() { done } -# The lines of stdin that a ROCR_/HIP_VISIBLE_DEVICES list ($1) leaves, in its -# order, as the ROCm runtime reads it: indices into the lines, a repeat -# dropped, an index past the end ending the list. Unset or empty, or a list -# that names devices by UUID, leaves the lines as they are. -visible() { - lines="$(cat)" - list="$(printf '%s' "$1" | tr -d ' ')" - case "$list" in - "" | *[!0-9,]*) [ -z "$lines" ] || printf '%s\n' "$lines"; return 0 ;; +# 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)" - kept="," - for i in $(printf '%s' "$list" | tr ',' ' '); do - [ "$i" -lt "$count" ] || break - case "$kept" in *",$i,"*) continue ;; esac - kept="$kept$i," - printf '%s\n' "$lines" | sed -n "$((i + 1))p" - done + 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 AMD GPUs as HIP numbers them, device 0 first: ROCR_VISIBLE_DEVICES -# narrows the driver's list, then HIP_VISIBLE_DEVICES (or CUDA_VISIBLE_DEVICES). -amd_gpus() { - kfd_gpus | visible "${ROCR_VISIBLE_DEVICES:-}" | visible "${HIP_VISIBLE_DEVICES:-${CUDA_VISIBLE_DEVICES:-}}" +# 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)" @@ -115,22 +174,43 @@ case "$os $arch" in # 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. - gpus="$(amd_gpus)" - first="$(printf '%s\n' "$gpus" | sed -n '1p')" - gpu="" device=0 - for g in $gpus; do - case "$g" in gfx1151 | gfx1150) gpu="$g"; break ;; esac - device=$((device + 1)) - 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 - if [ "$device" -ne 0 ]; then - device_hint="export HIP_VISIBLE_DEVICES=$device" - 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" - warn "(where HIP_VISIBLE_DEVICES or ROCR_VISIBLE_DEVICES is set already, list the $gpu first there)" + 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" diff --git a/tests/scripts/test_packaging.py b/tests/scripts/test_packaging.py index 2699b9c..4093271 100644 --- a/tests/scripts/test_packaging.py +++ b/tests/scripts/test_packaging.py @@ -841,9 +841,60 @@ def test_the_visibility_variables_choose_device_0(self) -> None: 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) - run = self.install(HIP_VISIBLE_DEVICES="GPU-0123456789abcdef") # a UUID: not read, node order stands - self.assertEqual(run.returncode, 0, run.stderr) - self.assertIn("the gfx1151 is HIP device 1", 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. From 3d1fe0730fa0d6d2e548daa33c07ddc316c3e5b7 Mon Sep 17 00:00:00 2001 From: IsuraManchanayake Date: Fri, 13 Nov 2026 02:58:44 +1100 Subject: [PATCH 5/5] install.sh, README: the steps that add AMD's ROCm 10 apt repository `sudo apt install amdrocm-blas10.0-` fails on a machine that has never had AMD's ROCm 10 repository, which is most of them. The README now gives the whole sequence (key, source line, apt update, the package, the render group), and install.sh prints the repository steps before the apt line when apt has no source for stable.repo.amd.com/rocm/core, with the dist for the machine's Ubuntu (ubuntu2404 or ubuntu2604, the two AMD serves). On another release it says which ones AMD serves instead of guessing. The key goes in as the ASCII-armoured .asc AMD publishes, so no gpg is needed. --- README.md | 17 +++++++++-- install.sh | 33 ++++++++++++++++++--- tests/scripts/test_packaging.py | 52 +++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d5dddf1..9c4ac4b 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,26 @@ One of: - 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: `sudo apt install amdrocm-blas10.0-gfx1151` (or - `amdrocm-blas10.0-gfx1150`). The kernel must be one AMD supports for + 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 ### 1. Install diff --git a/install.sh b/install.sh index 252fbb0..41ee2da 100755 --- a/install.sh +++ b/install.sh @@ -52,8 +52,33 @@ need uname # 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 @@ -220,10 +245,10 @@ case "$os $arch" in 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="sudo apt install amdrocm-blas10.0-$gpu" + 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 (stable.repo.amd.com/rocm/core):" - warn " $rocm_missing" + 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" ;; *) @@ -362,7 +387,7 @@ fi if [ -n "$rocm_missing" ]; then echo echo " First install the ROCm 10 runtime the engine needs (from AMD's apt repository):" - echo " $rocm_missing" + printf '%s\n' "$rocm_missing" | sed 's/^/ /' fi if [ -n "$device_hint" ]; then echo diff --git a/tests/scripts/test_packaging.py b/tests/scripts/test_packaging.py index 4093271..9ee0873 100644 --- a/tests/scripts/test_packaging.py +++ b/tests/scripts/test_packaging.py @@ -791,6 +791,58 @@ def test_blas_kernels_for_another_chip_are_not_enough(self) -> None: 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):