Ship one Linux universal executable with shared network weights (bench 5551733)
Co-authored-by: GPT-6 Astra <codex@openai.com>
This commit is contained in:
15 files changed
+799
-38
No files matched your search
@@ -42,6 +42,7 @@ jobs:
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
source_sha: ${{ steps.plan.outputs.source_sha }}
|
||||
tag: ${{ steps.plan.outputs.tag }}
|
||||
linux_universal: ${{ steps.plan.outputs.linux_universal }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -134,6 +135,7 @@ jobs:
|
||||
unzip -q "$nim_root/nim.zip" -d "$nim_root"
|
||||
- name: Build and check one target
|
||||
id: build
|
||||
if: matrix.publish
|
||||
run: |
|
||||
if [[ "$RUNNER_OS" == Windows ]]; then
|
||||
export PATH="$(cygpath -u "$RUNNER_TEMP")/nim/nim-${NIM_VERSION}/bin:$PATH"
|
||||
@@ -141,8 +143,20 @@ jobs:
|
||||
nim --version
|
||||
git lfs install --local
|
||||
python3 ../ci/scripts/release.py build --target "$RELEASE_TARGET" --tag "$RELEASE_TAG" --artifacts ../artifacts
|
||||
- name: Build and check Linux slice with shared weights
|
||||
if: needs.plan.outputs.linux_universal == 'true' && (matrix.target == 'linux-amd64-universal' || matrix.target == 'linux-arm64-universal')
|
||||
run: |
|
||||
git lfs install --local
|
||||
python3 ../ci/scripts/release.py slice-linux --target "$RELEASE_TARGET" --tag "$RELEASE_TAG" --artifacts ../slices
|
||||
- name: Upload internal Linux slice
|
||||
if: needs.plan.outputs.linux_universal == 'true' && (matrix.target == 'linux-amd64-universal' || matrix.target == 'linux-arm64-universal')
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.target }}-slice
|
||||
path: slices/*
|
||||
if-no-files-found: error
|
||||
- name: Publish this target to Gitea
|
||||
if: needs.plan.outputs.tag != ''
|
||||
if: needs.plan.outputs.tag != '' && matrix.publish
|
||||
env:
|
||||
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||
GITEA_REPO: ${{ secrets.GITEA_REPO }}
|
||||
@@ -150,8 +164,130 @@ jobs:
|
||||
GITEA_RELEASE_BODY: "Automated release artifacts built by GitHub Actions."
|
||||
run: python3 ../ci/scripts/publish_gitea_release.py --tag "$RELEASE_TAG" --target-commitish "$SOURCE_SHA" --files ../artifacts/*
|
||||
- name: Upload this target to Actions
|
||||
if: matrix.publish
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.build.outputs.artifact_name }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-package:
|
||||
name: Combine Linux architectures
|
||||
needs: [plan, build]
|
||||
# An unrelated platform failure must not prevent a complete Linux release.
|
||||
# Missing or failed Linux slices still fail the download/checksum checks.
|
||||
if: ${{ !cancelled() && needs.plan.outputs.linux_universal == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
artifact_name: ${{ steps.package.outputs.artifact_name }}
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
RELEASE_TAG: ${{ needs.plan.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: ci
|
||||
lfs: false
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.plan.outputs.source_sha }}
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
lfs: false
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: 'linux-*-universal-slice'
|
||||
merge-multiple: true
|
||||
path: slices
|
||||
- name: Package both checked slices
|
||||
id: package
|
||||
working-directory: source
|
||||
run: python3 ../ci/scripts/release.py combine-linux --slices ../slices --tag "$RELEASE_TAG" --artifacts ../artifacts
|
||||
- name: Check packaging and cache regressions
|
||||
working-directory: source
|
||||
run: |
|
||||
python3 -m unittest discover -s ../ci/tests -p 'test_release*.py'
|
||||
python3 -m unittest discover -s ../ci/tests -p 'test_linux_universal.py'
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.package.outputs.artifact_name }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-check:
|
||||
name: Check combined Linux package (${{ matrix.arch }})
|
||||
needs: [plan, linux-package]
|
||||
if: ${{ !cancelled() && needs.linux-package.result == 'success' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {arch: amd64, runner: ubuntu-24.04}
|
||||
- {arch: arm64, runner: ubuntu-24.04-arm}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
ARTIFACT_NAME: ${{ needs.linux-package.outputs.artifact_name }}
|
||||
SOURCE_SHA: ${{ needs.plan.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: ci
|
||||
lfs: false
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.plan.outputs.source_sha }}
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
lfs: false
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: artifacts
|
||||
- name: Verify and extract the release archive
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -c "$ARTIFACT_NAME.sha256"
|
||||
tar -xzf "$ARTIFACT_NAME.tar.gz"
|
||||
sha256sum -c "$ARTIFACT_NAME.sha256"
|
||||
- name: Check native dispatch, production bench and UCI through the launcher
|
||||
working-directory: source
|
||||
run: |
|
||||
export HEIMDALL="$GITHUB_WORKSPACE/artifacts/$ARTIFACT_NAME"
|
||||
export HEIMDALL_CACHE_DIR="$RUNNER_TEMP/heimdall-cache"
|
||||
"$HEIMDALL" simd
|
||||
python3 ../ci/scripts/check_binary_benches.py --commit "$SOURCE_SHA" -- "$HEIMDALL"
|
||||
python3 -m unittest discover -s ../ci/tests -p 'test_uci.py'
|
||||
python3 -m unittest discover -s ../ci/tests -p 'test_runtime_simd.py'
|
||||
|
||||
linux-publish:
|
||||
name: Publish combined Linux package
|
||||
needs: [plan, linux-package, linux-check]
|
||||
if: ${{ !cancelled() && needs.linux-check.result == 'success' && needs.plan.outputs.tag != '' }}
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: release-${{ needs.plan.outputs.tag }}-linux-universal
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
RELEASE_TAG: ${{ needs.plan.outputs.tag }}
|
||||
SOURCE_SHA: ${{ needs.plan.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
lfs: false
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ needs.linux-package.outputs.artifact_name }}
|
||||
path: artifacts
|
||||
- name: Publish checked package to Gitea
|
||||
env:
|
||||
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||
GITEA_REPO: ${{ secrets.GITEA_REPO }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_RELEASE_BODY: "Automated release artifacts built by GitHub Actions."
|
||||
run: python3 scripts/publish_gitea_release.py --tag "$RELEASE_TAG" --target-commitish "$SOURCE_SHA" --files artifacts/*
|
||||
@@ -28,8 +28,9 @@ compiler flags, network architecture settings, and build targets.
|
||||
so no network downloads need to happen.** It invokes the native build with
|
||||
`SKIP_DEPS=1`, skipping dependency installation and network fetching, and writes
|
||||
the engine to `bin/heimdall` (`.exe` on Windows).
|
||||
- Local prerequisites include Nim **2.2.6** (pinned in `heimdall.nimble`), the
|
||||
declared Nimble packages, Clang, the platform linker (LLD on Linux/Windows,
|
||||
- Local prerequisites include Nim **2.2.2 or greater** (required by
|
||||
`heimdall.nimble`; CI uses 2.2.6), the declared Nimble packages, Clang,
|
||||
the platform linker (LLD on Linux/Windows,
|
||||
Apple ld on macOS), and the actual NNUE weights selected by `EVALFILE`.
|
||||
Multilayer TI inference is the default and requires an explicit `EVALFILE`.
|
||||
Use `SINGLE_LAYER=1` for the local `threans.bin` debugging fixture; the old
|
||||
|
||||
@@ -33,6 +33,8 @@ endif
|
||||
endif
|
||||
NET_NAME := $(notdir $(EVALFILE))
|
||||
NET_ID := $(basename $(NET_NAME))
|
||||
# Combined Linux releases keep one shared network beside their cached engine.
|
||||
EMBED_NET ?= 1
|
||||
LD := lld
|
||||
SRCDIR := src
|
||||
MAIN ?= $(SRCDIR)/heimdall.nim
|
||||
@@ -153,6 +155,7 @@ CUSTOM_FLAGS := -d:singleLayer=$(if $(filter 1,$(SINGLE_LAYER)),true,false) \
|
||||
-d:patchVersion=$(PATCH_VERSION) \
|
||||
-d:evalFile=$(EVALFILE) \
|
||||
-d:netID=$(NET_ID) \
|
||||
-d:embedNet=$(if $(filter 1,$(EMBED_NET)),true,false) \
|
||||
-d:thpPageAlignment:$(THP_PAGE_ALIGNMENT) \
|
||||
-d:esc_exit_editing
|
||||
|
||||
|
||||
@@ -29,21 +29,26 @@ Heimdall is a strong chess engine written in Nim. As far as I know, this is the
|
||||
### How to pick the right executable
|
||||
|
||||
Fetching the latest stable release is the easiest way to install Heimdall. For
|
||||
versions after **1.5**, choose the archive for your operating system and CPU
|
||||
family. The universal binaries select the supported SIMD backend automatically.
|
||||
current releases after **1.5**, choose the archive for your operating system.
|
||||
Universal builds select the supported SIMD backend automatically.
|
||||
|
||||
| Your computer | Executable filename ends with |
|
||||
| Your computer | Archive filename ends with |
|
||||
| --- | --- |
|
||||
| Windows on Intel or AMD | `windows-amd64-universal.exe` |
|
||||
| Linux on Intel or AMD | `linux-amd64-universal` |
|
||||
| Linux on ARM64 / AArch64 | `linux-arm64-universal` |
|
||||
| macOS on Intel or Apple Silicon | `macos-universal` |
|
||||
| Windows on Intel or AMD | `windows-amd64-universal.zip` |
|
||||
| Linux on Intel, AMD or ARM64 / AArch64 | `linux-universal.tar.gz` |
|
||||
| macOS on Intel or Apple Silicon | `macos-universal.tar.gz` |
|
||||
|
||||
Download the matching `.zip` (Windows) or `.tar.gz` (Linux/macOS) archive from
|
||||
the [latest release](https://git.nocturn9x.space/nocturn9x/heimdall/releases),
|
||||
extract it, and select the executable in your chess GUI. All builds require a
|
||||
64-bit system; the macOS build requires macOS 11 or later.
|
||||
|
||||
The Linux executable selects AMD64 or ARM64 and extracts its engine and shared
|
||||
network into a private runtime cache on first launch. See the
|
||||
[cache requirements and settings](docs/RELEASES.md#linux-universal-executable).
|
||||
Earlier releases may offer separate `linux-amd64-universal` and
|
||||
`linux-arm64-universal` downloads; choose the one matching your CPU.
|
||||
|
||||
For source builds, platform-specific binaries, testing, and release details,
|
||||
see the [building guide](docs/BUILDING.md). The [testing guide](docs/TESTING.md)
|
||||
covers regression tests, focused correctness checks, and benchmarks.
|
||||
|
||||
+9
-1
@@ -6,7 +6,7 @@ for the universal archive that matches your system.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Nim 2.2.6, pinned in `heimdall.nimble`
|
||||
- Nim 2.2.2 or greater, as required by `heimdall.nimble`
|
||||
- Clang and the platform linker (LLD on Linux/Windows, Apple `ld` on macOS)
|
||||
- Git LFS when fetching network weights
|
||||
|
||||
@@ -32,6 +32,14 @@ backend selection. Run `bin/heimdall simd` to see the selected backend, or set
|
||||
`make macos-universal SKIP_DEPS=1 EVALFILE=/absolute/path/to/net.bin` combines
|
||||
Intel and Apple Silicon slices.
|
||||
|
||||
On Linux, `SIMD=universal` builds for the selected CPU family. The combined
|
||||
`linux-universal` release is a self-extracting executable containing both native
|
||||
builds and one shared network. It requires `/bin/sh`, GNU coreutils, `gzip`, and
|
||||
a writable private cache on a filesystem that permits execution. See
|
||||
[the Linux packaging instructions](RELEASES.md#linux-universal-executable) for
|
||||
cache settings and local assembly. Internal slices use `EMBED_NET=0` and load
|
||||
`network.bin` beside the executable; ordinary builds keep `EMBED_NET=1`.
|
||||
|
||||
Portable targets use generic CPU tuning. For example, `make avx2 TUNE=znver2`
|
||||
uses `-march=x86-64-v3 -mtune=znver2` while retaining the AVX2 requirement.
|
||||
The old `legacy`, `modern`, `zen2`, and `vnni` target names are gone. Intel and
|
||||
|
||||
+97
-12
@@ -22,10 +22,90 @@ The [README](../README.md#how-to-pick-the-right-executable) explains which
|
||||
universal executable to download; [SIMD documentation](SIMD.md) covers individual
|
||||
backend names for manual builds and older releases.
|
||||
The **Release binaries** GitHub workflow automatically publishes only universal
|
||||
binaries: Linux amd64, Linux arm64, Windows amd64, and combined macOS. Each target
|
||||
has its own job so it can be rebuilt independently. Individual SIMD artifacts
|
||||
downloads: combined Linux AMD64/ARM64, Windows amd64, and combined macOS. Targets
|
||||
can be selected independently. Individual SIMD artifacts
|
||||
are available through manual runs. The SIMD correctness workflow remains manual-only.
|
||||
|
||||
## Linux universal executable
|
||||
|
||||
`heimdall-<version>-linux-universal` is one executable file for AMD64 and ARM64.
|
||||
It contains a `/bin/sh` launcher, two compressed native engines built with
|
||||
`SIMD=universal EMBED_NET=0`, and **one compressed copy of the network weights**.
|
||||
The AMD64 engine retains runtime x86 SIMD selection; ARM64 selects scalar or NEON.
|
||||
Linux ELF files specify one machine architecture, so the shell launcher chooses
|
||||
and extracts the native engine before executing it. This does not use emulation.
|
||||
|
||||
Download the `.tar.gz` archive, extract it and select the executable in your chess
|
||||
GUI. The executable can be moved or symlinked by itself. It requires `/bin/sh`,
|
||||
`gzip` and GNU coreutils (`uname`, `readlink`, `dd`, `sha256sum`, `stat`, `id`,
|
||||
`mkdir`, `mktemp`, `chmod`, `mv`, `rm`). The engines retain their normal Linux
|
||||
library requirements. A GUI must accept executable scripts; separate
|
||||
`linux-amd64-universal` and `linux-arm64-universal` ELF downloads remain manually
|
||||
selectable and are also used by older releases.
|
||||
|
||||
On first launch, the executable extracts only the selected engine and shared
|
||||
`network.bin` into a private cache. The cache location is:
|
||||
|
||||
1. `$HEIMDALL_CACHE_DIR`, if set;
|
||||
2. `$XDG_CACHE_HOME/heimdall`, if set;
|
||||
3. `$HOME/.cache/heimdall` otherwise.
|
||||
|
||||
The cache path must be absolute, writable, owned by the current user and have
|
||||
mode `700`. The cache directory itself and each entry must not be symlinks.
|
||||
Its filesystem must permit execution: if the default cache is on a `noexec`
|
||||
mount, set `HEIMDALL_CACHE_DIR` to a private directory on an executable filesystem.
|
||||
Each content/launcher version and CPU family gets a separate entry. Extracted
|
||||
engine/network files have modes `500`/`400`. Both are SHA-256 checked before
|
||||
**every** launch; missing or corrupt files are extracted again. Temporary files
|
||||
are private and installed atomically, allowing simultaneous starts and repair
|
||||
while an older process is running. Interrupted extraction is cleaned up on normal
|
||||
exit and catchable termination signals. Old entries are not removed automatically;
|
||||
you can delete the cache when no Heimdall process is using it. The next launch
|
||||
recreates it. As with any process, SIGKILL or a power failure can leave temporary
|
||||
files, which can also be deleted then.
|
||||
|
||||
First launch adds decompression and disk writes; subsequent launches still read
|
||||
and hash the cached engine and weights. The launcher emits errors on stderr and
|
||||
keeps stdout clear for UCI. It uses `exec`, preserving arguments, environment
|
||||
(including `HEIMDALL_SIMD`), UCI pipes, working directory, PID, signals and exit
|
||||
status. The engine's default network loads from `network.bin` beside its cached
|
||||
executable; `EvalFile` overrides and resetting to `<default>` continue to work.
|
||||
Ordinary builds still embed their weights (`EMBED_NET=1`, the default).
|
||||
|
||||
CI builds and bench-checks internal slices on `ubuntu-24.04` and
|
||||
`ubuntu-24.04-arm` from the same source SHA. Each slice archive records that SHA,
|
||||
architecture settings, and engine/network hashes. Assembly rejects mismatched
|
||||
sources, settings, weights, ELF architectures or checksums. It writes one shared
|
||||
weights payload, then both native runners check the resulting executable with
|
||||
the production bench and UCI/runtime-SIMD regressions. Publication waits for both
|
||||
checks. Internal slice archives remain available in Actions but are never
|
||||
published to Gitea. The `all` and `linux` selections additionally build the
|
||||
standalone ELF variants with embedded weights.
|
||||
|
||||
To assemble locally, build each internal slice on its matching Linux host from
|
||||
the same commit, network and Makefile settings:
|
||||
|
||||
```sh
|
||||
# On AMD64:
|
||||
python scripts/release.py slice-linux --target linux-amd64-universal --skip-deps --artifacts build/slices
|
||||
# On ARM64:
|
||||
python scripts/release.py slice-linux --target linux-arm64-universal --skip-deps --artifacts build/slices
|
||||
# Collect both *-universal-slice.tar.gz archives in build/slices, then on Linux:
|
||||
python scripts/release.py combine-linux --slices build/slices --artifacts build/release
|
||||
```
|
||||
|
||||
Supply the same `--tag` to all three commands for a tagged release. Compilation
|
||||
still goes through the source checkout's Makefile; assembly compiles nothing.
|
||||
Pass custom network/architecture settings through Make's environment, for example
|
||||
`MAKEFLAGS='EVALFILE=/absolute/path/to/net.bin'`. The source Makefile must support
|
||||
`EMBED_NET=0`; older tags without it cannot produce this combined artifact.
|
||||
No cross-compiler is needed. Test the resulting executable on both CPU families.
|
||||
|
||||
The release publishes the executable, its `<executable>.sha256`, and a `.tar.gz`
|
||||
archive containing both. After extracting, run `sha256sum -c <executable>.sha256`.
|
||||
The self-extracting file is deterministic for identical inputs and packaging
|
||||
Python/zlib versions; the outer download archive retains normal release metadata.
|
||||
|
||||
## macOS targets
|
||||
|
||||
| Native Make target | Required build host | Release target | Backend |
|
||||
@@ -67,31 +147,34 @@ In GitHub **Actions → Release binaries → Run workflow**:
|
||||
3. Set `release_tag` to the existing tag, such as `1.5.1-dev`.
|
||||
4. Leave `source_ref` empty to build the exact commit referenced by that tag.
|
||||
|
||||
Only the selected target is compiled, bench-checked, packaged and uploaded.
|
||||
Only the selected target is compiled, bench-checked, packaged and uploaded
|
||||
(`linux-universal` builds and checks both Linux slices).
|
||||
The existing tag is not moved. A rerun replaces only files belonging to that
|
||||
target; other release assets and older aggregate archives remain intact.
|
||||
|
||||
Tag pushes always select `universal`. Manual runs and the local planning command
|
||||
also default to `universal`; choose `all` explicitly to build every individual
|
||||
SIMD variant as well. Manual `linux`, `windows`, and `macos` selections include
|
||||
all variants for that platform (`macos` selects all three Mac builds). A complete
|
||||
target name builds just that artifact. No push to a branch or pull request starts
|
||||
the release workflow.
|
||||
all variants for that platform, including the combined package (`macos` selects
|
||||
all three Mac builds). A complete target name builds just that artifact. No push
|
||||
to a branch or pull request starts the release workflow.
|
||||
|
||||
Current selectable targets:
|
||||
|
||||
- `linux-amd64-sse2`, `linux-amd64-ssse3`, `linux-amd64-sse41`,
|
||||
`linux-amd64-avx2`, `linux-amd64-avx512`, `linux-amd64-avx512-vnni`.
|
||||
- `linux-amd64-universal`, `linux-arm64-neon`, `linux-arm64-universal`.
|
||||
- `linux-amd64-universal`, `linux-arm64-neon`, `linux-arm64-universal`, `linux-universal`.
|
||||
- `windows-amd64-sse2`, `windows-amd64-ssse3`, `windows-amd64-sse41`,
|
||||
`windows-amd64-avx2`, `windows-amd64-avx512`, `windows-amd64-avx512-vnni`.
|
||||
- `windows-amd64-universal`.
|
||||
- `macos-amd64-sse2`, `macos-arm64-neon`, `macos-universal`.
|
||||
|
||||
The catalog lives in `scripts/release.py`; Makefile owns compiler flags, network
|
||||
settings and filename versioning. Each selected target becomes an independent
|
||||
matrix job, including its upload. Jobs for the same release tag and target are
|
||||
serialized to prevent simultaneous replacements of the same assets.
|
||||
settings and filename versioning. Native targets become independent matrix jobs,
|
||||
including their uploads. `linux-universal` expands into two native slice builds
|
||||
followed by assembly, checks on both architectures, and publication. Publication
|
||||
jobs for the same release tag and target are serialized to prevent simultaneous
|
||||
replacements of the same assets.
|
||||
|
||||
## Source selection and publishing
|
||||
|
||||
@@ -109,9 +192,9 @@ added without recreating the tag.
|
||||
Publishing uses the existing `GITEA_BASE_URL`, `GITEA_REPO` and `GITEA_TOKEN`
|
||||
secrets. If the tag has no release record yet, the existing publisher creates one;
|
||||
stable versions start as drafts, while alpha/beta/rc/dev releases are prereleases.
|
||||
Every target owns three uniquely named files: its executable, an executable-name
|
||||
Each target owns three uniquely named files: its executable, an executable-name
|
||||
`.sha256` file, and a `.tar.gz` archive (`.zip` on Windows) containing both.
|
||||
Checksums are per binary so one target cannot overwrite another target's manifest.
|
||||
Checksums are per executable so one target cannot overwrite another target's manifest.
|
||||
|
||||
For a local check using existing dependencies and weights:
|
||||
|
||||
@@ -124,6 +207,8 @@ The build command runs from the source checkout on a matching host, uses its
|
||||
Makefile, and checks the resulting binary against that commit's recorded bench.
|
||||
It does not publish anything. The default output directory is `artifacts`; the
|
||||
example keeps local artifacts under ignored `build/` instead.
|
||||
Use `plan --target linux-universal` to select the combined CI package, and
|
||||
`combine-linux` to assemble it locally after building both slices.
|
||||
The helper requires GNU Make with `--eval` support. macOS CI installs Homebrew's
|
||||
Make and places its `libexec/gnubin` directory on `PATH` ahead of Apple's Make.
|
||||
|
||||
|
||||
+15
-3
@@ -97,7 +97,16 @@ attributes on x86; LTO preserves those boundaries. Do not add native or advanced
|
||||
ISA flags globally to a universal build. The scalar path may be autovectorized
|
||||
within the baseline ISA. `SIMD=auto` retains the existing native build behavior.
|
||||
|
||||
Linux and Windows binaries cover one CPU family each. On a Mac,
|
||||
Each Linux ELF and Windows executable covers one CPU family. The combined Linux
|
||||
release is a self-extracting executable containing AMD64 and ARM64 ELF engines,
|
||||
each built with `SIMD=universal EMBED_NET=0`, and one shared network payload. Its
|
||||
shell launcher extracts the selected engine and weights into a private cache,
|
||||
checks their hashes and uses `exec` to start the engine. There is no emulation.
|
||||
Arguments, UCI streams, the working directory and `HEIMDALL_SIMD` pass through
|
||||
unchanged. See [Linux release packaging](RELEASES.md#linux-universal-executable)
|
||||
for cache requirements, tools and assembly commands.
|
||||
|
||||
On a Mac,
|
||||
`make macos-universal SKIP_DEPS=1 EVALFILE=/absolute/path/to/net.bin` compiles
|
||||
both CPU families and combines them with `xcrun lipo`. Both slices use the same
|
||||
network layout; the disk weights are embedded once per slice. This target needs
|
||||
@@ -226,8 +235,11 @@ Windows, Intel Mac and Apple Silicon. The Apple Silicon job also checks both
|
||||
slices of the combined executable using Rosetta. QEMU's Opteron G1, Conroe,
|
||||
Penryn and Haswell models check minimum ISA compatibility and runtime fallback,
|
||||
including AVX2 hardware without OSXSAVE support.
|
||||
The **Release binaries** workflow gives every Linux, Windows and macOS artifact
|
||||
its own job. Tag pushes build only universal targets; manual dispatch can also
|
||||
The **Release binaries** workflow builds each CPU slice on its native runner.
|
||||
For the combined Linux package it assembles both slices, then checks the same
|
||||
archive through its launcher on native AMD64 and ARM64 runners before publishing.
|
||||
Tag pushes publish combined Linux, Windows amd64 and combined macOS universal
|
||||
downloads; manual dispatch can also
|
||||
select individual SIMD targets, a platform, or all variants and publish them to
|
||||
an existing tag. Intel and Apple Silicon Mac jobs
|
||||
run natively on `macos-15-intel` and `macos-15`. AVX-512/VNNI correctness runs
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ and can be added to chess GUIs such as Arena, En Croissant, and Cutechess.
|
||||
- `Ponder`: allow searching while the opponent searches.
|
||||
- `UCI_ShowWDL`: show predicted win, draw, and loss probabilities.
|
||||
- `UCI_Chess960`: enable Fischer Random and Double Fischer Random chess.
|
||||
- `EvalFile`: path to a compatible external neural network; `<default>` uses the embedded network.
|
||||
- `EvalFile`: path to a compatible external neural network; `<default>` restores the shipped network (embedded in ordinary builds, shared in the combined Linux executable).
|
||||
- `NormalizeScore`: normalize displayed scores to a win probability. Enabled by default.
|
||||
- `EnableWeirdTCs`: permit untested time controls such as sudden death and moves to go.
|
||||
- `MultiPV`: number of principal variations; time limits are shared across lines.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Mattia Giambirtone & All Contributors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Generated release header. Compressed engine slices and shared weights follow.
|
||||
|
||||
# Parse the complete function before reaching the binary payload.
|
||||
heimdall_main() {
|
||||
fail() { echo "heimdall: $*" >&2; exit 1; }
|
||||
[ "$(uname -s)" = Linux ] || fail 'this executable requires Linux'
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
arch=amd64
|
||||
engine_offset=@AMD64_OFFSET@
|
||||
engine_size=@AMD64_SIZE@
|
||||
engine_hash=@AMD64_HASH@
|
||||
;;
|
||||
aarch64|arm64)
|
||||
arch=arm64
|
||||
engine_offset=@ARM64_OFFSET@
|
||||
engine_size=@ARM64_SIZE@
|
||||
engine_hash=@ARM64_HASH@
|
||||
;;
|
||||
*) fail 'this executable requires AMD64 or ARM64 Linux' ;;
|
||||
esac
|
||||
self=$(readlink -f -- "$0") || fail 'cannot locate this executable'
|
||||
cache_root=${HEIMDALL_CACHE_DIR:-${XDG_CACHE_HOME:-${HOME:?HOME or HEIMDALL_CACHE_DIR must be set}/.cache}/heimdall}
|
||||
case "$cache_root" in /*) ;; *) fail 'cache directory must be an absolute path' ;; esac
|
||||
original_umask=$(umask)
|
||||
umask 077
|
||||
mkdir -p -- "$cache_root" || fail "cannot create cache directory $cache_root"
|
||||
private_directory() {
|
||||
[ -d "$1" ] && [ ! -L "$1" ] &&
|
||||
[ "$(stat -c %u -- "$1")" = "$(id -u)" ] &&
|
||||
[ "$(stat -c %a -- "$1")" = 700 ]
|
||||
}
|
||||
private_directory "$cache_root" || fail 'cache must be a private, user-owned directory (mode 700), not a symlink'
|
||||
cache="$cache_root/@BUNDLE_ID@-$arch"
|
||||
mkdir -p -- "$cache" || fail "cannot create cache directory $cache"
|
||||
private_directory "$cache" || fail 'unsafe cache entry'
|
||||
valid_files() {
|
||||
[ -f "$1/heimdall" ] && [ ! -L "$1/heimdall" ] &&
|
||||
[ -x "$1/heimdall" ] && [ -f "$1/network.bin" ] && [ ! -L "$1/network.bin" ] &&
|
||||
(cd -- "$1" && sha256sum --status -c <<SUMS
|
||||
$engine_hash heimdall
|
||||
@NETWORK_HASH@ network.bin
|
||||
SUMS
|
||||
)
|
||||
}
|
||||
if ! valid_files "$cache"; then
|
||||
stage=$(mktemp -d "$cache/.extract.XXXXXXXXXX") || fail 'cannot create extraction directory'
|
||||
trap 'rm -rf -- "$stage"' 0
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
extract() {
|
||||
dd if="$self" bs=65536 skip="$1" count="$2" iflag=skip_bytes,count_bytes status=none |
|
||||
gzip -dc > "$3"
|
||||
}
|
||||
extract "$engine_offset" "$engine_size" "$stage/heimdall" || fail 'engine extraction failed'
|
||||
extract @NETWORK_OFFSET@ @NETWORK_SIZE@ "$stage/network.bin" || fail 'network extraction failed'
|
||||
chmod 500 "$stage/heimdall" && chmod 400 "$stage/network.bin" || fail 'cannot set cache permissions'
|
||||
valid_files "$stage" || fail 'payload checksum mismatch; download the executable again'
|
||||
# Atomic file replacement also repairs corrupt caches without modifying
|
||||
# an inode used by a running engine. Concurrent writers publish identical
|
||||
# verified bytes; no lock or stale lock recovery is needed.
|
||||
mv -fT -- "$stage/network.bin" "$cache/network.bin" &&
|
||||
mv -fT -- "$stage/heimdall" "$cache/heimdall" || fail 'cannot install cached files'
|
||||
rm -rf -- "$stage"
|
||||
trap - 0 HUP INT TERM
|
||||
fi
|
||||
umask "$original_umask"
|
||||
# exec preserves UCI pipes, cwd, arguments, PID, signals and exit status.
|
||||
# If execution fails (e.g. a noexec mount), the shell reports the error.
|
||||
exec "$cache/heimdall" "$@"
|
||||
}
|
||||
heimdall_main "$@"
|
||||
exit 1
|
||||
# Compressed payload follows.
|
||||
+168
-6
@@ -18,15 +18,19 @@
|
||||
"""Plan independent release jobs and build/package one Makefile backend."""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
|
||||
@@ -47,16 +51,31 @@ TARGETS = {
|
||||
}
|
||||
TARGETS["macos-universal"] = dict(target="macos-universal", os="macos", arch="universal",
|
||||
runner="macos-15", backend="universal", make_target="macos-universal")
|
||||
LINUX_SLICES = ("linux-amd64-universal", "linux-arm64-universal")
|
||||
TARGETS["linux-universal"] = dict(target="linux-universal", os="linux", arch="universal",
|
||||
backend="universal")
|
||||
|
||||
|
||||
def includes_linux_bundle(selection):
|
||||
return selection in ("all", "universal", "linux", "linux-universal")
|
||||
|
||||
|
||||
def matrix(selection):
|
||||
targets = [value for key, value in TARGETS.items()
|
||||
if selection in ("all", value["os"], key) or
|
||||
(selection == "universal" and value["backend"] == "universal")]
|
||||
(selection == "universal" and value["backend"] == "universal"
|
||||
and key not in LINUX_SLICES)]
|
||||
if not targets:
|
||||
raise ValueError(f"Unknown release target {selection!r}; choose all, universal, linux, windows, macos, or "
|
||||
+ ", ".join(TARGETS))
|
||||
return {"include": targets}
|
||||
# The combined Linux artifact uses two native builds. Individual slices are
|
||||
# published only when explicitly selected (including all/platform selections).
|
||||
jobs = {target["target"]: dict(target, publish=True) for target in targets
|
||||
if target["target"] != "linux-universal"}
|
||||
if includes_linux_bundle(selection):
|
||||
for name in LINUX_SLICES:
|
||||
jobs.setdefault(name, dict(TARGETS[name], publish=False))
|
||||
return {"include": list(jobs.values())}
|
||||
|
||||
|
||||
def git(*args):
|
||||
@@ -91,13 +110,15 @@ def plan(args):
|
||||
and os.environ.get("GITHUB_REF_TYPE") == "tag")
|
||||
# Automatic releases contain only universal binaries; expanded selections
|
||||
# remain available through manual workflow runs and the local CLI.
|
||||
selected = matrix("universal" if tag_push else args.target)
|
||||
selection = "universal" if tag_push else args.target
|
||||
selected = matrix(selection)
|
||||
tag = args.tag
|
||||
if not tag and tag_push:
|
||||
tag = os.environ["GITHUB_REF_NAME"]
|
||||
version_flags(tag)
|
||||
source = release_source(args.ref, tag, os.environ.get("GITHUB_REF", "HEAD"))
|
||||
values = dict(matrix=json.dumps(selected, separators=(",", ":")), source_sha=source, tag=tag)
|
||||
values = dict(matrix=json.dumps(selected, separators=(",", ":")), source_sha=source, tag=tag,
|
||||
linux_universal=str(includes_linux_bundle(selection)).lower())
|
||||
outputs(values)
|
||||
print(json.dumps(values, indent=2))
|
||||
|
||||
@@ -147,7 +168,139 @@ def package(binary, directory):
|
||||
return [copied, checksum, archive]
|
||||
|
||||
|
||||
def validate_elf(data, machine):
|
||||
if (len(data) < 64 or data[:7] != b"\x7fELF\x02\x01\x01"
|
||||
or int.from_bytes(data[16:18], "little") not in (2, 3)
|
||||
or int.from_bytes(data[18:20], "little") != machine):
|
||||
raise ValueError(f"Expected a little-endian ELF64 executable for machine {machine}")
|
||||
|
||||
|
||||
def linux_executable(amd64, arm64, network, output):
|
||||
"""Write a shell header, two compressed engines and one shared network."""
|
||||
for data, machine in ((amd64, 62), (arm64, 183)):
|
||||
validate_elf(data, machine)
|
||||
payloads = dict(AMD64=amd64, ARM64=arm64, NETWORK=network)
|
||||
template = Path(__file__).with_name("linux-universal.sh").read_text(encoding="utf-8")
|
||||
values = {key + "_HASH": hashlib.sha256(data).hexdigest() for key, data in payloads.items()}
|
||||
values["BUNDLE_ID"] = hashlib.sha256((template + json.dumps(values, sort_keys=True)).encode()).hexdigest()
|
||||
compressed = {key: gzip.compress(data, mtime=0) for key, data in payloads.items()}
|
||||
for key, data in compressed.items():
|
||||
values[key + "_SIZE"] = str(len(data))
|
||||
values[key + "_OFFSET"] = "0"
|
||||
# Offsets are decimal and their own lengths contribute to the header size.
|
||||
while True:
|
||||
header = re.sub(r"@([A-Z0-9_]+)@", lambda match: values[match[1]], template).encode("utf-8")
|
||||
offset = len(header)
|
||||
previous = dict(values)
|
||||
for key, data in compressed.items():
|
||||
values[key + "_OFFSET"] = str(offset)
|
||||
offset += len(data)
|
||||
if previous == values:
|
||||
break
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("wb") as stream:
|
||||
stream.write(header)
|
||||
for data in compressed.values():
|
||||
stream.write(data)
|
||||
output.chmod(0o755)
|
||||
|
||||
|
||||
def slice_linux(args):
|
||||
"""Build an engine without embedded weights and record its matching network."""
|
||||
target = TARGETS[args.target]
|
||||
flags = version_flags(args.tag)
|
||||
system, arch, stable, development, _ = make_config(flags)
|
||||
if system != "linux" or arch != target["arch"]:
|
||||
raise ValueError(f"{args.target} requires its matching native Linux host")
|
||||
source = source_commit("HEAD")
|
||||
if args.tag:
|
||||
release_source(source, args.tag, "HEAD")
|
||||
flags.append("EMBED_NET=0")
|
||||
# The tagged Makefile owns settings. Older sources must already support
|
||||
# external default weights; merely passing an unknown Make variable is unsafe.
|
||||
rule = ("heimdall-slice-config:\n"
|
||||
"\t@echo $(EMBED_NET) $(filter -d:embedNet=%,$(CUSTOM_FLAGS))\n"
|
||||
"\t@echo $(EVALFILE)\n\t@echo $(MAIN)\n\t@echo $(CUSTOM_FLAGS)")
|
||||
config = subprocess.check_output(["make", "--no-print-directory", "-s", "--eval=" + rule,
|
||||
"heimdall-slice-config", *flags], text=True).splitlines()
|
||||
if len(config) != 4 or config[0].strip() != "0 -d:embedNet=false":
|
||||
raise ValueError("The source Makefile does not support EMBED_NET=0")
|
||||
network_path = (Path(config[2]).parent / config[1]).resolve()
|
||||
settings = [flag for flag in shlex.split(config[3]) if not flag.startswith("-d:evalFile=")]
|
||||
directory = Path("build/release-slices") / arch
|
||||
binary = directory / "heimdall"
|
||||
command = ["make", "universal", "NIMBLE_FLAGS=-y", *flags, f"EXE_BASE={binary}"]
|
||||
if args.skip_deps:
|
||||
command.append("SKIP_DEPS=1")
|
||||
subprocess.run(command, check=True)
|
||||
shutil.copyfile(network_path, directory / "network.bin")
|
||||
subprocess.run([sys.executable, str(Path(__file__).with_name("check_binary_benches.py")),
|
||||
"--commit", source, "--", str(binary)], check=True)
|
||||
data = binary.read_bytes()
|
||||
network = (directory / "network.bin").read_bytes()
|
||||
validate_elf(data, 62 if arch == "amd64" else 183)
|
||||
metadata = dict(source=source, arch=arch, settings=settings,
|
||||
engine_sha256=hashlib.sha256(data).hexdigest(),
|
||||
network_sha256=hashlib.sha256(network).hexdigest())
|
||||
base = stable if "IS_RELEASE=1" in flags else development
|
||||
args.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
archive = args.artifacts / (base + "-universal-slice.tar.gz")
|
||||
with tarfile.open(archive, "w:gz") as bundle:
|
||||
for name, contents in (("heimdall", data), ("network.bin", network),
|
||||
("metadata.json", json.dumps(metadata, sort_keys=True).encode())):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(contents)
|
||||
info.mode = 0o755 if name == "heimdall" else 0o644
|
||||
bundle.addfile(info, io.BytesIO(contents))
|
||||
print("Packaged internal slice: " + str(archive), flush=True)
|
||||
|
||||
|
||||
def read_linux_slice(path, arch, source):
|
||||
with tarfile.open(path) as archive:
|
||||
if sorted(archive.getnames()) != ["heimdall", "metadata.json", "network.bin"]:
|
||||
raise ValueError(f"Unexpected members in {path}")
|
||||
if any(not member.isfile() for member in archive.getmembers()):
|
||||
raise ValueError(f"Non-file member in {path}")
|
||||
metadata = json.load(archive.extractfile("metadata.json"))
|
||||
engine = archive.extractfile("heimdall").read()
|
||||
network = archive.extractfile("network.bin").read()
|
||||
if not isinstance(metadata, dict) or not isinstance(metadata.get("settings"), list):
|
||||
raise ValueError(f"Invalid slice metadata in {path}")
|
||||
if metadata.get("source") != source or metadata.get("arch") != arch:
|
||||
raise ValueError(f"Source/architecture mismatch in {path}")
|
||||
for key, data in (("engine", engine), ("network", network)):
|
||||
if metadata.get(key + "_sha256") != hashlib.sha256(data).hexdigest():
|
||||
raise ValueError(f"Checksum mismatch for {key} in {path}")
|
||||
validate_elf(engine, 62 if arch == "amd64" else 183)
|
||||
return engine, network, metadata
|
||||
|
||||
|
||||
def combine_linux(args):
|
||||
flags = version_flags(args.tag)
|
||||
system, arch, stable, development, _ = make_config(flags)
|
||||
if system != "linux":
|
||||
raise ValueError("combine-linux requires a Linux source checkout")
|
||||
source = source_commit("HEAD")
|
||||
if args.tag:
|
||||
release_source(source, args.tag, "HEAD")
|
||||
base = (stable if "IS_RELEASE=1" in flags else development).removesuffix("-" + arch)
|
||||
slices = [read_linux_slice(args.slices / (base + "-" + cpu + "-universal-slice.tar.gz"), cpu, source)
|
||||
for cpu in ("amd64", "arm64")]
|
||||
if slices[0][1] != slices[1][1] or slices[0][2]["settings"] != slices[1][2]["settings"]:
|
||||
raise ValueError("Linux slices must use identical network weights and architecture settings")
|
||||
name = base + "-universal"
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
binary = Path(temp) / name
|
||||
linux_executable(slices[0][0], slices[1][0], slices[0][1], binary)
|
||||
files = package(binary, args.artifacts)
|
||||
outputs(dict(artifact_name=name))
|
||||
print("Packaged: " + ", ".join(map(str, files)), flush=True)
|
||||
|
||||
|
||||
def build(args):
|
||||
if args.target == "linux-universal":
|
||||
raise ValueError("Use slice-linux for linux-amd64-universal and linux-arm64-universal on their native hosts, "
|
||||
"then use combine-linux --slices DIRECTORY")
|
||||
target = TARGETS[args.target]
|
||||
flags = version_flags(args.tag)
|
||||
system, arch, release, prerelease, extension = make_config(flags)
|
||||
@@ -188,10 +341,19 @@ def main():
|
||||
building.add_argument("--tag", default="")
|
||||
building.add_argument("--artifacts", type=Path, default=Path("artifacts"))
|
||||
building.add_argument("--skip-deps", action="store_true", help="Use locally installed dependencies and weights")
|
||||
slicing = commands.add_parser("slice-linux", help="Build one internal Linux slice with external default weights")
|
||||
slicing.add_argument("--target", required=True, choices=LINUX_SLICES)
|
||||
slicing.add_argument("--tag", default="")
|
||||
slicing.add_argument("--artifacts", type=Path, default=Path("slices"))
|
||||
slicing.add_argument("--skip-deps", action="store_true")
|
||||
combining = commands.add_parser("combine-linux", help="Combine bench-checked Linux slice artifacts")
|
||||
combining.add_argument("--slices", type=Path, required=True, help="Directory containing both internal slice archives")
|
||||
combining.add_argument("--tag", default="")
|
||||
combining.add_argument("--artifacts", type=Path, default=Path("artifacts"))
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
(plan if args.command == "plan" else build)(args)
|
||||
except (ValueError, subprocess.CalledProcessError) as exc:
|
||||
{"plan": plan, "build": build, "slice-linux": slice_linux, "combine-linux": combine_linux}[args.command](args)
|
||||
except (OSError, ValueError, tarfile.TarError, subprocess.CalledProcessError) as exc:
|
||||
parser.exit(1, str(exc) + "\n")
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import heimdall/util/memory/thp/alloc
|
||||
when defined(simd):
|
||||
import heimdall/util/simd_dispatch
|
||||
import std/typetraits
|
||||
when not EMBED_NET:
|
||||
import std/os
|
||||
|
||||
when defined(simd):
|
||||
import heimdall/util/simd
|
||||
@@ -113,7 +115,11 @@ proc newEvalState*(networkPath: string = "", verbose: static bool = true): EvalS
|
||||
# zero = true: EvalStateObj holds a managed board ref that must start nil
|
||||
result = allocHugePage[EvalStateObj](zero = true)
|
||||
if networkPath == "":
|
||||
when not VERBATIM_NET:
|
||||
when not EMBED_NET:
|
||||
when verbose:
|
||||
echo "info string loading bundled network"
|
||||
network = loadNet(getAppDir() / "network.bin")
|
||||
elif not VERBATIM_NET:
|
||||
when verbose:
|
||||
echo "info string loading built-in network"
|
||||
network = loadNet(newStringStream(DEFAULT_NET_WEIGHTS))
|
||||
|
||||
@@ -70,8 +70,9 @@ const
|
||||
14, 14, 15, 15, 15, 15, 14, 14,
|
||||
]
|
||||
DEFAULT_NET_PATH* {.define: "evalFile".} = ""
|
||||
EMBED_NET* {.booldefine: "embedNet".} = true
|
||||
DEFAULT_NET_WEIGHTS* = block:
|
||||
when not VERBATIM_NET:
|
||||
when not VERBATIM_NET and EMBED_NET:
|
||||
staticRead(DEFAULT_NET_PATH)
|
||||
else:
|
||||
""
|
||||
@@ -314,6 +315,8 @@ proc dumpNet*(net: Network, path: string) =
|
||||
|
||||
proc loadNet*(path: string): Network =
|
||||
let net = newFileStream(path, fmRead)
|
||||
if net == nil:
|
||||
raise newException(IOError, "Cannot open network: " & path)
|
||||
defer: net.close()
|
||||
|
||||
return net.loadNet()
|
||||
|
||||
@@ -88,8 +88,8 @@ const HELP_TEXT* = """heimdall help menu:
|
||||
- ibucket : Print the current king input bucket
|
||||
- obucket : Print the current output bucket
|
||||
- material : Print the sum of material (using 1, 3, 3, 5, 9 as values) currently on the board
|
||||
- verbatim <path> : Dumps the built-in network to the specified path, straight from the binary
|
||||
- network : Prints the name of the network embedded into the engine
|
||||
- verbatim <path> : Dumps the default network to the specified path in memory layout
|
||||
- network : Prints the name of the default network shipped with the engine
|
||||
- uci : Switches from mixed mode to UCI mode
|
||||
- icu : The opposite of the uci command, reverts back to mixed mode.
|
||||
This nonstandard command is (obviously) available even in UCI mode.
|
||||
@@ -100,4 +100,4 @@ const HELP_TEXT* = """heimdall help menu:
|
||||
- getStats <book> : Collect statistics about an opening book
|
||||
- getScale <o> <n>: Print the new expected eval scale given the old (o) and new (n)
|
||||
absolute mean values returned by getStats
|
||||
"""
|
||||
"""
|
||||
@@ -0,0 +1,244 @@
|
||||
# Copyright 2026 Mattia Giambirtone & All Contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Authored with assistance from AI agents.
|
||||
|
||||
"""Check the combined Linux executable, shared weights and private cache."""
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
import gzip
|
||||
import io
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("release", ROOT / "scripts/release.py")
|
||||
release = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(release)
|
||||
|
||||
|
||||
def elf(machine):
|
||||
header = bytearray(64)
|
||||
header[:7] = b"\x7fELF\x02\x01\x01"
|
||||
header[16:18] = (3).to_bytes(2, "little")
|
||||
header[18:20] = machine.to_bytes(2, "little")
|
||||
return bytes(header)
|
||||
|
||||
|
||||
class LinuxPackageTests(unittest.TestCase):
|
||||
def test_executable_contains_one_network_and_two_valid_compressed_slices(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
directory = Path(temp)
|
||||
binary = directory / "heimdall-linux-universal"
|
||||
network = b"shared network weights" * 100
|
||||
release.linux_executable(elf(62), elf(183), network, binary)
|
||||
data = binary.read_bytes()
|
||||
header = data.split(b"# Compressed payload follows.\n", 1)[0].decode()
|
||||
self.assertNotIn("@NETWORK", header)
|
||||
for key, expected in (("amd64", elf(62)), ("arm64", elf(183))):
|
||||
block = header.split("arch=" + key, 1)[1].split(";;", 1)[0]
|
||||
offset = int(re.search(r"engine_offset=(\d+)", block)[1])
|
||||
size = int(re.search(r"engine_size=(\d+)", block)[1])
|
||||
self.assertEqual(gzip.decompress(data[offset:offset + size]), expected)
|
||||
offset, size = map(int, re.search(r'extract (\d+) (\d+) "\$stage/network.bin"', header).groups())
|
||||
self.assertEqual(gzip.decompress(data[offset:offset + size]), network)
|
||||
self.assertEqual(offset + size, len(data))
|
||||
release.linux_executable(elf(62), elf(183), network, binary)
|
||||
self.assertEqual(binary.read_bytes(), data)
|
||||
files = release.package(binary, directory / "artifacts")
|
||||
self.assertEqual(files[1].read_text(), hashlib.sha256(data).hexdigest() + " " + binary.name + "\n")
|
||||
with tarfile.open(files[2]) as archive:
|
||||
self.assertEqual(set(archive.getnames()), {binary.name, files[1].name})
|
||||
if os.name == "posix":
|
||||
self.assertEqual(archive.getmember(binary.name).mode, 0o755)
|
||||
|
||||
def test_wrong_architecture_non_elf_and_truncated_inputs_are_rejected(self):
|
||||
for bad in (elf(183), b"#!/bin/sh\n", elf(62)[:20]):
|
||||
with self.assertRaisesRegex(ValueError, "ELF64"):
|
||||
release.validate_elf(bad, 62)
|
||||
|
||||
def test_combine_rejects_wrong_source_checksums_network_and_settings(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
directory = Path(temp)
|
||||
def write_slice(arch, **overrides):
|
||||
data = elf(62 if arch == "amd64" else 183)
|
||||
network = overrides.pop("network", b"shared weights")
|
||||
metadata = dict(source="source", arch=arch, settings=["-d:embedNet=false"],
|
||||
engine_sha256=hashlib.sha256(data).hexdigest(),
|
||||
network_sha256=hashlib.sha256(network).hexdigest())
|
||||
metadata.update(overrides)
|
||||
path = directory / ("heimdall-dev-abcdef-linux-" + arch + "-universal-slice.tar.gz")
|
||||
with tarfile.open(path, "w:gz") as archive:
|
||||
for name, payload in (("heimdall", data), ("network.bin", network),
|
||||
("metadata.json", json.dumps(metadata).encode())):
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(payload)
|
||||
archive.addfile(member, io.BytesIO(payload))
|
||||
write_slice("amd64")
|
||||
write_slice("arm64")
|
||||
args = argparse.Namespace(tag="", slices=directory, artifacts=directory / "out")
|
||||
with patch.object(release, "make_config", return_value=("linux", "amd64", "unused",
|
||||
"heimdall-dev-abcdef-linux-amd64", "x")), \
|
||||
patch.object(release, "source_commit", return_value="source"), \
|
||||
patch.object(release, "outputs") as outputs, patch("builtins.print"):
|
||||
release.combine_linux(args)
|
||||
self.assertEqual(outputs.call_args.args[0]["artifact_name"], "heimdall-dev-abcdef-linux-universal")
|
||||
for changes, error in ((dict(source="other"), "Source/architecture"),
|
||||
(dict(engine_sha256="bad"), "Checksum"),
|
||||
(dict(network=b"other weights"), "identical network"),
|
||||
(dict(settings=["other settings"]), "identical network")):
|
||||
write_slice("arm64", **changes)
|
||||
with self.assertRaisesRegex(ValueError, error):
|
||||
release.combine_linux(args)
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux"), "Linux launcher uses Linux readlink")
|
||||
class LinuxLauncherTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="heimdall bundle ")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.directory = Path(self.temp.name)
|
||||
self.bundle = self.directory / "relocated package"
|
||||
self.bundle.mkdir()
|
||||
self.launcher = self.bundle / "heimdall"
|
||||
self.network = b"one shared network"
|
||||
engines = []
|
||||
for arch in ("amd64", "arm64"):
|
||||
engines.append(("#!" + sys.executable + "\n" +
|
||||
"import json, os, sys, time\nfrom pathlib import Path\n" +
|
||||
"assert Path(sys.argv[0]).with_name('network.bin').read_bytes() == " + repr(self.network) + "\n" +
|
||||
"if 'wait' in sys.argv: print(os.getpid(), flush=True); time.sleep(30)\n" +
|
||||
"print(json.dumps([" + repr(arch) + ", sys.argv[1:], " +
|
||||
"os.getcwd(), os.environ.get('HEIMDALL_SIMD'), sys.stdin.read()]))\n" +
|
||||
"print('engine stderr', file=sys.stderr)\n" +
|
||||
"sys.exit(23)\n").encode())
|
||||
# Launcher protocol tests use tiny scripts; production packaging validates ELF.
|
||||
with patch.object(release, "validate_elf"):
|
||||
release.linux_executable(*engines, self.network, self.launcher)
|
||||
commands = self.directory / "commands"
|
||||
commands.mkdir()
|
||||
uname = commands / "uname"
|
||||
uname.write_text('#!/bin/sh\ncase "$1" in -s) echo "${TEST_OS:-Linux}";; -m) echo "$TEST_ARCH";; esac\n')
|
||||
uname.chmod(0o755)
|
||||
self.env = {**os.environ, "PATH": str(commands) + ":" + os.environ["PATH"],
|
||||
"TEST_ARCH": "x86_64", "HEIMDALL_SIMD": "scalar",
|
||||
"HEIMDALL_CACHE_DIR": str(self.directory / "private cache")}
|
||||
|
||||
def run_launcher(self, command=None, **env):
|
||||
return subprocess.run([str(command or self.launcher), "a b", "", "--flag"],
|
||||
input="uci\nisready\n", capture_output=True, text=True,
|
||||
cwd=self.directory, env={**self.env, **env}, timeout=10)
|
||||
|
||||
def test_selects_both_families_and_preserves_uci_arguments_environment_and_cwd(self):
|
||||
for arch, expected in (("x86_64", "amd64"), ("amd64", "amd64"),
|
||||
("aarch64", "arm64"), ("arm64", "arm64")):
|
||||
with self.subTest(arch=arch):
|
||||
result = self.run_launcher(TEST_ARCH=arch)
|
||||
self.assertEqual(result.returncode, 23, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout),
|
||||
[expected, ["a b", "", "--flag"], str(self.directory),
|
||||
"scalar", "uci\nisready\n"])
|
||||
self.assertEqual(result.stderr, "engine stderr\n")
|
||||
|
||||
def test_symlink_and_path_launches_locate_the_payload(self):
|
||||
link = self.directory / "engine link"
|
||||
link.symlink_to(self.launcher)
|
||||
for command, env in ((link, {}), ("heimdall", {"PATH": str(self.bundle) + ":" + self.env["PATH"]})):
|
||||
result = self.run_launcher(command, **env)
|
||||
self.assertEqual(result.returncode, 23, result.stderr)
|
||||
|
||||
def test_exec_preserves_pid_and_direct_signal_delivery(self):
|
||||
with subprocess.Popen([str(self.launcher), "wait"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, env=self.env) as process:
|
||||
try:
|
||||
self.assertEqual(int(process.stdout.readline()), process.pid)
|
||||
process.terminate()
|
||||
self.assertEqual(process.wait(timeout=5), -signal.SIGTERM)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
|
||||
def test_repeated_launch_reuses_cache_and_corruption_is_repaired(self):
|
||||
self.assertEqual(self.run_launcher().returncode, 23)
|
||||
root = Path(self.env["HEIMDALL_CACHE_DIR"])
|
||||
cache = next(root.iterdir())
|
||||
binary, network = cache / "heimdall", cache / "network.bin"
|
||||
for path in (root, cache):
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o700)
|
||||
self.assertEqual(binary.stat().st_mode & 0o777, 0o500)
|
||||
self.assertEqual(network.stat().st_mode & 0o777, 0o400)
|
||||
before = binary.stat().st_mtime_ns
|
||||
self.assertEqual(self.run_launcher().returncode, 23)
|
||||
self.assertEqual(binary.stat().st_mtime_ns, before)
|
||||
network.chmod(0o600)
|
||||
network.write_bytes(b"corrupt")
|
||||
self.assertEqual(self.run_launcher().returncode, 23)
|
||||
self.assertEqual(network.read_bytes(), self.network)
|
||||
binary.unlink()
|
||||
self.assertEqual(self.run_launcher().returncode, 23)
|
||||
self.assertEqual(sorted(p.name for p in cache.iterdir()), ["heimdall", "network.bin"])
|
||||
|
||||
def test_concurrent_cold_starts_publish_identical_files_without_stale_locks(self):
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(lambda _: self.run_launcher(), range(16)))
|
||||
for result in results:
|
||||
self.assertEqual(result.returncode, 23, result.stderr)
|
||||
cache = next(Path(self.env["HEIMDALL_CACHE_DIR"]).iterdir())
|
||||
self.assertEqual(sorted(p.name for p in cache.iterdir()), ["heimdall", "network.bin"])
|
||||
|
||||
def test_unsupported_os_architecture_and_truncated_payload_fail_on_stderr(self):
|
||||
for env, message in (({"TEST_OS": "Darwin"}, "requires Linux"),
|
||||
({"TEST_ARCH": "riscv64"}, "AMD64 or ARM64")):
|
||||
result = self.run_launcher(**env)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertIn(message, result.stderr)
|
||||
self.launcher.write_bytes(self.launcher.read_bytes()[:-10])
|
||||
result = self.run_launcher()
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertIn("extraction failed", result.stderr)
|
||||
root = Path(self.env["HEIMDALL_CACHE_DIR"])
|
||||
self.assertEqual(list(root.glob("*/.extract.*")), [])
|
||||
|
||||
def test_unsafe_cache_permissions_symlinks_and_relative_paths_are_rejected(self):
|
||||
root = Path(self.env["HEIMDALL_CACHE_DIR"])
|
||||
root.mkdir(mode=0o755)
|
||||
self.assertIn("private", self.run_launcher().stderr)
|
||||
root.rmdir()
|
||||
target = self.directory / "target"
|
||||
target.mkdir(mode=0o700)
|
||||
root.symlink_to(target, target_is_directory=True)
|
||||
self.assertIn("not a symlink", self.run_launcher().stderr)
|
||||
self.assertIn("absolute path", self.run_launcher(HEIMDALL_CACHE_DIR="relative").stderr)
|
||||
|
||||
def test_xdg_cache_default(self):
|
||||
result = self.run_launcher(HEIMDALL_CACHE_DIR="", XDG_CACHE_HOME=str(self.directory / "xdg"))
|
||||
self.assertEqual(result.returncode, 23, result.stderr)
|
||||
self.assertEqual(len(list((self.directory / "xdg/heimdall").glob("*/network.bin"))), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -57,6 +57,21 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
mac = release.matrix("macos-universal")["include"][0]
|
||||
self.assertEqual(mac["make_target"], "macos-universal")
|
||||
self.assertEqual(mac["arch"], "universal")
|
||||
self.assertEqual({job["target"] for job in jobs if job["publish"]},
|
||||
{"windows-amd64-universal", "macos-universal"})
|
||||
|
||||
def test_combined_linux_builds_both_slices_without_publishing_them(self):
|
||||
jobs = release.matrix("linux-universal")["include"]
|
||||
self.assertEqual({job["target"] for job in jobs}, set(release.LINUX_SLICES))
|
||||
self.assertTrue(all(not job["publish"] for job in jobs))
|
||||
self.assertTrue(release.includes_linux_bundle("universal"))
|
||||
for selection in ("linux", "all"):
|
||||
jobs = release.matrix(selection)["include"]
|
||||
self.assertEqual(len(jobs), len({job["target"] for job in jobs}))
|
||||
self.assertTrue(all(job["publish"] for job in jobs))
|
||||
for selection in release.LINUX_SLICES:
|
||||
self.assertFalse(release.includes_linux_bundle(selection))
|
||||
self.assertTrue(release.matrix(selection)["include"][0]["publish"])
|
||||
|
||||
def test_published_additions_use_the_tagged_source(self):
|
||||
with patch.object(release, "source_commit", side_effect=lambda ref: {"refs/tags/1.5.1-dev": "tagged", "master": "newer"}[ref]), \
|
||||
@@ -84,6 +99,7 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
patch.object(release, "outputs") as outputs, patch("builtins.print"):
|
||||
release.plan(args)
|
||||
self.assertEqual(outputs.call_args.args[0]["tag"], "1.5.1-dev")
|
||||
self.assertEqual(outputs.call_args.args[0]["linux_universal"], "true")
|
||||
self.assertEqual(json.loads(outputs.call_args.args[0]["matrix"]), release.matrix("universal"))
|
||||
|
||||
def test_default_cli_plan_selects_only_universal_binaries(self):
|
||||
@@ -106,6 +122,8 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
directory = Path(temp)
|
||||
names = set()
|
||||
for target in release.TARGETS.values():
|
||||
if target["target"] == "linux-universal":
|
||||
continue # The combined Linux archive is covered separately.
|
||||
binary = directory / ("heimdall-1.5.1-" + target["target"] + (".exe" if target["os"] == "windows" else ""))
|
||||
binary.write_bytes(b"test executable")
|
||||
binary.chmod(0o755)
|
||||
|
||||
Reference in new issue
Block a user