Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a570d38c2b
|
|||
|
12903ac97a
|
|||
|
baa5e82c16
|
|||
|
14a248638b
|
|||
|
a12eacea82
|
|||
|
872dba5219
|
|||
|
d602d7e2c1
|
|||
|
5dde49ed0c
|
|||
|
59699841f5
|
|||
|
b182e53f83
|
|||
|
8a812ae6b3
|
|||
|
9e16ed867d
|
|||
|
660fea4a56
|
|||
|
b7bfe4c8e6
|
|||
|
d72809cf69
|
|||
|
e8a2611a88
|
|||
|
7c418f2f9b
|
|||
|
5f2b6dca57
|
|||
|
6a313a2c41
|
|||
|
fa63f28c19
|
|||
|
17f8102a9b
|
|||
|
1e2d2b0dc8
|
|||
|
6370fb5941
|
|||
|
97aab4aea2
|
|||
|
c7b02fbe1a
|
|||
|
00b1950b87
|
|||
|
f4156c9c00
|
|||
|
6a73b1ec86
|
|||
|
35293bafaa
|
|||
|
899f031976
|
|||
|
79fd2b4a9c
|
|||
|
e110f2419e
|
|||
|
344fed5589
|
|||
|
1fdb162c76
|
|||
|
708803afd7
|
|||
|
96d6d73c92
|
|||
|
7fe35dcf29
|
|||
|
f2befa5a4d
|
|||
|
27b192eee2
|
|||
|
e5e52a4946
|
|||
|
6e2f8df714
|
|||
|
6c42b7a8dd
|
|||
|
7cc48030f9
|
|||
|
f2fb343556
|
|||
|
2f430bd5cb
|
|||
|
f2657d025f
|
|||
|
4477560e9e
|
|||
|
4ba8d3b2f9
|
|||
|
7954d5010a
|
|||
|
7f66a42a4c
|
|||
|
1018f5c1a4
|
|||
|
14ded78eab
|
|||
|
70edfdcdfc
|
|||
|
5ef4974993
|
|||
|
61d82a67d8
|
|||
|
297963ee3f
|
|||
|
3a3d37a563
|
|||
|
79784c33d8
|
|||
|
25073310c7
|
|||
|
c8ad8b2806
|
|||
|
01948cb2fe
|
|||
|
2acfb53bfa
|
|||
|
f41874315d
|
|||
|
dcc7d7c614
|
|||
|
ef22de4886
|
|||
|
52b610a101
|
|||
|
50353a4631
|
|||
|
5d46ea5dac
|
|||
|
556c9c8a6e
|
|||
|
e191ee8ac2
|
|||
|
f37f0ffd3d
|
|||
|
94af695bda
|
|||
|
c1e0f7a708
|
|||
|
11dc969671
|
|||
|
9a598e40e3
|
|||
|
b607a75fd9
|
|||
|
d6a3386753
|
|||
|
c010a37702
|
|||
|
d8ec13e049
|
@@ -1 +1,15 @@
|
||||
# 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.
|
||||
|
||||
*
|
||||
13
.gitattributes
vendored
13
.gitattributes
vendored
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
|
||||
48
.github/actions/setup-nim-linux/action.yml
vendored
Normal file
48
.github/actions/setup-nim-linux/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
|
||||
name: Set up Nim on Linux
|
||||
description: Install the project's Nim version on x86_64 or AArch64
|
||||
inputs:
|
||||
version:
|
||||
description: Nim release version
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
NIM_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nim_root="$RUNNER_TEMP/nim"
|
||||
mkdir -p "$nim_root"
|
||||
case "$(uname -m)" in
|
||||
x86_64) archive="nim-${NIM_VERSION}-linux_x64.tar.xz" ;;
|
||||
aarch64) archive="nim-${NIM_VERSION}.tar.xz" ;;
|
||||
*) echo "Unsupported Nim host architecture" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://nim-lang.org/download/$archive" -o "$nim_root/$archive"
|
||||
tar -xJf "$nim_root/$archive" -C "$nim_root"
|
||||
if [[ "$(uname -m)" == aarch64 ]]; then
|
||||
# Nim 2.2.6 has no official Linux ARM64 binary archive.
|
||||
cd "$nim_root/nim-${NIM_VERSION}"
|
||||
sh build.sh
|
||||
bin/nim c -d:release koch
|
||||
./koch boot -d:release
|
||||
./koch nimble
|
||||
fi
|
||||
echo "$nim_root/nim-${NIM_VERSION}/bin" >> "$GITHUB_PATH"
|
||||
37
.github/actions/setup-nim-macos/action.yml
vendored
Normal file
37
.github/actions/setup-nim-macos/action.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
|
||||
name: Set up Nim on macOS
|
||||
description: Install Nim 2.2.6 for the native Mac architecture
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$(uname -m)" in
|
||||
x86_64) arch=x64 ;;
|
||||
arm64) arch=arm64 ;;
|
||||
*) echo "Unsupported macOS architecture" >&2; exit 1 ;;
|
||||
esac
|
||||
# Official 2.2.6 archives linked by nim-lang.org/install.html.
|
||||
release=2025-10-31-version-2-2-ab00c56904e3126ad826bb520d243513a139436a
|
||||
archive="nim-2.2.6-macosx_${arch}.tar.xz"
|
||||
nim_root="$RUNNER_TEMP/nim"
|
||||
mkdir -p "$nim_root"
|
||||
curl -fsSL "https://github.com/nim-lang/nightlies/releases/download/$release/$archive" -o "$nim_root/$archive"
|
||||
tar -xJf "$nim_root/$archive" -C "$nim_root"
|
||||
echo "$nim_root/nim-2.2.6/bin" >> "$GITHUB_PATH"
|
||||
146
.github/workflows/linux-release.yml
vendored
146
.github/workflows/linux-release.yml
vendored
@@ -1,146 +0,0 @@
|
||||
name: Linux release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Optional branch, tag, or SHA to build"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: linux-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Linux release binaries
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
NIM_VERSION: "2.2.6"
|
||||
CXX: clang++
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
lfs: false
|
||||
submodules: false
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
clang \
|
||||
curl \
|
||||
git-lfs \
|
||||
lld \
|
||||
make \
|
||||
xz-utils
|
||||
|
||||
- name: Build release binaries
|
||||
id: build
|
||||
env:
|
||||
BUILD_REF: ${{ github.event.inputs.ref || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
nim_root="$RUNNER_TEMP/nim"
|
||||
mkdir -p "$nim_root"
|
||||
|
||||
curl -fsSL \
|
||||
-o "$RUNNER_TEMP/nim-${NIM_VERSION}-linux_x64.tar.xz" \
|
||||
"https://nim-lang.org/download/nim-${NIM_VERSION}-linux_x64.tar.xz"
|
||||
tar -xJf "$RUNNER_TEMP/nim-${NIM_VERSION}-linux_x64.tar.xz" -C "$nim_root"
|
||||
export PATH="$nim_root/nim-${NIM_VERSION}/bin:$PATH"
|
||||
|
||||
nim --version
|
||||
nimble --version
|
||||
clang --version
|
||||
ld.lld --version
|
||||
git lfs install --local
|
||||
|
||||
ref="${BUILD_REF:-}"
|
||||
ref_name="${GITHUB_REF_NAME:-}"
|
||||
ref_type="${GITHUB_REF_TYPE:-}"
|
||||
case "$ref" in
|
||||
refs/tags/*)
|
||||
ref_type="tag"
|
||||
ref_name="${ref#refs/tags/}"
|
||||
;;
|
||||
refs/heads/*)
|
||||
ref_type="branch"
|
||||
ref_name="${ref#refs/heads/}"
|
||||
;;
|
||||
"")
|
||||
;;
|
||||
*)
|
||||
ref_name="$ref"
|
||||
;;
|
||||
esac
|
||||
|
||||
version="$ref_name"
|
||||
version="${version#v}"
|
||||
make_args=()
|
||||
if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "Using Makefile version ${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]} from ref ${ref_name}"
|
||||
make_args+=(
|
||||
"MAJOR_VERSION=${BASH_REMATCH[1]}"
|
||||
"MINOR_VERSION=${BASH_REMATCH[2]}"
|
||||
"PATCH_VERSION=${BASH_REMATCH[3]}"
|
||||
)
|
||||
else
|
||||
echo "Using Makefile default version for ref ${ref_name:-<unknown>}"
|
||||
fi
|
||||
|
||||
if [[ "$ref_type" == "tag" && ( "$ref_name" == *alpha* || "$ref_name" == *beta* || "$ref_name" == *rc* || "$ref_name" == *dev* ) ]]; then
|
||||
make prereleases
|
||||
else
|
||||
make ci-releases "${make_args[@]}"
|
||||
fi
|
||||
|
||||
core2_binary="$(find bin -maxdepth 1 -type f -name 'heimdall-*-linux-amd64-core2' -print -quit)"
|
||||
if [[ -z "$core2_binary" ]]; then
|
||||
echo "Could not find built Core 2 binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
release_base="$(basename "$core2_binary")"
|
||||
release_base="${release_base%-core2}"
|
||||
|
||||
mkdir -p artifacts
|
||||
cp "bin/${release_base}"-* artifacts/
|
||||
(
|
||||
cd artifacts
|
||||
sha256sum "${release_base}"-* > SHA256SUMS-linux-amd64.txt
|
||||
tar -czf "${release_base}.tar.gz" "${release_base}"-* SHA256SUMS-linux-amd64.txt
|
||||
)
|
||||
|
||||
echo "artifact_name=${release_base}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to Gitea release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
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: python scripts/publish_gitea_release.py --tag "$GITHUB_REF_NAME" --files artifacts/heimdall-*-linux-amd64-* artifacts/SHA256SUMS-linux-amd64.txt
|
||||
|
||||
- name: Upload release binaries
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.build.outputs.artifact_name }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
157
.github/workflows/release.yml
vendored
Normal file
157
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
|
||||
name: Release binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*", "*.*.*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "universal (default), all, linux, windows, macos, or one target (e.g. linux-amd64-avx2)"
|
||||
type: string
|
||||
default: universal
|
||||
source_ref:
|
||||
description: "Source branch/tag/SHA; defaults to release_tag, then the workflow ref"
|
||||
type: string
|
||||
release_tag:
|
||||
description: "Existing tag to publish to; leave empty for Actions artifacts only"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
source_sha: ${{ steps.plan.outputs.source_sha }}
|
||||
tag: ${{ steps.plan.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
lfs: false
|
||||
- name: Select targets and source
|
||||
id: plan
|
||||
env:
|
||||
RELEASE_TARGET: ${{ inputs.target || 'universal' }}
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: python3 scripts/release.py plan --target "$RELEASE_TARGET" --ref "$SOURCE_REF" --tag "$RELEASE_TAG"
|
||||
|
||||
build:
|
||||
name: ${{ matrix.target }}
|
||||
needs: plan
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: release-${{ needs.plan.outputs.tag || needs.plan.outputs.source_sha }}-${{ matrix.target }}
|
||||
cancel-in-progress: false
|
||||
defaults:
|
||||
run:
|
||||
shell: ${{ matrix.os == 'windows' && 'msys2 {0}' || 'bash' }}
|
||||
working-directory: source
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
NIM_VERSION: "2.2.6"
|
||||
RELEASE_TARGET: ${{ matrix.target }}
|
||||
RELEASE_TAG: ${{ needs.plan.outputs.tag }}
|
||||
SOURCE_SHA: ${{ needs.plan.outputs.source_sha }}
|
||||
# Also honored by Clang when building an existing tag's older Makefile.
|
||||
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
||||
steps:
|
||||
# Keep current CI tooling separate from the release's exact source revision.
|
||||
- name: Check out workflow tooling
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: ci
|
||||
lfs: false
|
||||
- name: Check out engine source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.plan.outputs.source_sha }}
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
lfs: false
|
||||
submodules: false
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang curl gcc git-lfs lld make xz-utils zlib1g-dev
|
||||
- name: Install macOS dependencies
|
||||
if: matrix.os == 'macos'
|
||||
run: |
|
||||
brew install make git-lfs
|
||||
echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH"
|
||||
xcrun clang --version
|
||||
- name: Set up MSYS2
|
||||
if: matrix.os == 'windows'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
update: true
|
||||
install: >-
|
||||
curl git make unzip
|
||||
mingw-w64-clang-x86_64-clang
|
||||
mingw-w64-clang-x86_64-git-lfs
|
||||
mingw-w64-clang-x86_64-lld
|
||||
mingw-w64-clang-x86_64-python
|
||||
- name: Install Nim on Linux
|
||||
if: matrix.os == 'linux'
|
||||
uses: ./ci/.github/actions/setup-nim-linux
|
||||
with:
|
||||
version: ${{ env.NIM_VERSION }}
|
||||
- name: Install Nim on macOS
|
||||
if: matrix.os == 'macos'
|
||||
uses: ./ci/.github/actions/setup-nim-macos
|
||||
- name: Install Nim on Windows
|
||||
if: matrix.os == 'windows'
|
||||
run: |
|
||||
nim_root="$(cygpath -u "$RUNNER_TEMP")/nim"
|
||||
mkdir -p "$nim_root"
|
||||
curl -fsSL "https://nim-lang.org/download/nim-${NIM_VERSION}_x64.zip" -o "$nim_root/nim.zip"
|
||||
unzip -q "$nim_root/nim.zip" -d "$nim_root"
|
||||
- name: Build and check one target
|
||||
id: build
|
||||
run: |
|
||||
if [[ "$RUNNER_OS" == Windows ]]; then
|
||||
export PATH="$(cygpath -u "$RUNNER_TEMP")/nim/nim-${NIM_VERSION}/bin:$PATH"
|
||||
fi
|
||||
nim --version
|
||||
git lfs install --local
|
||||
python3 ../ci/scripts/release.py build --target "$RELEASE_TARGET" --tag "$RELEASE_TAG" --artifacts ../artifacts
|
||||
- name: Publish this target to Gitea
|
||||
if: needs.plan.outputs.tag != ''
|
||||
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 ../ci/scripts/publish_gitea_release.py --tag "$RELEASE_TAG" --target-commitish "$SOURCE_SHA" --files ../artifacts/*
|
||||
- name: Upload this target to Actions
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.build.outputs.artifact_name }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
153
.github/workflows/simd.yml
vendored
Normal file
153
.github/workflows/simd.yml
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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.
|
||||
|
||||
name: SIMD correctness
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: simd-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux ${{ matrix.backend }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {runner: ubuntu-24.04, backend: scalar}
|
||||
- {runner: ubuntu-24.04, backend: sse2, cpu_model: Opteron_G1}
|
||||
- {runner: ubuntu-24.04, backend: ssse3, cpu_model: Conroe}
|
||||
- {runner: ubuntu-24.04, backend: sse41, cpu_model: Penryn}
|
||||
- {runner: ubuntu-24.04, backend: avx2}
|
||||
- {runner: ubuntu-24.04-arm, backend: neon}
|
||||
- {runner: ubuntu-24.04, backend: universal}
|
||||
- {runner: ubuntu-24.04-arm, backend: universal}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
NIM_VERSION: "2.2.6"
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang lld make gcc curl xz-utils python3 zlib1g-dev
|
||||
- uses: ./.github/actions/setup-nim-linux
|
||||
with:
|
||||
version: ${{ env.NIM_VERSION }}
|
||||
- name: Install Nim dependencies
|
||||
run: make deps NIMBLE_FLAGS=-y
|
||||
- name: Run shared SIMD and NNUE checks
|
||||
run: make test-simd SIMD="$BACKEND" PYTHON=python3
|
||||
- name: Verify minimum x86 instruction set
|
||||
if: matrix.cpu_model
|
||||
env:
|
||||
CPU_MODEL: ${{ matrix.cpu_model }}
|
||||
run: |
|
||||
sudo apt-get install -y qemu-user
|
||||
qemu-x86_64 -cpu "$CPU_MODEL" "build/simd/$BACKEND/test_simd"
|
||||
- name: Verify runtime fallback on older CPUs and missing OS vector state
|
||||
if: matrix.backend == 'universal' && matrix.runner == 'ubuntu-24.04'
|
||||
run: |
|
||||
sudo apt-get install -y qemu-user
|
||||
for cpu in Opteron_G1 Conroe Penryn Haswell,-hle,-rtm Haswell,-hle,-rtm,-xsave; do
|
||||
qemu-x86_64 -cpu "$cpu" build/simd/universal/test_simd
|
||||
qemu-x86_64 -cpu "$cpu" build/simd/universal/test_multilayer
|
||||
done
|
||||
- name: Build engine and run UCI regressions
|
||||
run: |
|
||||
make dev SIMD="$BACKEND" IS_TEST=1 EVALFILE="$PWD/build/simd/$BACKEND/multilayer-ti.bin"
|
||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
|
||||
universal-platforms:
|
||||
name: Universal ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {runner: windows-latest, os: windows}
|
||||
- {runner: macos-15-intel, os: macos}
|
||||
- {runner: macos-15, os: macos}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
shell: ${{ matrix.os == 'windows' && 'msys2 {0}' || 'bash' }}
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
NIM_VERSION: "2.2.6"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install macOS dependencies
|
||||
if: matrix.os == 'macos'
|
||||
run: |
|
||||
brew install make
|
||||
echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH"
|
||||
- uses: ./.github/actions/setup-nim-macos
|
||||
if: matrix.os == 'macos'
|
||||
- name: Set up Windows compiler
|
||||
if: matrix.os == 'windows'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
update: true
|
||||
install: >-
|
||||
curl git make unzip
|
||||
mingw-w64-clang-x86_64-clang
|
||||
mingw-w64-clang-x86_64-lld
|
||||
mingw-w64-clang-x86_64-python
|
||||
- name: Install Nim on Windows
|
||||
if: matrix.os == 'windows'
|
||||
run: |
|
||||
nim_root="$(cygpath -u "$RUNNER_TEMP")/nim"
|
||||
mkdir -p "$nim_root"
|
||||
curl -fsSL "https://nim-lang.org/download/nim-${NIM_VERSION}_x64.zip" -o "$nim_root/nim.zip"
|
||||
unzip -q "$nim_root/nim.zip" -d "$nim_root"
|
||||
- name: Test every native backend and UCI dispatch
|
||||
run: |
|
||||
if [[ "$RUNNER_OS" == Windows ]]; then
|
||||
export PATH="$(cygpath -u "$RUNNER_TEMP")/nim/nim-${NIM_VERSION}/bin:$PATH"
|
||||
export HEIMDALL=bin/heimdall.exe
|
||||
fi
|
||||
make deps NIMBLE_FLAGS=-y
|
||||
make test-simd SIMD=universal PYTHON=python3
|
||||
make dev SIMD=universal IS_TEST=1 EVALFILE="$PWD/build/simd/universal/multilayer-ti.bin"
|
||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
- name: Test combined macOS executable natively and through Rosetta
|
||||
if: matrix.runner == 'macos-15'
|
||||
run: |
|
||||
softwareupdate --install-rosetta --agree-to-license
|
||||
make macos-universal SKIP_DEPS=1 IS_TEST=1 EVALFILE="$PWD/build/simd/universal/multilayer-ti.bin"
|
||||
xcrun lipo -verify_arch x86_64 arm64 bin/heimdall
|
||||
bin/heimdall simd
|
||||
arch -x86_64 bin/heimdall simd
|
||||
bin/heimdall bench 6 > build/macos-universal/arm-bench.txt
|
||||
arch -x86_64 bin/heimdall bench 6 > build/macos-universal/x86-bench.txt
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
def nodes(arch):
|
||||
return re.search(r"(\d+) nodes \d+ nps", Path(f"build/macos-universal/{arch}-bench.txt").read_text()).group(1)
|
||||
assert nodes("arm") == nodes("x86")
|
||||
PY
|
||||
154
.github/workflows/windows-release.yml
vendored
154
.github/workflows/windows-release.yml
vendored
@@ -1,154 +0,0 @@
|
||||
name: Windows release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Optional branch, tag, or SHA to build"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Windows release binaries
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 120
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: msys2 {0}
|
||||
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: "1"
|
||||
NIM_VERSION: "2.2.6"
|
||||
CXX: clang++
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
lfs: false
|
||||
submodules: false
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
update: true
|
||||
install: >-
|
||||
curl
|
||||
git
|
||||
make
|
||||
unzip
|
||||
mingw-w64-clang-x86_64-clang
|
||||
mingw-w64-clang-x86_64-git-lfs
|
||||
mingw-w64-clang-x86_64-lld
|
||||
mingw-w64-clang-x86_64-python
|
||||
|
||||
- name: Build release binaries
|
||||
id: build
|
||||
env:
|
||||
BUILD_REF: ${{ github.event.inputs.ref || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_temp="$(cygpath -u "$RUNNER_TEMP")"
|
||||
nim_root="$runner_temp/nim"
|
||||
mkdir -p "$nim_root"
|
||||
|
||||
curl -fsSL \
|
||||
-o "$runner_temp/nim-${NIM_VERSION}_x64.zip" \
|
||||
"https://nim-lang.org/download/nim-${NIM_VERSION}_x64.zip"
|
||||
unzip -q "$runner_temp/nim-${NIM_VERSION}_x64.zip" -d "$nim_root"
|
||||
export PATH="$nim_root/nim-${NIM_VERSION}/bin:$PATH"
|
||||
|
||||
nim --version
|
||||
nimble --version
|
||||
clang --version
|
||||
ld.lld --version
|
||||
git lfs install --local
|
||||
|
||||
ref="${BUILD_REF:-}"
|
||||
ref_name="${GITHUB_REF_NAME:-}"
|
||||
ref_type="${GITHUB_REF_TYPE:-}"
|
||||
case "$ref" in
|
||||
refs/tags/*)
|
||||
ref_type="tag"
|
||||
ref_name="${ref#refs/tags/}"
|
||||
;;
|
||||
refs/heads/*)
|
||||
ref_type="branch"
|
||||
ref_name="${ref#refs/heads/}"
|
||||
;;
|
||||
"")
|
||||
;;
|
||||
*)
|
||||
ref_name="$ref"
|
||||
;;
|
||||
esac
|
||||
|
||||
version="$ref_name"
|
||||
version="${version#v}"
|
||||
make_args=()
|
||||
if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "Using Makefile version ${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]} from ref ${ref_name}"
|
||||
make_args+=(
|
||||
"MAJOR_VERSION=${BASH_REMATCH[1]}"
|
||||
"MINOR_VERSION=${BASH_REMATCH[2]}"
|
||||
"PATCH_VERSION=${BASH_REMATCH[3]}"
|
||||
)
|
||||
else
|
||||
echo "Using Makefile default version for ref ${ref_name:-<unknown>}"
|
||||
fi
|
||||
|
||||
if [[ "$ref_type" == "tag" && ( "$ref_name" == *alpha* || "$ref_name" == *beta* || "$ref_name" == *rc* || "$ref_name" == *dev* ) ]]; then
|
||||
make prereleases
|
||||
else
|
||||
make ci-releases "${make_args[@]}"
|
||||
fi
|
||||
|
||||
core2_binary="$(find bin -maxdepth 1 -type f -name 'heimdall-*-windows-amd64-core2.exe' -print -quit)"
|
||||
if [[ -z "$core2_binary" ]]; then
|
||||
echo "Could not find built Core 2 binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
release_base="$(basename "$core2_binary")"
|
||||
release_base="${release_base%-core2.exe}"
|
||||
|
||||
mkdir -p artifacts
|
||||
cp "bin/${release_base}"-* artifacts/
|
||||
(
|
||||
cd artifacts
|
||||
sha256sum *.exe > SHA256SUMS-windows-amd64.txt
|
||||
)
|
||||
|
||||
echo "artifact_name=${release_base}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to Gitea release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
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: python scripts/publish_gitea_release.py --tag "$GITHUB_REF_NAME" --files artifacts/*
|
||||
|
||||
- name: Upload release binaries
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.build.outputs.artifact_name }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
# ---> Nim
|
||||
nimcache/
|
||||
nimblecache/
|
||||
|
||||
14
.gitmodules
vendored
14
.gitmodules
vendored
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
[submodule "networks"]
|
||||
path = networks
|
||||
url = https://git.nocturn9x.space/heimdall-engine/networks
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
14
3rdparty/king_plane_buckets.txt
vendored
14
3rdparty/king_plane_buckets.txt
vendored
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
# Copied from src/heimdall/nnue.nim INPUT_BUCKETS.
|
||||
0, 1, 2, 3, 3, 2, 1, 0,
|
||||
4, 5, 6, 7, 7, 6, 5, 4,
|
||||
|
||||
277
AGENTS.md
Normal file
277
AGENTS.md
Normal file
@@ -0,0 +1,277 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Working on Heimdall
|
||||
|
||||
Heimdall is a UCI chess engine written in Nim, with NNUE evaluation and an
|
||||
optional terminal UI. Use the existing Makefile as the source of truth for
|
||||
compiler flags, network architecture settings, and build targets.
|
||||
|
||||
## Build and verification
|
||||
|
||||
- **Never use `nim check` to verify changes.** Verify Nim changes by compiling
|
||||
through the Makefile, using `make dev` under normal circumstances.
|
||||
- **Use `make dev` when all Makefile dependencies are already satisfied locally,
|
||||
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,
|
||||
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
|
||||
PSQ-only production network is not a compatible TI file.
|
||||
- **`make native` should almost never be needed. Use it only if `make dev` fails
|
||||
for reasons related to missing dependencies.** Inspect the failure first:
|
||||
`make native` runs `nimble install -d`, initializes the network submodule, and
|
||||
fetches the selected weights using Git LFS, so it may access the network.
|
||||
Do not use it to retry source-code compilation errors.
|
||||
- Do not run bare `make`: its default target is intended for OpenBench and does
|
||||
not prepare the network. `nimble build` is unsupported. Do not replace the
|
||||
Makefile with ad hoc Nim compiler invocations.
|
||||
- Keep validation proportional to the change. Documentation-only changes do not
|
||||
require an engine build. Report the checks actually run and any blockers.
|
||||
|
||||
## Tests and benchmarks
|
||||
|
||||
Run commands from the repository root, with dependencies and weights already
|
||||
available. Choose tests relevant to the affected behavior:
|
||||
|
||||
- `make test`: builds `bin/testdall` with `IS_TEST=1` and runs a depth-9 bench.
|
||||
- After `make dev`, run
|
||||
`python -m unittest discover -s tests -p 'test_*.py'` for UCI and Python test-tool
|
||||
regressions. To enable runtime checks, build with
|
||||
`make dev IS_TEST=1 EXE_BASE=bin/testdall` and set `HEIMDALL=bin/testdall` when
|
||||
running the Python tests.
|
||||
- `make test-suite`: runs deeper benches and perft comparisons. This is slow and
|
||||
requires Python and Stockfish on `PATH`; see `python tests/suite.py -h` for
|
||||
explicit binary paths.
|
||||
- `make bench`: builds with `make dev` and runs the engine's search benchmark.
|
||||
|
||||
<!-- Testing documentation consolidated with assistance from AI agents. -->
|
||||
|
||||
### Focused correctness tests
|
||||
|
||||
The production architecture is multilayer. Select the single-layer debug build
|
||||
explicitly when using the toy fixture:
|
||||
|
||||
```sh
|
||||
make dev SINGLE_LAYER=1 IS_TEST=1 EXE_BASE=bin/heimdall-single
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_single_layer.nim IS_TEST=1 EXE_BASE=bin/test-single-layer EVALFILE="$PWD/threans.bin"
|
||||
bin/test-single-layer
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_nnue.nim IS_TEST=1 EXE_BASE=bin/test-single-nnue EVALFILE="$PWD/threans.bin"
|
||||
bin/test-single-nnue
|
||||
```
|
||||
|
||||
This configuration loads the local, untracked `threans.bin` fixture with 32
|
||||
neurons, one input/output bucket, horizontal mirroring, separate king planes,
|
||||
SCReLU, QA=255, QB=64, and scale=400. Threat weights are always loaded.
|
||||
Initialization rebuilds both threat accumulators. Evaluation applies queued threat
|
||||
diffs, rebuilding the moving king's perspective when its orientation changes.
|
||||
The output head combines PSQ and TI before activation.
|
||||
The dedicated test verifies the published accumulator values and
|
||||
evaluations (startpos=98, Kiwipete=-205), plus inference from saved accumulators.
|
||||
The single-layer output head is scalar even in SIMD builds; PSQ and TI updates use
|
||||
the selected backend. The loader always expects threat rows between the PSQ
|
||||
weights and FT biases. Use `VERBATIM_NET=0` with threans.
|
||||
The fixture must never be shipped in a release. No fixture is downloaded by
|
||||
`make dev`; use an absolute `EVALFILE` for standalone tests.
|
||||
|
||||
The default multilayer configuration (`SINGLE_LAYER=0`) uses a 512-neuron FT,
|
||||
16 PSQ input buckets, eight output buckets, hidden sizes 16 and 32, dual
|
||||
activation, and scale 305. Both scalar and SIMD inference
|
||||
sum PSQ and TI before pairwise activation. It retains Heimdall's existing integer
|
||||
quantization and output layout; matching a different trainer requires matching
|
||||
its dimensions, quantization, bias scaling and file layout. Threat rows are always
|
||||
present, with no PP rows. `VERBATIM_NET=0` remains required.
|
||||
|
||||
For a trained network, use `make dev EVALFILE=/absolute/path/to/net.bin`
|
||||
and override the Makefile architecture variables as needed. For reproducible
|
||||
correctness checks without trained weights:
|
||||
|
||||
```sh
|
||||
python tests/make_multilayer_fixture.py build/tests/multilayer-ti.bin
|
||||
make dev MAIN=tests/test_multilayer.nim IS_TEST=1 EXE_BASE=bin/test-multilayer EVALFILE="$PWD/build/tests/multilayer-ti.bin" EVAL_SCALE=400
|
||||
bin/test-multilayer
|
||||
make dev MAIN=tests/test_nnue.nim IS_TEST=1 EXE_BASE=bin/test-multilayer-nnue EVALFILE="$PWD/build/tests/multilayer-ti.bin"
|
||||
bin/test-multilayer-nnue
|
||||
```
|
||||
|
||||
The synthetic fixture is only test data; its fixed expectations use `EVAL_SCALE=400`.
|
||||
The dedicated multilayer test compares
|
||||
all output buckets and both perspectives against an independent canonical-layout
|
||||
oracle, checks the effect of TI before activation, and verifies exact file export
|
||||
and reload. Repeat with the scalar flags below. To check the optional single
|
||||
activation, generate with `--dual 0` and build with `DUAL_ACTIVATION=0`.
|
||||
The generator's `--l1` must match `L1_SIZE`; input/output buckets and hidden sizes
|
||||
are fixed to the defaults above. SIMD multilayer inference requires `L1_SIZE` to
|
||||
be divisible by 128 and both hidden sizes to be divisible by the selected
|
||||
backend's int32 lane count. Other shapes use the scalar output head.
|
||||
|
||||
Build standalone Nim tests through `make dev`, using `MAIN`, `EXE_BASE`, and an
|
||||
absolute `EVALFILE` path. Use `IS_TEST=1` for correctness checks and optimized
|
||||
default builds for speed measurements.
|
||||
|
||||
Use focused tests for incremental/fresh NNUE evaluation, threat indexing,
|
||||
move-generation and state/hash invariants, and search limits. NNUE checks include
|
||||
pending updates, cloning, the 255-ply boundary, and all 960 castling arrangements
|
||||
for both colors. Every comparison checks all PSQ and TI accumulator lanes as well
|
||||
as the final score:
|
||||
|
||||
```sh
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_nnue.nim IS_TEST=1 EXE_BASE=bin/test-nnue EVALFILE="$PWD/threans.bin"
|
||||
bin/test-nnue
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_movegen.nim IS_TEST=1 EXE_BASE=bin/test-movegen EVALFILE="$PWD/threans.bin"
|
||||
bin/test-movegen
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_limits.nim IS_TEST=1 EXE_BASE=bin/test-limits EVALFILE="$PWD/threans.bin"
|
||||
bin/test-limits
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_threat_index.nim IS_TEST=1 EXE_BASE=bin/test-threat-index EVALFILE="$PWD/threans.bin"
|
||||
bin/test-threat-index
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_threats.nim IS_TEST=1 EXE_BASE=bin/test-threats EVALFILE="$PWD/threans.bin"
|
||||
bin/test-threats
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_threat_diff.nim IS_TEST=1 EXE_BASE=bin/test-threat-diff EVALFILE="$PWD/threans.bin"
|
||||
bin/test-threat-diff
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_threat_updates.nim IS_TEST=1 EXE_BASE=bin/test-threat-updates EVALFILE="$PWD/threans.bin"
|
||||
bin/test-threat-updates
|
||||
```
|
||||
|
||||
The threat-index test checks every table entry against geometric attacks and
|
||||
explicit exclusion rules, verifies the color bounds, and checks retained feature
|
||||
indices for collisions and overflow. It also checks both indexers across perspectives
|
||||
and mirroring, and verifies the perspective masks. The threat-collection test compares
|
||||
runtime attacks and collected features against independent board geometry, including
|
||||
friendly pawn defenses, writable output slices, and positions before and after special
|
||||
moves and undo. Fixed expected indices from Viridithas additionally verify both
|
||||
perspectives of the starting position and Kiwipete.
|
||||
|
||||
The threat-diff test uses synthetic weights to check signed i8 arithmetic, empty
|
||||
and unequal update lists, cancellation, full list capacity, unused tails, and
|
||||
parent preservation for both perspectives. It also checks full rebuilds and, in
|
||||
SIMD builds, all 256 signed-byte values through an unaligned widening load.
|
||||
Build it with `L1_SIZE=768` to exercise a larger accumulator: this arithmetic test
|
||||
does not load the network fixture. Repeat at that width with the scalar flags
|
||||
below. TI arithmetic supports SSE2, SSSE3, SSE4.1, AVX2, AVX-512 and AArch64 NEON;
|
||||
its accumulator buffers require `ALIGNMENT_BOUNDARY` alignment and its width must
|
||||
be divisible by `CHUNK_SIZE`.
|
||||
The threat-update test covers slider
|
||||
discoveries and obstructions, occupied targets changing identity, pawn defenses,
|
||||
king mirroring, promotions, en passant, castling, delayed refreshes, and selective
|
||||
perspective updates. It also checks reuse of dirty child slots, pending and
|
||||
evaluated undo, initialization after existing board history, cloning with pending
|
||||
updates, and real moves below null moves.
|
||||
|
||||
To check the scalar NNUE path, repeat its build with `SIMD=scalar`; the diagnostic
|
||||
prints the selected backend. Use `SIMD=sse2`, `SIMD=ssse3`, `SIMD=sse41`, `SIMD=avx2`,
|
||||
`SIMD=avx512`, `SIMD=avx512-vnni` or `SIMD=neon` to select a specific backend.
|
||||
`SIMD=universal` builds runtime-dispatched kernels with one shared AVX-512
|
||||
packing layout; `HEIMDALL_SIMD` forces a supported backend at startup and
|
||||
`bin/heimdall simd` reports the selection. `make test-simd SIMD=universal` tests
|
||||
every backend supported by the runner. Ordinary code must retain baseline ISA
|
||||
flags in this build; do not add global native/AVX flags.
|
||||
`SIMD=auto` is the default. The Makefile clears backend defines from local
|
||||
configuration and `EXTRA_NFLAGS` before selecting its backend, so
|
||||
`EXTRA_NFLAGS=-u:simd` alone does not select scalar.
|
||||
|
||||
`make test-simd SIMD=sse2` runs the common primitive, multilayer (both activation
|
||||
modes), NNUE state and TI arithmetic/update tests with generated synthetic weights.
|
||||
Use the same target with `SIMD=neon` on AArch64 or any other supported backend.
|
||||
No network weights are downloaded. `SIMD_TEST_RUNNER` optionally prefixes test
|
||||
execution with an emulator. See [docs/SIMD.md](docs/SIMD.md) for build targets,
|
||||
backend contracts, CI coverage and cross-compilation.
|
||||
|
||||
`tests/test_alloc.nim` checks allocation alignment. Add `EXTRA_NFLAGS=-d:noTHP`
|
||||
to its build to exercise the allocator without huge-page advice; the same flag
|
||||
can be used with the NNUE test.
|
||||
|
||||
### Performance comparisons
|
||||
|
||||
For performance comparisons, build separate baseline and candidate executables
|
||||
with identical flags and network, then alternate runs on one CPU:
|
||||
|
||||
```sh
|
||||
python scripts/compare_performance.py bin/baseline bin/candidate --cpu 2 --pairs 12 --perf --output comparison.json
|
||||
```
|
||||
|
||||
The script checks node counts and saves paired timings, hardware counters and a
|
||||
bootstrap interval. Omit `--perf` if hardware counters are unavailable. Use
|
||||
`--mode perft --depth 7` for movegen comparisons. `tests/bench_nnue.nim` and
|
||||
`tests/bench_setup.nim` provide separate inference and worker-setup benchmarks;
|
||||
build them with the same `MAIN`/`EVALFILE` pattern. Measure optimized builds for
|
||||
speed and use `IS_TEST=1` for correctness checks. Confirm microbenchmark gains
|
||||
with full search: a repeated NNUE input corpus can hide branch-prediction costs.
|
||||
|
||||
For real UCI node/time budgets on a selected FEN corpus, use `--mode uci`:
|
||||
|
||||
```sh
|
||||
python scripts/compare_performance.py bin/baseline bin/candidate --mode uci --positions src/heimdall/resources/misc/bench.txt --count 24 --offset 1 --stride 2 --limit-kind nodes --limit 200000 --cpu 2 --pairs 8 --perf --output comparison-uci.json
|
||||
```
|
||||
|
||||
Use `--limit-kind time --limit 200` for 200 milliseconds per position. UCI NPS
|
||||
uses the summed final search node/time reports, excluding engine startup and
|
||||
position resets; raw results also record whole-process wall time. Fixed-node,
|
||||
single-thread comparisons require matching per-position nodes, depths and best
|
||||
moves. Timed and multithreaded searches do not have identical trees and are not
|
||||
playing-strength tests. `--count`, `--offset` and `--stride` select distinct,
|
||||
normalized FENs; too-short or incomplete searches are rejected.
|
||||
|
||||
### Profile-guided compilation
|
||||
|
||||
Optional profile-guided compilation is available through the same dev target:
|
||||
|
||||
```sh
|
||||
make dev PGO=1 EXE_BASE=bin/heimdall-pgo
|
||||
```
|
||||
|
||||
This needs Python and a matching `llvm-profdata` installation. It builds an
|
||||
instrumented engine, trains with node and time budgets, merges the profiles, and
|
||||
rebuilds using them. The default training set is 24 even-indexed positions from
|
||||
the built-in benchmark corpus; the odd-indexed selection above is held out.
|
||||
The ordinary full benchmark includes training positions, so it is not a held-out
|
||||
PGO validation. Profile artifacts stay in ignored `build/pgo/`. Override
|
||||
`PGO_DIR`, `PGO_POSITIONS`, `PGO_TRAIN_ARGS`, `PGO_TRAIN_NODES`, or `PGO_TRAIN_MSEC`
|
||||
to customize training. Normal dev and OpenBench builds do not enable PGO.
|
||||
|
||||
## Project layout
|
||||
|
||||
- `src/heimdall.nim`: executable entry point and command handling.
|
||||
- `src/heimdall/`: board representation, move generation, search, transposition
|
||||
tables, evaluation, and NNUE inference.
|
||||
- `src/heimdall/threats/`: TI feature indexing, diff collection, and row arithmetic.
|
||||
`eval.nim` owns the state wrappers; `refreshThreats(self, side, position)` and
|
||||
`refreshPSQ` both rebuild the current frame from the explicit position.
|
||||
- `src/heimdall/uci/`: protocol parsing, sessions, and search workers.
|
||||
- `src/heimdall/tui/`: terminal UI, play/analysis flows, input, and rendering.
|
||||
- `src/heimdall/util/`: shared helpers, perft, SIMD, memory allocation, tuning,
|
||||
and data formats.
|
||||
- `tests/`: regression tests, perft positions, and focused benchmarks.
|
||||
- `scripts/`: performance, release, and development utilities.
|
||||
- `networks/`: Git submodule containing the NNUE weights managed by Git LFS.
|
||||
|
||||
## Editing conventions
|
||||
|
||||
- Follow nearby Nim code: four-space indentation, existing naming conventions,
|
||||
grouped imports where appropriate, and `##` documentation for public APIs.
|
||||
- Preserve unrelated working-tree changes. Keep patches focused and avoid
|
||||
incidental formatting or dependency/network updates.
|
||||
- Treat search, move generation, NNUE, SIMD, and allocation code as performance
|
||||
sensitive. Support optimization claims with measurements and check correctness
|
||||
separately from speed.
|
||||
- When changing position or evaluation state, check make/unmake, cloning,
|
||||
incremental updates, and special moves, including Chess960 castling.
|
||||
- Preserve compatibility with the supported SIMD and scalar paths when changing
|
||||
shared code. Keep build settings in the Makefile rather than duplicating them.
|
||||
- Keep generated binaries, caches, downloaded weights, and benchmark artifacts
|
||||
out of source changes unless the task explicitly calls for them.
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
# By @agethereal. Thanks Andy!
|
||||
FROM ubuntu:24.04
|
||||
|
||||
|
||||
434
Makefile
434
Makefile
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
.DEFAULT_GOAL := openbench
|
||||
|
||||
.SUFFIXES:
|
||||
@@ -8,11 +22,27 @@ CC := clang
|
||||
EXE_BASE := bin/heimdall
|
||||
EXE_EXT := $(if $(OS),.exe,)
|
||||
EXE := $(EXE_BASE)$(EXE_EXT)
|
||||
EVALFILE := ../networks/files/gramr.bin
|
||||
SINGLE_LAYER ?= 0
|
||||
ifeq ($(SINGLE_LAYER),1)
|
||||
EVALFILE := $(CURDIR)/threans.bin
|
||||
else
|
||||
EVALFILE := ../networks/files/tyrfing.bin
|
||||
ifeq ($(strip $(EVALFILE)),)
|
||||
$(error Set EVALFILE to a multilayer TI network when SINGLE_LAYER=0)
|
||||
endif
|
||||
endif
|
||||
NET_NAME := $(notdir $(EVALFILE))
|
||||
NET_ID := $(basename $(NET_NAME))
|
||||
LD := lld
|
||||
SRCDIR := src
|
||||
MAIN ?= $(SRCDIR)/heimdall.nim
|
||||
EXTRA_NFLAGS ?=
|
||||
NIMBLE_FLAGS ?=
|
||||
# Select an inference backend independently of the build host for testing.
|
||||
SIMD ?= auto
|
||||
# CPU tuning for portable targets does not change their instruction-set baseline.
|
||||
TUNE ?= generic
|
||||
HOST_ARCH := $(shell $(CC) -dumpmachine)
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
SETENV = set GIT_LFS_SKIP_SMUDGE=1 &&
|
||||
@@ -20,30 +50,61 @@ else
|
||||
SETENV = GIT_LFS_SKIP_SMUDGE=1
|
||||
endif
|
||||
|
||||
STACK_SIZE := 8388608
|
||||
LFLAGS := -flto
|
||||
|
||||
LFLAGS := -flto -fuse-ld=$(LD)
|
||||
|
||||
# Linux is megabased and grants us 8 MiB of glorious stack by default. Other
|
||||
# systems might be sad little betas and only give us 1 MiB (or even much less).
|
||||
# Nothing a few platform-specific linker flags can't fix.
|
||||
ifeq ($(OS),Windows_NT)
|
||||
# Windows' default stack size of 1MiB causes stack overflows as soon as the engine
|
||||
# enters UCI mode, so bump it to 8MiB
|
||||
LFLAGS += -Wl,--stack,8388608
|
||||
# PE/COFF: reserve 8 MiB (the optional second value would be the commit size).
|
||||
LFLAGS += -fuse-ld=$(LD)
|
||||
ifneq ($(or $(findstring mingw,$(HOST_ARCH)),$(findstring windows-gnu,$(HOST_ARCH))),)
|
||||
# MinGW's linker driver accepts GNU-style options.
|
||||
LFLAGS += -Wl,--stack,$(STACK_SIZE)
|
||||
else
|
||||
# MSVC-targeting Clang invokes lld-link, which uses Microsoft's syntax.
|
||||
LFLAGS += -Wl,/stack:$(STACK_SIZE)
|
||||
endif
|
||||
else
|
||||
UNAME_S ?= $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
MACOSX_DEPLOYMENT_TARGET ?= 11.0
|
||||
export MACOSX_DEPLOYMENT_TARGET
|
||||
# Mach-O's -stack_size requires a hexadecimal value. ld64.lld currently
|
||||
# ignores this option, so use Apple's linker for the macOS build.
|
||||
LFLAGS += -fuse-ld=ld -Wl,-stack_size,0x800000 -mmacosx-version-min=$(MACOSX_DEPLOYMENT_TARGET)
|
||||
else ifeq ($(UNAME_S),Linux)
|
||||
# Record the requested size in the ELF PT_GNU_STACK program header.
|
||||
LFLAGS += -fuse-ld=$(LD) -Wl,-z,stack-size=$(STACK_SIZE)
|
||||
else
|
||||
$(error Unsupported host OS '$(UNAME_S)')
|
||||
endif
|
||||
endif
|
||||
|
||||
HINTSFLAG = $(if $(filter 1,$(SKIP_DEPS)),--hints:off,)
|
||||
|
||||
ifeq ($(SINGLE_LAYER),1)
|
||||
INPUT_BUCKETS := 1
|
||||
OUTPUT_BUCKETS := 1
|
||||
L1_SIZE := 32
|
||||
EVAL_SCALE := 400
|
||||
else
|
||||
INPUT_BUCKETS := 16
|
||||
OUTPUT_BUCKETS := 8
|
||||
L1_SIZE := 512
|
||||
EVAL_SCALE := 305
|
||||
endif
|
||||
MERGED_KINGS := 0
|
||||
EVAL_NORMALIZE_FACTOR := 292
|
||||
HORIZONTAL_MIRRORING := 1
|
||||
VERBATIM_NET := 0
|
||||
FT_SIZE := 768
|
||||
L1_SIZE := 1536
|
||||
L2_SIZE := 16
|
||||
L3_SIZE := 32
|
||||
EVAL_SCALE := 322
|
||||
FT_QUANT_BITS := 8
|
||||
L1_QUANT_BITS := 7
|
||||
L1_BIAS_SHIFT := 0
|
||||
QUANT_BITS := 6
|
||||
FT_SCALE_BITS := 7
|
||||
DUAL_ACTIVATION := 1
|
||||
@@ -55,12 +116,26 @@ IS_TEST := 0
|
||||
DBG_SYMBOLS := 0
|
||||
MAJOR_VERSION := 1
|
||||
MINOR_VERSION := 5
|
||||
PATCH_VERSION := 0
|
||||
PATCH_VERSION := 1
|
||||
THP_PAGE_ALIGNMENT := 2097152
|
||||
|
||||
ifeq ($(IS_RELEASE),1)
|
||||
ifeq ($(SINGLE_LAYER),1)
|
||||
$(error The single-layer debug fixture must not be released)
|
||||
endif
|
||||
ifeq ($(NET_NAME),threans.bin)
|
||||
$(error threans must not be released)
|
||||
endif
|
||||
endif
|
||||
|
||||
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
CFLAGS := -flto -mmacosx-version-min=$(MACOSX_DEPLOYMENT_TARGET)
|
||||
else
|
||||
CFLAGS := -flto -static
|
||||
CUSTOM_FLAGS := -d:outputBuckets=$(OUTPUT_BUCKETS) \
|
||||
endif
|
||||
CUSTOM_FLAGS := -d:singleLayer=$(if $(filter 1,$(SINGLE_LAYER)),true,false) \
|
||||
-d:outputBuckets=$(OUTPUT_BUCKETS) \
|
||||
-d:inputBuckets=$(INPUT_BUCKETS) \
|
||||
-d:ftSize=$(FT_SIZE) \
|
||||
-d:l1Size=$(L1_SIZE) \
|
||||
@@ -69,6 +144,7 @@ CUSTOM_FLAGS := -d:outputBuckets=$(OUTPUT_BUCKETS) \
|
||||
-d:evalScale=$(EVAL_SCALE) \
|
||||
-d:ftQuantBits=$(FT_QUANT_BITS) \
|
||||
-d:l1QuantBits=$(L1_QUANT_BITS) \
|
||||
-d:l1BiasShift=$(L1_BIAS_SHIFT) \
|
||||
-d:quantBits=$(QUANT_BITS) \
|
||||
-d:ftScaleBits=$(FT_SCALE_BITS) \
|
||||
-d:evalNormalizeFactor=$(EVAL_NORMALIZE_FACTOR) \
|
||||
@@ -78,7 +154,7 @@ CUSTOM_FLAGS := -d:outputBuckets=$(OUTPUT_BUCKETS) \
|
||||
-d:evalFile=$(EVALFILE) \
|
||||
-d:netID=$(NET_ID) \
|
||||
-d:thpPageAlignment:$(THP_PAGE_ALIGNMENT) \
|
||||
-d:esc_exit_editing
|
||||
-d:esc_exit_editing
|
||||
|
||||
ifeq ($(MERGED_KINGS),1)
|
||||
CUSTOM_FLAGS += -d:mergedKings=true
|
||||
@@ -135,75 +211,162 @@ ifeq ($(DBG_SYMBOLS),1)
|
||||
CFLAGS += -fno-omit-frame-pointer -ggdb
|
||||
endif
|
||||
|
||||
NFLAGS := --path:src --panics:on --mm:atomicArc -d:useMalloc -o:$(EXE) $(HINTSFLAG) $(CUSTOM_FLAGS) --deepcopy:on --cc:$(CC) --passL:"$(LFLAGS)"
|
||||
NFLAGS := --path:src --panics:on --mm:atomicArc -d:useMalloc -o:$(EXE) $(HINTSFLAG) $(CUSTOM_FLAGS) --deepcopy:on --cc:$(CC) --passL:"$(LFLAGS)" --maxLoopIterationsVM:536870912 $(EXTRA_NFLAGS) -u:simd -u:avx2 -u:avx512 -u:vnni -u:sse2 -u:ssse3 -u:sse41 -u:neon -u:runtimeSimd
|
||||
|
||||
|
||||
CFLAGS_AVX512 := $(CFLAGS) -mtune=znver4 -march=x86-64-v4
|
||||
CFLAGS_AVX512 := $(CFLAGS) -march=x86-64-v4 -mtune=$(TUNE)
|
||||
NFLAGS_AVX512 := $(NFLAGS) --passC:"$(CFLAGS_AVX512)" -d:simd -d:avx512
|
||||
|
||||
CFLAGS_VNNI := $(CFLAGS_AVX512) -mavx512vnni
|
||||
NFLAGS_VNNI := $(NFLAGS) --passC:"$(CFLAGS_VNNI)" -d:simd -d:avx512 -d:vnni
|
||||
CFLAGS_AVX512_VNNI := $(CFLAGS_AVX512) -mavx512vnni
|
||||
NFLAGS_AVX512_VNNI := $(NFLAGS) --passC:"$(CFLAGS_AVX512_VNNI)" -d:simd -d:avx512 -d:vnni
|
||||
|
||||
CFLAGS_MODERN := $(CFLAGS) -mtune=haswell -march=haswell
|
||||
NFLAGS_MODERN := $(NFLAGS) --passC:"$(CFLAGS_MODERN)" -d:simd -d:avx2
|
||||
CFLAGS_AVX2 := $(CFLAGS) -march=x86-64-v3 -mtune=$(TUNE)
|
||||
NFLAGS_AVX2 := $(NFLAGS) --passC:"$(CFLAGS_AVX2)" -d:simd -d:avx2
|
||||
|
||||
CFLAGS_ZEN2 := $(CFLAGS) -march=znver2 -mtune=znver2
|
||||
NFLAGS_ZEN2 := $(NFLAGS) --passC:"$(CFLAGS_ZEN2)" -d:simd -d:avx2
|
||||
|
||||
CFLAGS_NATIVE := $(CFLAGS) -mtune=native -march=native
|
||||
ifneq ($(filter aarch64% arm64%,$(HOST_ARCH)),)
|
||||
NATIVE_ARCH_FLAGS := -mcpu=native
|
||||
else
|
||||
NATIVE_ARCH_FLAGS := -mtune=native -march=native
|
||||
endif
|
||||
CFLAGS_NATIVE := $(CFLAGS) $(NATIVE_ARCH_FLAGS)
|
||||
NFLAGS_NATIVE := $(NFLAGS) --passC:"$(CFLAGS_NATIVE)" -d:simd -d:avx2
|
||||
|
||||
# Native build for machines without AVX2: keep the host's -march=native (so it
|
||||
# still uses whatever the CPU supports) but disable the AVX2 SIMD codepath
|
||||
NFLAGS_NATIVE_LEGACY := $(NFLAGS) --passC:"$(CFLAGS_NATIVE)" -u:simd -u:avx2
|
||||
NFLAGS_SCALAR := $(NFLAGS) --passC:"$(CFLAGS_NATIVE)"
|
||||
|
||||
CFLAGS_LEGACY := $(CFLAGS) -mtune=core2 -march=core2
|
||||
NFLAGS_LEGACY := $(NFLAGS) --passC:"$(CFLAGS_LEGACY)" -u:simd -u:avx2
|
||||
CFLAGS_SSE2 := $(CFLAGS) -march=x86-64 -mtune=$(TUNE)
|
||||
NFLAGS_SSE2 := $(NFLAGS) --passC:"$(CFLAGS_SSE2)" -d:simd -d:sse2
|
||||
CFLAGS_SSSE3 := $(CFLAGS) -march=x86-64 -mssse3 -mtune=$(TUNE)
|
||||
NFLAGS_SSSE3 := $(NFLAGS) --passC:"$(CFLAGS_SSSE3)" -d:simd -d:ssse3
|
||||
CFLAGS_SSE41 := $(CFLAGS) -march=x86-64 -msse4.1 -mtune=$(TUNE)
|
||||
NFLAGS_SSE41 := $(NFLAGS) --passC:"$(CFLAGS_SSE41)" -d:simd -d:sse41
|
||||
CFLAGS_NEON := $(CFLAGS) -march=armv8-a -mtune=$(TUNE)
|
||||
NFLAGS_NEON := $(NFLAGS) --passC:"$(CFLAGS_NEON)" -d:simd -d:neon
|
||||
|
||||
OS_TAG := $(if $(OS),windows,linux)
|
||||
# All ordinary code and startup stay at the platform baseline. NNUE routines
|
||||
# carry per-function ISA attributes; never use -march=native for this target.
|
||||
ifneq ($(filter aarch64% arm64%,$(HOST_ARCH)),)
|
||||
NFLAGS_UNIVERSAL := $(NFLAGS_NEON) -d:runtimeSimd
|
||||
else
|
||||
NFLAGS_UNIVERSAL := $(NFLAGS_SSE2) -d:runtimeSimd
|
||||
endif
|
||||
|
||||
OS_TAG := $(if $(OS),windows,$(if $(filter Darwin,$(UNAME_S)),macos,linux))
|
||||
ARCH_TAG := $(if $(filter aarch64% arm64%,$(HOST_ARCH)),arm64,amd64)
|
||||
|
||||
COMMIT := $(shell git rev-parse --short=6 HEAD 2>/dev/null || echo unknown)
|
||||
RELEASE_BASE := heimdall-$(MAJOR_VERSION).$(MINOR_VERSION).$(PATCH_VERSION)-$(OS_TAG)-amd64
|
||||
PRERELEASE_BASE := heimdall-dev-$(COMMIT)-$(OS_TAG)-amd64
|
||||
RELEASE_BASE := heimdall-$(MAJOR_VERSION).$(MINOR_VERSION).$(PATCH_VERSION)-$(OS_TAG)-$(ARCH_TAG)
|
||||
PRERELEASE_BASE := heimdall-dev-$(COMMIT)-$(OS_TAG)-$(ARCH_TAG)
|
||||
BENCH_COMMIT ?= HEAD
|
||||
BENCH_DEPTH ?= 13
|
||||
BENCH_BIN_GLOB ?= bin/heimdall-*-$(OS_TAG)-amd64-*
|
||||
BENCH_BIN_GLOB ?= bin/heimdall-*-$(OS_TAG)-$(ARCH_TAG)-*
|
||||
BENCH_BINARIES ?= $(BENCH_BIN_GLOB)
|
||||
|
||||
# Optional profile-guided build; normal dev/OpenBench builds remain unchanged.
|
||||
PGO ?= 0
|
||||
PGO_DIR ?= build/pgo
|
||||
PGO_TRAIN_EXE_BASE ?= $(PGO_DIR)/heimdall-train
|
||||
PGO_TRAIN_EXE := $(PGO_TRAIN_EXE_BASE)$(EXE_EXT)
|
||||
PGO_POSITIONS ?= src/heimdall/resources/misc/bench.txt
|
||||
PGO_TRAIN_ARGS ?= --count 24 --offset 0 --stride 2
|
||||
PGO_TRAIN_NODES ?= 200000
|
||||
PGO_TRAIN_MSEC ?= 200
|
||||
PGO_RAW_NODES := $(abspath $(PGO_DIR)/nodes.profraw)
|
||||
PGO_RAW_TIME := $(abspath $(PGO_DIR)/time.profraw)
|
||||
PGO_DATA := $(abspath $(PGO_DIR)/heimdall.profdata)
|
||||
LLVM_PROFDATA ?= llvm-profdata
|
||||
PYTHON ?= python
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
PGO_PREPARE_DIR = if not exist "$(PGO_DIR)" mkdir "$(PGO_DIR)"
|
||||
PGO_NODE_ENV = set "LLVM_PROFILE_FILE=$(PGO_RAW_NODES)" &&
|
||||
PGO_TIME_ENV = set "LLVM_PROFILE_FILE=$(PGO_RAW_TIME)" &&
|
||||
else
|
||||
PGO_PREPARE_DIR = mkdir -p "$(PGO_DIR)"
|
||||
PGO_NODE_ENV = LLVM_PROFILE_FILE="$(PGO_RAW_NODES)"
|
||||
PGO_TIME_ENV = LLVM_PROFILE_FILE="$(PGO_RAW_TIME)"
|
||||
endif
|
||||
|
||||
|
||||
ifeq ($(SKIP_DEPS),)
|
||||
avx512: deps net
|
||||
vnni: deps net
|
||||
modern: deps net
|
||||
zen2: deps net
|
||||
legacy: deps net
|
||||
avx512-vnni: deps net
|
||||
avx2: deps net
|
||||
sse2: deps net
|
||||
ssse3: deps net
|
||||
sse41: deps net
|
||||
neon: deps net
|
||||
scalar: deps net
|
||||
universal: deps net
|
||||
macos-universal: deps net
|
||||
native: deps net
|
||||
endif
|
||||
|
||||
|
||||
avx512:
|
||||
@echo Building AVX512 binary
|
||||
$(ECHO) nim c $(NFLAGS_AVX512) $(SRCDIR)/heimdall.nim
|
||||
@echo "Building x86-64-v4 binary (AVX-512)"
|
||||
$(ECHO) nim c $(NFLAGS_AVX512) $(MAIN)
|
||||
|
||||
vnni:
|
||||
@echo Building AVX512 VNNI binary
|
||||
$(ECHO) nim c $(NFLAGS_VNNI) $(SRCDIR)/heimdall.nim
|
||||
avx512-vnni:
|
||||
@echo "Building x86-64-v4 binary (AVX-512 VNNI)"
|
||||
$(ECHO) nim c $(NFLAGS_AVX512_VNNI) $(MAIN)
|
||||
|
||||
modern:
|
||||
@echo Building Haswell binary
|
||||
$(ECHO) nim c $(NFLAGS_MODERN) $(SRCDIR)/heimdall.nim
|
||||
avx2:
|
||||
@echo "Building x86-64-v3 binary (AVX2)"
|
||||
$(ECHO) nim c $(NFLAGS_AVX2) $(MAIN)
|
||||
|
||||
zen2:
|
||||
@echo Building Zen 2 binary
|
||||
$(ECHO) nim c $(NFLAGS_ZEN2) $(SRCDIR)/heimdall.nim
|
||||
sse2:
|
||||
@echo "Building x86-64 binary (SSE2)"
|
||||
$(ECHO) nim c $(NFLAGS_SSE2) $(MAIN)
|
||||
|
||||
legacy:
|
||||
@echo Building Core 2 binary
|
||||
$(ECHO) nim c $(NFLAGS_LEGACY) $(SRCDIR)/heimdall.nim
|
||||
ssse3:
|
||||
@echo "Building x86-64 binary (SSSE3)"
|
||||
$(ECHO) nim c $(NFLAGS_SSSE3) $(MAIN)
|
||||
|
||||
sse41:
|
||||
@echo "Building x86-64 binary (SSE4.1)"
|
||||
$(ECHO) nim c $(NFLAGS_SSE41) $(MAIN)
|
||||
|
||||
neon:
|
||||
@echo "Building AArch64 binary (NEON)"
|
||||
$(ECHO) nim c $(NFLAGS_NEON) $(MAIN)
|
||||
|
||||
# Native macOS convenience targets retain the portable feature-set backends.
|
||||
.PHONY: macos-amd64 macos-arm64
|
||||
macos-amd64:
|
||||
ifneq ($(OS_TAG)-$(ARCH_TAG),macos-amd64)
|
||||
$(error macos-amd64 requires an Intel macOS compiler; use the matching Mac host)
|
||||
endif
|
||||
$(MAKE) sse2
|
||||
|
||||
macos-arm64:
|
||||
ifneq ($(OS_TAG)-$(ARCH_TAG),macos-arm64)
|
||||
$(error macos-arm64 requires an Apple Silicon macOS compiler; use the matching Mac host)
|
||||
endif
|
||||
$(MAKE) neon
|
||||
|
||||
scalar:
|
||||
@echo Building native scalar binary
|
||||
$(ECHO) nim c $(NFLAGS_SCALAR) $(MAIN)
|
||||
|
||||
universal:
|
||||
@echo Building universal binary with runtime SIMD selection
|
||||
$(ECHO) nim c $(NFLAGS_UNIVERSAL) $(MAIN)
|
||||
|
||||
# One Mach-O containing both the x86 runtime-dispatched and AArch64 slices.
|
||||
MACOS_UNIVERSAL_DIR ?= build/macos-universal
|
||||
.PHONY: macos-universal
|
||||
macos-universal:
|
||||
ifneq ($(OS_TAG),macos)
|
||||
$(error macos-universal requires a macOS host and Apple SDK)
|
||||
endif
|
||||
$(MAKE) -s dev SIMD=universal PGO=0 HOST_ARCH=x86_64-apple-darwin CFLAGS="$(CFLAGS) -target x86_64-apple-macos$(MACOSX_DEPLOYMENT_TARGET)" LFLAGS="$(LFLAGS) -target x86_64-apple-macos$(MACOSX_DEPLOYMENT_TARGET)" EXTRA_NFLAGS="$(EXTRA_NFLAGS) --cpu:amd64 --nimcache:$(MACOS_UNIVERSAL_DIR)/amd64/cache" EXE_BASE="$(MACOS_UNIVERSAL_DIR)/amd64/heimdall" EXE="$(MACOS_UNIVERSAL_DIR)/amd64/heimdall"
|
||||
$(MAKE) -s dev SIMD=universal PGO=0 HOST_ARCH=arm64-apple-darwin CFLAGS="$(CFLAGS) -target arm64-apple-macos$(MACOSX_DEPLOYMENT_TARGET)" LFLAGS="$(LFLAGS) -target arm64-apple-macos$(MACOSX_DEPLOYMENT_TARGET)" EXTRA_NFLAGS="$(EXTRA_NFLAGS) --cpu:arm64 --nimcache:$(MACOS_UNIVERSAL_DIR)/arm64/cache" EXE_BASE="$(MACOS_UNIVERSAL_DIR)/arm64/heimdall" EXE="$(MACOS_UNIVERSAL_DIR)/arm64/heimdall"
|
||||
mkdir -p "$(dir $(EXE))"
|
||||
xcrun lipo -create "$(MACOS_UNIVERSAL_DIR)/amd64/heimdall" "$(MACOS_UNIVERSAL_DIR)/arm64/heimdall" -output "$(EXE)"
|
||||
|
||||
deps:
|
||||
@echo Verifying dependencies
|
||||
$(ECHO) nimble install -d
|
||||
$(ECHO) nimble install -d $(NIMBLE_FLAGS)
|
||||
|
||||
net:
|
||||
@echo Preparing neural network
|
||||
@@ -212,7 +375,7 @@ net:
|
||||
$(ECHO) git -C networks lfs fetch --include="files/$(NET_NAME)" && git -C networks lfs checkout "files/$(NET_NAME)"
|
||||
|
||||
|
||||
ARCH_DEFINES := $(shell echo | $(CC) -march=native -E -dM -)
|
||||
ARCH_DEFINES := $(if $(filter universal,$(SIMD)),,$(shell echo | $(CC) $(NATIVE_ARCH_FLAGS) -E -dM -))
|
||||
AVX512_SUPPORTED := 0
|
||||
VNNI_SUPPORTED := 0
|
||||
ifneq ($(findstring __AVX512F__, $(ARCH_DEFINES)),)
|
||||
@@ -229,51 +392,115 @@ ifneq ($(findstring __AVX2__, $(ARCH_DEFINES)),)
|
||||
AVX2_SUPPORTED := 1
|
||||
endif
|
||||
|
||||
BASE_RELEASE_BINARIES := bin/$(RELEASE_BASE)-core2$(EXE_EXT) bin/$(RELEASE_BASE)-haswell$(EXE_EXT) bin/$(RELEASE_BASE)-zen2$(EXE_EXT)
|
||||
SSE2_SUPPORTED := 0
|
||||
ifneq ($(findstring __SSE2__, $(ARCH_DEFINES)),)
|
||||
SSE2_SUPPORTED := 1
|
||||
endif
|
||||
SSSE3_SUPPORTED := 0
|
||||
ifneq ($(findstring __SSSE3__, $(ARCH_DEFINES)),)
|
||||
SSSE3_SUPPORTED := 1
|
||||
endif
|
||||
SSE41_SUPPORTED := 0
|
||||
ifneq ($(findstring __SSE4_1__, $(ARCH_DEFINES)),)
|
||||
SSE41_SUPPORTED := 1
|
||||
endif
|
||||
NEON_SUPPORTED := 0
|
||||
ifneq ($(findstring __aarch64__, $(ARCH_DEFINES)),)
|
||||
ifneq ($(findstring __ARM_NEON, $(ARCH_DEFINES)),)
|
||||
NEON_SUPPORTED := 1
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(ARCH_TAG),arm64)
|
||||
RELEASE_BINARIES := bin/$(RELEASE_BASE)-neon$(EXE_EXT)
|
||||
CI_RELEASE_BINARIES := $(RELEASE_BINARIES)
|
||||
PRERELEASE_BINARIES := bin/$(PRERELEASE_BASE)-neon$(EXE_EXT)
|
||||
else
|
||||
BASE_RELEASE_BINARIES := bin/$(RELEASE_BASE)-sse2$(EXE_EXT) bin/$(RELEASE_BASE)-ssse3$(EXE_EXT) bin/$(RELEASE_BASE)-sse41$(EXE_EXT) bin/$(RELEASE_BASE)-avx2$(EXE_EXT)
|
||||
RELEASE_BINARIES := $(BASE_RELEASE_BINARIES)
|
||||
CI_RELEASE_BINARIES := $(BASE_RELEASE_BINARIES) bin/$(RELEASE_BASE)-avx512$(EXE_EXT) bin/$(RELEASE_BASE)-vnni$(EXE_EXT)
|
||||
PRERELEASE_BINARIES := bin/$(PRERELEASE_BASE)-core2$(EXE_EXT) bin/$(PRERELEASE_BASE)-haswell$(EXE_EXT) bin/$(PRERELEASE_BASE)-zen2$(EXE_EXT) bin/$(PRERELEASE_BASE)-avx512$(EXE_EXT) bin/$(PRERELEASE_BASE)-vnni$(EXE_EXT)
|
||||
CI_RELEASE_BINARIES := $(BASE_RELEASE_BINARIES) bin/$(RELEASE_BASE)-avx512$(EXE_EXT) bin/$(RELEASE_BASE)-avx512-vnni$(EXE_EXT)
|
||||
PRERELEASE_BINARIES := bin/$(PRERELEASE_BASE)-sse2$(EXE_EXT) bin/$(PRERELEASE_BASE)-ssse3$(EXE_EXT) bin/$(PRERELEASE_BASE)-sse41$(EXE_EXT) bin/$(PRERELEASE_BASE)-avx2$(EXE_EXT) bin/$(PRERELEASE_BASE)-avx512$(EXE_EXT) bin/$(PRERELEASE_BASE)-avx512-vnni$(EXE_EXT)
|
||||
|
||||
ifeq ($(AVX512_SUPPORTED),1)
|
||||
RELEASE_BINARIES += bin/$(RELEASE_BASE)-avx512$(EXE_EXT)
|
||||
endif
|
||||
|
||||
ifeq ($(VNNI_SUPPORTED),1)
|
||||
RELEASE_BINARIES += bin/$(RELEASE_BASE)-vnni$(EXE_EXT)
|
||||
RELEASE_BINARIES += bin/$(RELEASE_BASE)-avx512-vnni$(EXE_EXT)
|
||||
endif
|
||||
endif
|
||||
|
||||
RELEASE_BINARIES += bin/$(RELEASE_BASE)-universal$(EXE_EXT)
|
||||
CI_RELEASE_BINARIES += bin/$(RELEASE_BASE)-universal$(EXE_EXT)
|
||||
PRERELEASE_BINARIES += bin/$(PRERELEASE_BASE)-universal$(EXE_EXT)
|
||||
|
||||
ifeq ($(VNNI_SUPPORTED),1)
|
||||
define NATIVE_BUILD_CMD
|
||||
@echo "Building native target (AVX512 VNNI)"
|
||||
$(ECHO) nim c $(NFLAGS_VNNI) $(SRCDIR)/heimdall.nim
|
||||
@echo Native target built
|
||||
endef
|
||||
AUTO_SIMD := avx512-vnni
|
||||
else ifeq ($(AVX512_SUPPORTED),1)
|
||||
define NATIVE_BUILD_CMD
|
||||
@echo "Building native target (AVX512)"
|
||||
$(ECHO) nim c $(NFLAGS_AVX512) $(SRCDIR)/heimdall.nim
|
||||
@echo Native target built
|
||||
endef
|
||||
AUTO_SIMD := avx512
|
||||
else ifeq ($(AVX2_SUPPORTED),1)
|
||||
define NATIVE_BUILD_CMD
|
||||
@echo "Building native target (AVX2)"
|
||||
$(ECHO) nim c $(NFLAGS_NATIVE) $(SRCDIR)/heimdall.nim
|
||||
@echo Native target built
|
||||
endef
|
||||
AUTO_SIMD := avx2
|
||||
else ifeq ($(SSE41_SUPPORTED),1)
|
||||
AUTO_SIMD := sse41
|
||||
else ifeq ($(SSSE3_SUPPORTED),1)
|
||||
AUTO_SIMD := ssse3
|
||||
else ifeq ($(SSE2_SUPPORTED),1)
|
||||
AUTO_SIMD := sse2
|
||||
else ifeq ($(NEON_SUPPORTED),1)
|
||||
AUTO_SIMD := neon
|
||||
else
|
||||
AUTO_SIMD := scalar
|
||||
endif
|
||||
|
||||
SELECTED_SIMD := $(if $(filter auto,$(SIMD)),$(AUTO_SIMD),$(SIMD))
|
||||
BACKEND_FLAGS_avx512-vnni = $(NFLAGS_AVX512_VNNI)
|
||||
BACKEND_FLAGS_avx512 = $(NFLAGS_AVX512)
|
||||
BACKEND_FLAGS_avx2 = $(if $(filter auto,$(SIMD)),$(NFLAGS_NATIVE),$(NFLAGS_AVX2))
|
||||
BACKEND_FLAGS_ssse3 = $(NFLAGS_SSSE3)
|
||||
BACKEND_FLAGS_sse41 = $(NFLAGS_SSE41)
|
||||
BACKEND_FLAGS_sse2 = $(NFLAGS_SSE2)
|
||||
BACKEND_FLAGS_neon = $(NFLAGS_NEON)
|
||||
BACKEND_FLAGS_scalar = $(NFLAGS_SCALAR)
|
||||
BACKEND_FLAGS_universal = $(NFLAGS_UNIVERSAL)
|
||||
ifeq ($(filter $(SELECTED_SIMD),avx512-vnni avx512 avx2 sse2 ssse3 sse41 neon scalar universal),)
|
||||
$(error Unknown SIMD backend '$(SIMD)': use auto, universal, scalar, sse2, ssse3, sse41, avx2, avx512, avx512-vnni, or neon)
|
||||
endif
|
||||
|
||||
define NATIVE_BUILD_CMD
|
||||
@echo "Building native target (legacy, no AVX2)"
|
||||
$(ECHO) nim c $(NFLAGS_NATIVE_LEGACY) $(SRCDIR)/heimdall.nim
|
||||
@echo "Building native target ($(SELECTED_SIMD))"
|
||||
$(ECHO) nim c $(BACKEND_FLAGS_$(SELECTED_SIMD)) $(MAIN)
|
||||
@echo Native target built
|
||||
endef
|
||||
endif
|
||||
|
||||
native:
|
||||
$(NATIVE_BUILD_CMD)
|
||||
|
||||
dev:
|
||||
ifeq ($(PGO),1)
|
||||
$(MAKE) -s pgo SKIP_DEPS=1
|
||||
else
|
||||
$(MAKE) -s native SKIP_DEPS=1
|
||||
endif
|
||||
|
||||
.PHONY: pgo
|
||||
pgo:
|
||||
ifneq ($(abspath $(MAIN)),$(abspath $(SRCDIR)/heimdall.nim))
|
||||
$(error PGO training requires the engine MAIN, not a standalone test)
|
||||
endif
|
||||
@echo Building optional profile-guided native target
|
||||
@$(PGO_PREPARE_DIR)
|
||||
$(MAKE) -s dev PGO=0 EXE_BASE="$(PGO_TRAIN_EXE_BASE)" EXE="$(PGO_TRAIN_EXE)" CFLAGS="$(CFLAGS) -fprofile-instr-generate" LFLAGS="$(LFLAGS) -fprofile-instr-generate"
|
||||
$(PGO_NODE_ENV) $(PYTHON) scripts/uci_workload.py "$(PGO_TRAIN_EXE)" --positions "$(PGO_POSITIONS)" $(PGO_TRAIN_ARGS) --limit-kind nodes --limit $(PGO_TRAIN_NODES)
|
||||
$(PGO_TIME_ENV) $(PYTHON) scripts/uci_workload.py "$(PGO_TRAIN_EXE)" --positions "$(PGO_POSITIONS)" $(PGO_TRAIN_ARGS) --limit-kind time --limit $(PGO_TRAIN_MSEC)
|
||||
$(LLVM_PROFDATA) merge "$(PGO_RAW_NODES)" "$(PGO_RAW_TIME)" -output="$(PGO_DATA)"
|
||||
$(MAKE) -s dev PGO=0 EXE_BASE="$(EXE_BASE)" EXE="$(EXE)" CFLAGS="$(CFLAGS) -fprofile-instr-use=$(PGO_DATA)" LFLAGS="$(LFLAGS) -fprofile-instr-use=$(PGO_DATA)"
|
||||
@echo Profile-guided native target built
|
||||
|
||||
.PHONY: dev native universal sse2 ssse3 sse41 avx2 avx512 avx512-vnni neon scalar test-simd
|
||||
SIMD_TEST_RUNNER ?=
|
||||
SIMD_TEST_DIR ?= build/simd/$(SELECTED_SIMD)
|
||||
test-simd:
|
||||
$(PYTHON) scripts/test_simd.py --backend "$(SELECTED_SIMD)" --runner "$(SIMD_TEST_RUNNER)" --directory "$(SIMD_TEST_DIR)" --exe-extension "$(EXE_EXT)"
|
||||
|
||||
test:
|
||||
$(MAKE) -s native SKIP_DEPS=1 IS_TEST=1 EXE_BASE=bin/testdall
|
||||
@@ -306,7 +533,7 @@ endif
|
||||
ifeq ($(VNNI_SUPPORTED),1)
|
||||
define VNNI_RELEASES_CMD
|
||||
@echo AVX512 VNNI support detected
|
||||
$(MAKE) -s vnni SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-vnni
|
||||
$(MAKE) -s avx512-vnni SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-avx512-vnni
|
||||
@echo Finished AVX-512 VNNI build
|
||||
endef
|
||||
else
|
||||
@@ -315,44 +542,65 @@ endif
|
||||
|
||||
releases: deps net
|
||||
@echo Building platform targets
|
||||
$(MAKE) -s legacy SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-core2
|
||||
@echo Finished Core 2 build
|
||||
$(MAKE) -s modern SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-haswell
|
||||
@echo Finished Haswell build
|
||||
$(MAKE) -s zen2 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-zen2
|
||||
@echo Finished Zen 2 build
|
||||
ifeq ($(ARCH_TAG),arm64)
|
||||
$(MAKE) -s neon SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-neon
|
||||
else
|
||||
$(MAKE) -s sse2 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-sse2
|
||||
@echo Finished SSE2 build
|
||||
$(MAKE) -s ssse3 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-ssse3
|
||||
@echo Finished SSSE3 build
|
||||
$(MAKE) -s sse41 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-sse41
|
||||
@echo Finished SSE4.1 build
|
||||
$(MAKE) -s avx2 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-avx2
|
||||
@echo Finished AVX2 build
|
||||
$(AVX512_RELEASES_CMD)
|
||||
$(VNNI_RELEASES_CMD)
|
||||
endif
|
||||
$(MAKE) -s universal SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-universal
|
||||
$(MAKE) -s check-release-benches SKIP_DEPS=1 BENCH_BINARIES="$(RELEASE_BINARIES)"
|
||||
@echo All platform targets built and checked
|
||||
|
||||
ci-releases: deps net
|
||||
@echo Building CI release platform targets
|
||||
$(MAKE) -s legacy SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-core2
|
||||
@echo Finished Core 2 build
|
||||
$(MAKE) -s modern SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-haswell
|
||||
@echo Finished Haswell build
|
||||
$(MAKE) -s zen2 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-zen2
|
||||
@echo Finished Zen 2 build
|
||||
ifeq ($(ARCH_TAG),arm64)
|
||||
$(MAKE) -s neon SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-neon
|
||||
else
|
||||
$(MAKE) -s sse2 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-sse2
|
||||
@echo Finished SSE2 build
|
||||
$(MAKE) -s ssse3 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-ssse3
|
||||
@echo Finished SSSE3 build
|
||||
$(MAKE) -s sse41 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-sse41
|
||||
@echo Finished SSE4.1 build
|
||||
$(MAKE) -s avx2 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-avx2
|
||||
@echo Finished AVX2 build
|
||||
$(MAKE) -s avx512 SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-avx512
|
||||
@echo Finished AVX-512 build
|
||||
$(MAKE) -s vnni SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-vnni
|
||||
$(MAKE) -s avx512-vnni SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-avx512-vnni
|
||||
@echo Finished AVX-512 VNNI build
|
||||
endif
|
||||
$(MAKE) -s universal SKIP_DEPS=1 IS_RELEASE=1 EXE_BASE=bin/$(RELEASE_BASE)-universal
|
||||
$(MAKE) -s check-release-benches SKIP_DEPS=1 BENCH_BINARIES="$(CI_RELEASE_BINARIES)"
|
||||
@echo All CI release platform targets built and checked
|
||||
|
||||
prereleases: deps net
|
||||
@echo Building prerelease platform targets
|
||||
$(MAKE) -s legacy SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-core2
|
||||
@echo Finished Core 2 build
|
||||
$(MAKE) -s modern SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-haswell
|
||||
@echo Finished Haswell build
|
||||
$(MAKE) -s zen2 SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-zen2
|
||||
@echo Finished Zen 2 build
|
||||
ifeq ($(ARCH_TAG),arm64)
|
||||
$(MAKE) -s neon SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-neon
|
||||
else
|
||||
$(MAKE) -s sse2 SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-sse2
|
||||
@echo Finished SSE2 build
|
||||
$(MAKE) -s ssse3 SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-ssse3
|
||||
@echo Finished SSSE3 build
|
||||
$(MAKE) -s sse41 SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-sse41
|
||||
@echo Finished SSE4.1 build
|
||||
$(MAKE) -s avx2 SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-avx2
|
||||
@echo Finished AVX2 build
|
||||
$(MAKE) -s avx512 SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-avx512
|
||||
@echo Finished AVX-512 build
|
||||
$(MAKE) -s vnni SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-vnni
|
||||
$(MAKE) -s avx512-vnni SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-avx512-vnni
|
||||
@echo Finished AVX-512 VNNI build
|
||||
endif
|
||||
$(MAKE) -s universal SKIP_DEPS=1 EXE_BASE=bin/$(PRERELEASE_BASE)-universal
|
||||
$(MAKE) -s check-release-benches SKIP_DEPS=1 BENCH_BINARIES="$(PRERELEASE_BINARIES)"
|
||||
@echo All prerelease platform targets built and checked
|
||||
|
||||
|
||||
383
README.md
383
README.md
@@ -1,308 +1,97 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||

|
||||
|
||||
[](https://deepwiki.com/nocturn9x/heimdall)
|
||||
|
||||
# heimdall
|
||||
|
||||
Heimdall is a strong chess engine written in Nim. As far as I know, this is the strongest Nim engine that has ever been tested (please
|
||||
let me know should that not be the case), sitting around the top 50 rank globally.
|
||||
|
||||
Heimdall is a strong chess engine written in Nim. As far as I know, this is the strongest Nim engine that has ever been tested, sitting around the top 40 rank globally.
|
||||
|
||||
##### Logo by @kan, thank you!
|
||||
|
||||
## Building and Installation
|
||||
|
||||
**Note**: Do **not** run a bare `make` command! This will not update the neural networks submodule and is meant to be used by [OpenBench](https://gitbub.com/AndyGrant/OpenBench) only.
|
||||
|
||||
**Note 2**: To build from source, there's also a useful AI-generated guide you can find [here](https://deepwiki.com/nocturn9x/heimdall/2.1-building-from-source)
|
||||
|
||||
|
||||
### Requirements
|
||||
- The Nim compiler (2.2.6). See [here](https://codeberg.org/janAkali/grabnim) for more details
|
||||
- The clang compiler (any reasonably modern version will do)
|
||||
- The lld linker script (LLVM linker driver). This isn't installed on all systems even when clang is, so make sure it's there!
|
||||
- Git LFS (see [here](https://docs.github.com/en/repositories/working-with-files/managing-large-files/installing-git-large-file-storage))
|
||||
|
||||
|
||||
Running `make native` is the simplest option: it will build the most optimized executable possible, but your CPU needs to support at least AVX2 (AVX512 VNNI or AVX512 are used if available).
|
||||
|
||||
To produce a more generic binary that is still modern, run `make zen2`: the resulting executable will be able to run on more than just your specific processor family.
|
||||
|
||||
For older CPUs, and a much more generic binary, try `make modern`; For (very) old CPUs without AVX2 support, run `make legacy` instead.
|
||||
|
||||
There are also explicit `vnni` and `avx512` targets that can be built, though if your processor supports either AVX512 VNNI or AVX512 `make native` will catch that and use it for the build.
|
||||
|
||||
In every case, the resulting executable will be located at `bin/$(EXE)` (`bin/heimdall` by default).
|
||||
|
||||
You can also grab the latest stable version from the [releases](https://git.nocturn9x.space/nocturn9x/heimdall/releases) page, see [here](#how-to-pick-the-right-executable) for more details.
|
||||
|
||||
**Note for Nim users**: Building via `nimble build` is no longer supported, as it required me to duplicate flags and functionality across two files. The Makefile
|
||||
is the only supported build method!
|
||||
|
||||
### How to pick the right executable
|
||||
|
||||
**Note**: This only applies to versions 1.3 or higher
|
||||
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.
|
||||
|
||||
In hopes of providing the best experience to as many users as possible, I target several machine types when building release binaries.
|
||||
| Your computer | Executable 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` |
|
||||
|
||||
Targets from best to worst (speed-wise):
|
||||
- `vnni`: Requires AVX512 VNNI support. Pick this over `avx512` only if your CPU explicitly supports AVX512 VNNI
|
||||
- `avx512`: Requires a very modern processor with AVX512 support. The speed difference is generally measurable only the newest Ryzen 9000 series of processors (and contemporary Intel chips)
|
||||
- `zen2`: Tuned for Zen 2 CPUs (later ones work too)
|
||||
- `haswell`: Tuned for Haswell-era CPUs with AVX2 support. Most modern CPUs should be able to run this
|
||||
- `core2`: Tuned for very old CPUs without AVX2 support. _Significantly_ slower than all of the above
|
||||
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.
|
||||
|
||||
All of the targets require a 64 bit processor: Heimdall does not (and will never) support 32 bit systems
|
||||
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.
|
||||
|
||||
## Testing
|
||||
## Documentation
|
||||
|
||||
Just run `make test-suite`: sit back, relax, get yourself a cup of coffee and wait for it to finish (it _will_ take a long time)
|
||||
|
||||
|
||||
**Note**: The test suite requires Python (stdlib only) and expects stockfish to be installed and in the system's PATH. Alternatively, it
|
||||
is possible to specify the location of both Heimdall and Stockfish (run `python tests/suite.py -h` for more information)
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Heimdall implements the [UCI](https://en.wikipedia.org/wiki/Universal_Chess_Interface) protocol to communicate with chess GUIs and other programs.
|
||||
To use it at its best, you can add it to any number of chess GUIs like Arena, En Croissant or Cutechess. I strive to have Heimdall work flawlessly with
|
||||
any GUI (within reason), so please let me know if you find any issues!
|
||||
|
||||
If you prefer to use it from the command line, there is a fairly advanced user interface supporting colored output, command history, line editing
|
||||
and much more, powered by [nim-noise](https://github.com/jangko/nim-noise) (for all keyboard bindings see [here](https://github.com/jangko/nim-noise?tab=readme-ov-file#key-binding))
|
||||
|
||||
|
||||
Heimdall supports the following UCI options:
|
||||
- `HClear`: Clears all history tables. This is done automatically at every new game, so you shouldn't need to do this normally
|
||||
- `TTClear`: Clears the transposition table. Like history clearing, this is done at every new game, so you shouldn't need this
|
||||
- `Ponder`: Allows Heimdall to search while its opponent is also searching. A `go ponder` command will not start a ponder search unless this is set
|
||||
- `UCI_ShowWDL`: Display the predicted win, draw and loss probability (see `NormalizeScore` below for more info). Not all GUIs support this, so only enable
|
||||
it if you know the one you're using does
|
||||
- `UCI_Chess960`: Switches Heimdall to playing Fischer random chess (also known as chess960). Heimdall supports Double Fischer random chess as well
|
||||
- `EvalFile`: Path to the neural network to use for evaluation. Its default value of `<default>` will cause Heimdall to use the network embedded in
|
||||
the executable. Do *not* set this to anything other than a valid path that the engine can access, or it _will_ crash (and no, empty strings don't work
|
||||
either!). Keep in mind that the network has to conform to the architecture of Heimdall's built-in one (check [here](#evaluation) for details)
|
||||
- `NormalizeScore`: Enables score normalization. This means that displayed scores will be normalized such that +1.0 means a 50% probability
|
||||
of winning against an equally strong opponent when there's around 58 points of material on the board (using the standard 1, 3, 3, 5, 9 weights
|
||||
for pawns, minor pieces, rooks and queens). Thanks to the stockfish folks who developed the [WDL model](https://github.com/official-stockfish/WDL_model)! This
|
||||
option is enabled by default
|
||||
- `EnableWeirdTCs`: Allows Heimdall to play with untested/weird/outdaded time controls such as moves to go or sudden death: Heimdall will
|
||||
refuse to search with those unless this is set! See [here](#enableweirdtcs) for more details on why this exists
|
||||
- `MultiPV`: The number of best moves to search for. The default value of one is best suited for strength, but you can set this to more
|
||||
if you want the engine to analyze different lines. Note that a time-limited search will share limits across all lines
|
||||
- `Threads`: How many threads to allocate for search. By default Heimdall will only search with one thread
|
||||
- `Hash`: The size of the hash table in mebibytes (aka REAL megabytes). The default is 64
|
||||
- `MoveOverhead`: How much time (in milliseconds) Heimdall will subtract from its own remaining time to account for communication delays with an external
|
||||
program (usually a GUI or match manager). Particularly useful when playing games over a network (for example through a Lichess bot or on an internet chess
|
||||
server). This is set to 250 by default
|
||||
- `Minimal`: Enables minimal logging, where only the final info line is printed instead of one for each depth searched
|
||||
- `Contempt`: A static, side-to-move relative offset added to the static evaluation. Defaults to 0. The higher this is, the less willing Heimdall will be to draw
|
||||
|
||||
### Notes on command-line usage
|
||||
|
||||
For the fancy terminal interface, see [here](#built-in-tui)
|
||||
|
||||
To make command-line usage more friendly to us fleshy things, Heimdall implements a so-called "mixed mode": if it detects that it's connected to a TTY (a terminal)
|
||||
it will start up a user interface that supports both UCI commands (with some slight tweaks) and a set of custom commands (type `help` for more info). The following
|
||||
environment variables control the behavior of mixed mode:
|
||||
- `NO_COLOR`: If set, colored output will be disabled
|
||||
- `NO_TUI` : If set, the engine will start up in UCI mode right away (no colored output)
|
||||
- `NO_LOGO` : If set, heimdall's logo will not be printed on startup
|
||||
|
||||
Once `uci` is sent, Heimdall will switch to UCI mode: colored output will be turned off and mixed mode will be disabled; You can type `icu` to head back to mixed mode.
|
||||
|
||||
The mixed mode interface can be exited from by pressing either Ctrl+C, Ctrl+D (these also work for UCI) or Esc and then confirming when prompted (mixed mode only)
|
||||
|
||||
|
||||
### Built-in TUI
|
||||
|
||||
Thanks to the power of AI (Claude Opus 4.6 and GPT 5.4, to be specific), Heimdall now features an advanced text user interface to perform game analysis and playing against
|
||||
the engine, right from the terminal. It uses [Kitty](http://github.com/kovidgoyal/kitty/)'s graphics protocol to render a pretty chessboard to the screen, so this will only
|
||||
work on terminal emulators that implement it (just use kitty, it's great). Once you launch it, type `:help` to learn how to use it!
|
||||
|
||||
The built-in TUI is currently supported on Linux only.
|
||||
|
||||
Many thanks to whoever runs [this](https://sashite.dev/assets/chess/) website: the chess assets are beautiful! <3
|
||||
|
||||
P.S.: This part of heimdall does not respect `NO_COLOR` (sorry!)
|
||||
|
||||
Known supported terminals:
|
||||
- Kitty (perfect, recommended)
|
||||
- Ghostty (perfect)
|
||||
- WezTerm (near perfect)
|
||||
- Konsole (decent)
|
||||
|
||||
Any other terminals are 99.99% likely NOT to work. Do report any issues, if they're easily fixable I'll merge a fix.
|
||||
|
||||
Note that Konsole has the following known issues:
|
||||
- Mouse movement is jittery. This is unfixable: Konsole does not support reporting mouse movements in terms of pixels, just terminal cells.
|
||||
This means the mouse always snaps to the center of the closest cell, there is nothing I can do about that
|
||||
- The image assets look a bit rough and artifact-y. Not sure if this is fixable
|
||||
|
||||
Here follows a brief usage guide for the TUI generated by Claude
|
||||
|
||||
#### Getting Started
|
||||
|
||||
Launch the TUI with `heimdall tui`. You'll see a chessboard on the left and an info panel on the right. Type `:help` to see all commands.
|
||||
|
||||
#### Making Moves
|
||||
|
||||
There are five ways to input moves:
|
||||
- **Mouse click**: Click a piece to select it (legal moves are highlighted), then click the destination
|
||||
- **Drag and drop**: Drag a piece to a legal destination square
|
||||
- **UCI notation**: Type `e2e4` and press Enter
|
||||
- **SAN notation**: Type `Nf3`, `O-O`, `e8=Q`, etc.
|
||||
- **Square selection**: Type `e2` to select the piece, then `e4` to move it
|
||||
|
||||
#### General usage notes
|
||||
|
||||
- Promotions default to queen. Press `Shift+Q` to toggle auto-queen off; you'll then be prompted to choose Q/R/B/N when a pawn promotes.
|
||||
- Right-click a square to toggle a square highlight. Right-drag on the board to draw a user arrow; drawing the same arrow again removes it, while right-drag with
|
||||
`Shift`/`Ctrl` draws red arrows, `Alt` draws blue arrows, and both modifier groups together draw yellow arrows (much like Lichess/Chess.com).
|
||||
- User arrows and square highlights are stored per position, so when you navigate through a PGN and come back to a move your annotations are restored.
|
||||
- The command line supports basic cursor movement and editing: use Left/Right to move one character, `Ctrl+A` to jump to the beginning and `Ctrl+E` to jump to the end.
|
||||
- The board size scales down automatically to fit smaller terminals. If the window drops below the supported minimum size, the TUI shows a resize warning.
|
||||
- Global keyboard shortcuts only fire when the input buffer is empty, so text input and pasted commands keep their literal characters.
|
||||
|
||||
#### Analysis
|
||||
|
||||
- `:go` starts/stops continuous engine analysis on the current position
|
||||
- `:set multipv 3` shows multiple analysis lines (sorted by strength, with WDL probabilities)
|
||||
- Press `Shift+M` to set a mate-finder limit in moves for analysis. Enter `none` to clear it. If analysis is already running, Heimdall restarts the search with the new mate target.
|
||||
- `:arrows` toggles best-move arrows on the board. With MultiPV enabled, Heimdall shows the top move as the main arrow and additional candidate moves as lighter secondary arrows.
|
||||
- `:stop` halts the current search
|
||||
- Left/Right arrow keys undo/redo moves; the engine restarts analysis on each position change
|
||||
- Press `Shift+S` to enter board setup mode: In board setup mode you can drag pieces freely between squares. Dropping a piece off the board deletes it.
|
||||
Type `p/n/b/r/q/k` to arm spawning a black piece; use `Shift+<key>` to arm the white version. Press `w`/`x` to toggle white queen-side/king-side castling
|
||||
rights. Press `y`/`z` to toggle black queen-side/king-side castling rights. Press `Esc` to validate the edited position and exit back to analysis. Invalid
|
||||
setups are rejected gracefully and keep you in board setup mode so you can fix them.
|
||||
|
||||
#### Playing Against the Engine
|
||||
|
||||
- `:play` starts a game setup wizard: choose variant, side, your clock (`5m+3s`, `10m`, `1h`, `none`), then the engine limits. Engine limits can be combined with commas, for example `5m+3s, depth 20`, `depth 20, nodes 200000`, or `same`.
|
||||
- `:resign` forfeits the game, `:takeback` (or `:tb`) undoes your last move (if enabled)
|
||||
- `:exit` leaves play mode
|
||||
- `:rematch` begins a new game with the same settings as the just-finished one. If you picked randomized sides to move, a new one is picked.
|
||||
- `softnodes N` uses a soft per-move node target; the setup wizard then asks whether to also set a hard cap. If you do, the hard cap must be at least `N`. This also works when `softnodes` is combined with other limits such as `5m+3s, depth 20, softnodes 100000`.
|
||||
- While the engine is thinking, you can queue premoves by dragging one of your pieces, by square selection (`e2` then `e4`), or by typing a UCI move such as `e2e4`. Premoves resolve in queue order, with the first several highlighted using different colors on the board and the palette cycling after that. If the next premove becomes illegal after an engine move, the remaining queued premoves are cleared. Click a highlighted premove square to remove the most recently queued premove that touches that square.
|
||||
- `:watch` is the engine-vs-engine equivalent of `:play` and configures Heimdall to play against itself.
|
||||
|
||||
#### PGN Support
|
||||
|
||||
- `:load game.pgn` loads a PGN for replay. If the file contains multiple games, specify which one: `:load game.pgn 3`
|
||||
- Navigate with Left/Right arrows, Home/End to jump to start/end
|
||||
- Press `Shift+L` or run `:analyse` / `:analyze` to request a full computer analysis of the loaded game. Heimdall asks for a per-position limit (`500ms`, `1s`, `depth 20`, `nodes 200000`, `mate 6`, etc.) and whether to analyze from the end or the beginning of the game. Reverse analysis is the default.
|
||||
- `:stop` cancels a running computer analysis and keeps the positions analyzed so far.
|
||||
- Once a report exists, Heimdall shows a `Computer Analysis` pane with per-side ACPL and accuracy plus move-specific details such as centipawn loss, best move and judgment.
|
||||
- The move list is annotated with Lichess-style mistake markers during replay analysis, using color to distinguish inaccuracies, mistakes and blunders.
|
||||
- A live graph is rendered below the board and follows the current move as you scroll through the PGN. It includes opening/midgame/endgame divider markers and a scale derived from the
|
||||
extrema encountered during the analysis run.
|
||||
- Press `Shift+W` or use `:wdl` to toggle the graph between eval and WDL views
|
||||
- Press `Shift+H` to hide/show the replay analysis graph without discarding the report
|
||||
- `:pgn output.pgn` exports the current move history as a PGN file with metadata
|
||||
|
||||
#### Chess960 / DFRC
|
||||
|
||||
- `:frc 518` loads a Chess960 position by Scharnagl number (0-959)
|
||||
- `:dfrc 123 456` loads a Double Fischer Random position
|
||||
- `:chess960 on/off` toggles Chess960 mode manually
|
||||
|
||||
#### Engine Settings
|
||||
|
||||
`:set <option> <value>` configures the engine. Autocomplete is available (type `:set ` and use Tab/arrows). Options include:
|
||||
`hash`, `threads`, `multipv`, `depth`, `contempt`, `moveoverhead`, `ponder`, `normalizescore`, `evalfile`, `chess960`
|
||||
|
||||
Hash accepts human-readable sizes: `:set hash 1 GB`, `:set hash 256 MiB`, or bare numbers (interpreted as MiB).
|
||||
|
||||
`:set normalizescore on/off` controls score normalization for live analysis, cached analysis lines, game-analysis reports, and the replay graph.
|
||||
|
||||
`:clear` resets all engine state (transposition table, move ordering histories).
|
||||
|
||||
#### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Shift+A` | Toggle best-move arrows |
|
||||
| `Shift+F` | Flip board |
|
||||
| `Shift+H` | Hide/show the replay analysis graph |
|
||||
| `Shift+L` | Request computer analysis for the loaded PGN |
|
||||
| `Shift+M` | Set mate-finder limit (analysis only) |
|
||||
| `Shift+Q` | Toggle auto-queen promotion |
|
||||
| `Shift+S` | Enter board setup mode (analysis only) |
|
||||
| `Shift+W` | Toggle the replay report graph between eval and WDL |
|
||||
| `w` / `x` | In board setup, toggle white queen-side / king-side castling |
|
||||
| `y` / `z` | In board setup, toggle black queen-side / king-side castling |
|
||||
| Left/Right | Undo/redo moves |
|
||||
| Home/End | Go to first/last position |
|
||||
| `Ctrl+A` / `Ctrl+E` | Move to the start / end of the input line |
|
||||
| Ctrl+C | Quit immediately |
|
||||
| Ctrl+D (x2) | Quit with confirmation |
|
||||
| ESC | Cancel current action |
|
||||
| Tab | Accept autocomplete suggestion |
|
||||
|
||||
#### Other Commands
|
||||
|
||||
- `:fen` copies the current FEN to clipboard; `:fen <fen>` loads a position
|
||||
- `:reset` resets to the starting position
|
||||
- `:flip` flips the board view
|
||||
- `:arrows` toggles engine move arrows (primary line plus lighter MultiPV arrows)
|
||||
- `:threats` toggles threat square highlighting
|
||||
- [Building and installation](docs/BUILDING.md)
|
||||
- [Testing and benchmarks](docs/TESTING.md)
|
||||
- [UCI and command-line usage](docs/UCI.md)
|
||||
- [Built-in terminal UI](docs/TUI.md)
|
||||
- [SIMD builds](docs/SIMD.md)
|
||||
- [Release workflow](docs/RELEASES.md)
|
||||
|
||||
## Search
|
||||
|
||||
Heimdall implements [negamax](https://en.wikipedia.org/wiki/Negamax) search with [alpha-beta pruning](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning) in a [PVS](https://en.wikipedia.org/wiki/Principal_variation_search) framework to search the game tree
|
||||
and utilizes dozens of heuristics to help it navigate the gigantic search space of chess
|
||||
Heimdall implements [negamax](https://en.wikipedia.org/wiki/Negamax) search with [alpha-beta pruning](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning) in a [PVS](https://en.wikipedia.org/wiki/Principal_variation_search) framework to search the game tree and utilizes dozens of heuristics to help it navigate the gigantic search space of chess.
|
||||
|
||||
## Evaluation
|
||||
|
||||
Heimdall currently uses [NNUE](https://en.wikipedia.org/wiki/Efficiently_updatable_neural_network) (Efficiently Updatable Neural Network) to evaluate positions. All of heimdall's networks
|
||||
are trained with [bullet](https://github.com/jw1912/bullet) using data obtained from selfplay of previous versions,
|
||||
while previous HCE releases used the lichess-big3 dataset for tuning. The current network architecture consists of a horizontally
|
||||
mirrored perspective network (with pairwise reduction) featuring a first layer of 1536 neurons with 16 input buckets and two middle
|
||||
layers of 16 and 32 neurons respectively (with 8 output buckets), which is commonly represented as (768x16hm->1536)x2-pw->(16->32->1)x8
|
||||
Heimdall currently uses [NNUE](https://en.wikipedia.org/wiki/Efficiently_updatable_neural_network) (Efficiently Updatable Neural Network) to evaluate positions. All of Heimdall's networks are trained with [bullet](https://github.com/jw1912/bullet) using data obtained from selfplay of previous versions, while previous HCE releases used the lichess-big3 dataset for tuning. The current network architecture consists of a threat-input, horizontally mirrored perspective network (with pairwise reduction) featuring a first layer of 512 neurons with 16 input buckets and two middle layers of 16 (dual-activated) and 32 neurons respectively (with 8 output buckets), which is commonly represented as `(768x16hm+60144hm->512)x2-pw->(16x2->32->1)x8`.
|
||||
|
||||
Network files must also match the feature indexing, king bucket table, and bias quantization used by the engine. Heimdall adds L1 biases before the requantization shift, while some networks store biases intended to be added after it. For those networks, set the Makefile's `L1_BIAS_SHIFT` to the number of requantization bits (8 with the current architecture) to rescale the biases during loading. Otherwise, the file can load successfully but produce incorrect evaluations. The default `L1_BIAS_SHIFT=0` preserves Heimdall's native bias format.
|
||||
|
||||
## EnableWeirdTCs
|
||||
|
||||
Heimdall is designed (and tested) to play at the standard time controls of time + increment: since I do not have the hardware nor
|
||||
the time to test others (like sudden death or moves to go), support for outdated/nonstandard time controls has been hidden behind
|
||||
the `EnableWeirdTCs` option. Unless this option is set, Heimdall will refuse to play either if its own increment is missing/zero
|
||||
or if it is told to play with a cyclic time control, aka "moves to go" (this one is especially important because it is not taken
|
||||
into account at all in time management!): this technically means Heimdall is not fully UCI compliant unless `EnableWeirdTCs` is
|
||||
enabled, but I believe this trade-off is worth it, as it means that if it does indeed perform worse at untested time controls then
|
||||
the tester will have full knowledge as to why that is. If that upsets you or makes you want to not test Heimdall, that's fine! I'm
|
||||
sorry you feel that way, but this is my engine after all :)
|
||||
|
||||
|
||||
## More info
|
||||
|
||||
Heimdall is sometimes available on [Lichess](https://lichess.org/@/Nimfish) under its old name (Nimfish), feel free to challenge it!
|
||||
I try to keep the engine running on there always up to date with the changes on the master branch. The hardware running it is quite
|
||||
heterogenous however, so expect big rating swings
|
||||
Heimdall is designed and tested to play at standard time controls of time plus increment. Since I do not have the hardware or time to test others, support for outdated or nonstandard time controls is hidden behind the `EnableWeirdTCs` option. Unless this option is set, Heimdall refuses to play if its increment is missing or zero, or if it is told to play with a cyclic time control such as moves to go. Moves to go are especially important because they are not taken into account in time management.
|
||||
|
||||
## Strength
|
||||
|
||||
Lots of people are kind enough to test Heimdall on their own hardware. Here's a summary of the rating lists I'm aware of (please contact
|
||||
me if you want me to add yours)
|
||||
Lots of people are kind enough to test Heimdall on their own hardware. Here's a summary of the rating lists I'm aware of (please contact me if you want me to add yours).
|
||||
|
||||
|
||||
| Version | Estimated | TCEC | CCRL 40/15 1CPU | CCRL 40/15 4CPU | CCRL Chess324 1CPU | CCRL FRC 40/2 | CCRL DFRC 40/2 | CCRL Blitz 2+1 1CPU | CCRL Blitz 2+1 8CPU | MCERL | CEGT 40/20 | CEGT 5'+3'' | CEGT 40/4 |
|
||||
| --------- | --------- | -------- | --------------- | --------------- | ------------------ | ------------- | -------------- | ------------------- | ------------------- | ----- | ---------- | ----------- | --------- |
|
||||
| 0.1 | 2531 | - | 2436 | - | - | N/A | N/A | - | - | - | - | - | - |
|
||||
| 0.2 | 2706 | - | 2669 | - | - | N/A | N/A | - | - | - | - | - | - |
|
||||
| 0.3 | 2837 | - | - | - | - | N/A | N/A | - | - | - | - | - | - |
|
||||
| 0.4 | 2888 | - | 2859 | - | - | 2929 | - | - | - | - | - | - | - |
|
||||
| 1.0 | 3230 | 3163* | 3192 | - | - | 3376 | - | - | - | - | - | - | - |
|
||||
| 1.1 | 3370 | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| 1.1.1 | 3390** | - | 3360 | - | - | 3564 | - | 3383 | - | 3440 | - | 3286 | 3268 |
|
||||
| 1.2 | 3490 | - | - | - | - | - | - | - | - | 3470 | - | - | - |
|
||||
| 1.2.{1,2} | 3500 | - | 3376 | 3439 | - | 3627 | - | 3467 | - | 3479 | 3301 | - | |
|
||||
| 1.3 | 3548*** | - | 3419 | - | - | - | - | 3510 | - | - | 3337 | - | 3373 |
|
||||
| 1.3.{1,2} | 3530 | 3307**** | 3423 | - | - | 3721 | - | - | 3578 | - | - | 3404 | - |
|
||||
| 1.4 | 3626 | - | 3494 | 3550 | - | 3823 | 3481\*\*\*\*\* | - | - | - | 3443 | - | - |
|
||||
| 1.4.1 | 3659 | - | 3514 | - | - | - | - | 3615 | - | - | 3459 | - | - |
|
||||
| 1.4.2 | 3660 | - | 3503 | 3562 | 3542 | 3851 | - | - | - | - | - | - | - |
|
||||
| Version | Estimated | TCEC | CCRL 40/15 1CPU | CCRL 40/15 4CPU | CCRL Chess324 1CPU | CCRL FRC 40/2 | CCRL Blitz 2+1 1CPU | CCRL Blitz 2+1 8CPU | CEGT 40/20 | CEGT 5'+3'' | CEGT 40/4 |
|
||||
| --------- | --------- | -------- | --------------- | --------------- | ------------------ | ------------- | ------------------- | ------------------- | ---------- | ----------- | --------- |
|
||||
| 0.1 | 2531 | - | 2436 | - | - | N/A | - | - | - | - | - |
|
||||
| 0.2 | 2706 | - | 2669 | - | - | N/A | - | - | - | - | - |
|
||||
| 0.3 | 2837 | - | - | - | - | N/A | - | - | - | - | - |
|
||||
| 0.4 | 2888 | - | 2859 | - | - | 2929 | - | - | - | - | - |
|
||||
| 1.0 | 3230 | 3163* | 3192 | - | - | 3376 | - | - | - | - | - |
|
||||
| 1.1 | 3370 | - | - | - | - | - | - | - | - | - | - |
|
||||
| 1.1.1 | 3390** | - | 3360 | - | - | 3564 | 3383 | - | - | 3286 | 3268 |
|
||||
| 1.2 | 3490 | - | - | - | - | - | - | - | - | - | - |
|
||||
| 1.2.{1,2} | 3500 | - | 3376 | 3439 | - | 3627 | 3467 | - | 3301 | - | |
|
||||
| 1.3 | 3548*** | - | 3419 | - | - | - | 3510 | - | 3337 | - | 3373 |
|
||||
| 1.3.{1,2} | 3530 | 3307**** | 3423 | - | - | 3721 | - | 3578 | - | 3404 | - |
|
||||
| 1.4 | 3626 | - | 3494 | 3550 | - | 3823 | - | - | 3443 | - | - |
|
||||
| 1.4.1 | 3659 | - | 3514 | - | - | - | 3615 | - | 3459 | - | - |
|
||||
| 1.4.2 | 3660 | - | 3503 | 3562 | 3542 | 3851 | - | - | - | - | - |
|
||||
| 1.5.0 | 3750 | - | 3554 | 3586 | - | 3911 | - | - | - | - | - |
|
||||
|
||||
*: Beta version, not final 1.0 release
|
||||
|
||||
@@ -314,45 +103,29 @@ me if you want me to add yours)
|
||||
|
||||
\*\*\*\*\*: Version 1.4.0-beta-301171
|
||||
|
||||
|
||||
**Note**: Ratings of late versions are likely to fluctuate a lot as the number of games on the relevant list increases. They do eventually stabilize.
|
||||
|
||||
|
||||
__Note__: Unless otherwise specified, estimated strenght is measured for standard chess at a short time control (8 seconds with 0.08 seconds increment)
|
||||
with 1 search thread and a 16MB hash table over 1000 or 2000 game pairs against the previous version (except for version 0.1 where it was tested in a gauntlet)
|
||||
using the Pohl opening book (up to version 1.0) and the UHO_Lichess_4852_v1 book for later versions, and is therefore not as accurate as the other ratings
|
||||
which are provided by testers running the engine at longer TCs against a pool of different opponents.
|
||||
__Note__: Unless otherwise specified, estimated strength is measured for standard chess at a short time control (8 seconds with 0.08 seconds increment), with 1 search thread and a 16MB hash table over 1000 or 2000 game pairs against the previous version, using the Pohl opening book (up to version 1.0) and the UHO_Lichess_4852_v1 book for later versions.
|
||||
|
||||
## Notes
|
||||
|
||||
This repository was extracted from a monorepo that you can check out [here](https://git.nocturn9x.space/nocturn9x/CPG) (look into the `Chess/`
|
||||
directory): all history before the first commit here can be found there!
|
||||
|
||||
This repository was extracted from a monorepo that you can check out [here](https://git.nocturn9x.space/nocturn9x/CPG) (look into the `Chess/` directory): all history before the first commit here can be found there.
|
||||
|
||||
## Credits
|
||||
|
||||
Many thanks to all the folks on the Engine Programming and Stockfish servers on Discord: your help has been invaluable and Heimdall literally
|
||||
would not exist without the help of all of you. In no particular order, I'd like to thank:
|
||||
- @analog-hors (okay, she's first for a reason): for her awesome article about magic bitboards as well as providing the initial code for the
|
||||
HCE tuner and the NN inference to get me started on NNUE
|
||||
- @ciekce: for helping me debug countless issues. Also helping me on morelayers and with general net stuff. Cool cat indeed
|
||||
- @sroelants: provided debugging help and lots of good ideas to steal
|
||||
- @tsoj: Saved my ass by solving some major performance bottlenecks and helping me debug my broken threading code
|
||||
- @viren, @zuppadcipolle, @toanth, @fuuryy: Debugging help
|
||||
- @DarkNeutrino, @yoshie2000, @87flowers, @kazapps_08388, @swedishchef: for lending cores to my OB instance
|
||||
- @Quinniboi10, @ksw0518: For joining [MattBench](https://chess.n9x.co) (best OB instance ever FYI). Y'all are the OGs
|
||||
- All other Mattbench members <3
|
||||
- @ceorwmt: for helping with datagen
|
||||
- @cj5716, @affinelytyped: Provided lots of ideas to steal and helped with debugging
|
||||
- @jw1912: For creating bullet (it's awesome, use it) and helping with debugging twofold LMR (+140 Elo!)
|
||||
- @__arandomnoob: For debugging a critical bug in my alpha-beta pruning worth over 100 STC Elo. Very cool, very dangerous. Beware of HCE
|
||||
- @agethereal (aka Andy Grant) for helping with debugging and creating the amazing piece of software that is [OpenBench](https://gitbub.com/AndyGrant/OpenBench)
|
||||
Many thanks to all the folks on the Engine Programming and Stockfish servers on Discord: your help has been invaluable and Heimdall literally would not exist without the help of all of you. In no particular order, I'd like to thank:
|
||||
|
||||
- @analog-hors (okay, she's first for a reason): for her awesome article about magic bitboards as well as providing the initial code for the HCE tuner and the NN inference to get me started on NNUE.
|
||||
- @ciekce: for helping me debug countless issues, helping me on morelayers, and helping with general net stuff.
|
||||
- @sroelants: for debugging help and lots of good ideas to steal.
|
||||
- @tsoj: for solving major performance bottlenecks and helping me debug my broken threading code.
|
||||
- @viren, @zuppadcipolle, @toanth, and @fuuryy: debugging help.
|
||||
- @DarkNeutrino, @yoshie2000, @87flowers, @kazapps_08388, and @swedishchef: for lending cores to my OpenBench instance.
|
||||
- @Quinniboi10 and @ksw0518: for joining [MattBench](https://chess.n9x.co), along with all other MattBench members.
|
||||
- @ceorwmt: for helping with datagen.
|
||||
- @cj5716 and @affinelytyped: for ideas and debugging help.
|
||||
- @jw1912: for creating bullet and helping with debugging twofold LMR (+140 Elo!).
|
||||
- @__arandomnoob: for debugging a critical bug in my alpha-beta pruning worth over 100 STC Elo.
|
||||
- @agethereal (aka Andy Grant): for helping with debugging and creating the amazing [OpenBench](https://github.com/AndyGrant/OpenBench).
|
||||
|
||||
Y'all are awesome! <3
|
||||
|
||||
|
||||
**P.S.** I probably forgot someone, please let me know should that be the case!
|
||||
|
||||
|
||||
**P.P.S**: If you read this far, congrats! Here's a free easter egg (there's more :)): set the environment variable `FUNNY_ESC` before starting
|
||||
heimdall and press Esc :>
|
||||
|
||||
41
REUSE.toml
Normal file
41
REUSE.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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.
|
||||
|
||||
version = 1
|
||||
|
||||
# These formats are consumed as data. Keep license notices here so that adding
|
||||
# comments does not change their contents or break their readers.
|
||||
[[annotations]]
|
||||
path = [
|
||||
".vscode/settings.json",
|
||||
"Engines/Heimdall.json",
|
||||
"src/heimdall/resources/magics/*.json",
|
||||
"src/heimdall/resources/misc/bench.txt",
|
||||
"tests/all.txt",
|
||||
"tests/chess960.txt",
|
||||
"tests/illegal_edge_cases.txt",
|
||||
"tests/standard.txt",
|
||||
"tests/standard_heavy.txt",
|
||||
]
|
||||
SPDX-FileCopyrightText = "Mattia Giambirtone & All Contributors"
|
||||
SPDX-License-Identifier = "Apache-2.0"
|
||||
|
||||
# Generated from https://github.com/lichess-org/chess-openings.
|
||||
# Retain the upstream dataset's CC0 dedication when the index is regenerated.
|
||||
[[annotations]]
|
||||
path = "src/heimdall/resources/openings/lichess.tsv"
|
||||
SPDX-FileCopyrightText = "lichess.org contributors"
|
||||
SPDX-License-Identifier = "CC0-1.0"
|
||||
16
TODOs.md
16
TODOs.md
@@ -1,3 +1,19 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# TODO list for Heimdall releases
|
||||
|
||||
## 1.5 - ETA: ~1-2 month(s)
|
||||
|
||||
545
docs/AUDIT_PROGRESS.md
Normal file
545
docs/AUDIT_PROGRESS.md
Normal file
@@ -0,0 +1,545 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Project audit progress
|
||||
|
||||
Started: 2026-09-05
|
||||
|
||||
Status: **Complete** — the initial correctness pass, thirteen performance
|
||||
experiments, final regression checks and combined measurements are complete.
|
||||
Changes are local and uncommitted. All builds used `make dev`; no `nim check`.
|
||||
|
||||
Final observed gains on this Ryzen 9 5900X: **+2.56% search throughput at depth
|
||||
14**, **+1.64% at depth 16**, and **+6.84% start-position perft throughput**.
|
||||
Repeated eight-thread position setup improved from about **4.99 s to 0.0318 s
|
||||
per 1,000 setups**. Search nodes and evaluation checksums are unchanged. These
|
||||
are workload-specific measurements, not playing-strength results. Detailed
|
||||
experiments, rejected candidates, intervals and reproduction data follow below.
|
||||
|
||||
## Scope and stopping point
|
||||
|
||||
This is a bounded maintenance pass: map the engine and its checks, establish a
|
||||
baseline, investigate input handling and test reliability, then inspect selected
|
||||
search/data-processing paths for correctness or avoidable work. Fix reproducible
|
||||
issues and validate them before moving on. Stop after a few well-supported fixes
|
||||
and a final regression pass; record larger or uncertain work for a future pass.
|
||||
|
||||
Use the Makefile's `dev` target for builds; do not use `nim check`. No user input
|
||||
is needed for this pass. Changes remain local and uncommitted.
|
||||
|
||||
## Project map
|
||||
|
||||
- `src/heimdall.nim`: CLI dispatch, benchmark, data tools, UCI/TUI entry points.
|
||||
- `src/heimdall/{position,board,movegen,moves,bitboards}.nim`: chess state and rules.
|
||||
- `src/heimdall/{search,transpositions,nnue,eval}.nim`: search and evaluation.
|
||||
- `src/heimdall/uci/`: command parsing, session lifecycle, search worker.
|
||||
- `src/heimdall/tui/`: interactive board, analysis, play, input and rendering.
|
||||
- `src/heimdall/util/`: limits, datasets, memory, chess helpers and tuning.
|
||||
- `tests/`: Stockfish perft comparisons, position suites, viriformat unit tests.
|
||||
- `Makefile`: `dev` builds with installed dependencies; `test` and `test-suite`
|
||||
enable runtime checks. Full suite uses deep perft and is explicitly expensive.
|
||||
|
||||
## Activity log
|
||||
|
||||
### 1. Initial reconnaissance — complete
|
||||
|
||||
- Working tree was clean; no applicable `AGENTS.md` files found.
|
||||
- Read build configuration, existing test drivers and CLI entry point.
|
||||
- Baseline `make dev` passed (native AVX2). Stockfish and Python are installed.
|
||||
- Baseline `bench 8 --silent`: **386,302 nodes**. Timing is only a smoke check,
|
||||
not a controlled performance measurement.
|
||||
|
||||
### 2. Test reliability and UCI boundaries — complete
|
||||
|
||||
- Reproduced false-success suite status with a deliberately failed comparison in
|
||||
both serial and parallel modes: both returned `None` (shell success).
|
||||
- Reproduced imported verbose comparator failure: `AttributeError` from treating
|
||||
`__builtins__` as a module when it is a dictionary.
|
||||
- Reproduced SIGSEGVs for `position`, `go depth`, `go wtime`, and `getScale`.
|
||||
`setoption name` also read past the token array and printed invalid bytes.
|
||||
- Reproduced rejection of tab-separated UCI commands.
|
||||
- Reproduced hangs after `go wtime 1000 btime 1000` / `go ponder depth 1`, followed
|
||||
by `stop`: rejected worker requests left the search marked busy without a
|
||||
completion response. Moved rejection checks before asynchronous dispatch.
|
||||
- Inspected TT sizing: UCI `Hash` compared MiB with an entry count, unnecessarily
|
||||
reallocating and clearing the table even when setting its current size.
|
||||
- Added parser guards, recognized tab-separated tokens and multiword option
|
||||
names/values, and allowed limits after `searchmoves`.
|
||||
- The comparator also accepted two empty engine outputs as matching results.
|
||||
It now requires completed perft summaries consistent with the divided counts;
|
||||
real zero-node terminal positions remain valid. Crash diagnostics now show the
|
||||
captured merged output instead of `None`.
|
||||
|
||||
### 3. Regression verification — complete
|
||||
|
||||
- Added 12 Python test-tool regressions and 9 engine integration tests, with
|
||||
subcases for missing arguments and each rejected-search path. All 21 pass on
|
||||
both the default dev build and the build with runtime checks enabled.
|
||||
- The depth-8 benchmark remains exactly **386,302 nodes** after the fixes.
|
||||
- Standard positions: **174/174 passed** at depth 3 against Stockfish.
|
||||
- Full collection: **1,155/1,155 passed** at depth 3 against Stockfish, in 254.92
|
||||
seconds with two workers. This includes standard, Chess960 and edge positions.
|
||||
- Documented quick regression commands in `README.md`.
|
||||
- Non-bulk edge cases with verbose output: **21/21 passed** at depth 3.
|
||||
- Checked dev build succeeded. Final regressions include allowed time-control
|
||||
exemptions, colored perft output and malformed `set` aliases. The checked
|
||||
binary also matches the 386,302-node depth-8 benchmark.
|
||||
- Verified actual shell failure statuses with `/bin/true` standing in for an
|
||||
engine emitting no perft: both serial and parallel suite modes now exit 1.
|
||||
- Reviewed viriformat reads and relabel chunk dispatch/cleanup. No changes in
|
||||
those modules during this pass; one allocation hotspot is recorded below.
|
||||
- Stopped after four verified issue groups and the final regression pass, as
|
||||
planned. No benchmark NPS or playing-strength improvement is claimed.
|
||||
|
||||
## Findings and fixes
|
||||
|
||||
1. **Test runner false success and broken verbose output** — regression tests
|
||||
pass. Added common summary/exit status, empty-file handling,
|
||||
explicit pool cleanup and a one-worker minimum for single-core systems.
|
||||
2. **UCI parser out-of-bounds access and token handling** — regression tests
|
||||
pass. Also reject negative perft depth and nonpositive
|
||||
search depth before entering recursive search.
|
||||
3. **Rejected-search deadlock** — synchronous validation in place; all three
|
||||
rejected-search paths pass `stop`, a new game and a subsequent valid search.
|
||||
4. **Redundant hash allocation/clearing** — compare configured MiB to configured
|
||||
MiB; regression confirms resetting 64 MiB does not resize, switching to 1 MiB
|
||||
resizes once, and repeating 1 MiB does not resize again.
|
||||
|
||||
## Validation
|
||||
|
||||
- Baseline and final `make dev`: passed.
|
||||
- Final `make dev IS_TEST=1 EXE_BASE=bin/testdall`: passed.
|
||||
- Baseline depth-8 benchmark: 386,302 nodes.
|
||||
- Standard-position perft comparison at depth 3: 174/174 passed.
|
||||
- All-position perft comparison at depth 3: 1,155/1,155 passed.
|
||||
- Python test-tool and UCI regressions: 21/21 passed on each build.
|
||||
- Post-fix depth-8 benchmark: 386,302 nodes on each build (unchanged).
|
||||
- Non-bulk edge cases with verbose output: 21/21 passed at depth 3.
|
||||
- Intentional failing comparisons: serial and parallel suite exit codes are 1.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
## Initial review coverage and limits
|
||||
|
||||
| Area | Work performed |
|
||||
| --- | --- |
|
||||
| Build and test tools | Read Makefile/CLI/test runners; built both configurations; repaired failure reporting. |
|
||||
| UCI | Reviewed parser, dispatch and worker rejection paths; reproduced and fixed crashes/hangs. |
|
||||
| Chess rules | Read selected FEN, attacks and move-handling code; compared move generation to Stockfish. |
|
||||
| Search and memory | Reviewed search lifecycle, limits and TT allocation; fixed redundant UCI hash resizing. |
|
||||
| Data processing | Reviewed viriformat reader and relabel chunk scheduling/cleanup; recorded profiling follow-up. |
|
||||
| TUI, SIMD and playing strength | Mapped only; no claim of full review or strength testing. |
|
||||
|
||||
## Reproduction and validation commands
|
||||
|
||||
```sh
|
||||
make dev
|
||||
python -m unittest discover -s tests -p 'test_*.py' -v
|
||||
make dev IS_TEST=1 EXE_BASE=bin/testdall
|
||||
HEIMDALL=bin/testdall python -m unittest discover -s tests -p 'test_*.py' -v
|
||||
NO_COLOR=1 NO_LOGO=1 bin/heimdall bench 8 --silent
|
||||
NO_COLOR=1 NO_LOGO=1 python tests/suite.py -d 3 -b -p -w 2 -s -f tests/all.txt --heimdall bin/heimdall
|
||||
python tests/suite.py -d 3 -s --no-silent -f tests/illegal_edge_cases.txt --heimdall bin/heimdall
|
||||
```
|
||||
|
||||
## Initial deferred follow-up
|
||||
|
||||
- FEN parsing has a larger validation surface (rank/file counts, numeric ranges,
|
||||
and assertion-based king checks); this needs a separate pass with malformed-FEN
|
||||
coverage rather than a partial parser rewrite here.
|
||||
- Profile viriformat ingestion before optimizing it: `readViriformatGame` calls
|
||||
`readExact(4)` for every move, allocating a fresh string for each record. A
|
||||
reusable stack buffer may reduce allocation overhead on large datasets. This
|
||||
is a source-level observation, not a measured throughput claim.
|
||||
- Search lifecycle review here focused on rejected requests. Rapid valid
|
||||
`go`/`stop`/`ponderhit` sequences and completion-message ownership deserve a
|
||||
separate concurrency stress pass.
|
||||
- Full depth-6/7 suite and playing-strength tests are outside this bounded pass.
|
||||
|
||||
## Second pass: performance investigation
|
||||
|
||||
Requested 2026-09-05. The earlier stopping point applies only to the initial
|
||||
correctness pass. This investigation covers CPU profiles, generated C and machine
|
||||
code, move generation, NNUE updates/inference, search bookkeeping, and memory
|
||||
traffic. It will include repeated measurements, experiments that are rejected,
|
||||
and correctness validation for accepted changes. No `nim check` or user input.
|
||||
|
||||
### Plan and experimental discipline
|
||||
|
||||
1. Save baseline binaries and record compiler/CPU/build configuration.
|
||||
2. Profile representative single-thread search and perft workloads with `perf`;
|
||||
inspect call stacks, hardware counters, and generated C/assembly.
|
||||
3. Audit hidden array/object copies, zero-initialization, managed-reference
|
||||
traffic, heap allocations, and missed inlining/vectorization in hot code.
|
||||
4. Make isolated experimental changes and measure alternating baseline/candidate
|
||||
runs on the same CPU. Keep only correctness-preserving improvements supported
|
||||
by the measurements; do not tune search heuristics or claim Elo gains.
|
||||
5. Validate retained changes with deterministic search benchmarks, checked builds,
|
||||
Stockfish perft comparisons, and targeted evaluation checks where needed.
|
||||
6. Finish with a reproducible evidence summary and prioritized remaining work.
|
||||
|
||||
### Environment and activity
|
||||
|
||||
- Host: AMD Ryzen 9 5900X, 12 cores / 24 threads, AVX2, 64 MiB L3 in two domains.
|
||||
- `perf`, Valgrind, Clang, LLVM objdump and CPU affinity tools are installed.
|
||||
- `perf_event_paranoid=2`; testing whether user-space hardware events are usable.
|
||||
- Existing first-pass changes are preserved as the new performance baseline.
|
||||
- Compiler: Nim 2.2.6, Clang 22.1.8, native AVX2, atomic ARC, LTO. Baseline
|
||||
executable: `bin/perf-baseline`; symbolized profile executable:
|
||||
`bin/perf-baseline-profile` (`make dev DBG_SYMBOLS=1`). Raw profiles, generated
|
||||
C snapshots, assembly and measurement JSON are in `.cache/perf-audit/`.
|
||||
- CPU 2 is used for single-thread measurements; its SMT sibling is CPU 14.
|
||||
This is a live workstation, so results need paired repetitions rather than
|
||||
relying on a single timing. No global frequency/governor settings were changed.
|
||||
|
||||
### Baseline profiles
|
||||
|
||||
User-space hardware events work without changing system permissions. A depth-16
|
||||
search benchmark visited **16,052,624 nodes**. Its 7,000-sample cycle profile had
|
||||
no lost samples. Self-time (functions can contain inlined callees):
|
||||
|
||||
| Function/path | Share of search samples |
|
||||
| --- | ---: |
|
||||
| NNUE forward inference | 22.28% |
|
||||
| Incremental accumulator update | 17.59% |
|
||||
| Main non-PV search | 16.43% |
|
||||
| Move scoring | 8.83% |
|
||||
| Accumulator refresh | 5.00% |
|
||||
| Quiescence search | 4.64% |
|
||||
| Static exchange evaluation | 4.58% |
|
||||
| Make move | 3.87% |
|
||||
|
||||
Start-position bulk perft at depth 7 matched **3,195,901,860 nodes**, with pawn
|
||||
generation (21.82%), make-move (17.28%), bishop generation (12.60%) and knight
|
||||
generation (11.02%) leading its profile. Search and perft clearly stress different
|
||||
paths; a perft speedup alone will not be called an engine speedup.
|
||||
|
||||
### Generated-code findings and experiment queue
|
||||
|
||||
- **Eliminated copy:** the `ftOut` array cast in `forwardFast` produces two array
|
||||
copies and a zero-initialization in generated C. Final assembly has one local
|
||||
activation buffer and no copy/clear call; Clang removes this overhead. Do not
|
||||
replace it with an unsafe pointer cast merely to make the C look shorter.
|
||||
- **Real copy candidate:** SEE selects its seven-element weight array by value;
|
||||
the copy survives into machine code and sampled memory operations. Test a
|
||||
borrowed pointer to the selected immutable weights.
|
||||
- **Synchronization:** `Atomic[Option[int]]` uses a spin lock in generated C,
|
||||
including for relaxed loads. `mateDepth` only uses this once per iteration, so
|
||||
it is not a priority. Per-thread node counters perform locked read-modify-write
|
||||
on every node despite having one writer; investigate a cheaper atomic publish.
|
||||
- **NNUE:** investigate redundant initialization during refresh, inference work
|
||||
for zero activations, and whether delayed updates can skip intermediate work.
|
||||
- **Movegen/search:** inspect selection-sort bookkeeping and the expensive pawn
|
||||
path; preserve move order as well as legal move sets in accepted changes.
|
||||
- Added `scripts/compare_performance.py` for alternating baseline/candidate runs,
|
||||
deterministic node-count checks, raw samples and a bootstrap interval. Running
|
||||
a baseline-versus-itself calibration first.
|
||||
- Added optional `MAIN` and `EXTRA_NFLAGS` Makefile variables so diagnostic Nim
|
||||
programs and isolated compiler caches can use `make dev` with the exact engine
|
||||
configuration. Default build behavior is unchanged.
|
||||
- Calibration (six alternating pairs of the identical depth-14 binary): geometric
|
||||
mean difference -0.32%, bootstrap interval [-1.74%, +1.08%]. This establishes
|
||||
that an isolated 1% timing change is not convincing here.
|
||||
- Added `tests/test_nnue.nim`. Baseline checked AVX2 build passes **7,940** comparisons
|
||||
of incremental versus fresh evaluation, checksum **1,192,624**, covering every
|
||||
move flag, Chess960 castling, delayed evaluation, long walks, undo and null moves.
|
||||
- SEE pointer experiment completed 12 alternating benchmark pairs with
|
||||
instruction, cycle and branch counters; rejected (results below).
|
||||
- **New C aliasing candidate:** `MoveList.len` is a `uint8`. In pawn generation's
|
||||
final assembly, every move store is followed by a reload of that length byte
|
||||
before incrementing it. C permits character-sized accesses to alias other
|
||||
objects, preventing Clang from retaining the index in a register. Testing a
|
||||
native integer length; the public `len()` return type remains `int`.
|
||||
- **Hidden clearing on undo:** despite the first pass replacing `pop()` with
|
||||
`setLen()`, generated `setLen(seq[Position])` still clears all 344 bytes of each
|
||||
discarded position. `Position` defines a custom copy hook to forbid implicit
|
||||
copying, which makes Nim's `supportsCopyMem` false; `shrink` then resets removed
|
||||
elements. `setLenUninit` calls the same shrink path, so it would not fix this.
|
||||
Assembly confirms the stores. Investigating a narrowly scoped way to truncate
|
||||
this unmanaged stack without weakening the no-copy policy.
|
||||
- Added `tests/bench_nnue.nim` to benchmark forward inference independently over
|
||||
128 captured accumulators from representative benchmark positions. It includes
|
||||
the implementation only in the diagnostic binary to access private state;
|
||||
production visibility is unchanged.
|
||||
- Follow-up assembly disproves the proposed *byte-specific* explanation for
|
||||
move-list reloads: Nim passes `-fno-strict-aliasing`, and widening the length
|
||||
still leaves the reload after each move store. Recording that experiment,
|
||||
then testing a saved length across the store instead of changing its type.
|
||||
- A small `make dev` probe confirms that a no-op `=wasMoved` hook removes the
|
||||
position-clearing shrink loop while preserving the compile-time ban on implicit
|
||||
copying. Before applying it to `Position`, guard every field with
|
||||
`supportsCopyMem` so future managed fields cannot silently invalidate the hook.
|
||||
- User explicitly approved removing the custom `Position` copy hook. Test that
|
||||
simpler approach first: it should restore Nim's normal unmanaged-type handling
|
||||
and remove the shrink clearing without depending on a custom moved-from hook.
|
||||
- Found a separate setup bottleneck: every `setBoard` allocates and zeroes a new
|
||||
huge-page eval state for every worker, then copies the entire accumulator stack
|
||||
although only its initialized prefix is usable. Investigate storage reuse and
|
||||
measure repeated UCI position setup separately from steady-state search.
|
||||
- Saved-index append assembly now confirms the intended improvement: the pawn
|
||||
loop increments a register after the move store instead of reloading length
|
||||
from memory. The original byte-sized layout is preserved in this experiment.
|
||||
- Removing the `Position` copy hook also produces the expected machine code:
|
||||
the sequence shrink path now just stores the new length. Five isolated engine
|
||||
variants built successfully with `make dev`; each retained the 386,302-node
|
||||
depth-8 smoke benchmark. Full paired measurements follow.
|
||||
- While extending NNUE coverage, found a boundary mismatch: search calls raw
|
||||
evaluation at ply 255, but `MAX_ACCUMULATORS = 255` only provides indices 0–254.
|
||||
Added deterministic 255-ply knight cycles with eager and delayed evaluation to
|
||||
reproduce this under `make dev IS_TEST=1` before fixing the capacity.
|
||||
- **Confirmed correctness fix:** the new checked test fails on the baseline with
|
||||
`index 255 not in 0 .. 254`. Increased the accumulator capacity to 256, accounting
|
||||
for the root. This is separate from the performance experiments.
|
||||
- Capacity fix passes **8,471** checked incremental/full-refresh comparisons,
|
||||
checksum **1,227,928**, including both maximum-depth paths and their undo chains.
|
||||
- Built an NNUE forward experiment that skips all-zero *pairs* of packed inputs.
|
||||
It preserves the original `vecDpbusdx2` grouping, including AVX2 intermediate
|
||||
arithmetic. First compare its output checksum and isolated inference cost;
|
||||
only then consider a full-search timing (branch behavior can differ there).
|
||||
- **New measured memory finding:** `allocHeapAligned` reserves a rounded multiple
|
||||
of 2 MiB, but `hugePageAlloc` passes the original, shorter size to `madvise`.
|
||||
Reading `/proc/<running-benchmark>/smaps` confirms the consequence: the
|
||||
**1,768 KiB NNUE mapping has `THPeligible: 0`, `AnonHugePages: 0`**, despite its
|
||||
`hg` advice flag. The 3,020 KiB history mapping gets only one 2 MiB huge page;
|
||||
the 64 MiB TT gets its full allocation in huge pages. Test advising the entire
|
||||
already-allocated range; no global kernel setting changes are needed.
|
||||
- Benchmark reporting issue: `runBench` uses process CPU time even with multiple
|
||||
search threads, which sums their CPU usage and understates parallel throughput.
|
||||
Prepared a small fix using monotonic elapsed time for `threads > 1`; preserve
|
||||
CPU timing for the single-thread comparisons. Apply during consolidation so
|
||||
it does not complicate the isolated experiment baselines.
|
||||
- Concurrency check prompted by counter ownership: worker node counts are reset
|
||||
only when each worker dequeues `Go`. The main thread can inspect totals earlier
|
||||
and see the preceding search's counts. Reproduce a long search followed by a
|
||||
short node-limited one during validation; if confirmed, clear idle workers'
|
||||
counters before dispatch. This does not introduce concurrent counter writers.
|
||||
- **Reproduced:** alternating 50,000- and 1,000-node searches with four workers
|
||||
sharing one CPU caused a short search to return a fallback move with no search
|
||||
iteration. Saved the transcript in `stale-counter-baseline.txt`. Added a targeted
|
||||
regression and prepared a pre-dispatch counter reset for consolidation.
|
||||
- The targeted stale-counter regression fails on the baseline as expected.
|
||||
The other **23 Python regressions pass**, including node-limit and worker-restart
|
||||
coverage added in this pass.
|
||||
- Integrated worker-state reuse and the idle-worker counter reset. A checked
|
||||
engine built with `make dev IS_TEST=1` now passes **all 24 Python regressions**,
|
||||
including the previously failing race test. Added direct reuse/self-copy checks
|
||||
to the NNUE diagnostic before starting the remaining paired measurements.
|
||||
- Direct reuse and self-copy coverage now passes **8,491** checked evaluation
|
||||
comparisons, checksum **1,228,801**. No production lookup-table values, pruning
|
||||
thresholds, move ordering rules or network weights have been changed.
|
||||
- Full-range advice is verified in a live candidate process: history storage now
|
||||
has **4,096 KiB** in huge pages and each NNUE state **2,048 KiB**, with
|
||||
`THPeligible: 1`. These are observed allocations, not a guarantee of future
|
||||
huge-page availability under memory pressure. Raw summaries are saved beside
|
||||
the baseline smaps evidence.
|
||||
- All four NNUE candidates (sparse inference, refresh scratch initialization,
|
||||
per-perspective delayed updates, and worker-state reuse) pass **8,482** checked
|
||||
comparisons, checksum **1,228,392**, including cloning a partially evaluated
|
||||
path and undoing independently. Built a separate capacity-fixed NNUE reference
|
||||
so their timings do not attribute the necessary array-size correction to an
|
||||
algorithm change.
|
||||
|
||||
### Experiment results
|
||||
|
||||
| Experiment | Measurement | Decision |
|
||||
| --- | --- | --- |
|
||||
| Borrow SEE weights instead of copying | 12 depth-14 pairs: -1.68% throughput, 95% interval [-2.76%, -0.53%]; instructions -0.325%, cycles +1.83%; nodes unchanged | Reverted. The copy is real, but removing it did not make this build faster. Keep the evidence rather than claiming success from the C diff. |
|
||||
| Widen move-list length to `int` | 12 depth-14 pairs: +1.18%, interval [+0.22%, +2.12%]; nodes unchanged | Small positive observation, but the targeted reload remains. Prefer testing the direct append fix before retaining a layout change. |
|
||||
| Preserve the append index | Search: +0.51%, inconclusive. Four start-position depth-7 perft pairs: **+2.88%**, interval [+1.18%, +4.00%]; instructions -1.22%; every run returns 3,195,901,860 nodes | Retained for demonstrated movegen improvement. No independent whole-search gain is claimed. |
|
||||
| Remove `Position` copy hook | Eight depth-14 pairs: +2.29%, interval [+1.31%, +3.34%]; nodes unchanged | Retained. Added a compile-time plain-data check to protect movegen's existing raw stack copies. The direct `shrink` follow-up is recorded below. |
|
||||
| Replace per-node locked increments with atomic load/store | Eight depth-14 pairs: +0.75%, interval [-1.37%, +2.43%]; instructions +0.045%, cycles -0.93%; nodes unchanged | Not retained: ownership permits it, but single-thread timing is inconclusive. |
|
||||
| Direct sequence `shrink` after removing the copy hook | Search: -0.09%, inconclusive. Four depth-7 perft pairs against the copy-hook fix: -0.09%, interval [-1.55%, +0.87%]; instructions -1.39%, cycles unchanged | Not retained: removing calls/instructions did not improve either measured workload. |
|
||||
| Advise the full rounded huge-page allocation | Six depth-14 pairs: +3.13%, interval [+2.46%, +3.87%]; instructions unchanged, cycles -2.75%; nodes unchanged | Retained. smaps verifies restored huge-page backing. Full pages can increase RSS by about 1.3 MiB per search manager on this configuration; the virtual allocation was already rounded. |
|
||||
| Skip zero NNUE input pairs | Initial repeated-corpus forward test: +3.42%. Six full-search pairs against the capacity-fixed reference: **-14.65%**, interval [-16.43%, -13.38%]; instructions +4.75%, branches +39.33%, branch misses +250.13%; nodes unchanged | Rejected. Repeating a small activation corpus gave a misleading picture of branch behavior in search. The full-search reference also removes the capacity-change confound from the initial microbenchmark. |
|
||||
| Per-perspective lazy accumulator reconstruction | Six depth-14 pairs: +0.19%, interval [-1.91%, +1.91%]; instructions +0.16%, cycles effectively unchanged; nodes/checksums unchanged | Not retained. The additional validity state and reconstruction logic did not produce a demonstrated search gain. Prototype and checked-test results remain available. |
|
||||
| Leave refresh scratch arrays uninitialized | Six depth-14 pairs: +0.59%, interval [-1.05%, +2.02%]; instructions -0.020%; checksums/nodes unchanged | Not retained: confirmed redundant 512-byte clearing, but no demonstrated whole-search gain in this run. |
|
||||
| Seed selection sort from its first element | Six depth-14 pairs: +0.93%, interval [-0.22%, +2.08%]; instructions +0.812%, branches -0.728%; nodes unchanged | Not retained: simpler source did not produce a clear throughput improvement. |
|
||||
| Specialize SEE by its compile-time context | Six depth-14 pairs: +2.20%, interval [+1.40%, +2.92%]; cycles -2.22%, instructions +0.39%; nodes unchanged | Retained. All call sites use constant contexts. This worked better than the earlier weight-pointer experiment, despite executing more instructions. |
|
||||
| Reuse worker NNUE storage and copy live prefixes | Four paired runs of 1,000 setups / 8 threads: about 4.99 s to 0.0318 s (157× setup throughput). `perf stat`: minor faults 3,116,771 → 9,661; baseline spent 4.10 s in the kernel | Retained. This removes repeated allocation/page faults and unused frame copies; it is **not** a 157× search speedup. |
|
||||
|
||||
The SEE result also demonstrates why instruction count alone is insufficient:
|
||||
the shorter code was slower. Exact results are in `see-comparison.json`, and
|
||||
baseline/candidate assembly is retained for future compiler/code-layout work.
|
||||
|
||||
### Bounded experiment queue — complete
|
||||
|
||||
All thirteen experiments above are complete. Retained the supported changes and
|
||||
removed the rejected or inconclusive prototypes from production source. The
|
||||
direct `shrink()` follow-up did remove instructions, but did not improve either
|
||||
measured workload. The remaining work is combined performance measurement and
|
||||
final regression validation; no additional tuning candidates are being added.
|
||||
|
||||
The comparison runner now terminates the whole subprocess group on timeout or
|
||||
interruption, including engines started beneath `perf`/`taskset`. A deliberately
|
||||
timed-out parent/child fixture verified that no running descendant remained.
|
||||
|
||||
While checking the touched allocator's portability, found that its no-THP path
|
||||
uses ordinary `alloc`, which does not promise the over-alignment required by SIMD
|
||||
objects. Added a `noTHP` build define to exercise that existing fallback locally
|
||||
and an allocation-alignment test. Reproduce before changing the fallback; this
|
||||
is a correctness check on the allocator, not another open-ended tuning project.
|
||||
The forced-fallback test **fails on the old path** at the 64-byte alignment
|
||||
assertion. Updated allocation to honor `alignof(T)` (and at least 64 bytes), using
|
||||
the matching aligned free on every platform. Native huge-page behavior retains
|
||||
the same 2 MiB alignment and full-range advice. Testing 64- and 128-byte objects
|
||||
in both paths, plus the NNUE code on the fallback path.
|
||||
Both allocation paths now pass the 64-/128-byte alignment test. The optional
|
||||
`-d:noTHP` define allows exercising the fallback without changing kernel settings.
|
||||
The full checked SIMD NNUE test also passes on the forced fallback: **8,491**
|
||||
comparisons, checksum **1,228,801**. The fallback alignment problem is fixed;
|
||||
actual Windows/macOS execution is not available on this Linux host.
|
||||
|
||||
### Memory-counter follow-up
|
||||
|
||||
Three runs each of the baseline and full-advice prototype at depth 14 recorded
|
||||
the same 8,107,163 nodes and effectively identical instruction counts. Average
|
||||
L1 data-TLB misses fell **38.6 million → 19.3 million**; L2 misses from data-cache
|
||||
misses fell **443.0 million → 431.5 million**. The TLB measurements vary markedly
|
||||
(perf reports ±7.44% and ±31.55%), so treat these as supporting evidence for the
|
||||
mapping fix, not a precise universal reduction. Profiles and counter logs remain
|
||||
in `.cache/perf-audit/`.
|
||||
|
||||
### Final consolidation and validation — complete
|
||||
|
||||
- Default and checked `make dev` builds pass all **24 Python regressions** each.
|
||||
- Final checked AVX2 and scalar NNUE diagnostics both pass **8,491 comparisons**,
|
||||
checksum **1,228,801**. This includes cloning/reusing live prefixes, pending
|
||||
updates, independent undo, every move flag, and the maximum-ply boundary.
|
||||
- The checked viriformat tests pass. Tuning-build, broader perft, memory checks,
|
||||
combined paired timings and multithread throughput checks follow sequentially
|
||||
so validation work does not compete with performance measurements.
|
||||
- Tuning-enabled checked build passes and preserves **386,302** depth-8 nodes.
|
||||
- All **1,155/1,155** positions pass bulk perft against Stockfish at depth 4.
|
||||
All **21/21** edge positions pass non-bulk depth-4 perft with runtime checks.
|
||||
- All **7/7** heavy standard positions pass bulk perft at depth 6. Valgrind
|
||||
Memcheck (`--error-exitcode=99 --leak-check=no`) reports no errors while the
|
||||
final checked NNUE diagnostic completes its 8,491 comparisons.
|
||||
- Correctness validation is complete. Final combined measurements run at search
|
||||
depths 14/16 and perft depth 7, followed by multithread smoke/scaling checks and
|
||||
a fresh cycle profile. Timing jobs do not overlap builds or the test suites.
|
||||
|
||||
### Combined results
|
||||
|
||||
The final executable includes all retained changes and the correctness fixes.
|
||||
Compare it directly with the saved performance baseline; do not add up the
|
||||
isolated experiment gains. Both use the default optimized `make dev` flags.
|
||||
|
||||
| Workload | Paired runs | Throughput change | Bootstrap 95% interval | Nodes per run |
|
||||
| --- | ---: | ---: | --- | ---: |
|
||||
| Search, depth 14 | 12 | **+2.56%** | [+1.77%, +3.27%] | 8,107,163 |
|
||||
| Search, depth 16 | 6 | **+1.64%** | [+0.47%, +2.95%] | 16,052,624 |
|
||||
| Start-position bulk perft, depth 7 | 4 | **+6.84%** | [+5.94%, +7.72%] | 3,195,901,860 |
|
||||
|
||||
Depth-14 medians: baseline **1,143,621 NPS**, final **1,177,519 NPS**. Paired
|
||||
hardware-counter changes: cycles **-2.49%**, instructions **-0.19%**, branches
|
||||
**+1.22%**, branch misses **-0.37%**. The table uses the geometric mean of paired
|
||||
ratios, which differs from the ratio of the two medians. Raw samples and binary
|
||||
hashes: `.cache/perf-audit/final-search-d14.json`.
|
||||
|
||||
Depth-16 medians: baseline **1,173,819 NPS**, final **1,191,809.5 NPS**. Cycles
|
||||
fall **1.61%**, instructions **0.21%**, with identical nodes in all runs. The
|
||||
smaller gain at depth 16 is recorded separately rather than pooled with depth 14.
|
||||
Raw samples: `.cache/perf-audit/final-search-d16.json`.
|
||||
|
||||
Combined perft medians: baseline **188,502,781 NPS**, final **201,435,246.5 NPS**.
|
||||
Cycles fall **6.17%**, instructions **3.26%**, branches **1.53%**; every run
|
||||
returns **3,195,901,860 nodes**. Raw samples:
|
||||
`.cache/perf-audit/final-perft-d7.json`. This is a movegen workload; the separate
|
||||
search measurements above determine the observed engine-search gain.
|
||||
|
||||
### Multithread checks and final profile
|
||||
|
||||
All 24 timed searches across three FENs and 1/2/4/8 threads complete normally.
|
||||
Each search has a 1.5-second move limit and affinity to physical cores 0–11.
|
||||
Median NPS across those three positions:
|
||||
|
||||
| Threads | Baseline | Final |
|
||||
| ---: | ---: | ---: |
|
||||
| 1 | 1,178,830 | 1,183,744 |
|
||||
| 2 | 2,359,063 | 2,395,744 |
|
||||
| 4 | 4,815,830 | 4,899,041 |
|
||||
| 8 | 9,167,330 | 9,120,052 |
|
||||
|
||||
These short, nondeterministic parallel searches demonstrate scaling and successful
|
||||
completion. Per-position changes are mixed; no statistically supported parallel
|
||||
speedup is claimed. The worker-restart, tiny-node-limit and stale-worker-count
|
||||
regressions also pass in both optimized and checked engine builds. Raw output:
|
||||
`.cache/perf-audit/smp-comparison.json`.
|
||||
|
||||
The four-thread CLI bench now reports elapsed wall time: a depth-12 run reports
|
||||
**2.543 s** within **2.722 s** of measured whole-process time. Its reported
|
||||
**4,745,641 NPS** therefore reflects aggregate parallel throughput instead of
|
||||
dividing by the sum of thread CPU times. Single-thread bench timing is preserved.
|
||||
Evidence: `.cache/perf-audit/final-smp-bench.json`.
|
||||
|
||||
The final depth-16 cycle profile has no lost samples. Leading self-time shares
|
||||
(including any callees the compiler inlines):
|
||||
|
||||
| Function/path | Share |
|
||||
| --- | ---: |
|
||||
| NNUE forward inference | 23.83% |
|
||||
| Incremental accumulator update | 17.62% |
|
||||
| Main non-PV search | 16.75% |
|
||||
| Move scoring | 10.20% |
|
||||
| Accumulator refresh | 4.85% |
|
||||
| Quiescence search | 4.25% |
|
||||
| Make move | 4.07% |
|
||||
|
||||
NNUE inference, updates and refreshes still account for about **46.3%** of sampled
|
||||
cycles. Percentages are relative shares, not absolute regressions; specialization
|
||||
also changes inlining and attribution. Use the paired timings for speed claims.
|
||||
Final profile and flat report: `.cache/perf-audit/final-search.data` and
|
||||
`.cache/perf-audit/final-search-flat.txt`.
|
||||
|
||||
### Retained changes and remaining priorities
|
||||
|
||||
Retained performance changes: plain-data `Position` handling eliminates the
|
||||
344-byte clearing on undo; move-list append preserves its index across the store;
|
||||
huge-page advice covers the rounded allocation; SEE specializes its constant
|
||||
context; worker NNUE storage is reused and only live frame prefixes are copied.
|
||||
Also fixed the NNUE maximum-ply capacity, fallback allocation alignment, stale
|
||||
worker totals at a new search, and parallel bench timing. The earlier UCI and
|
||||
test-runner fixes remain in place.
|
||||
|
||||
No playing-strength claim is made. All deterministic search node counts and
|
||||
NNUE checksums are preserved, and no heuristics, pruning thresholds or network
|
||||
weights changed. Native AVX2, scalar, tuning-enabled, and forced-no-THP builds
|
||||
were exercised; other CPU architectures and actual Windows/macOS execution were
|
||||
not available on this Linux host. Whole huge-page backing can use about 1.3 MiB
|
||||
more resident memory per search manager on this configuration.
|
||||
|
||||
Future work, in priority order, rather than extending this pass indefinitely:
|
||||
|
||||
1. Profile NNUE vector kernels and accumulator memory traffic with larger,
|
||||
diverse search traces. The sparse-input and extra-laziness prototypes here
|
||||
did not improve full search; repeated-input microbenchmarks are insufficient.
|
||||
2. Investigate history/TT and network locality across the CPU's two L3 domains
|
||||
under longer multithread searches. The short scaling sample above cannot
|
||||
distinguish small wins from scheduling and tree variation.
|
||||
3. Revisit per-thread node publishing only with an independent multithread
|
||||
measurement and node-limit correctness checks; the single-thread experiment
|
||||
did not establish a useful gain.
|
||||
4. Follow up separately on malformed-FEN handling and dataset-reader allocation
|
||||
behavior recorded in the first pass. Neither is part of the retained hot-path
|
||||
changes.
|
||||
|
||||
Reproduction tooling lives in `scripts/compare_performance.py`,
|
||||
`tests/bench_nnue.nim`, `tests/bench_setup.nim`, `tests/test_nnue.nim`, and
|
||||
`tests/test_alloc.nim`; build examples are in `README.md`. All raw experiment
|
||||
JSON, logs, C snapshots, assembly and profiles remain in `.cache/perf-audit/`,
|
||||
with baseline/candidate binaries in `bin/`. The bounded investigation is finished;
|
||||
no benchmark or test job is intentionally left running.
|
||||
50
docs/BUILDING.md
Normal file
50
docs/BUILDING.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Building and installation
|
||||
|
||||
The latest stable release is the easiest way to install Heimdall. See the
|
||||
[download table in the README](../README.md#how-to-pick-the-right-executable)
|
||||
for the universal archive that matches your system.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Nim 2.2.6, pinned in `heimdall.nimble`
|
||||
- Clang and the platform linker (LLD on Linux/Windows, Apple `ld` on macOS)
|
||||
- Git LFS when fetching network weights
|
||||
|
||||
The Makefile is the only supported build interface. Do not use bare `make` or
|
||||
`nimble build`; bare `make` is reserved for OpenBench.
|
||||
|
||||
## Build from source
|
||||
|
||||
With dependencies and weights already installed, use `make dev`. It selects a
|
||||
backend for the host CPU and writes `bin/heimdall`. A fresh setup can use
|
||||
`make native`, which installs dependencies, initializes the network submodule,
|
||||
and fetches the selected weights.
|
||||
|
||||
For an explicit local build with an existing network:
|
||||
|
||||
```sh
|
||||
make dev SIMD=avx2 EVALFILE=/absolute/path/to/net.bin
|
||||
```
|
||||
|
||||
Use `make dev SIMD=universal EVALFILE=/absolute/path/to/net.bin` for runtime
|
||||
backend selection. Run `bin/heimdall simd` to see the selected backend, or set
|
||||
`HEIMDALL_SIMD=avx2` to force one. On macOS,
|
||||
`make macos-universal SKIP_DEPS=1 EVALFILE=/absolute/path/to/net.bin` combines
|
||||
Intel and Apple Silicon slices.
|
||||
|
||||
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
|
||||
Apple Silicon Macs can use `make macos-amd64` and `make macos-arm64`
|
||||
respectively. The resulting executable is in `bin/$(EXE)`.
|
||||
|
||||
See [SIMD.md](SIMD.md) for backend targets, scalar builds, universal dispatch,
|
||||
cross-compilation, and architecture-specific constraints. See
|
||||
[RELEASES.md](RELEASES.md) for release artifacts and workflow details.
|
||||
|
||||
## Legacy releases
|
||||
|
||||
For releases 1.3 through 1.5.0, the historical targets from fastest to slowest
|
||||
were `vnni`, `avx512`, `zen2`, `haswell`, and `core2`. All require a 64-bit
|
||||
processor. Releases after 1.5 use the universal binaries described in the
|
||||
[README](../README.md#how-to-pick-the-right-executable).
|
||||
351
docs/PERFORMANCE_AUDIT_2.md
Normal file
351
docs/PERFORMANCE_AUDIT_2.md
Normal file
@@ -0,0 +1,351 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Performance audit, follow-up pass
|
||||
|
||||
Started: 2026-09-05
|
||||
|
||||
Status: **Complete**. This pass began from the complete, uncommitted state
|
||||
documented in `docs/AUDIT_PROGRESS.md`. Thirteen new experiments produced five
|
||||
retained performance improvements and one capture-only correctness fix. Combined
|
||||
throughput improved **3.86% at search depth 14, 4.28% at search depth 16, and
|
||||
11.28% at start-position bulk-perft depth 7** on this host. Broad correctness
|
||||
checks passed; the three inconclusive/slower NNUE candidates were reverted.
|
||||
All Nim builds used `make dev`; this audit did not use `nim check`.
|
||||
|
||||
## Baseline and discipline
|
||||
|
||||
- Preserve all pre-existing working-tree changes from the earlier audit.
|
||||
- Save an exact baseline executable before changing production code.
|
||||
- Keep search node counts, perft totals, and NNUE comparison checksums stable
|
||||
within throughput experiments; isolate and identify any correctness change
|
||||
that necessarily changes the search tree.
|
||||
- Use isolated candidates and paired runs on the same CPU for timing claims.
|
||||
- Treat move generation and NNUE as the primary targets, while following profile
|
||||
evidence into other hot paths when warranted.
|
||||
- Record unsuccessful and inconclusive experiments as well as retained changes.
|
||||
|
||||
## Initial queue
|
||||
|
||||
1. Rebuild and fingerprint the current baseline; confirm deterministic search,
|
||||
NNUE, and move-generation checks.
|
||||
2. Capture fresh search and perft profiles from the consolidated prior result.
|
||||
3. Audit generated code and data layout in move generation, move scoring, NNUE
|
||||
update/refresh/inference, and make/unmake paths.
|
||||
4. Measure isolated hypotheses, promote only representative wins, and validate
|
||||
retained changes in SIMD and scalar configurations.
|
||||
|
||||
## Activity log
|
||||
|
||||
### 1. Reconnaissance and profiling
|
||||
|
||||
- Read the complete prior audit. Its retained changes are the baseline for this
|
||||
pass: move-list index preservation, plain-data position-stack truncation,
|
||||
full-range huge-page advice, compile-time SEE specialization, and reusable
|
||||
per-worker NNUE storage.
|
||||
- Avoiding already rejected experiments: borrowed SEE weights, widened move-list
|
||||
length, per-node atomic load/store publication, direct sequence shrink, sparse
|
||||
NNUE inference, per-perspective lazy accumulators, uninitialized refresh
|
||||
scratch, and seeded selection sort.
|
||||
- Baseline rebuilt successfully with `make dev`: SHA-256
|
||||
`f9ae98b51a9ea3fd2da741cad8a95d5e5f36e9d250885fd65d0930a5e4606039`.
|
||||
Toolchain and host match the prior pass: Nim 2.2.6, Clang 22.1.8, Ryzen 9
|
||||
5900X, native AVX2. The saved binary is
|
||||
`.cache/perf-audit2/baseline`.
|
||||
- A symbolized depth-16 profile visited the expected **16,052,624 nodes** at
|
||||
1,116,427 NPS and lost no samples. Leading self-cycle shares: NNUE forward
|
||||
23.23%, accumulator updates 17.45%, main non-PV search 16.57%, move scoring
|
||||
10.55%, refresh 4.68%, quiescence 4.53%, and make-move 4.00%.
|
||||
- Annotated assembly attributes most NNUE forward samples to the L1 `vpmaddubsw`
|
||||
/ `vpmaddwd` propagation loop. Clang already unrolls its two output vectors
|
||||
and two source-pair accumulators into four independent chains. First novel
|
||||
experiment: preserve the exact paired arithmetic while processing two source
|
||||
groups per iteration with eight independent chains.
|
||||
- Movegen review found that `capturesOnly=true` still calls `generateCastling`.
|
||||
This contradicts the public contract and can admit a quiet castle into
|
||||
quiescence. It is queued for a targeted reproducer and occurrence count rather
|
||||
than being mixed into the first throughput experiment.
|
||||
- Added a focused capture-only movegen diagnostic. It fails on the baseline
|
||||
because a castling-ready position returns two captures plus two castles.
|
||||
Guarding castling generation makes the diagnostic pass. This changes the
|
||||
deterministic search tree (depth 8: 386,302 -> 387,438; depth 14: 8,107,163
|
||||
-> 8,581,626), proving the bug reached quiescence. It is retained as a
|
||||
contract/correctness fix, but no NPS comparison across that tree change is
|
||||
treated as a speed result.
|
||||
- Compile-time-specialized the move picker for quiescence after enforcing that
|
||||
invariant. Killer and countermove tables contain quiet refutations, so capture
|
||||
scoring can omit those impossible probes without changing move order.
|
||||
- Fresh start-position depth-7 bulk-perft profile returned the expected
|
||||
**3,195,901,860 nodes**. Leading self-cycle shares were pawn generation 22.08%,
|
||||
make-move 17.06%, bishop generation 11.47%, rook generation 10.65%, perft
|
||||
bookkeeping 9.98%, knight generation 7.89%, king generation 7.25%, and
|
||||
castling generation 5.53%.
|
||||
- Castling assembly showed generic `Piece.shortCastling`/`longCastling` case
|
||||
trees for targets whose piece kind and color are already known, plus three
|
||||
separate occupancy tests per side. Replaced them with direct home-rank target
|
||||
squares and one union clearance mask. Chess960 rook/king removal from the
|
||||
occupancy and per-square attack tests are unchanged.
|
||||
- A temporary depth-16 instrumented search counted **12,268,544 evaluations**:
|
||||
380,900 with no pending update, 11,151,130 with one, and 736,514 (6.0%)
|
||||
with two or more. Multi-update evaluations represented roughly 1.98 million
|
||||
update applications, about 15% of all applied updates. This is enough to test
|
||||
fusing adjacent simple quiet updates, provided every intermediate accumulator
|
||||
frame is still materialized for correct undo/evaluate behavior. The temporary
|
||||
instrumentation is not part of production source.
|
||||
|
||||
## Experiment results
|
||||
|
||||
| Experiment | Representative measurement | Decision |
|
||||
| --- | --- | --- |
|
||||
| Eight independent AVX2 L1 accumulation chains | 8 depth-14 search pairs: -1.56%, bootstrap interval [-3.99%, +0.94%]; instructions +3.52%, cycles +1.40%; nodes unchanged. All 8,491 checked NNUE comparisons preserved checksum 1,228,801. | Reverted. More instructions and no representative search gain; register pressure is a possible cause, not an established one. |
|
||||
| Compile-time quiescence move-picker specialization | 8 depth-14 pairs against the capture-correct baseline: **+1.78%**, interval [+0.29%, +3.38%]; instructions -0.60%, cycles -1.51%, branches -2.26%; every run visited 8,581,626 nodes. | Retained. It removes impossible quiet-refutation probes while preserving move order and node count. |
|
||||
| Dedicated compile-time capture generator | 8 depth-14 pairs against the specialized picker: -0.01%, interval [-1.55%, +1.83%]; instructions -0.23%, cycles -0.05%, branches -0.32%; nodes unchanged. | Reverted. Removing masked-off pawn-push work did not produce a representative cycle or throughput gain. |
|
||||
| Direct castling targets and union clearance mask | Perft, 4 depth-7 pairs: **+1.40%**, interval [+0.32%, +2.49%]; instructions -3.92%, cycles -1.28%, branches -5.28%; 3,195,901,860 nodes. Search, 8 depth-14 pairs: **+0.90%**, interval [+0.10%, +1.73%]; cycles -0.76%, branch misses -1.43%; 8,581,626 nodes. | Retained. Both representative workloads support the change, and broad Chess960 validation passed. |
|
||||
| Caller-side null en-passant guard | Perft, 4 depth-7 pairs: +2.91%, interval [+0.57%, +5.30%], instructions -2.11%. Search, 8 depth-14 pairs: **-3.04%**, interval [-4.40%, -1.78%], instructions -0.18% but cycles +3.20% and branch misses +2.12%; nodes unchanged in each workload. | Reverted. The appealing perft gain translated into a clear search regression, which takes precedence. |
|
||||
| Fuse paired quiet NNUE updates while retaining both frames | 8 depth-14 pairs: +0.26%, interval [-0.34%, +0.86%]; instructions -0.13%, cycles -0.22%, branches -0.40%, branch misses +0.50%; nodes unchanged. Checked NNUE diagnostic preserved all 8,491 comparisons and checksum 1,228,801. | Reverted. Correct but inconclusive, and the added pairing machinery was not justified by the small effect. |
|
||||
| One enemy threat map for all quiet king moves | 4 depth-7 perft pairs: **-5.23%**, interval [-5.82%, -4.65%]; instructions +5.42%, cycles +5.63%, branches +5.53%; nodes unchanged. | Reverted. Per-destination `isAttacked` short-circuiting is markedly cheaper than generating every enemy attack. |
|
||||
| Transpose continuation history for sibling locality | 8 depth-14 pairs: +0.52%, interval [-0.06%, +1.06%]; instructions +1.95%, cycles -0.69%, branch misses +0.30%; nodes unchanged. | Reverted. Small and inconclusive; pre-indexing the shared history context may be worth testing separately. |
|
||||
| Fuse piece relocation's bitboard/hash updates | 8 depth-14 pairs: **-1.63%**, interval [-3.01%, -0.20%]; instructions -0.31%, cycles +1.66%, branch misses +1.12%; nodes unchanged. Expanded checked movegen/state diagnostic passed. | Reverted. Fewer instructions did not compensate for the representative cycle regression. |
|
||||
| Fuse mixed Finny-refresh remainders | 8 depth-14 pairs: +0.77%, interval [-0.14%, +1.44%]; instructions -0.35%, cycles -0.89%. Independent 6 depth-16 pairs: +0.63%, interval [-0.50%, +1.66%]; instructions -0.38%, cycles -0.83%; 18,132,000 nodes throughout. Checked SIMD and scalar comparisons passed. | Reverted. Both representative estimates were positive but neither excluded noise; the extra refresh dispatch and exported kernel were not justified by a clear search win. |
|
||||
| Defer make-move king/rook metadata until needed | Perft, 4 depth-7 pairs: **+1.14%**, interval [+0.89%, +1.53%]; instructions -1.21%, cycles -0.95%. Search, 8 depth-14 pairs: -0.13%, interval [-0.98%, +0.88%]; instructions -0.23%, cycles +0.02%; nodes unchanged. Full checked movegen/state test passed. | Retained as a measured perft improvement with neutral search. No whole-search gain is attributed to this change. |
|
||||
| Compile-time pawn color | Perft, 4 depth-7 pairs: **+3.85%**, interval [+3.05%, +4.65%]; instructions -4.08%, cycles -3.26%, branches -2.80%. Search, 8 depth-14 pairs: +0.49%, interval [-0.78%, +1.73%]; instructions -0.23%, cycles -0.51%; nodes unchanged. Full checked movegen/state test passed. | Retained for the clear perft gain; isolated search effect is inconclusive, with no demonstrated regression. Combined-patch confirmation passed. |
|
||||
| Pre-indexed continuation rows and capture-only scoring | 8 depth-14 pairs: **+2.84%**, interval [+1.91%, +4.07%]; instructions -2.19%, cycles -2.80%, branches -0.72%; nodes unchanged. Optimized and checked depth-8 benches both preserved 387,438 nodes. | Retained. Resolving the shared history context once made the earlier inconclusive layout change worthwhile. |
|
||||
|
||||
## Validation log
|
||||
|
||||
- All mandatory invariants in the new movegen and NNUE diagnostics use
|
||||
`doAssert`; validation does not rely on production `assert` statements
|
||||
surviving optimized builds. Confirmed after the user's reminder.
|
||||
- Baseline build and profiling completed without dependency downloads.
|
||||
- Eight-chain NNUE candidate: checked diagnostic passed 8,491 comparisons with
|
||||
checksum 1,228,801 before the slower candidate was reverted.
|
||||
- Capture-only movegen diagnostic: reproduced the baseline assertion failure,
|
||||
then passed after excluding castling.
|
||||
- Direct castling candidate: focused checked movegen diagnostic passed; baseline
|
||||
and candidate start-position depth-6 perft both returned 119,060,324 nodes.
|
||||
- Paired-update NNUE candidate: checked AVX2 diagnostic passed all 8,491
|
||||
comparisons with checksum 1,228,801 before the inconclusive code was reverted.
|
||||
- Expanded checked movegen diagnostic passed **39,407 capture-list comparisons
|
||||
and 37,405 state/hash transitions**, including every root move in the full
|
||||
corpus and deterministic deeper walks. Undo and cloned history restoration
|
||||
matched all saved parents, and every special-move flag was exercised.
|
||||
- Continuation-layout candidate compiled successfully and preserved depth-8
|
||||
search at 387,438 nodes; its completed depth-14 result is in the table above.
|
||||
- Fused-relocation candidate passed **44,987 capture-list checks and 41,065
|
||||
state/hash transitions**, now including cleared castling paths for all 960
|
||||
arrangements and both colors. Stationary-king, stationary-rook, and king/rook
|
||||
swaps were explicitly exercised. Depth-8 search stayed at 387,438 nodes.
|
||||
- NNUE mixed-refresh-tail candidate passed **8,491 comparisons, checksum
|
||||
1,228,801**, in both explicitly identified SIMD and scalar checked builds.
|
||||
Depth-8 search stayed at 387,438 nodes before paired timing.
|
||||
- Deferred-metadata candidate passed the full movegen/state diagnostic
|
||||
(44,987 / 41,065), with all regression conditions expressed as `doAssert`.
|
||||
Its depth-8 search remained 387,438 nodes.
|
||||
- Pawn-color candidate passed the same 44,987 / 41,065 invariant checks and
|
||||
preserved the 387,438-node depth-8 benchmark before performance measurement.
|
||||
- The full movegen diagnostic also passed in the optimized default build,
|
||||
still running all 44,987 / 41,065 `doAssert` invariants.
|
||||
- Pre-indexed scoring compiled successfully and preserved the depth-8 search
|
||||
count at 387,438 before checked-engine and performance validation.
|
||||
|
||||
## Follow-up activity
|
||||
|
||||
- Resumed the interrupted castling whole-search measurement using the saved
|
||||
binaries. No partial or interrupted run is treated as a completed result.
|
||||
- Tested a continuation-history transpose: index by preceding move first and
|
||||
candidate move last so sibling destinations share nearby cache lines. This
|
||||
changes storage layout, not scores or history updates.
|
||||
- Expanded `tests/test_movegen.nim` to compare capture lists against filtered
|
||||
full lists, rebuild all position hashes/bitboards after moves, and check
|
||||
make/unmake, null moves, and clones across the standard/Chess960 corpus.
|
||||
- NNUE weights occupy an ordinary global object, unlike the huge-page-backed
|
||||
accumulator and history storage. Inspected that large read-mostly table's
|
||||
actual mapping before considering a new allocator.
|
||||
- Mapping inspection during an actual benchmark showed **40 MiB of anonymous
|
||||
huge pages in the 41.1 MiB BSS mapping containing the 36.2 MiB network**.
|
||||
This host already promotes the weights automatically. Deprioritized a new
|
||||
global-network allocator: it would add ownership/initialization complexity
|
||||
without addressing a demonstrated missing huge-page mapping here.
|
||||
- Tested fusing relocation's remove/spawn bookkeeping into
|
||||
one XOR per piece/color bitboard and per applicable hash. The moving piece
|
||||
retains its kind and color, so the classification need not run twice.
|
||||
- Separate search-heuristic finding: `isKillerMove` bounds `ply` using
|
||||
`killerMoves[0].high()` (the inner dimension) instead of the outer ply
|
||||
dimension. With one killer slot, non-root lookups are disabled. Recorded for
|
||||
a dedicated heuristic/strength test; not mixed into throughput-only changes.
|
||||
- Scalar validation forces the Makefile's native legacy branch with
|
||||
`AVX2_SUPPORTED=0 AVX512_SUPPORTED=0 VNNI_SUPPORTED=0`. Merely putting `-u:simd`
|
||||
in `EXTRA_NFLAGS` is insufficient because native SIMD defines come later.
|
||||
The NNUE diagnostic now prints its selected backend to make this verifiable.
|
||||
- Tested batching the mixed add/sub remainders in Finny-cache refreshes. The
|
||||
candidate preserved the existing quad batches and used two-/four-feature
|
||||
kernels for the tail. It was reverted after inconclusive search measurements.
|
||||
- Followed the make-move profile into unconditional metadata loads: king
|
||||
square/piece and both castling-rook squares were read even for ordinary pawn
|
||||
or minor-piece moves, then kept live across stack growth and copying. Tested
|
||||
and retained reading metadata only in the appropriate special-move branch.
|
||||
- Completed the remaining queue: deferred make-move metadata, color-specialized
|
||||
pawn generation, and pre-indexed move-scoring history. Froze production
|
||||
changes before full regression and aggregate performance confirmation.
|
||||
- Pawn-color specialization replaces repeated runtime direction/rank decisions
|
||||
with two compile-time versions selected once at the generator entry. Move
|
||||
order, legality masks, and the shared en-passant helper remain unchanged.
|
||||
- Final scoring experiment combines the previously tested history transpose
|
||||
with row pointers resolved once per sibling list, avoiding repeated index
|
||||
calculations. Capture-only scoring also omits the impossible quiet-history
|
||||
branch. All history values, updates, and ordering ties remain unchanged.
|
||||
- All thirteen experiments are complete. Production source stayed frozen during
|
||||
aggregate measurements and broad validation; five throughput candidates and
|
||||
the separately identified capture-only correctness fix are retained.
|
||||
|
||||
## Retained changes in this pass
|
||||
|
||||
1. Exclude castling from capture-only generation (correctness; changes search
|
||||
nodes, and is isolated from all throughput comparisons).
|
||||
2. Specialize capture-only move scoring, removing impossible quiet-refutation
|
||||
probes and quiet-history work.
|
||||
3. Compute castling destinations directly and combine occupancy-clearance tests.
|
||||
4. Defer king/rook metadata in make-move until its relevant branch.
|
||||
5. Specialize pawn generation for the two colors without changing move order.
|
||||
6. Store continuation history by preceding move, and resolve the three shared
|
||||
row pointers once per move list.
|
||||
|
||||
The prior audit's uncommitted changes are preserved. NNUE production code is
|
||||
unchanged from that starting point: all three new arithmetic/update candidates
|
||||
were reverted after measurement. The NNUE test now reports its actual backend.
|
||||
|
||||
## Aggregate measurements and final validation
|
||||
|
||||
- **Depth-14 search, 8 pairs:** +3.86% throughput, interval [+2.89%, +4.96%];
|
||||
instructions -3.23%, cycles -3.81%, branches -3.26%; every run searched
|
||||
8,581,626 nodes. Raw result: `.cache/perf-audit2/final-search-depth14.json`.
|
||||
- **Depth-16 search, 6 pairs:** +4.28% throughput, interval [+3.58%, +4.92%];
|
||||
instructions -3.23%, cycles -4.18%, branches -3.43%; every run searched
|
||||
18,132,000 nodes. Raw result: `.cache/perf-audit2/final-search-depth16.json`.
|
||||
- **Start-position depth-7 bulk perft, 4 pairs:** +11.28% throughput, interval
|
||||
[+10.70%, +11.85%]; instructions -8.78%, cycles -10.05%, branches -7.46%;
|
||||
every run returned 3,195,901,860 nodes. Raw result:
|
||||
`.cache/perf-audit2/final-perft.json`. This is a separately measured combined
|
||||
result, not a sum of individual candidate estimates.
|
||||
- Final `make dev` completed and produced `bin/heimdall`.
|
||||
- The final movegen implementation passed **44,987 capture-list checks and
|
||||
41,065 state/hash transitions** in both checked and optimized builds. These
|
||||
include all special-move flags and all 960 cleared castling arrangements for
|
||||
both colors; mandatory checks use `doAssert` in both configurations.
|
||||
- Final checked SIMD NNUE diagnostic passed 8,491 comparisons, checksum
|
||||
1,228,801, with the retained move-generation changes.
|
||||
- Final checked scalar and forced-no-THP SIMD NNUE diagnostics also passed
|
||||
8,491 comparisons each, with the identical checksum 1,228,801.
|
||||
- Python test-tool and UCI regressions: **24/24 passed on the final native
|
||||
binary, and 24/24 on the checked binary** (6.24 / 6.39 seconds).
|
||||
Logs: `.cache/perf-audit2/python-default.log` and `python-checked.log`.
|
||||
- Checked non-bulk edge-position perft: **21/21 passed against Stockfish at
|
||||
depth 4** (7.15 seconds). Log: `.cache/perf-audit2/perft-checked-edge.log`.
|
||||
- Full standard/Chess960 corpus: **1,155/1,155 passed against Stockfish at
|
||||
depth 4**, using optimized bulk perft (129.54 seconds).
|
||||
Log: `.cache/perf-audit2/perft-all.log`.
|
||||
- Heavy standard positions: **7/7 passed against Stockfish at depth 6**,
|
||||
using optimized bulk perft (75.82 seconds).
|
||||
Log: `.cache/perf-audit2/perft-heavy.log`.
|
||||
- `make dev IS_TEST=1 ENABLE_TUNING=1` compiled successfully; its depth-8 smoke
|
||||
benchmark also visited exactly 387,438 nodes.
|
||||
- Valgrind Memcheck on the final optimized depth-8 benchmark reported **0 errors
|
||||
from 0 contexts**, with 387,438 nodes. Leak checking was disabled; no leak-free
|
||||
claim is made. Log: `.cache/perf-audit2/valgrind-final.log`.
|
||||
- A final symbolized depth-16 profile visited 18,132,000 nodes and lost no
|
||||
samples (319K cycle samples). Leading current self-cycle shares: NNUE forward
|
||||
23.19%, accumulator updates 18.28%, main non-PV search 16.97%, quiet move
|
||||
scoring 5.96%, quiescence 4.90%, refresh 4.82%, and make-move 4.08%.
|
||||
NNUE forward/update/refresh still totals about **46.3%**. Changed inlining and
|
||||
the corrected search tree mean these shares are current profiling targets,
|
||||
not direct before/after function-speed ratios.
|
||||
- Combined search is compared against `.cache/perf-audit2/capture-only-fix`,
|
||||
which has the same corrected tree. Perft can also be compared to the original
|
||||
saved baseline because ordinary perft's legal move set is unchanged.
|
||||
- Results are host/compiler-specific paired measurements, not Elo estimates.
|
||||
Intervals are exploratory bootstrap intervals from the saved raw pairs.
|
||||
- Final source review checked the equivalence of the union clearance mask,
|
||||
castling flag/target correspondence, unchanged pawn move order, consistent
|
||||
transposition of every continuation-history read/write, matching 1/2/4-ply
|
||||
conditions, and row-pointer lifetime within the owning search manager.
|
||||
- Final tracked diff and new test/journal whitespace checks found no errors.
|
||||
`src/heimdall/nnue.nim` has no remaining diff; the final executable fingerprint
|
||||
still matches the measured binary after all diagnostic builds.
|
||||
- The thirteen exploratory experiments account for **122 paired measurements**,
|
||||
excluding warmups, profiles, smoke checks, and aggregate confirmation runs.
|
||||
- Including aggregate confirmation, **140 pairs / 280 timed runs** completed,
|
||||
in addition to warmups, correctness checks, and sampling profiles.
|
||||
- Final native binary SHA-256:
|
||||
`faa42416c80b5c13a3bd31c1fac5d8b34db07daa19aa67eda36f7a4e34f1846b`.
|
||||
- Capture-correct search baseline SHA-256:
|
||||
`e307be825bc6382b6588268ac42ca69249905e2ddf802a876c7a96e206c81799`.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run from the repository root with the existing local dependencies and weights.
|
||||
The saved baseline binaries are local ignored artifacts, not regenerated from
|
||||
the final source. Run timed comparisons sequentially, without competing builds
|
||||
or tests. CPU 2 was used here; select an available CPU on another host. New output
|
||||
names below preserve the recorded audit samples.
|
||||
|
||||
```sh
|
||||
make dev
|
||||
python scripts/compare_performance.py .cache/perf-audit2/capture-only-fix bin/heimdall --depth 14 --pairs 8 --cpu 2 --perf --output .cache/perf-audit2/repeat-search14.json
|
||||
python scripts/compare_performance.py .cache/perf-audit2/capture-only-fix bin/heimdall --depth 16 --pairs 6 --cpu 2 --perf --output .cache/perf-audit2/repeat-search16.json
|
||||
python scripts/compare_performance.py .cache/perf-audit2/baseline bin/heimdall --mode perft --depth 7 --pairs 4 --cpu 2 --perf --output .cache/perf-audit2/repeat-perft.json
|
||||
```
|
||||
|
||||
Focused correctness checks (build variants of the same `MAIN` sequentially):
|
||||
|
||||
```sh
|
||||
make dev MAIN=tests/test_movegen.nim IS_TEST=1 EXE_BASE=bin/test-movegen EVALFILE="$PWD/networks/files/gramr.bin"
|
||||
bin/test-movegen
|
||||
make dev MAIN=tests/test_movegen.nim EXE_BASE=bin/test-movegen-opt EVALFILE="$PWD/networks/files/gramr.bin"
|
||||
bin/test-movegen-opt
|
||||
make dev MAIN=tests/test_nnue.nim IS_TEST=1 EXE_BASE=bin/test-nnue EVALFILE="$PWD/networks/files/gramr.bin"
|
||||
bin/test-nnue
|
||||
make dev MAIN=tests/test_nnue.nim IS_TEST=1 AVX2_SUPPORTED=0 AVX512_SUPPORTED=0 VNNI_SUPPORTED=0 EXE_BASE=bin/test-nnue-scalar EVALFILE="$PWD/networks/files/gramr.bin"
|
||||
bin/test-nnue-scalar
|
||||
make dev MAIN=tests/test_nnue.nim IS_TEST=1 EXTRA_NFLAGS=-d:noTHP EXE_BASE=bin/test-nnue-fallback EVALFILE="$PWD/networks/files/gramr.bin"
|
||||
bin/test-nnue-fallback
|
||||
make dev IS_TEST=1 EXE_BASE=bin/testdall
|
||||
python -m unittest discover -s tests -p 'test_*.py'
|
||||
HEIMDALL=bin/testdall python -m unittest discover -s tests -p 'test_*.py'
|
||||
python tests/suite.py -d 4 -b -p -w 4 -s -f tests/all.txt --heimdall bin/heimdall
|
||||
python tests/suite.py -d 6 -b -p -w 2 -s -f tests/standard_heavy.txt --heimdall bin/heimdall
|
||||
python tests/suite.py -d 4 -p -w 2 -s -f tests/illegal_edge_cases.txt --heimdall bin/testdall
|
||||
```
|
||||
|
||||
## Limits and future targets
|
||||
|
||||
- No network weights, network architecture, dependency versions, or system
|
||||
performance settings were changed. Generated binaries, raw timings, profiles,
|
||||
and diagnostic logs remain ignored under `.cache/perf-audit2/`.
|
||||
- Performance claims apply to this Ryzen/AVX2/Clang configuration and these
|
||||
deterministic workloads. Scalar correctness was checked, not scalar speed;
|
||||
AVX-512/VNNI and other operating systems were not runtime-tested here.
|
||||
- No Elo or playing-strength claim is made. The capture-only correctness fix
|
||||
changes the search tree; all search speed comparisons isolate that change by
|
||||
using the capture-correct baseline.
|
||||
- NNUE still consumes roughly 46.3% of sampled self cycles across forward,
|
||||
update, and refresh. Future candidates need a new hypothesis, not another
|
||||
unmeasured increase in unrolling or update-batching machinery.
|
||||
- The separate killer-table dimension finding remains a dedicated
|
||||
heuristic/strength-testing task. It is documented above, not silently folded
|
||||
into the throughput work.
|
||||
- Prior audit changes were preserved; nothing was committed or downloaded.
|
||||
272
docs/PERFORMANCE_CARRYOVER.md
Normal file
272
docs/PERFORMANCE_CARRYOVER.md
Normal file
@@ -0,0 +1,272 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Search-optimization carryover experiments
|
||||
|
||||
Started: 2026-09-06
|
||||
|
||||
Status: **Complete**. Evaluated selected ideas from `perf/search-optimizations`
|
||||
(`898de94`) on top of `perf/audit-optimizations` (`230b018`). Retained limiter
|
||||
fast paths, shared-perspective NNUE decoding, and optional PGO; rejected both
|
||||
prefetch candidates. Fixed depth-limited MultiPV completion and a newly traced
|
||||
root-aspiration early-stop bug. Final native timed throughput improved **1.65%**;
|
||||
opt-in PGO improved a further **3.77%** in its separate final comparison. Changes
|
||||
remain local and uncommitted pending review. All Nim builds used `make dev`;
|
||||
mandatory diagnostic invariants use `doAssert`.
|
||||
|
||||
## Order and acceptance criteria
|
||||
|
||||
1. Fix depth-limited MultiPV completion, including mixed depth/node/time limits.
|
||||
Test limiter fast paths separately from the correctness change.
|
||||
2. Test decoding each NNUE move once for both accumulator perspectives. This is
|
||||
distinct from the previously rejected consecutive-move batching experiment.
|
||||
3. Add and evaluate optional PGO with separate training and held-out workloads;
|
||||
keep the default `dev` and OpenBench build behavior unchanged.
|
||||
4. Test TT and accumulator prefetches separately, then confirm retained changes.
|
||||
|
||||
Compare optimized binaries with identical networks on CPU 2, using alternating
|
||||
pairs and hardware counters where applicable. Do not overlap timed runs with
|
||||
builds or other benchmarks. Preserve deterministic node counts/checksums for
|
||||
throughput comparisons and distinguish any correctness-driven tree change.
|
||||
Record rejected experiments, not just wins. Keep binaries, profiles, and raw
|
||||
measurements under ignored `.cache/perf-audit3/`.
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
- Starting working tree was clean; branch matches its remote.
|
||||
- Reproduced the MultiPV issue before this pass: with `MultiPV=3`, `go depth 2`
|
||||
reports all three depth-1 lines but only the first depth-2 line. Adding a
|
||||
generous node limit reproduces the same result.
|
||||
- Cause: `highestDepth` is published after each variation, but the hard-depth
|
||||
test interprets it as completion of the whole iteration. The old branch's
|
||||
early return only bypasses that check when neither node nor time limits exist.
|
||||
- Queued PGO, shared-perspective decoding, and prefetching for isolated trials.
|
||||
|
||||
## Results
|
||||
|
||||
### Limit correctness and measurement setup
|
||||
|
||||
- Saved a fresh optimized baseline; depth-13 bench remains **5,834,033 nodes**.
|
||||
SHA-256: `5a95efebec4ccc2df105278a44a4eae5cd85675bd2a17d532ec891b19ecb5076`.
|
||||
- Added MultiPV regressions for depths 1/2/3, 1/4 threads, and four combinations
|
||||
of depth/node/time limits. All 24 cases reproduced the missing-variation bug.
|
||||
- Moved depth termination to the between-iterations soft check and removed it
|
||||
from hard checks, including mixed limits. Single-PV bench remains 5,834,033.
|
||||
- The checked standalone limiter diagnostic passed depth boundaries, hard node
|
||||
limits including child counts, deterministic expired-clock sampling, pondering,
|
||||
disable/re-enable, and clearing/reusing the limiter.
|
||||
- Worker result selection can intentionally append a deeper worker PV at
|
||||
shutdown; tests require all requested-depth lines from the main search but do
|
||||
not incorrectly require that final worker line to obey the main depth cap.
|
||||
- Added a UCI workload driver for real node/time budgets and disjoint corpus
|
||||
subsets. It reports aggregate search NPS from final UCI node/time summaries;
|
||||
it excludes startup/reset time from NPS but records whole-process wall time.
|
||||
Five tool regressions passed. A 24-position/200,000-node smoke run completed
|
||||
4,800,012 nodes; these tiny budget overshoots must match in deterministic pairs.
|
||||
- Tested cached limiter-kind/time-sampling gates against the isolated
|
||||
correctness-fixed binary, not against a different search contract.
|
||||
- The optimized fast-path candidate passed all **31 Python regressions**; the
|
||||
checked correctness-only engine also passed the new 24-case MultiPV matrix.
|
||||
Mandatory standalone limiter checks passed on both implementations.
|
||||
- Fixed-node UCI comparisons additionally require per-position node counts,
|
||||
attained depths, and bestmove/ponder strings to match. Timed searches naturally
|
||||
visit different numbers of nodes, so their throughput is measured without
|
||||
pretending that their trees or playing strength are identical.
|
||||
- Prepared optional `make dev PGO=1` plumbing while timing the limiter. Training
|
||||
will use 24 even-indexed FENs, with node and time budgets in separate raw
|
||||
profiles; the 24 odd-indexed FENs are held out. These are held-out positions,
|
||||
not a claim of independent game-level cross-validation. Default builds and
|
||||
OpenBench behavior are unchanged. PGO evaluation comes after the NNUE trial.
|
||||
|
||||
### 1. Limiter fast paths — retained
|
||||
|
||||
- Against the correctness-only fix, eight depth-14 pairs: **-0.13%** throughput,
|
||||
95% bootstrap interval **[-0.81%, +0.35%]**; instructions -0.66%, cycles +0.03%.
|
||||
No depth-search improvement is claimed.
|
||||
- Eight fixed-time UCI pairs (24 held-out FENs, 200 ms each): **+0.91%** search
|
||||
throughput, interval **[+0.40%, +1.44%]**. Retained for the timed-search gain.
|
||||
Timed runs perform different work; raw whole-process counters are not
|
||||
interpreted as fixed-work reductions. Results: `limits-depth14.json` and
|
||||
`limits-time.json` under `.cache/perf-audit3/`.
|
||||
|
||||
### 2. Shared-perspective NNUE decoding — retained
|
||||
|
||||
- Ported only the shared move decode and single refresh flag, not prefetching or
|
||||
the older allocation/clone implementation. The 256-frame capacity and live
|
||||
prefix copying remain intact. Existing scalar/SIMD arithmetic kernels are
|
||||
reused without changing their operations.
|
||||
- Checked SIMD and forced-scalar NNUE diagnostics both passed **8,491
|
||||
comparisons, checksum 1,228,801**, including clone/rebinding and the 255-ply
|
||||
boundary. The native depth-13 smoke benchmark stayed at **5,834,033 nodes**.
|
||||
- Eight depth-14 pairs: **+0.56%**, interval **[-0.07%, +1.24%]**; instructions
|
||||
-0.62%, cycles -0.56%. Eight held-out 200,000-node UCI pairs: **+0.93%**,
|
||||
interval **[-0.25%, +1.78%]**; instructions -0.55%, cycles -0.88%.
|
||||
All per-position results match, but neither timing interval excludes zero.
|
||||
- The predeclared final confirmation, eight pairs with 1,000,000 nodes per
|
||||
held-out position: **+1.56%**, interval **[+0.58%, +2.50%]**; instructions
|
||||
-0.58%, cycles -1.58%. Per-position nodes/depths/best moves match. Retained
|
||||
based on this longer representative workload, not the inconclusive short
|
||||
runs. Every sample is included, including the slower fifth short pair and
|
||||
seventh long pair. Raw results: `nnue-pair-depth14.json`, `nnue-pair-nodes.json`, and
|
||||
`nnue-pair-confirm.json` under the artifact directory.
|
||||
- Expanded the NNUE diagnostic to all 960 castling arrangements for both colors,
|
||||
explicitly covering stationary kings/rooks and king/rook swaps. Also tightened
|
||||
limiter tests for mate-only limits, node budgets during pondering, and clock
|
||||
override/reset behavior. The expanded NNUE tests passed **17,731 comparisons,
|
||||
checksum 4,912,681**, on both SIMD and scalar. All mandatory limiter checks
|
||||
and all **31 Python regressions** passed with the accepted native engine.
|
||||
|
||||
### 3. Optional PGO — retained, opt-in only
|
||||
|
||||
- `make dev PGO=1` trains a separate executable with node and timed searches,
|
||||
merges exactly those two profiles, and uses the merged profile in the final
|
||||
build. Explicit `EXE` overrides are preserved without overwriting the trainer;
|
||||
standalone test `MAIN` values are rejected for this engine-only workflow.
|
||||
- Clang and `llvm-profdata` both report version **22.1.8**. Training and held-out
|
||||
positions are disjoint after whitespace normalization/deduplication. No
|
||||
machine-function-splitting flag or default OpenBench change is imported.
|
||||
- The actual build completed without warnings or dependency downloads, including
|
||||
an explicit `EXE=.cache/perf-audit3/pgo-candidate` override. Training completed
|
||||
4,800,006 fixed-budget nodes and 3,670,018 timed nodes; the merged front-end
|
||||
profile contains **5,173 functions and 38,856 blocks**. Build log and profiles
|
||||
are under the ignored artifact directory.
|
||||
- PGO passed depth-13 bench (**5,834,033 nodes**) and all **31 Python tests**.
|
||||
Eight held-out fixed-node pairs against the accepted native NNUE version:
|
||||
**+4.52%**, interval **[+2.62%, +6.60%]**; instructions -6.98%, cycles -4.83%,
|
||||
branches -13.32%. All per-position results match. Eight fixed-time pairs:
|
||||
**+5.05%**, interval **[+3.71%, +6.46%]**. Retained as an optional build path;
|
||||
normal dev/OpenBench defaults remain unchanged. Raw results are `pgo-nodes.json`
|
||||
and `pgo-time.json`. The standalone-test guard also rejected an invalid `MAIN`
|
||||
in a dry-run check, before any training/build actions.
|
||||
|
||||
### 4. Prefetch candidates — both reverted
|
||||
|
||||
- Prepared the old branch's approximate-child-key TT prefetch before make-move
|
||||
and exact prefetch after a null move. Existing post-move exact prefetches and
|
||||
atomic node publication remain untouched. No accumulator prefetch is included
|
||||
in this candidate. Built optimized/checked versions, then tested
|
||||
depth-14 throughput against the accepted native NNUE version (not PGO).
|
||||
- TT candidate passed optimized depth-13 bench (**5,834,033 nodes**), checked
|
||||
depth-9 bench (**774,807 nodes**), and all **31 checked-engine Python tests**.
|
||||
Eight depth-14 pairs: **+1.33%**, interval **[-0.31%, +3.26%]**; instructions
|
||||
+0.61%, cycles -1.41%. This was inconclusive. The final confirmation used six
|
||||
pairs with 1,000,000 nodes per held-out position; acceptance required a positive interval.
|
||||
- TT confirmation: **+0.08%**, interval **[-1.37%, +1.41%]**; instructions
|
||||
+0.61%, cycles -0.35%. Per-position results match, but the latency benefit did
|
||||
not confirm. **Reverted** the approximate-key helper and all three new TT hints;
|
||||
`search.nim` was unchanged at this point (a later correctness fix is below). Results:
|
||||
`tt-prefetch-depth14.json` and `tt-prefetch-confirm.json`.
|
||||
- Prepared accumulator destination prefetching as the next separate candidate.
|
||||
It hints only the first cache line of the next frame for each perspective;
|
||||
frame capacity, lazy-update bookkeeping, and arithmetic remain unchanged.
|
||||
- The accumulator candidate was built on the accepted NNUE version without TT
|
||||
changes. Checked SIMD/scalar correctness and measured depth-14 plus held-out
|
||||
200,000-node throughput separately from PGO.
|
||||
- Accumulator-prefetch checked SIMD/scalar tests both passed **17,731
|
||||
comparisons, checksum 4,912,681**; optimized depth-13 bench preserved
|
||||
**5,834,033 nodes**.
|
||||
- Accumulator depth-14 result, eight pairs: **-1.31%**, interval
|
||||
**[-2.33%, -0.09%]**; instructions +0.07%, cycles +1.23%.
|
||||
- Accumulator fixed-node result, eight pairs: **-0.99%**, interval
|
||||
**[-1.86%, -0.26%]**; instructions +0.07%, cycles +0.80%. Per-position results
|
||||
match, but both workloads show a slowdown. **Reverted** the helper and both
|
||||
destination hints. Results: `acc-prefetch-depth14.json` and
|
||||
`acc-prefetch-nodes.json`.
|
||||
|
||||
## Final validation — additional correctness finding
|
||||
|
||||
- Final production code contains the depth/MultiPV correctness fix, root
|
||||
aspiration depth guard, limiter fast paths, and shared-perspective NNUE decoding.
|
||||
Optional PGO remains opt-in.
|
||||
Neither prefetch candidate remains. No node-publication, allocation, global
|
||||
huge-page-advice, move-ordering, or search-heuristic changes were imported.
|
||||
- Rebuilt normal/checked engines and focused diagnostics; then confirmed aggregate
|
||||
native throughput against the original baseline and PGO against final native
|
||||
using 400 ms per held-out position. PGO was retrained after the additional
|
||||
correctness fix; neither rejected prefetch experiment is in the final builds.
|
||||
- Focused tests passed: limiter diagnostics; **17,731 NNUE comparisons** each
|
||||
on SIMD, scalar, and no-THP builds (checksum **4,912,681**); **44,987 move-list
|
||||
checks and 41,065 state/hash transitions**; all **31 checked-engine Python
|
||||
regressions**. The optimized final pass then caught an intermittent short-node
|
||||
test failure, so aggregate timing was paused rather than dismissing it.
|
||||
- Reproduced the same failure in the **original baseline: 4 of 30 stress runs**.
|
||||
Temporary tracing ruled out a reporting-only problem and identified an empty
|
||||
root PV after an aspiration retry at **depth zero** (one trace: 7,332 actual
|
||||
nodes for a 50,000-node request). No node/time limit had expired. A warm shared
|
||||
TT can cause repeated fail-high retries until the unbounded reduction reaches
|
||||
quiescence, which may return a TT/stand-pat score without searching a move.
|
||||
- Clamp root aspiration retry depth to at least one. This is a separate search
|
||||
correctness fix, not a throughput claim. The existing stress test now performs
|
||||
48 alternating searches per run and documents both late-worker and warm-TT
|
||||
coverage. All temporary tracing is removed. Revalidated and retrained PGO because
|
||||
production search code changed after its earlier measurements.
|
||||
- With the clamp, optimized and checked engines both pass all **31 Python
|
||||
regressions**. **60 consecutive expanded warm-TT stress runs passed** (2,880
|
||||
searches). Depth-13 remains **5,834,033 nodes**, checked depth-9 **774,807**.
|
||||
Final PGO profiles are under `.cache/perf-audit3/pgo-final-data/`.
|
||||
- PGO retraining completed without warnings: 4,800,006 fixed-budget training
|
||||
nodes, 3,639,302 timed training nodes; **5,173 functions, 38,857 blocks** in
|
||||
the merged profile. The final executable is `.cache/perf-audit3/pgo-final`.
|
||||
- Confirmed zero overlap between training and held-out sets both as complete
|
||||
normalized FENs and as board/turn/castling/en-passant tuples. They are still
|
||||
position-level, not independent game-level, held-out sets.
|
||||
- The benchmark tool's normal script and module (`python -m scripts...`) entry
|
||||
points both work. All five pure-Python workload regressions passed again;
|
||||
whitespace checking is clean. The only remaining `search.nim` change is the
|
||||
root aspiration depth guard.
|
||||
- Final PGO passed all **31 Python regressions** and **10 additional stress
|
||||
runs (480 searches)**. Two-pair fixed-node smoke checks matched per-position
|
||||
nodes, depths, and best moves across original baseline, final native, and final
|
||||
PGO. These short smoke runs are correctness checks, not speed claims.
|
||||
|
||||
## Aggregate confirmation
|
||||
|
||||
- Final native versus the original `230b018` baseline, eight alternating pairs,
|
||||
24 held-out positions at **400 ms each**, CPU 2: **+1.65% search throughput**,
|
||||
95% bootstrap interval **[+1.20%, +2.10%]**. Every pair was positive.
|
||||
Native includes both correctness fixes; fixed-node signatures and depth-13
|
||||
bench still match on the selected workloads. Raw results:
|
||||
`.cache/perf-audit3/final-native-time.json`.
|
||||
- Final retrained PGO versus final native, six alternating pairs on the same
|
||||
400 ms held-out workload: **+3.77%**, interval **[+0.25%, +6.92%]**. This wider
|
||||
interval includes all samples, including the two slower final pairs. It is
|
||||
consistent with the earlier positive held-out PGO measurements, but not a
|
||||
promise of the same gain on other machines. Raw results:
|
||||
`.cache/perf-audit3/final-pgo-time.json`.
|
||||
- Fixed-time samples perform different amounts of work. Their raw whole-process
|
||||
counters are not interpreted as fixed-work instruction reductions. No Elo or
|
||||
playing-strength improvement is claimed, and component percentages are not
|
||||
added together.
|
||||
|
||||
## Handoff
|
||||
|
||||
- Final normal engine: `bin/heimdall`; checked engine: `bin/testdall`; tested PGO
|
||||
engine: `.cache/perf-audit3/pgo-final`. Reproduce the optional build with
|
||||
`make dev PGO=1 EXE_BASE=bin/heimdall-pgo`. Default dev/OpenBench builds stay native.
|
||||
- All **31 Python regressions** pass on normal, checked, and final PGO engines.
|
||||
Mandatory limiter, NNUE SIMD/scalar/no-THP, and movegen/state checks pass.
|
||||
Original/final depth-13 benches are **5,834,033 nodes**; fixed-node per-position
|
||||
signatures match across all three optimized engines. The perft tool smoke
|
||||
preserves **4,865,609 nodes** at start-position depth 5.
|
||||
- No temporary tracing, new TT hints, or accumulator prefetch helpers remain.
|
||||
Binaries, profiles, and raw measurements are ignored artifacts, not source
|
||||
changes. No commits, pushes, dependency downloads, or branch changes were made
|
||||
during this pass.
|
||||
- Measurements are specific to this Linux/Ryzen 9 5900X host, native AVX2,
|
||||
Nim 2.2.6 and Clang/LLVM 22.1.8. Scalar correctness was also checked; other
|
||||
hardware/operating systems and playing strength were not evaluated here.
|
||||
131
docs/RELEASES.md
Normal file
131
docs/RELEASES.md
Normal file
@@ -0,0 +1,131 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Release builds
|
||||
|
||||
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
|
||||
are available through manual runs. The SIMD correctness workflow remains manual-only.
|
||||
|
||||
## macOS targets
|
||||
|
||||
| Native Make target | Required build host | Release target | Backend |
|
||||
| --- | --- | --- | --- |
|
||||
| `macos-amd64` | Intel Mac | `macos-amd64-sse2` | SSE2 |
|
||||
| `macos-arm64` | Apple Silicon Mac | `macos-arm64-neon` | NEON |
|
||||
| `macos-universal` | Either Mac CPU family | `macos-universal` | Runtime x86 SIMD + ARM NEON |
|
||||
|
||||
With Nim 2.2.6, Apple Clang, and the dependencies and network already installed:
|
||||
|
||||
```sh
|
||||
make macos-amd64 SKIP_DEPS=1 EVALFILE=/absolute/path/to/net.bin
|
||||
# On Apple Silicon:
|
||||
make macos-arm64 SKIP_DEPS=1 EVALFILE=/absolute/path/to/net.bin
|
||||
```
|
||||
|
||||
The combined Mac target cross-compiles both slices with Apple Clang and joins
|
||||
them using `lipo`; it embeds the weights once per slice. It uses separate build
|
||||
caches and disables PGO because both architectures need to build on either host.
|
||||
|
||||
These targets use the same network architecture and quantization settings as
|
||||
every other Makefile build and write `bin/heimdall` by default. The single-slice
|
||||
targets require the matching native host; the combined target supports either
|
||||
Mac CPU family. `MACOSX_DEPLOYMENT_TARGET=11.0` is the default minimum OS target; it can
|
||||
be overridden for a local build. The linker remains Apple ld with an 8 MiB stack.
|
||||
macOS builds use system libraries and do not request static linking.
|
||||
|
||||
CI uses native `macos-15-intel` and `macos-15` runners and the official Nim 2.2.6
|
||||
macOS binaries. It runs the production bench on each resulting executable.
|
||||
The deployment target does not mean every older macOS release is tested.
|
||||
No Apple signing identity or notarization step is configured.
|
||||
|
||||
## Add one artifact to an existing release
|
||||
|
||||
In GitHub **Actions → Release binaries → Run workflow**:
|
||||
|
||||
1. Choose **master** in **Use workflow from**, to use the current CI tooling.
|
||||
2. Set `target` to one complete target name, such as `macos-arm64-neon`.
|
||||
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.
|
||||
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.
|
||||
|
||||
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`.
|
||||
- `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.
|
||||
|
||||
## Source selection and publishing
|
||||
|
||||
`source_ref` accepts a branch, tag or commit SHA. With no `release_tag`, a manual
|
||||
run uploads only GitHub Actions artifacts. With a `release_tag`, the source must
|
||||
resolve to the same commit as that tag; mismatches fail before starting builds.
|
||||
This keeps the source of a newly added binary consistent with the release.
|
||||
|
||||
The current workflow and packaging helpers are checked out separately from the
|
||||
engine source. This allows current CI tooling to build an older tag without
|
||||
changing its files. The tagged Makefile must already support the selected SIMD
|
||||
backend. The 1.5.1-dev source contains both SSE2 and NEON, so its Mac builds can be
|
||||
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
|
||||
`.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.
|
||||
|
||||
For a local check using existing dependencies and weights:
|
||||
|
||||
```sh
|
||||
python scripts/release.py plan --target macos --tag 1.5.1-dev
|
||||
python scripts/release.py build --target linux-amd64-sse2 --skip-deps --artifacts build/release
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Runner labels are documented in the [GitHub runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
Compiler archives are linked from [Nim's previous releases](https://nim-lang.org/install.html).
|
||||
255
docs/SIMD.md
Normal file
255
docs/SIMD.md
Normal file
@@ -0,0 +1,255 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# SIMD backends
|
||||
|
||||
Inference, PSQ updates and threat updates use the same `vec*` API in
|
||||
`src/heimdall/util/simd.nim`. Static builds select one backend at compile time. `SIMD=universal` compiles the
|
||||
same kernels for several backends and selects one at startup, sharing one network
|
||||
and accumulator representation.
|
||||
|
||||
| Backend | Register bytes | Build selection | Portable target |
|
||||
| --- | ---: | --- | --- |
|
||||
| SSE2 | 16 | `make dev SIMD=sse2` | `make sse2` (baseline x86-64) |
|
||||
| SSSE3 | 16 | `make dev SIMD=ssse3` | `make ssse3` (x86-64 + SSSE3) |
|
||||
| SSE4.1 | 16 | `make dev SIMD=sse41` | `make sse41` (x86-64 + SSE4.1) |
|
||||
| AVX2 | 32 | `make dev SIMD=avx2` | `make avx2` (x86-64-v3) |
|
||||
| AVX-512 | 64 | `make dev SIMD=avx512` | `make avx512` |
|
||||
| AVX-512 VNNI | 64 | `make dev SIMD=avx512-vnni` | `make avx512-vnni` |
|
||||
| AArch64 NEON | 16 | `make dev SIMD=neon` | `make neon` (ARMv8-A) |
|
||||
| Scalar | — | `make dev SIMD=scalar` | `make scalar` (host architecture) |
|
||||
|
||||
Supply an absolute `EVALFILE` for the selected network architecture, as with any
|
||||
other build. `make dev` never fetches dependencies or weights. The named platform
|
||||
targets prepare dependencies unless `SKIP_DEPS=1` is supplied.
|
||||
|
||||
`SIMD=auto` is the default: VNNI, AVX-512, AVX2, SSE4.1, SSSE3, SSE2, NEON, then
|
||||
scalar, according to the compiler's native feature macros. AArch64 detection uses
|
||||
`-mcpu=native`. Explicit SSE2, SSSE3, SSE4.1 and NEON selections use their baseline
|
||||
ISA flags, even when compiling on a newer CPU. Selecting a backend does not emulate
|
||||
its instructions: running it requires the corresponding CPU or an emulator.
|
||||
The Makefile clears backend defines from local `nim.cfg` and `EXTRA_NFLAGS`
|
||||
before applying its selection. Use `SIMD=scalar` for scalar inference checks;
|
||||
disabling only the AVX feature probes can now select SSE2, SSSE3 or SSE4.1.
|
||||
|
||||
Public target and artifact names use this scheme only for versions newer than
|
||||
1.5.0. The [README legacy artifact guide](../README.md#legacy-artifacts-versions-13-through-150)
|
||||
covers earlier downloads. Old Make target names are not aliases.
|
||||
|
||||
Portable targets separate compatibility from tuning: `avx2` uses
|
||||
`-march=x86-64-v3`, `avx512` uses `-march=x86-64-v4`, and `avx512-vnni` adds
|
||||
`-mavx512vnni` to v4. SSE targets start at `-march=x86-64` and explicitly enable
|
||||
SSSE3 or SSE4.1 as needed. `TUNE=generic` is the portable default;
|
||||
`make avx2 TUNE=znver2` changes only `-mtune`, retaining the same v3 requirement.
|
||||
Native compiler flags still use the build host's CPU. No separate Zen 2 artifact
|
||||
is published.
|
||||
|
||||
`make macos-amd64` and `make macos-arm64` are convenience targets for native
|
||||
Intel and Apple Silicon Mac hosts, selecting SSE2 and NEON respectively. The
|
||||
Makefile uses Apple ld, an 8 MiB stack, and a configurable
|
||||
`MACOSX_DEPLOYMENT_TARGET` (default `11.0`). See [release builds](RELEASES.md) for
|
||||
the corresponding artifact names and independently selectable CI jobs.
|
||||
|
||||
## Universal binaries
|
||||
|
||||
```sh
|
||||
make dev SIMD=universal EVALFILE=/absolute/path/to/net.bin
|
||||
bin/heimdall simd
|
||||
HEIMDALL_SIMD=sse2 bin/heimdall bench 9
|
||||
make test-simd SIMD=universal
|
||||
```
|
||||
|
||||
`make universal SKIP_DEPS=1` builds the same target with locally available
|
||||
prerequisites. On x86-64 it contains scalar/autovectorized, SSE2, SSSE3, SSE4.1,
|
||||
AVX2, AVX-512 and AVX-512 VNNI kernels. On AArch64 it contains scalar and NEON.
|
||||
The default priority follows that order, choosing the last supported backend.
|
||||
The x86 compiler runtime checks CPUID and the operating system's enabled vector
|
||||
register state. Unsupported or unknown `HEIMDALL_SIMD` overrides fail at startup.
|
||||
Static builds ignore the override and report only their compiled backend.
|
||||
Selection is immutable after initialization and shared by all search threads.
|
||||
`heimdall simd` reports the selected and supported backends without loading weights.
|
||||
|
||||
Each ISA variant specializes an entire PSQ operation, TI diff/rebuild, or forward
|
||||
pass. Vector primitives remain inline and vector values never cross the dispatch
|
||||
boundary. The `simdKernel` pragma in `util/simd_dispatch.nim` specializes backend
|
||||
conditionals and binds primitives to the corresponding module. Scalar accumulator
|
||||
arithmetic explicitly wraps, including in correctness builds. Small accumulator
|
||||
rows that do not meet the chosen vector width use the scalar implementation.
|
||||
|
||||
The Makefile compiles ordinary engine code and initialization for baseline
|
||||
x86-64 or ARMv8-A. ISA-specific kernels and their helpers carry function target
|
||||
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,
|
||||
`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
|
||||
Apple's SDK, uses separate Nim caches, and builds without PGO. A native universal
|
||||
build can still use the existing optional PGO flow.
|
||||
|
||||
## Shared packing layout
|
||||
|
||||
Multilayer networks with FT widths divisible by 128 use the same AVX-512 dense
|
||||
weight permutation on every backend. PSQ weights, TI weights, FT biases and both
|
||||
accumulator stacks remain in canonical neuron order. The disk format is unchanged;
|
||||
export reverses the dense weight permutation.
|
||||
|
||||
CJ's trick produces the same packed bytes using narrower registers. Each letter
|
||||
below represents eight int16 values, and each packed pair contains sixteen bytes:
|
||||
|
||||
```text
|
||||
AVX-512: pack([a,b,c,d], [e,f,g,h]) = [ae,bf,cg,dh]
|
||||
AVX2: concat(pack([a,b], [e,f]), pack([c,d], [g,h]))
|
||||
SSE/NEON: concat(pack(a,e), pack(b,f), pack(c,g), pack(d,h))
|
||||
```
|
||||
|
||||
Concatenation is consecutive stores, requiring no extra shuffle instructions.
|
||||
Each perspective is processed separately in blocks of 64 pairwise products.
|
||||
The dense weight loader always uses the same four-byte group order:
|
||||
`[0,1,8,9,2,3,10,11,4,5,12,13,6,7,14,15]`. Scalar inference uses the inverse index
|
||||
mapping. The non-VNNI two-dot operations retain their eight-input grouping.
|
||||
Small debug architectures whose FT width is not divisible by 128 retain canonical
|
||||
dense weights and use the scalar head. Incompatible hidden-layer widths also
|
||||
fall back to that head. The single-layer debugging architecture is unchanged.
|
||||
|
||||
## Maintenance boundary
|
||||
|
||||
SSE2, SSSE3 and SSE4.1 instantiate `simd_backends/x86_128.nim`, using the existing pinned
|
||||
`nimsimd` dependency. `vecMaddubs16` selects between two implementations: SSE2
|
||||
widens bytes, forms int32 pair sums, then saturates; SSSE3 has a direct instruction.
|
||||
Signed int32 min/max
|
||||
and low multiplication use SSE2 sequences in the SSE2/SSSE3 builds. SSE4.1
|
||||
selects direct `_mm_max_epi32`, `_mm_min_epi32`, `_mm_mullo_epi32` and
|
||||
`_mm_cvtepi8_epi16` intrinsics in that same module. It uses an explicit SSE4.1
|
||||
feature flag and does not require SSE4.2, POPCNT or AVX.
|
||||
|
||||
NEON uses a small local C adapter and Nim declarations in `simd_backends/`.
|
||||
The pinned `nimsimd` NEON bindings expose unsigned operations only, so they
|
||||
cannot express the signed inference operations directly. The adapter uses the
|
||||
compiler's standard `arm_neon.h`; there is no new library, generated binding
|
||||
step, or fetched portability header. Its one opaque register type preserves the
|
||||
existing interchangeable `VEPI16`/`VEPI32` API. ARM support is limited to
|
||||
little-endian AArch64; AArch32, SVE and optional ARM dot-product extensions are
|
||||
outside this implementation.
|
||||
|
||||
SIMD threat-row updates and rebuilds keep four accumulator registers live,
|
||||
sharing each feature index and row address across four chunks. A single-register
|
||||
loop handles smaller widths and remaining lanes. `vecLoadI8AsI16x2` loads two
|
||||
consecutive chunks: its shared x86 fallback uses the existing widening loads,
|
||||
while NEON shares one 16-byte load between the low and high signed-byte halves.
|
||||
NEON's NNUE dot helpers use `SADALP` to widen, sum and accumulate signed int16
|
||||
pairs, retaining the existing pair saturation and x2 wrapping semantics.
|
||||
The byte multiply-add splits even and odd bytes within each 16-bit lane,
|
||||
multiplies them separately, and uses a saturating 16-bit add. Each product fits
|
||||
in int16, so this avoids widening pair sums to int32 and narrowing them again.
|
||||
The common PSQ quiet/capture updates process four vectors per iteration on SSE2,
|
||||
AVX2 and NEON, with a single-vector loop for smaller widths and remaining lanes.
|
||||
Other backends retain compiler-controlled unrolling for these PSQ operations.
|
||||
The first matrix multiply processes two four-byte input groups per iteration
|
||||
on SSE2 and NEON to reduce register pressure. The saturated pair products and x2 int16
|
||||
wrapping retain their original grouping; only the wrapping int32 sums are
|
||||
regrouped into one accumulation chain.
|
||||
|
||||
Existing inference changes written against `vec*` apply to every backend.
|
||||
Adding a new primitive still requires a wrapper and a shared contract test;
|
||||
compiler or runner upgrades can still need attention. This keeps maintenance
|
||||
small but cannot guarantee literally zero maintenance.
|
||||
|
||||
## Integer and layout contract
|
||||
|
||||
- Loads/stores use a whole register. Callers retain the existing 64-byte
|
||||
accumulator alignment. `vecLoadI8AsI16` reads exactly one signed byte per int16
|
||||
lane, accepts unaligned input and sign extends without reading a full register.
|
||||
`vecLoadI8AsI16x2` reads twice that many bytes into two int16 vectors and also
|
||||
accepts unaligned input.
|
||||
- Integer adds, subtracts and low products wrap. Signed high multiplication,
|
||||
arithmetic versus logical shifts, and saturating int16-to-uint8 packing retain
|
||||
the x86 semantics. The NEON shift wrappers handle oversized counts explicitly.
|
||||
- Primitive SSE2, SSSE3, SSE4.1 and NEON packing concatenates two eight-lane
|
||||
inputs, and `vecPermute` is an identity. Inference uses the common packing
|
||||
schedule above; primitive contracts and disk weights remain unchanged.
|
||||
- The new backends match **non-VNNI** `vecDpbusd`/`vecDpbusdx2`: adjacent byte
|
||||
products saturate to int16, and x2 adds those pair sums with int16 wrapping
|
||||
before widening. Existing VNNI instead accumulates full products directly into
|
||||
int32. Tests preserve both contracts; arbitrary overflowing inputs are not
|
||||
assumed to agree across VNNI, non-VNNI and scalar inference.
|
||||
|
||||
## Verification and CI
|
||||
|
||||
Run `make test-simd SIMD=universal`, `SIMD=sse2`, `SIMD=ssse3`, `SIMD=sse41`, `SIMD=neon`, or another
|
||||
backend. It generates synthetic weights under ignored `build/simd/` and runs:
|
||||
|
||||
- `test_simd`: every primitive, signed edges and randomized lanes, overflow,
|
||||
saturating dot products, packing, shifts, and unaligned byte widening.
|
||||
- `test_multilayer`: both activation modes, both perspectives and all buckets
|
||||
against the independent canonical-layout oracle; exact export and reload.
|
||||
- `test_nnue`: all-lane incremental/fresh comparisons, pending updates, cloning,
|
||||
the ply boundary and all Chess960 castling arrangements.
|
||||
- `test_threat_diff` at width 768 and `test_threat_updates`.
|
||||
|
||||
For universal builds the target discovers supported backends and forces each one
|
||||
through the same binaries. It also checks rejection of unknown/unsupported
|
||||
backends. Python/UCI regressions include matching scores, node counts and best
|
||||
moves across the available backends. Use `SIMD_TEST_DIR` to keep cross-build
|
||||
artifacts separate; `SIMD_TEST_RUNNER` and the Makefile executable suffix also
|
||||
apply to universal tests.
|
||||
|
||||
For threat-row tail checks, also build `tests/test_threat_diff.nim` at one or
|
||||
five int16 vector widths: `L1_SIZE=8`/`40` for SSE and NEON, `16`/`80` for AVX2,
|
||||
and `32`/`160` for AVX-512. Use an absolute `EVALFILE` as for other standalone
|
||||
tests. The arithmetic test does not load the file. `test_nnue` directly checks
|
||||
PSQ quiet/capture updates at one, four and five vector widths, including int16
|
||||
wrapping and parent preservation.
|
||||
|
||||
Start the GitHub SIMD workflow manually from **Actions → SIMD correctness →
|
||||
Run workflow** (`workflow_dispatch`). It runs the same target for scalar, SSE2, SSSE3, SSE4.1 and
|
||||
AVX2 on Linux x86-64 and NEON on native `ubuntu-24.04-arm`, then builds the engine
|
||||
and runs Python/UCI regressions. Universal builds run on both Linux CPU families,
|
||||
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
|
||||
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
|
||||
require suitable hardware; release bench checks skip unsupported binaries.
|
||||
Nim 2.2.6 is installed from its source archive on Linux ARM64 because that
|
||||
release has no official Linux ARM64 binary archive.
|
||||
|
||||
For cross-compilation, keep using `make dev`/`make test-simd`: supply
|
||||
`EXTRA_NFLAGS=--cpu:arm64`, `HOST_ARCH=aarch64-linux-gnu`, cross Clang target/sysroot
|
||||
settings through `CFLAGS` and `LFLAGS`, and `SIMD=neon` or `SIMD=universal`.
|
||||
`SIMD_TEST_RUNNER="qemu-aarch64 -L /path/to/sysroot"`
|
||||
prefixes test execution. Cross-compilers need target libc, compiler runtime and,
|
||||
for the full engine, zlib development files. The named `neon` target assumes a
|
||||
native ARM compiler unless these overrides are supplied.
|
||||
|
||||
Correctness under emulation does not establish performance on physical Core 2,
|
||||
older SSE2 CPUs or ARM hardware. Measure optimized binaries with the same
|
||||
network and ISA flags using `scripts/compare_performance.py`; confirm any
|
||||
inference microbenchmark gain with full search before making a speed claim.
|
||||
|
||||
References: [Clang SSE2 intrinsics](https://clang.llvm.org/doxygen/emmintrin_8h.html),
|
||||
[Clang SSSE3 intrinsics](https://clang.llvm.org/doxygen/tmmintrin_8h.html),
|
||||
[Arm NEON intrinsics](https://arm-software.github.io/acle/neon_intrinsics/advsimd.html),
|
||||
[GitHub runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners),
|
||||
[Nim release archives](https://nim-lang.org/install.html).
|
||||
73
docs/TESTING.md
Normal file
73
docs/TESTING.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Testing and benchmarks
|
||||
|
||||
Run commands from the repository root. Documentation changes do not require an
|
||||
engine build. For source changes, use the Makefile; never use `nim check`.
|
||||
|
||||
## Regular checks
|
||||
|
||||
With dependencies and weights available:
|
||||
|
||||
```sh
|
||||
make dev
|
||||
python -m unittest discover -s tests -p 'test_*.py'
|
||||
```
|
||||
|
||||
`make test` builds `bin/testdall` with `IS_TEST=1` and runs a depth-9 bench.
|
||||
`make test-suite` runs deeper benches and perft comparisons and requires Python
|
||||
and Stockfish on `PATH`. `make bench` builds with `make dev` and runs the search
|
||||
benchmark.
|
||||
|
||||
For Python tests that launch the engine, build a test binary and select it:
|
||||
|
||||
```sh
|
||||
make dev IS_TEST=1 EXE_BASE=bin/testdall
|
||||
HEIMDALL=bin/testdall python -m unittest discover -s tests -p 'test_*.py'
|
||||
```
|
||||
|
||||
## Focused NNUE checks
|
||||
|
||||
The local `threans.bin` fixture is for the single-layer debug architecture.
|
||||
Use an absolute `EVALFILE` path and never ship this fixture in a release:
|
||||
|
||||
```sh
|
||||
make dev SINGLE_LAYER=1 MAIN=tests/test_nnue.nim IS_TEST=1 \
|
||||
EXE_BASE=bin/test-nnue EVALFILE="$PWD/threans.bin"
|
||||
bin/test-nnue
|
||||
```
|
||||
|
||||
The focused tests include `test_single_layer.nim`, `test_nnue.nim`,
|
||||
`test_movegen.nim`, `test_limits.nim`, `test_threat_index.nim`,
|
||||
`test_threats.nim`, `test_threat_diff.nim`, and `test_threat_updates.nim`.
|
||||
Build each through `make dev` with `SINGLE_LAYER=1`, `IS_TEST=1`, and the
|
||||
appropriate `MAIN`, `EXE_BASE`, and `EVALFILE` values. The threat-diff test can
|
||||
be widened with `L1_SIZE=768`; it does not load the network fixture.
|
||||
|
||||
To test the default multilayer architecture without trained weights:
|
||||
|
||||
```sh
|
||||
python tests/make_multilayer_fixture.py build/tests/multilayer-ti.bin
|
||||
make dev MAIN=tests/test_multilayer.nim IS_TEST=1 \
|
||||
EXE_BASE=bin/test-multilayer \
|
||||
EVALFILE="$PWD/build/tests/multilayer-ti.bin" EVAL_SCALE=400
|
||||
bin/test-multilayer
|
||||
```
|
||||
|
||||
Repeat with `SIMD=scalar` for the scalar path. `make test-simd SIMD=universal`
|
||||
exercises every backend supported by the runner; use a specific backend such as
|
||||
`SIMD=sse2` or `SIMD=neon` when needed. See [SIMD.md](SIMD.md) for the complete
|
||||
matrix and cross-compilation options.
|
||||
|
||||
## Performance comparisons
|
||||
|
||||
Build baseline and candidate binaries with identical flags and network, then
|
||||
run paired measurements:
|
||||
|
||||
```sh
|
||||
python scripts/compare_performance.py bin/baseline bin/candidate \
|
||||
--cpu 2 --pairs 12 --perf --output comparison.json
|
||||
```
|
||||
|
||||
Use `--mode perft --depth 7` for move-generation comparisons or `--mode uci`
|
||||
with a FEN corpus for fixed node or time budgets. The script checks node counts
|
||||
and records timings, counters, and a bootstrap interval. `tests/bench_nnue.nim`
|
||||
and `tests/bench_setup.nim` provide focused inference and setup benchmarks.
|
||||
62
docs/TUI.md
Normal file
62
docs/TUI.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Built-in terminal UI
|
||||
|
||||
The built-in TUI provides analysis and play from a terminal. It uses Kitty's
|
||||
graphics protocol and currently supports Linux. Start it with:
|
||||
|
||||
```sh
|
||||
heimdall tui
|
||||
```
|
||||
|
||||
Kitty, Ghostty, WezTerm, and Konsole are supported, with Kitty recommended.
|
||||
Other terminals may not implement the required graphics protocol. Konsole has
|
||||
known issues with mouse positioning and image quality. The TUI does not honor
|
||||
`NO_COLOR`.
|
||||
|
||||
## Moving and annotating
|
||||
|
||||
Moves can be entered by clicking, dragging, UCI notation such as `e2e4`, SAN
|
||||
notation such as `Nf3`, or square selection (`e2`, then `e4`). Promotions
|
||||
default to a queen; `Shift+Q` toggles auto-queen. Right-click highlights a
|
||||
square and right-drag draws arrows. Shift/Ctrl, Alt, or both modifier groups
|
||||
choose red, blue, or yellow arrows.
|
||||
|
||||
The board scales down to fit the terminal. Use `:help` for the complete command
|
||||
list. The input line supports Left/Right, `Ctrl+A`, and `Ctrl+E`.
|
||||
|
||||
## Analysis and play
|
||||
|
||||
- `:go` starts or stops continuous analysis.
|
||||
- `:set multipv 3` shows multiple lines; `:arrows` toggles engine arrows.
|
||||
- `Shift+M` sets a mate-finder limit; `Shift+S` enters board setup mode.
|
||||
- `:play` starts a game against the engine; `:watch` starts engine-vs-engine play.
|
||||
- `:resign`, `:takeback`, `:rematch`, and `:exit` control a game.
|
||||
- `:load game.pgn` loads a PGN; `Shift+L` or `:analyse` analyzes it.
|
||||
- `:pgn output.pgn` exports the current game.
|
||||
- `:frc 518` and `:dfrc 123 456` load Chess960 positions.
|
||||
|
||||
Analysis reports include ACPL, accuracy, move judgments, mistake markers, and a
|
||||
graph that can be switched between evaluation and WDL with `Shift+W`. `Shift+H`
|
||||
hides or shows the graph.
|
||||
|
||||
## Settings and shortcuts
|
||||
|
||||
`:set <option> <value>` configures `hash`, `threads`, `multipv`, `depth`,
|
||||
`contempt`, `moveoverhead`, `ponder`, `normalizescore`, `evalfile`, and
|
||||
`chess960`. Hash values accept units such as `1 GB` and `256 MiB`. `:clear`
|
||||
resets engine state. Other useful commands are `:fen`, `:reset`, `:flip`,
|
||||
`:arrows`, and `:threats`.
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `Shift+A` | Toggle best-move arrows |
|
||||
| `Shift+F` | Flip the board |
|
||||
| `Shift+H` | Hide/show the analysis graph |
|
||||
| `Shift+L` | Analyze the loaded PGN |
|
||||
| `Shift+M` | Set a mate-finder limit |
|
||||
| `Shift+Q` | Toggle auto-queen |
|
||||
| `Shift+S` | Enter board setup |
|
||||
| `Shift+W` | Toggle evaluation/WDL graph |
|
||||
| Left/Right | Undo/redo moves |
|
||||
| Home/End | First/last position |
|
||||
| `Ctrl+C` | Quit immediately |
|
||||
| `Esc` | Cancel the current action |
|
||||
47
docs/UCI.md
Normal file
47
docs/UCI.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# UCI and command-line usage
|
||||
|
||||
Heimdall implements the [UCI protocol](https://en.wikipedia.org/wiki/Universal_Chess_Interface)
|
||||
and can be added to chess GUIs such as Arena, En Croissant, and Cutechess.
|
||||
|
||||
## UCI options
|
||||
|
||||
- `HClear`: clear history tables; this happens automatically at every new game.
|
||||
- `TTClear`: clear the transposition table; this also happens automatically.
|
||||
- `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.
|
||||
- `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.
|
||||
- `Threads`: number of search threads; the default is one.
|
||||
- `Hash`: transposition-table size in MiB; the default is 64.
|
||||
- `MoveOverhead`: milliseconds reserved for GUI or network delays; the default is 250.
|
||||
- `Minimal`: print only the final search information line.
|
||||
- `Contempt`: side-to-move-relative draw avoidance offset; the default is 0.
|
||||
|
||||
## Mixed mode
|
||||
|
||||
When connected to a TTY, Heimdall starts a command-line interface with color,
|
||||
history, and line editing. `NO_COLOR` disables colors, `NO_TUI` starts directly
|
||||
in UCI mode, and `NO_LOGO` suppresses the startup logo. Sending `uci` switches
|
||||
to UCI mode; `icu` returns to mixed mode. Ctrl+C, Ctrl+D, or Esc exits the
|
||||
interface.
|
||||
|
||||
The `genfens` command generates seeded openings, for example:
|
||||
|
||||
```sh
|
||||
heimdall "genfens 100 seed 123 book None dfrc true" "quit"
|
||||
```
|
||||
|
||||
The `relabel` command replaces move scores in a
|
||||
[viriformat 3.0.0](https://docs.rs/viriformat/3.0.0/viriformat/) file:
|
||||
|
||||
```sh
|
||||
heimdall relabel --input=games.vf --output=relabeled.vf \
|
||||
--nodes-soft=5000 --nodes-hard=1000000 --hash=1 --threads=8 --join
|
||||
```
|
||||
|
||||
It accepts `--input`, `--output`, `--depth`, `--nodes-soft`, `--nodes-hard`,
|
||||
`--hash`, `--threads`, `--chunk-size`, `--skip`, `--limit`, and `--join`.
|
||||
Without `--join`, worker output remains in `OUTPUT.part-000` style shards.
|
||||
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
# Package
|
||||
|
||||
# TODO: Can we do some nimscript stuff to generate this automagically?
|
||||
@@ -13,7 +27,7 @@ bin = @["heimdall"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim == 2.2.6" # TODO: Nim 2.8 breaks stuff
|
||||
requires "nim >= 2.2.2"
|
||||
requires "jsony == 1.1.5"
|
||||
requires "nint128 == 0.3.3"
|
||||
requires "struct == 0.2.3"
|
||||
|
||||
2
networks
2
networks
Submodule networks updated: 77efb7273d...c29a305980
@@ -1,4 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Add a tunable parameter to src/heimdall/util/tunables.nim.
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
# 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.
|
||||
|
||||
import std/[algorithm, httpclient, os, sequtils, strutils, tables]
|
||||
|
||||
import heimdall/[board, movegen]
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
@@ -60,6 +74,11 @@ def proc_cpuinfo_flags() -> set[str]:
|
||||
if sep == "" or key.strip().lower() not in {"flags", "features"}:
|
||||
continue
|
||||
flags.update(flag.lower() for flag in value.split())
|
||||
# Linux uses these alternative names for the same CPUID features.
|
||||
if "pni" in flags:
|
||||
flags.add("sse3")
|
||||
if "abm" in flags:
|
||||
flags.add("lzcnt")
|
||||
return flags
|
||||
|
||||
|
||||
@@ -106,9 +125,25 @@ def cpuid_probe_source() -> str:
|
||||
int main(void) {
|
||||
#if defined(__i386__) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64)
|
||||
unsigned regs[4] = {0, 0, 0, 0};
|
||||
cpuidex(0, 0, regs);
|
||||
unsigned max_leaf = regs[0];
|
||||
cpuidex(1, 0, regs);
|
||||
unsigned leaf1_ecx = regs[2];
|
||||
int osxsave = (regs[2] & (1u << 27)) != 0;
|
||||
if (regs[3] & (1u << 26)) puts("sse2");
|
||||
if (leaf1_ecx & (1u << 0)) puts("sse3");
|
||||
if (leaf1_ecx & (1u << 9)) puts("ssse3");
|
||||
if (leaf1_ecx & (1u << 13)) puts("cx16");
|
||||
if (leaf1_ecx & (1u << 19)) puts("sse4_1");
|
||||
if (leaf1_ecx & (1u << 20)) puts("sse4_2");
|
||||
if (leaf1_ecx & (1u << 22)) puts("movbe");
|
||||
if (leaf1_ecx & (1u << 23)) puts("popcnt");
|
||||
cpuidex(0x80000000u, 0, regs);
|
||||
if (regs[0] >= 0x80000001u) {
|
||||
cpuidex(0x80000001u, 0, regs);
|
||||
if (regs[2] & (1u << 0)) puts("lahf_lm");
|
||||
if (regs[2] & (1u << 5)) puts("lzcnt");
|
||||
}
|
||||
int osxsave = (leaf1_ecx & (1u << 27)) != 0;
|
||||
if (!osxsave) {
|
||||
return 0;
|
||||
}
|
||||
@@ -116,6 +151,9 @@ def cpuid_probe_source() -> str:
|
||||
unsigned long long xcr0 = xgetbv0();
|
||||
int avx_state = (xcr0 & 0x6u) == 0x6u;
|
||||
int avx512_state = (xcr0 & 0xe6u) == 0xe6u;
|
||||
if (avx_state && (leaf1_ecx & (1u << 28))) puts("avx");
|
||||
if (avx_state && (leaf1_ecx & (1u << 29))) puts("f16c");
|
||||
if (max_leaf < 7) return 0;
|
||||
cpuidex(7, 0, regs);
|
||||
|
||||
if (avx_state && (regs[1] & (1u << 5))) {
|
||||
@@ -199,14 +237,18 @@ def host_cpu_flags() -> set[str]:
|
||||
|
||||
|
||||
def binary_cpu_requirements(binary: Path) -> set[str]:
|
||||
name = binary.name.lower()
|
||||
avx512_v4 = {"avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"}
|
||||
if "-vnni" in name:
|
||||
return avx512_v4 | {"avx512vnni"}
|
||||
if "-avx512" in name:
|
||||
return avx512_v4
|
||||
if "-haswell" in name or "-zen2" in name:
|
||||
return {"avx2", "fma", "bmi1", "bmi2"}
|
||||
name = binary.name.lower().removesuffix(".exe")
|
||||
sse2 = {"sse2"}
|
||||
ssse3 = sse2 | {"sse3", "ssse3"}
|
||||
sse41 = ssse3 | {"sse4_1"}
|
||||
v2 = sse41 | {"sse4_2", "popcnt", "cx16", "lahf_lm"}
|
||||
v3 = v2 | {"avx", "avx2", "fma", "f16c", "bmi1", "bmi2", "lzcnt", "movbe"}
|
||||
v4 = v3 | {"avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"}
|
||||
for suffix, required in (("avx512-vnni", v4 | {"avx512vnni"}),
|
||||
("avx512", v4), ("avx2", v3),
|
||||
("sse41", sse41), ("ssse3", ssse3), ("sse2", sse2)):
|
||||
if name.endswith("-" + suffix):
|
||||
return required
|
||||
return set()
|
||||
|
||||
|
||||
|
||||
209
scripts/compare_performance.py
Normal file
209
scripts/compare_performance.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Alternate paired engine workloads on one CPU and save raw samples."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import re
|
||||
import signal
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
if __package__:
|
||||
from .uci_workload import load_positions, run_workload
|
||||
else:
|
||||
from uci_workload import load_positions, run_workload
|
||||
|
||||
|
||||
def run(binary, depth, cpu, timeout, counters=False, mode="search"):
|
||||
command = [str(binary)]
|
||||
commands = None
|
||||
if mode == "search":
|
||||
command += ["bench", str(depth), "--silent"]
|
||||
else:
|
||||
commands = f"position startpos\ngo perft {depth} bulk nosplit\nquit\n"
|
||||
if cpu is not None:
|
||||
command = ["taskset", "-c", str(cpu), *command]
|
||||
if counters:
|
||||
command = ["perf", "stat", "-x", "\t", "--no-big-num", "-e",
|
||||
"instructions:u,cycles:u,branches:u,branch-misses:u", "--", *command]
|
||||
start = time.perf_counter()
|
||||
process = subprocess.Popen(
|
||||
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, start_new_session=os.name == "posix",
|
||||
env={**os.environ, "NO_COLOR": "1", "NO_LOGO": "1"},
|
||||
)
|
||||
try:
|
||||
stdout, stderr = process.communicate(commands, timeout=timeout)
|
||||
except (subprocess.TimeoutExpired, KeyboardInterrupt):
|
||||
# perf/taskset may be the direct child. Stop the engine beneath them too.
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.communicate()
|
||||
raise
|
||||
elapsed = time.perf_counter() - start
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"{command} exited {process.returncode}:\n{stdout}\n{stderr}")
|
||||
if mode == "search":
|
||||
matches = re.findall(r"^(\d+) nodes (\d+) nps$", stdout, re.MULTILINE)
|
||||
else:
|
||||
counts = re.findall(r"^Nodes searched \(bulk-counting: on\): (\d+)$", stdout, re.MULTILINE)
|
||||
speeds = re.findall(r"^Nodes per second: (\d+)$", stdout, re.MULTILINE)
|
||||
matches = list(zip(counts, speeds)) if len(counts) == len(speeds) else []
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(f"Missing benchmark summary from {binary}:\n{stdout}")
|
||||
nodes, nps = map(int, matches[0])
|
||||
if not nodes or not nps:
|
||||
raise RuntimeError("Benchmark must report positive nodes and throughput")
|
||||
sample = {"nodes": nodes, "nps": nps, "cpu_seconds": nodes / nps,
|
||||
"wall_seconds": elapsed, "time": time.time()}
|
||||
if counters:
|
||||
sample["counters"] = {}
|
||||
for line in stderr.splitlines():
|
||||
fields = line.split("\t")
|
||||
if len(fields) > 2 and fields[2] in ("instructions:u", "cycles:u", "branches:u", "branch-misses:u"):
|
||||
sample["counters"][fields[2]] = float(fields[0])
|
||||
if len(sample["counters"]) != 4:
|
||||
raise RuntimeError(f"Missing perf counters:\n{stderr}")
|
||||
return sample
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
# Log ratios treat equivalent speedups and slowdowns symmetrically.
|
||||
ratios = [math.log(pair["candidate"]["nps"] / pair["baseline"]["nps"]) for pair in samples]
|
||||
rng = random.Random(0)
|
||||
boot = sorted(statistics.mean(rng.choices(ratios, k=len(ratios))) for _ in range(10000))
|
||||
percent = lambda value: 100 * math.expm1(value)
|
||||
summary = {
|
||||
"pairs": len(samples),
|
||||
"geomean_speedup_percent": percent(statistics.mean(ratios)),
|
||||
"median_speedup_percent": percent(statistics.median(ratios)),
|
||||
"bootstrap_95_percent": [percent(boot[250]), percent(boot[9749])],
|
||||
"baseline_median_nps": statistics.median(pair["baseline"]["nps"] for pair in samples),
|
||||
"candidate_median_nps": statistics.median(pair["candidate"]["nps"] for pair in samples),
|
||||
}
|
||||
if all("counters" in pair[name] for pair in samples for name in ("baseline", "candidate")):
|
||||
summary["counter_change_percent"] = {
|
||||
event: percent(statistics.mean(
|
||||
math.log(pair["candidate"]["counters"][event] / pair["baseline"]["counters"][event])
|
||||
for pair in samples
|
||||
))
|
||||
for event in samples[0]["baseline"]["counters"]
|
||||
if all(pair[name]["counters"][event] > 0 for pair in samples for name in ("baseline", "candidate"))
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def binary_info(path):
|
||||
with path.open("rb") as binary:
|
||||
digest = hashlib.file_digest(binary, "sha256").hexdigest()
|
||||
return {"path": str(path), "sha256": digest}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("baseline", type=Path)
|
||||
parser.add_argument("candidate", type=Path)
|
||||
parser.add_argument("--mode", choices=("search", "perft", "uci"), default="search")
|
||||
parser.add_argument("--depth", type=int)
|
||||
parser.add_argument("--pairs", type=int, default=12)
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--cpu", type=int)
|
||||
parser.add_argument("--timeout", type=float, default=300)
|
||||
parser.add_argument("--perf", action="store_true", help="Record four user-space hardware counters")
|
||||
parser.add_argument("--positions", type=Path, help="FEN corpus for --mode uci")
|
||||
parser.add_argument("--count", type=int, default=24)
|
||||
parser.add_argument("--offset", type=int, default=0)
|
||||
parser.add_argument("--stride", type=int, default=1)
|
||||
parser.add_argument("--limit-kind", choices=("nodes", "time"), default="nodes")
|
||||
parser.add_argument("--limit", type=int, default=200000, help="Nodes or milliseconds per UCI position")
|
||||
parser.add_argument("--threads", type=int, default=1)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.depth is None:
|
||||
args.depth = 14 if args.mode == "search" else 6
|
||||
if args.pairs < 2 or args.warmups < 0 or args.depth < 1:
|
||||
parser.error("need at least two pairs, nonnegative warmups and a positive depth")
|
||||
binaries = {"baseline": args.baseline.resolve(strict=True), "candidate": args.candidate.resolve(strict=True)}
|
||||
report = {"host": platform.platform(), "mode": args.mode,
|
||||
"depth": args.depth if args.mode != "uci" else None, "cpu": args.cpu,
|
||||
"binaries": {name: binary_info(path) for name, path in binaries.items()}, "samples": []}
|
||||
if args.mode == "uci":
|
||||
if args.positions is None or args.limit < 1 or args.threads < 1:
|
||||
parser.error("UCI mode needs --positions and positive limit/threads")
|
||||
try:
|
||||
positions = load_positions(args.positions, args.count, args.offset, args.stride)
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
report["workload"] = {"positions": positions, "limit_kind": args.limit_kind,
|
||||
"limit": args.limit, "threads": args.threads}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
expected_nodes = None
|
||||
expected_searches = None
|
||||
|
||||
def sample(name):
|
||||
nonlocal expected_nodes, expected_searches
|
||||
if args.mode == "uci":
|
||||
value = run_workload(binaries[name], positions, args.limit_kind, args.limit,
|
||||
args.threads, args.cpu, args.timeout, args.perf)
|
||||
else:
|
||||
value = run(binaries[name], args.depth, args.cpu, args.timeout, args.perf, args.mode)
|
||||
if expected_nodes is None:
|
||||
expected_nodes = value["nodes"]
|
||||
deterministic = args.mode != "uci" or (args.limit_kind == "nodes" and args.threads == 1)
|
||||
if deterministic and value["nodes"] != expected_nodes:
|
||||
raise RuntimeError(f"Node count changed: {name} reports {value['nodes']}, expected {expected_nodes}")
|
||||
if deterministic and args.mode == "uci":
|
||||
signatures = [(search["nodes"], search["depth"], search["bestmove"])
|
||||
for search in value["searches"]]
|
||||
if expected_searches is None:
|
||||
expected_searches = signatures
|
||||
if signatures != expected_searches:
|
||||
raise RuntimeError(f"Fixed-node search results changed for {name}")
|
||||
return value
|
||||
|
||||
for _ in range(args.warmups):
|
||||
for name in binaries:
|
||||
sample(name)
|
||||
for index in range(args.pairs):
|
||||
order = ["baseline", "candidate"] if index % 2 == 0 else ["candidate", "baseline"]
|
||||
pair = {"order": order}
|
||||
for name in order:
|
||||
pair[name] = sample(name)
|
||||
report["samples"].append(pair)
|
||||
report["summary"] = summarize(report["samples"])
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(f"Pair {index + 1}/{args.pairs}: baseline {pair['baseline']['nps']:,.0f} nps; "
|
||||
f"candidate {pair['candidate']['nps']:,.0f} nps", flush=True)
|
||||
print(json.dumps(report["summary"], indent=2), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Run bench and extract the node count
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Publish release artifacts to a Gitea release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
199
scripts/release.py
Normal file
199
scripts/release.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Plan independent release jobs and build/package one Makefile backend."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
|
||||
X86_BACKENDS = ("sse2", "ssse3", "sse41", "avx2", "avx512", "avx512-vnni", "universal")
|
||||
# This catalog owns CI target selection; compiler and network settings stay in Makefile.
|
||||
PLATFORMS = (
|
||||
("linux", "amd64", "ubuntu-24.04", X86_BACKENDS),
|
||||
("linux", "arm64", "ubuntu-24.04-arm", ("neon", "universal")),
|
||||
("windows", "amd64", "windows-latest", X86_BACKENDS),
|
||||
("macos", "amd64", "macos-15-intel", ("sse2",)),
|
||||
("macos", "arm64", "macos-15", ("neon",)),
|
||||
)
|
||||
TARGETS = {
|
||||
f"{system}-{arch}-{backend}": dict(target=f"{system}-{arch}-{backend}",
|
||||
os=system, arch=arch, runner=runner, backend=backend)
|
||||
for system, arch, runner, backends in PLATFORMS
|
||||
for backend in backends
|
||||
}
|
||||
TARGETS["macos-universal"] = dict(target="macos-universal", os="macos", arch="universal",
|
||||
runner="macos-15", backend="universal", make_target="macos-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")]
|
||||
if not targets:
|
||||
raise ValueError(f"Unknown release target {selection!r}; choose all, universal, linux, windows, macos, or "
|
||||
+ ", ".join(TARGETS))
|
||||
return {"include": targets}
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(["git", *args], text=True).strip()
|
||||
|
||||
|
||||
def source_commit(ref):
|
||||
return git("rev-parse", "--verify", "--end-of-options", ref + "^{commit}")
|
||||
|
||||
|
||||
def release_source(ref, tag, workflow_ref):
|
||||
"""Published additions use the tagged source with the current workflow tooling."""
|
||||
if tag:
|
||||
subprocess.run(["git", "check-ref-format", "refs/tags/" + tag], check=True)
|
||||
tagged = source_commit("refs/tags/" + tag)
|
||||
source = source_commit(ref) if ref else tagged
|
||||
if source != tagged:
|
||||
raise ValueError("The source ref must match the release tag when publishing")
|
||||
return source
|
||||
return source_commit(ref or workflow_ref)
|
||||
|
||||
|
||||
def outputs(values):
|
||||
if path := os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(path, "a", encoding="utf-8") as stream:
|
||||
for key, value in values.items():
|
||||
print(f"{key}={value}", file=stream)
|
||||
|
||||
|
||||
def plan(args):
|
||||
tag_push = (os.environ.get("GITHUB_EVENT_NAME") == "push"
|
||||
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)
|
||||
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)
|
||||
outputs(values)
|
||||
print(json.dumps(values, indent=2))
|
||||
|
||||
|
||||
def version_flags(tag):
|
||||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", tag)
|
||||
if match:
|
||||
return ["IS_RELEASE=1", *[f"{part}_VERSION={n}" for part, n in
|
||||
zip(("MAJOR", "MINOR", "PATCH"), match.groups())]]
|
||||
# Untagged builds and alpha/beta/rc/dev tags use the Makefile prerelease naming.
|
||||
if tag and not any(marker in tag.lower() for marker in ("alpha", "beta", "rc", "dev")):
|
||||
raise ValueError("Use a semantic version or an alpha/beta/rc/dev tag")
|
||||
return ["IS_RELEASE=0"]
|
||||
|
||||
|
||||
def make_config(flags):
|
||||
# Query the source revision's Makefile, including older tags that predate this
|
||||
# helper. GNU Make --eval adds only a print target; no build flags are duplicated.
|
||||
rule = ("heimdall-release-config: ; @echo $(OS_TAG) $(ARCH_TAG) $(RELEASE_BASE) "
|
||||
"$(PRERELEASE_BASE) x$(EXE_EXT)")
|
||||
result = subprocess.check_output(
|
||||
["make", "--no-print-directory", "-s", "--eval=" + rule, "heimdall-release-config", *flags],
|
||||
text=True,
|
||||
).strip().split()
|
||||
if len(result) != 5:
|
||||
raise ValueError(f"Unexpected Makefile release configuration: {result!r}")
|
||||
return result
|
||||
|
||||
|
||||
def package(binary, directory):
|
||||
"""Each target owns its binary, checksum and archive, so uploads cannot collide."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
copied = directory / binary.name
|
||||
shutil.copy2(binary, copied)
|
||||
checksum = directory / (binary.name + ".sha256")
|
||||
checksum.write_bytes((hashlib.sha256(copied.read_bytes()).hexdigest() + " " + binary.name + "\n").encode("utf-8"))
|
||||
if binary.suffix == ".exe":
|
||||
archive = directory / (binary.stem + ".zip")
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
|
||||
for item in (copied, checksum):
|
||||
bundle.write(item, item.name)
|
||||
else:
|
||||
archive = directory / (binary.name + ".tar.gz")
|
||||
with tarfile.open(archive, "w:gz") as bundle:
|
||||
for item in (copied, checksum):
|
||||
bundle.add(item, arcname=item.name)
|
||||
return [copied, checksum, archive]
|
||||
|
||||
|
||||
def build(args):
|
||||
target = TARGETS[args.target]
|
||||
flags = version_flags(args.tag)
|
||||
system, arch, release, prerelease, extension = make_config(flags)
|
||||
if system != target["os"] or (target["arch"] != "universal" and arch != target["arch"]):
|
||||
raise ValueError(f"{args.target} requires a {target['os']}/{target['arch']} build host; "
|
||||
f"Makefile reports {system}/{arch}")
|
||||
source = source_commit("HEAD")
|
||||
if args.tag:
|
||||
release_source(source, args.tag, "HEAD")
|
||||
base = release if "IS_RELEASE=1" in flags else prerelease
|
||||
if target["arch"] == "universal":
|
||||
base = base.removesuffix("-" + arch)
|
||||
binary_base = Path("bin") / (base + "-" + target["backend"])
|
||||
binary = Path(str(binary_base) + extension.removeprefix("x"))
|
||||
command = ["make", target.get("make_target", target["backend"]), "NIMBLE_FLAGS=-y", *flags, f"EXE_BASE={binary_base}"]
|
||||
if args.skip_deps:
|
||||
command.append("SKIP_DEPS=1")
|
||||
print("Building " + args.target, flush=True)
|
||||
subprocess.run(command, check=True)
|
||||
# Use the current bench checker for current artifact names, with the source
|
||||
# tag's recorded bench. The tagged Makefile still owns compiler/network flags.
|
||||
subprocess.run([sys.executable, str(Path(__file__).with_name("check_binary_benches.py")),
|
||||
"--commit", source, "--", str(binary)], check=True)
|
||||
files = package(binary, args.artifacts)
|
||||
outputs(dict(artifact_name=binary_base.name))
|
||||
print("Packaged: " + ", ".join(map(str, files)), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
planning = commands.add_parser("plan", help="Select jobs and resolve the source revision")
|
||||
planning.add_argument("--target", default="universal")
|
||||
planning.add_argument("--ref", default="")
|
||||
planning.add_argument("--tag", default="")
|
||||
building = commands.add_parser("build", help="Build and check one target from the current directory")
|
||||
building.add_argument("--target", required=True, choices=TARGETS)
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
(plan if args.command == "plan" else build)(args)
|
||||
except (ValueError, subprocess.CalledProcessError) as exc:
|
||||
parser.exit(1, str(exc) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
86
scripts/test_simd.py
Normal file
86
scripts/test_simd.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Run the same vec*, inference, and state checks for any Makefile backend."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--backend", required=True, choices=("universal", "scalar", "sse2", "ssse3", "sse41", "avx2", "avx512", "avx512-vnni", "neon"))
|
||||
parser.add_argument("--runner", default="", help="Optional execution prefix, e.g. qemu-aarch64 -L /path/to/sysroot")
|
||||
parser.add_argument("--directory", type=Path, help="Separate artifacts for cross builds")
|
||||
parser.add_argument("--exe-extension", default=".exe" if sys.platform == "win32" else "")
|
||||
args = parser.parse_args()
|
||||
directory = args.directory or Path("build/simd") / args.backend
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
runner = shlex.split(args.runner)
|
||||
extension = args.exe_extension
|
||||
environment = dict(os.environ)
|
||||
environment.pop("HEIMDALL_SIMD", None)
|
||||
runtime_backends = None
|
||||
|
||||
def run(command, **kwargs):
|
||||
print(shlex.join(map(str, command)), flush=True)
|
||||
return subprocess.run(list(map(str, command)), check=True, **kwargs)
|
||||
|
||||
def check(name, fixture, *flags):
|
||||
nonlocal runtime_backends
|
||||
binary = directory / name
|
||||
run(["make", "dev", f"SIMD={args.backend}", "PGO=0", "SINGLE_LAYER=0", "IS_TEST=1", "IS_DEBUG=0",
|
||||
f"MAIN=tests/{name.split('-')[0]}.nim", f"EXE_BASE={binary}",
|
||||
f"EVALFILE={fixture.resolve()}", "EVAL_SCALE=400", "L1_SIZE=512", "L2_SIZE=16", "L3_SIZE=32",
|
||||
"INPUT_BUCKETS=16", "OUTPUT_BUCKETS=8", "DUAL_ACTIVATION=1", *flags])
|
||||
command = [*runner, str(binary) + extension]
|
||||
if args.backend != "universal":
|
||||
run(command)
|
||||
return
|
||||
if runtime_backends is None:
|
||||
detected = run(command, env=environment, capture_output=True, text=True)
|
||||
print(detected.stdout, end="", flush=True)
|
||||
line = next(line for line in detected.stdout.splitlines() if line.startswith("Supported SIMD backends: "))
|
||||
runtime_backends = line.partition(": ")[2].split()
|
||||
assert "scalar" in runtime_backends
|
||||
for rejected in {"invalid", "sse2", "ssse3", "sse41", "avx2", "avx512", "avx512-vnni", "neon"} - set(runtime_backends):
|
||||
result = subprocess.run(command, env={**environment, "HEIMDALL_SIMD": rejected}, capture_output=True, text=True)
|
||||
assert result.returncode == 1, (rejected, result.returncode, result.stderr)
|
||||
assert "heimdall:" in result.stderr and rejected in result.stderr, result.stderr
|
||||
for backend in runtime_backends:
|
||||
print(f"Forcing {backend}", flush=True)
|
||||
run(command, env={**environment, "HEIMDALL_SIMD": backend})
|
||||
|
||||
fixture = directory / "multilayer-ti.bin"
|
||||
run([sys.executable, "tests/make_multilayer_fixture.py", fixture])
|
||||
if args.backend != "scalar":
|
||||
check("test_simd", fixture)
|
||||
check("test_multilayer", fixture)
|
||||
check("test_nnue", fixture)
|
||||
check("test_threat_diff", fixture, "L1_SIZE=768")
|
||||
check("test_threat_updates", fixture)
|
||||
single_activation = directory / "single-activation-ti.bin"
|
||||
run([sys.executable, "tests/make_multilayer_fixture.py", single_activation, "--dual", "0"])
|
||||
check("test_multilayer-single-activation", single_activation, "DUAL_ACTIVATION=0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
140
scripts/uci_workload.py
Normal file
140
scripts/uci_workload.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Run a selected FEN corpus with real UCI node or time limits (also PGO training)."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_positions(path, count=24, offset=0, stride=1):
|
||||
if count < 1 or offset < 0 or stride < 1:
|
||||
raise ValueError("count/stride must be positive and offset nonnegative")
|
||||
positions = list(dict.fromkeys(
|
||||
" ".join(line.split()) for line in path.read_text().splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
))
|
||||
selected = positions[offset::stride][:count]
|
||||
if len(selected) != count:
|
||||
raise ValueError(f"requested {count} positions, found only {len(selected)}")
|
||||
return selected
|
||||
|
||||
|
||||
def commands_for(positions, limit_kind, limit, threads):
|
||||
if limit_kind not in ("nodes", "time") or limit < 1 or threads < 1:
|
||||
raise ValueError("invalid workload limit or thread count")
|
||||
commands = ["uci", "setoption name Hash value 64",
|
||||
f"setoption name Threads value {threads}",
|
||||
"setoption name UCI_Chess960 value true",
|
||||
"setoption name MoveOverhead value 0"]
|
||||
go = "nodes" if limit_kind == "nodes" else "movetime"
|
||||
for fen in positions:
|
||||
if "\n" in fen or "\r" in fen or len(fen.split()) != 6:
|
||||
raise ValueError("expected one six-field FEN per position")
|
||||
commands += ["ucinewgame", f"position fen {fen}", f"go {go} {limit}", "wait"]
|
||||
return "\n".join([*commands, "quit", ""])
|
||||
|
||||
|
||||
def parse_output(output, count):
|
||||
games = re.split(r"^bestmove .*$", output, flags=re.MULTILINE)
|
||||
bestmoves = re.findall(r"^bestmove (.*)$", output, re.MULTILINE)
|
||||
if len(games) != count + 1:
|
||||
raise RuntimeError(f"expected {count} completed searches, got {len(games) - 1}")
|
||||
result = []
|
||||
for game, bestmove in zip(games[:-1], bestmoves):
|
||||
lines = re.findall(r"^info depth (\d+) .*\btime (\d+) nodes (\d+)\b.*$", game, re.MULTILINE)
|
||||
if not lines:
|
||||
raise RuntimeError("search has no completed info summary")
|
||||
depth, milliseconds, nodes = map(int, lines[-1])
|
||||
if not nodes or not milliseconds:
|
||||
raise RuntimeError("workload search is too short or terminal; use another corpus/budget")
|
||||
result.append({"nodes": nodes, "milliseconds": milliseconds,
|
||||
"depth": depth, "bestmove": bestmove})
|
||||
return result
|
||||
|
||||
|
||||
def run_workload(binary, positions, limit_kind="nodes", limit=200000, threads=1,
|
||||
cpu=None, timeout=300, counters=False):
|
||||
commands = commands_for(positions, limit_kind, limit, threads)
|
||||
command = [str(binary)]
|
||||
if cpu is not None:
|
||||
command = ["taskset", "-c", str(cpu), *command]
|
||||
if counters:
|
||||
command = ["perf", "stat", "-x", "\t", "--no-big-num", "-e",
|
||||
"instructions:u,cycles:u,branches:u,branch-misses:u", "--", *command]
|
||||
started = time.perf_counter()
|
||||
process = subprocess.Popen(
|
||||
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, start_new_session=os.name == "posix",
|
||||
env={**os.environ, "NO_COLOR": "1", "NO_LOGO": "1"},
|
||||
)
|
||||
try:
|
||||
stdout, stderr = process.communicate(commands, timeout=timeout)
|
||||
except (subprocess.TimeoutExpired, KeyboardInterrupt):
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.communicate()
|
||||
raise
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"workload exited {process.returncode}:\n{stdout}\n{stderr}")
|
||||
searches = parse_output(stdout, len(positions))
|
||||
nodes = sum(search["nodes"] for search in searches)
|
||||
seconds = sum(search["milliseconds"] for search in searches) / 1000
|
||||
sample = {"nodes": nodes, "nps": nodes / seconds, "search_seconds": seconds,
|
||||
"wall_seconds": time.perf_counter() - started, "time": time.time(),
|
||||
"searches": searches}
|
||||
if counters:
|
||||
sample["counters"] = {}
|
||||
for line in stderr.splitlines():
|
||||
fields = line.split("\t")
|
||||
if len(fields) > 2 and fields[2] in ("instructions:u", "cycles:u", "branches:u", "branch-misses:u"):
|
||||
sample["counters"][fields[2]] = float(fields[0])
|
||||
if len(sample["counters"]) != 4:
|
||||
raise RuntimeError(f"missing perf counters:\n{stderr}")
|
||||
return sample
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
parser.add_argument("--positions", type=Path, required=True)
|
||||
parser.add_argument("--count", type=int, default=24)
|
||||
parser.add_argument("--offset", type=int, default=0)
|
||||
parser.add_argument("--stride", type=int, default=1)
|
||||
parser.add_argument("--limit-kind", choices=("nodes", "time"), default="nodes")
|
||||
parser.add_argument("--limit", type=int, default=200000)
|
||||
parser.add_argument("--threads", type=int, default=1)
|
||||
parser.add_argument("--cpu", type=int)
|
||||
args = parser.parse_args()
|
||||
positions = load_positions(args.positions, args.count, args.offset, args.stride)
|
||||
sample = run_workload(args.binary.resolve(strict=True), positions, args.limit_kind,
|
||||
args.limit, args.threads, args.cpu)
|
||||
print(f"{sample['nodes']} nodes {round(sample['nps'])} nps")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
204
src/heimdall.nim
204
src/heimdall.nim
@@ -11,11 +11,12 @@
|
||||
# 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.
|
||||
import std/[os, math, times, atomics, parseopt, strutils, strformat, options, random]
|
||||
import std/[os, math, times, monotimes, atomics, parseopt, strutils, strformat, options, random]
|
||||
|
||||
import heimdall/[moves, board, search, movegen, position, transpositions, eval]
|
||||
import heimdall/util/[magics, limits, tunables, book_augment, logs]
|
||||
import heimdall/util/[magics, limits, tunables, book_augment, logs, scharnagl, relabel as relabelUtil]
|
||||
import heimdall/uci/session
|
||||
import heimdall/util/simd_dispatch
|
||||
|
||||
|
||||
when not defined(windows):
|
||||
@@ -29,8 +30,7 @@ const benchFens = staticRead("heimdall/resources/misc/bench.txt").splitLines()
|
||||
|
||||
|
||||
proc runBench(depth: int = 13, threads: int = 1, silent: bool = false) =
|
||||
var transpositionTable = create(TranspositionTable)
|
||||
transpositionTable[] = newTranspositionTable(64 * 1024 * 1024)
|
||||
let transpositionTable = newTranspositionTable(64 * 1024 * 1024)
|
||||
var mgr = newSearchManager(@[startpos()], transpositionTable)
|
||||
mgr.limiter.addLimit(newDepthLimit(depth))
|
||||
mgr.logger.setColor(not existsEnv("NO_COLOR"))
|
||||
@@ -42,7 +42,9 @@ proc runBench(depth: int = 13, threads: int = 1, silent: bool = false) =
|
||||
var
|
||||
nodes = 0'u64
|
||||
bestMoveTotalNodes = 0'u64
|
||||
let startTime = cpuTime()
|
||||
let
|
||||
startTime = cpuTime()
|
||||
startWall = getMonoTime()
|
||||
for i, fen in benchFens:
|
||||
if not silent:
|
||||
echo &"Position {i + 1}/{len(benchFens)}: {fen}\n"
|
||||
@@ -65,19 +67,140 @@ proc runBench(depth: int = 13, threads: int = 1, silent: bool = false) =
|
||||
echo &"info string fraction of nodes spent on best move for this position: {round(bestMoveFrac * 100, 2)}% ({bestMoveNodes}/{totalNodes})"
|
||||
echo ""
|
||||
let
|
||||
endTime = cpuTime() - startTime
|
||||
# Process CPU time sums all search threads and understates parallel NPS.
|
||||
# Keep CPU timing for deterministic single-thread comparisons; use elapsed
|
||||
# monotonic time when measuring throughput across multiple threads.
|
||||
endTime = if threads == 1: cpuTime() - startTime
|
||||
else: (getMonoTime() - startWall).inNanoseconds().float / 1_000_000_000
|
||||
bestMoveFrac = bestMoveTotalNodes.float / nodes.float
|
||||
if not silent:
|
||||
echo &"info string fraction of nodes spent on best move for this bench: {round(bestMoveFrac * 100, 2)}% ({bestMoveTotalNodes}/{nodes})"
|
||||
echo &"{nodes} nodes {round(nodes.float / endTime).int} nps"
|
||||
|
||||
|
||||
proc runGenfens(command: string) =
|
||||
## Generate opening positions for OpenBench's datagen interface.
|
||||
##
|
||||
## OpenBench passes this as one quoted argument, rather than as ordinary
|
||||
## command-line options: `genfens N seed S book PATH [extra arguments]`.
|
||||
let args = command.splitWhitespace()
|
||||
if args.len < 6 or args[0] != "genfens" or args[2] != "seed" or args[4] != "book":
|
||||
stderr.writeLine("heimdall: genfens: expected 'genfens N seed S book PATH [options]'")
|
||||
quit(-1)
|
||||
|
||||
var
|
||||
count: int
|
||||
seed: uint64
|
||||
plies = none(int)
|
||||
dfrc = false
|
||||
book: seq[Position] = @[]
|
||||
|
||||
try:
|
||||
count = args[1].parseInt()
|
||||
seed = args[3].parseBiggestUInt()
|
||||
if count < 0:
|
||||
raise newException(ValueError, "count must not be negative")
|
||||
except ValueError:
|
||||
stderr.writeLine("heimdall: genfens: invalid count or seed")
|
||||
quit(-1)
|
||||
|
||||
# Extra arguments are intentionally simple and extensible. OpenBench
|
||||
# forwards this part verbatim, so accepting `plies N` and `moves N` gives
|
||||
# callers a useful way to control the generated line without changing the
|
||||
# fixed interface.
|
||||
var i = 6
|
||||
while i < args.len:
|
||||
if args[i] in ["plies", "moves", "depth"] and i + 1 < args.len:
|
||||
try:
|
||||
plies = some(args[i + 1].parseInt())
|
||||
except ValueError:
|
||||
stderr.writeLine(&"heimdall: genfens: invalid {args[i]} value")
|
||||
quit(-1)
|
||||
inc(i, 2)
|
||||
elif args[i] == "dfrc":
|
||||
if i + 1 >= args.len or args[i + 1] notin ["true", "false"]:
|
||||
stderr.writeLine("heimdall: genfens: dfrc requires true or false")
|
||||
quit(-1)
|
||||
dfrc = args[i + 1] == "true"
|
||||
inc(i, 2)
|
||||
else:
|
||||
inc(i)
|
||||
if plies.isSome() and plies.get() < 0:
|
||||
stderr.writeLine("heimdall: genfens: plies must not be negative")
|
||||
quit(-1)
|
||||
|
||||
# An explicit DFRC request selects fresh starting positions instead of a book.
|
||||
if not dfrc and args[5].toLowerAscii() != "none":
|
||||
try:
|
||||
for line in lines(args[5]):
|
||||
let fields = line.strip().splitWhitespace()
|
||||
if fields.len < 4 or line.strip().startsWith("#"):
|
||||
continue
|
||||
var halfmove = 0
|
||||
var fullmove = 1
|
||||
# Books used by OpenBench are EPD files. Convert their hmvc
|
||||
# and fmvn operations to the two trailing FEN fields.
|
||||
var j = 4
|
||||
while j < fields.len:
|
||||
if fields[j] == "hmvc" and j + 1 < fields.len:
|
||||
halfmove = fields[j + 1].strip(chars = {';'}).parseInt()
|
||||
elif fields[j] == "fmvn" and j + 1 < fields.len:
|
||||
fullmove = fields[j + 1].strip(chars = {';'}).parseInt()
|
||||
inc(j)
|
||||
book.add(fromFEN(fields[0..3].join(" ") & &" {halfmove} {fullmove}"))
|
||||
except CatchableError:
|
||||
stderr.writeLine(&"heimdall: genfens: could not read book '{args[5]}': {getCurrentExceptionMsg()}")
|
||||
quit(-1)
|
||||
|
||||
var picker = initRand(seed.int64)
|
||||
for _ in 0..<count:
|
||||
let initial =
|
||||
if dfrc:
|
||||
fromFEN(scharnaglToFEN(picker.rand(0..959), picker.rand(0..959)))
|
||||
elif book.len == 0:
|
||||
startpos()
|
||||
else:
|
||||
book[picker.rand(0 ..< book.len)]
|
||||
# Draw separately for each opening, using the same seeded RNG as the
|
||||
# starting position and moves. Explicit lengths consume no extra draw.
|
||||
let openingPlies = if plies.isSome(): plies.get() else: picker.rand(8..9)
|
||||
var board = newChessboard(@[initial])
|
||||
var moves = newMoveList()
|
||||
for _ in 0..<openingPlies:
|
||||
moves.clear()
|
||||
board.generateMoves(moves)
|
||||
if moves.len == 0:
|
||||
break
|
||||
board.doMove(moves[picker.rand(0 ..< moves.len.int)])
|
||||
echo &"info string genfens {board.position.toFEN(chess960=dfrc)}"
|
||||
|
||||
|
||||
when isMainModule:
|
||||
setControlCHook(proc () {.noconv.} = echo ""; quit(0))
|
||||
basicTests()
|
||||
let rawArgs = commandLineParams()
|
||||
if rawArgs == @["simd"]:
|
||||
printSimdInfo()
|
||||
quit(0)
|
||||
if rawArgs.len > 0:
|
||||
# OpenBench invokes genfens as a quoted command string and appends a
|
||||
# second quoted `quit` command. Handle that protocol before parseopt,
|
||||
# whose normal option grammar deliberately rejects space-separated
|
||||
# values for Heimdall's existing subcommands.
|
||||
if rawArgs[0].startsWith("genfens "):
|
||||
runGenfens(rawArgs[0])
|
||||
quit(0)
|
||||
elif rawArgs[0] == "genfens":
|
||||
var stopAt = rawArgs.len
|
||||
for i in 1..<rawArgs.len:
|
||||
if rawArgs[i] == "quit":
|
||||
stopAt = i
|
||||
break
|
||||
runGenfens(rawArgs[0..<stopAt].join(" "))
|
||||
quit(0)
|
||||
# This is horrible, but it works so ¯\_(ツ)_/¯
|
||||
var
|
||||
parser = initOptParser(commandLineParams())
|
||||
parser = initOptParser(rawArgs)
|
||||
augment = false
|
||||
magicGen = false
|
||||
runUCI = true
|
||||
@@ -104,9 +227,17 @@ when isMainModule:
|
||||
limit = 0
|
||||
skip = 0
|
||||
rounds = 1
|
||||
# Parameters for viriformat relabelling
|
||||
relabel = false
|
||||
relabelInput = none(string)
|
||||
relabelOutput = none(string)
|
||||
relabelDepth = none(int)
|
||||
relabelSoftNodesProvided = false
|
||||
relabelChunk = 1024
|
||||
relabelJoin = false
|
||||
|
||||
var runTUI = false
|
||||
const subcommands = ["magics", "testonly", "bench", "spsa", "chonk", "tui"]
|
||||
const subcommands = ["magics", "testonly", "bench", "spsa", "chonk", "relabel", "tui"]
|
||||
for kind, key, value in parser.getopt():
|
||||
case kind:
|
||||
of cmdArgument:
|
||||
@@ -118,7 +249,7 @@ when isMainModule:
|
||||
benchDepth = key.parseInt()
|
||||
continue
|
||||
|
||||
let inSubCommand = bench or getParams or magicGen or testOnly or augment
|
||||
let inSubCommand = bench or getParams or magicGen or testOnly or augment or relabel
|
||||
|
||||
if key in subcommands and inSubCommand:
|
||||
stderr.writeLine(&"heimdall: error: '{prevSubCmd}' subcommand does not accept any arguments")
|
||||
@@ -147,6 +278,9 @@ when isMainModule:
|
||||
of "chonk":
|
||||
# Hehe me make chonky book
|
||||
augment = true
|
||||
of "relabel":
|
||||
runUCI = false
|
||||
relabel = true
|
||||
of "tui":
|
||||
runUCI = false
|
||||
runTUI = true
|
||||
@@ -165,7 +299,8 @@ when isMainModule:
|
||||
benchSilent = true
|
||||
else:
|
||||
stderr.writeLine(&"heimdall: bench: error: unknown long option '{key}'")
|
||||
if augment:
|
||||
quit(-1)
|
||||
elif augment:
|
||||
case key:
|
||||
of "input":
|
||||
inputBook = some(value)
|
||||
@@ -207,6 +342,34 @@ when isMainModule:
|
||||
else:
|
||||
stderr.writeLine(&"heimdall: chonk: error: unknown long option '{key}'")
|
||||
quit(-1)
|
||||
elif relabel:
|
||||
case key:
|
||||
of "input":
|
||||
relabelInput = some(value)
|
||||
of "output":
|
||||
relabelOutput = some(value)
|
||||
of "nodes-soft":
|
||||
searcherNodes.soft = parseBiggestUInt(value)
|
||||
relabelSoftNodesProvided = true
|
||||
of "nodes-hard":
|
||||
searcherNodes.hard = parseBiggestUInt(value)
|
||||
of "hash":
|
||||
searcherHash = parseBiggestUInt(value)
|
||||
of "depth":
|
||||
relabelDepth = some(parseInt(value))
|
||||
of "threads":
|
||||
threads = parseInt(value)
|
||||
of "chunk-size":
|
||||
relabelChunk = parseInt(value)
|
||||
of "limit":
|
||||
limit = parseInt(value)
|
||||
of "skip":
|
||||
skip = parseInt(value)
|
||||
of "join":
|
||||
relabelJoin = true
|
||||
else:
|
||||
stderr.writeLine(&"heimdall: relabel: error: unknown long option '{key}'")
|
||||
quit(-1)
|
||||
else:
|
||||
stderr.writeLine(&"heimdall: error: unknown long option '{key}'")
|
||||
quit(-1)
|
||||
@@ -228,7 +391,26 @@ when isMainModule:
|
||||
quit(-1)
|
||||
of cmdEnd:
|
||||
break
|
||||
if not magicGen and not augment:
|
||||
if relabel:
|
||||
if not relabelInput.isSome() or not relabelOutput.isSome():
|
||||
stderr.writeLine("heimdall: relabel: error: --input and --output are required")
|
||||
quit(-1)
|
||||
try:
|
||||
relabelViriformat(relabelInput.get(), relabelOutput.get(), RelabelConfig(
|
||||
depth: relabelDepth,
|
||||
nodes: searcherNodes,
|
||||
softNodesProvided: relabelSoftNodesProvided,
|
||||
hashMiB: searcherHash,
|
||||
threads: threads,
|
||||
chunkSize: relabelChunk,
|
||||
skip: skip,
|
||||
limit: limit,
|
||||
join: relabelJoin
|
||||
))
|
||||
except CatchableError:
|
||||
stderr.writeLine(&"heimdall: relabel: error: {getCurrentExceptionMsg()}")
|
||||
quit(-1)
|
||||
elif not magicGen and not augment:
|
||||
if runTUI:
|
||||
when defined(windows):
|
||||
stderr.writeLine("heimdall: the built-in TUI is disabled on Windows because termios.h is unavailable")
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## Implements low-level bit operations
|
||||
|
||||
import std/[sugar, bitops, strutils]
|
||||
import std/[bitops, strutils]
|
||||
|
||||
import heimdall/[moves, pieces]
|
||||
|
||||
@@ -34,6 +34,13 @@ type
|
||||
BackwardLeft,
|
||||
BackwardRight
|
||||
|
||||
|
||||
{.push.}
|
||||
# Nim is big stupid and we need operators that work on more than
|
||||
# just the type itself.
|
||||
when (NimMajor, NimMinor, NimPatch) >= (2, 2, 12):
|
||||
{.warning[InvalidCmpOp]:off.}
|
||||
|
||||
func `shl`*(a: Bitboard, x: Natural): Bitboard {.borrow, inline.}
|
||||
func `shr`*(a: Bitboard, x: Natural): Bitboard {.borrow, inline.}
|
||||
func `and`*(a, b: Bitboard): Bitboard {.borrow, inline.}
|
||||
@@ -73,6 +80,8 @@ func toBitboard*(square: SomeInteger): Bitboard {.inline.} = Bitboard(1'u64) shl
|
||||
func toBitboard*(square: Square): Bitboard {.inline.} = square.int8.toBitboard()
|
||||
func toSquare*(b: Bitboard): Square {.inline.} = Square(b.countTrailingZeroBits())
|
||||
|
||||
{.pop.}
|
||||
|
||||
func lowestBit*(self: Bitboard): Bitboard {.inline.} =
|
||||
{.push overflowChecks:off.}
|
||||
result = self and Bitboard(-cast[int64](self))
|
||||
@@ -147,31 +156,30 @@ func pretty*(self: Bitboard): string =
|
||||
|
||||
func `$`*(self: Bitboard): string {.inline.} = self.pretty()
|
||||
|
||||
func generateShifters: array[White..Black, array[Direction, (Bitboard {.noSideEffect.} -> Bitboard)]] {.compileTime.} =
|
||||
result[White][Forward] = (x: Bitboard) => x shr 8
|
||||
result[White][Backward] = (x: Bitboard) => x shl 8
|
||||
result[White][Left] = (x: Bitboard) => x shr 1
|
||||
result[White][Right] = (x: Bitboard) => x shl 1
|
||||
result[White][ForwardRight] = (x: Bitboard) => x shr 7
|
||||
result[White][ForwardLeft] = (x: Bitboard) => x shr 9
|
||||
result[White][BackwardRight] = (x: Bitboard) => x shl 9
|
||||
result[White][BackwardLeft] = (x: Bitboard) => x shl 7
|
||||
|
||||
result[Black][Backward] = (x: Bitboard) => x shr 8
|
||||
result[Black][Forward] = (x: Bitboard) => x shl 8
|
||||
result[Black][Right] = (x: Bitboard) => x shr 1
|
||||
result[Black][Left] = (x: Bitboard) => x shl 1
|
||||
result[Black][BackwardLeft] = (x: Bitboard) => x shr 7
|
||||
result[Black][BackwardRight] = (x: Bitboard) => x shr 9
|
||||
result[Black][ForwardLeft] = (x: Bitboard) => x shl 9
|
||||
result[Black][ForwardRight] = (x: Bitboard) => x shl 7
|
||||
|
||||
|
||||
const shifters: array[White..Black, array[Direction, (Bitboard) {.noSideEffect.} -> Bitboard]] = generateShifters()
|
||||
|
||||
|
||||
func directionMask*(bitboard: Bitboard, color: PieceColor, direction: Direction): Bitboard {.inline.} =
|
||||
shifters[color][direction](bitboard)
|
||||
case color:
|
||||
of White:
|
||||
case direction:
|
||||
of Forward: result = bitboard shr 8
|
||||
of Backward: result = bitboard shl 8
|
||||
of Left: result = bitboard shr 1
|
||||
of Right: result = bitboard shl 1
|
||||
of ForwardRight: result = bitboard shr 7
|
||||
of ForwardLeft: result = bitboard shr 9
|
||||
of BackwardRight: result = bitboard shl 9
|
||||
of BackwardLeft: result = bitboard shl 7
|
||||
of Black:
|
||||
case direction:
|
||||
of Forward: result = bitboard shl 8
|
||||
of Backward: result = bitboard shr 8
|
||||
of Left: result = bitboard shl 1
|
||||
of Right: result = bitboard shr 1
|
||||
of ForwardRight: result = bitboard shl 7
|
||||
of ForwardLeft: result = bitboard shl 9
|
||||
of BackwardRight: result = bitboard shr 9
|
||||
of BackwardLeft: result = bitboard shr 7
|
||||
of None:
|
||||
result = Bitboard(0)
|
||||
|
||||
func directionMask*(square: Square, color: PieceColor, direction: Direction): Bitboard {.inline.} =
|
||||
directionMask(square.toBitboard(), color, direction)
|
||||
@@ -238,7 +246,7 @@ func shortKnightDownRight*(self: Bitboard, side: PieceColor): Bitboard {.inline.
|
||||
# We precompute as much stuff as possible: lookup tables are fast!
|
||||
|
||||
func computeKingBitboards: array[Square.smallest()..Square.biggest(), Bitboard] {.compileTime.} =
|
||||
for i in Square.all():
|
||||
for i in Square.items():
|
||||
let king = i.toBitboard()
|
||||
# It doesn't really matter which side we generate
|
||||
# the move for, they're identical for both
|
||||
@@ -259,7 +267,7 @@ func computeKingBitboards: array[Square.smallest()..Square.biggest(), Bitboard]
|
||||
|
||||
|
||||
func computeKnightBitboards: array[Square.smallest()..Square.biggest(), Bitboard] {.compileTime.} =
|
||||
for i in Square.all():
|
||||
for i in Square.items():
|
||||
let knight = i.toBitboard()
|
||||
# It doesn't really matter which side we generate
|
||||
# the move for, they're identical for both
|
||||
@@ -276,12 +284,12 @@ func computeKnightBitboards: array[Square.smallest()..Square.biggest(), Bitboard
|
||||
|
||||
|
||||
func computePawnAttackers(color: PieceColor): array[Square.smallest()..Square.biggest(), Bitboard] {.compileTime.} =
|
||||
for i in Square.all():
|
||||
for i in Square.items():
|
||||
let pawn = i.toBitboard()
|
||||
result[i] = pawn.backwardLeft(color) or pawn.backwardRight(color)
|
||||
|
||||
func computePawnAttacks(color: PieceColor): array[Square.smallest()..Square.biggest(), Bitboard] {.compileTime.} =
|
||||
for i in Square.all():
|
||||
for i in Square.items():
|
||||
let pawn = i.toBitboard()
|
||||
result[i] = pawn.forwardLeft(color) or pawn.forwardRight(color)
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@ func minorKey*(self: Chessboard): ZobristKey {.inline.} =
|
||||
func inCheck*(self: Chessboard): bool {.inline.} =
|
||||
return self.position.inCheck()
|
||||
|
||||
proc threats*(self: Chessboard): Bitboard {.inline.} =
|
||||
return self.positions[^1].threats()
|
||||
|
||||
proc canCastle*(self: Chessboard): tuple[queen, king: Square] {.inline.} =
|
||||
return self.position.canCastle()
|
||||
|
||||
@@ -183,4 +186,4 @@ func drawnByRepetition*(self: Chessboard, ply: int): bool {.inline.} =
|
||||
if self.positions[i].halfMoveClock == 0:
|
||||
return false
|
||||
dec(ply, 2)
|
||||
return false
|
||||
return false
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
# limitations under the License.
|
||||
|
||||
## Position evaluation utilities
|
||||
import heimdall/threats/[index, updates]
|
||||
import heimdall/[board, moves, pieces, position, nnue]
|
||||
import heimdall/util/memory/thp/alloc
|
||||
when defined(simd):
|
||||
import heimdall/util/simd_dispatch
|
||||
import std/typetraits
|
||||
|
||||
when defined(simd):
|
||||
import heimdall/util/simd
|
||||
@@ -23,13 +27,15 @@ when not VERBATIM_NET:
|
||||
import std/streams
|
||||
|
||||
|
||||
const MAX_ACCUMULATORS = 255
|
||||
# One root accumulator plus the 255 plies that search can evaluate.
|
||||
const MAX_ACCUMULATORS = 256
|
||||
|
||||
type
|
||||
|
||||
Score* = int32
|
||||
|
||||
Accumulator = object
|
||||
# Shared by the separate PSQ and TI stacks
|
||||
data {.align(ALIGNMENT_BOUNDARY).}: array[L1_SIZE, int16]
|
||||
kingSquare: Square
|
||||
|
||||
@@ -39,7 +45,7 @@ type
|
||||
pieces: array[Pawn..King, Bitboard]
|
||||
|
||||
# A record for an efficient update
|
||||
Update = tuple[move: Move, sideToMove: PieceColor, piece, captured: PieceKind, needsRefresh: array[White..Black, bool], posIndex: int]
|
||||
Update = tuple[move: Move, sideToMove: PieceColor, piece, captured: PieceKind, needsRefresh: bool, posIndex: int]
|
||||
|
||||
# The accumulator stack alone is well over a megabyte and is read/written on
|
||||
# every node of the search, so the eval state is allocated on 2MB huge pages
|
||||
@@ -49,8 +55,12 @@ type
|
||||
EvalStateObj = object
|
||||
# Current accumulator
|
||||
current: int
|
||||
# Accumulator stack. We keep one per ply
|
||||
# Accumulator stacks. We keep one per ply
|
||||
# Separating threat and PSQ accumulators allows
|
||||
# us to keep efficiently updating threats even
|
||||
# after a PSQ refresh
|
||||
accumulators: array[White..Black, array[MAX_ACCUMULATORS, Accumulator]]
|
||||
threatAccumulators: array[White..Black, array[MAX_ACCUMULATORS, Accumulator]]
|
||||
# Pending updates
|
||||
updates: array[MAX_ACCUMULATORS, Update]
|
||||
# Number of pending updates
|
||||
@@ -71,7 +81,8 @@ type
|
||||
## Unique owner of a huge-page-backed eval state. Holding one keeps the
|
||||
## underlying EvalState alive; dropping it releases the huge pages.
|
||||
|
||||
AlignedArray[K: static[int], T] = object
|
||||
when defined(simd):
|
||||
type AlignedArray[K: static[int], T] = object
|
||||
data {.align(ALIGNMENT_BOUNDARY).}: array[K, T]
|
||||
|
||||
|
||||
@@ -117,22 +128,30 @@ proc newEvalState*(networkPath: string = "", verbose: static bool = true): EvalS
|
||||
network = loadNet(networkPath)
|
||||
|
||||
|
||||
proc copyFrom*(self: EvalState, source: EvalState, board: Chessboard) =
|
||||
## Reuse this state's storage, copying only the live accumulator/update
|
||||
## prefixes. Frames beyond current are overwritten before they are read.
|
||||
## Both states must be idle; each search worker owns its destination.
|
||||
static:
|
||||
doAssert supportsCopyMem(Accumulator) and supportsCopyMem(Update)
|
||||
if self != source:
|
||||
self.current = source.current
|
||||
self.pending = source.pending
|
||||
for side in White..Black:
|
||||
copyMem(addr self.accumulators[side][0], addr source.accumulators[side][0],
|
||||
(source.current + 1) * sizeof(Accumulator))
|
||||
copyMem(addr self.threatAccumulators[side][0], addr source.threatAccumulators[side][0],
|
||||
(source.current + 1) * sizeof(Accumulator))
|
||||
copyMem(addr self.updates[0], addr source.updates[0], source.pending * sizeof(Update))
|
||||
self.cache = source.cache
|
||||
self.board = board
|
||||
|
||||
|
||||
proc clone*(self: EvalState, board: Chessboard): EvalStateOwner =
|
||||
## Creates an independent, huge-page-backed copy of the given eval state,
|
||||
## bound to the provided board. This replaces the previous deepCopy() of the
|
||||
## ref-based state: every worker needs its own accumulator stack so the
|
||||
## threads don't stomp on each other. The accumulators are copied as-is
|
||||
## (they get refreshed by init() on the next setBoard()), and the board is
|
||||
## bound here so the clone is immediately usable even if a search starts
|
||||
## before the next setBoard().
|
||||
# zero = true: the managed board ref must start nil before it is assigned
|
||||
## Create independently owned storage for the live evaluation state.
|
||||
# The managed board ref must start nil before assignment.
|
||||
result = allocHugePage[EvalStateObj](zero = true)
|
||||
result.raw.current = self.current
|
||||
result.raw.pending = self.pending
|
||||
result.raw.accumulators = self.accumulators
|
||||
result.raw.updates = self.updates
|
||||
result.raw.cache = self.cache
|
||||
result.raw.board = board
|
||||
result.raw.copyFrom(self, board)
|
||||
|
||||
|
||||
func shouldMirror(kingSq: Square): bool {.inline.} =
|
||||
@@ -148,12 +167,15 @@ proc kingBucket*(side: PieceColor, square: Square): int {.inline.} =
|
||||
## Returns the input bucket associated with the king
|
||||
## of the given side located at the given square
|
||||
|
||||
# We flip for white instead of black because the
|
||||
# bucket layout assumes a1=0 and we use a8=0 instead
|
||||
if side == White:
|
||||
return INPUT_BUCKETS[square.flipRank()]
|
||||
when NUM_INPUT_BUCKETS == 1:
|
||||
return 0
|
||||
else:
|
||||
return INPUT_BUCKETS[square]
|
||||
# We flip for white instead of black because the
|
||||
# bucket layout assumes a1=0 and we use a8=0 instead
|
||||
if side == White:
|
||||
return INPUT_BUCKETS[square.flipRank()]
|
||||
else:
|
||||
return INPUT_BUCKETS[square]
|
||||
|
||||
|
||||
func feature(perspective: PieceColor, color: PieceColor, piece: PieceKind, square, kingSquare: Square): int =
|
||||
@@ -195,9 +217,9 @@ proc mustRefresh(self: EvalState, side: PieceColor, prevKingSq, currKingSq: Squa
|
||||
return kingBucket(side, prevKingSq) != kingBucket(side, currKingSq)
|
||||
|
||||
|
||||
proc refresh(self: EvalState, side: PieceColor, position: Position, useCache: static bool = true) =
|
||||
## Performs an accumulator refresh for the given
|
||||
## side
|
||||
proc refreshPSQ(self: EvalState, side: PieceColor, position: Position, useCache: static bool = true) =
|
||||
## Performs an accumulator refresh for the PSQ part
|
||||
## of the network, for the given side
|
||||
|
||||
let
|
||||
kingSq = position.kingSquare(side)
|
||||
@@ -260,6 +282,16 @@ proc refresh(self: EvalState, side: PieceColor, position: Position, useCache: st
|
||||
self.accumulators[side][self.current] = self.cache[side][bucket][mirror].acc
|
||||
|
||||
|
||||
proc refreshThreats(self: EvalState, side: PieceColor, position: Position) =
|
||||
# On the 256, Mr. Jonathan Hallström had this to say about it:
|
||||
# "thats probably enough" - someone
|
||||
var indices: array[256, uint16]
|
||||
let n = indices.collectRefreshThreats(position, side)
|
||||
# toOpenArray(a, b) includes both ends.
|
||||
self.threatAccumulators[side][self.current].data.applyAllRowsZeroed(
|
||||
network.threatWeights, indices.toOpenArray(0, n.int - 1))
|
||||
|
||||
|
||||
proc resetCache(self: EvalState) {.inline.} =
|
||||
for side in White..Black:
|
||||
for bucket in 0..<NUM_INPUT_BUCKETS:
|
||||
@@ -279,22 +311,23 @@ proc init*(self: EvalState, board: Chessboard) =
|
||||
self.pending = 0
|
||||
self.board = board
|
||||
self.resetCache()
|
||||
self.refresh(White, board.position)
|
||||
self.refresh(Black, board.position)
|
||||
for side in White..Black:
|
||||
self.refreshPSQ(side, board.position)
|
||||
self.refreshThreats(side, board.position)
|
||||
|
||||
|
||||
func getKingCastlingTarget(move: Move, sideToMove: PieceColor): Square {.inline.} =
|
||||
if move.targetSquare < move.startSquare:
|
||||
return Piece(kind: King, color: sideToMove).longCastling()
|
||||
return createPiece(kind=King, color=sideToMove).longCastling()
|
||||
else:
|
||||
return Piece(kind: King, color: sideToMove).shortCastling()
|
||||
return createPiece(kind=King, color=sideToMove).shortCastling()
|
||||
|
||||
|
||||
func getRookCastlingTarget(move: Move, sideToMove: PieceColor): Square {.inline.} =
|
||||
if move.targetSquare < move.startSquare:
|
||||
return Piece(kind: Rook, color: sideToMove).longCastling()
|
||||
return createPiece(kind=Rook, color=sideToMove).longCastling()
|
||||
else:
|
||||
return Piece(kind: Rook, color: sideToMove).shortCastling()
|
||||
return createPiece(kind=Rook, color=sideToMove).shortCastling()
|
||||
|
||||
|
||||
func getNextKingSquare(move: Move, piece: PieceKind, sideToMove: PieceColor, previousKingSq: Square): Square {.inline.} =
|
||||
@@ -313,15 +346,14 @@ proc update*(self: EvalState, move: Move, sideToMove: PieceColor, piece: PieceKi
|
||||
# are relative to its own king, which is the only one that can have moved.
|
||||
# The opponent's accumulator sees our king as a regular piece and is always
|
||||
# updated incrementally
|
||||
var needsRefresh: array[White..Black, bool]
|
||||
needsRefresh[sideToMove] = self.mustRefresh(sideToMove, kingSq, nextKingSq)
|
||||
let needsRefresh = self.mustRefresh(sideToMove, kingSq, nextKingSq)
|
||||
# We use len() instead of high() because update() is called before the move is made, so the length of the sequence
|
||||
# will be the index of the next position once doMove is called
|
||||
self.updates[self.pending] = (move, sideToMove, piece, captured, needsRefresh, self.board.positions.len())
|
||||
inc(self.pending)
|
||||
|
||||
|
||||
proc applyUpdate(self: EvalState, color: PieceColor, move: Move, sideToMove: PieceColor, piece: PieceKind, captured=Empty) =
|
||||
proc applyPSQUpdate(self: EvalState, color: PieceColor, move: Move, sideToMove: PieceColor, piece: PieceKind, captured=Empty) =
|
||||
## Updates the accumulators for the given color with the given move
|
||||
## made by the given side with the given piece type. If the move is
|
||||
## a capture, the captured piece type is expected as the captured argument
|
||||
@@ -357,6 +389,48 @@ proc applyUpdate(self: EvalState, color: PieceColor, move: Move, sideToMove: Pie
|
||||
queue.apply(network.ft, self.accumulators[color][self.current - 1].data, self.accumulators[color][self.current].data)
|
||||
|
||||
|
||||
proc applyPSQUpdatePair(self: EvalState, move: Move, sideToMove: PieceColor, piece: PieceKind, captured=Empty) =
|
||||
## Update both PSQ accumulator perspectives with one shared move decode. The
|
||||
## feature rows differ by perspective, but the move kind and control flow
|
||||
## are identical and need only be worked out once.
|
||||
for color in White..Black:
|
||||
self.accumulators[color][self.current].kingSquare = self.accumulators[color][self.current - 1].kingSquare
|
||||
|
||||
let nonSideToMove = sideToMove.opposite()
|
||||
template oldAcc(color: PieceColor): untyped = self.accumulators[color][self.current - 1].data
|
||||
template newAcc(color: PieceColor): untyped = self.accumulators[color][self.current].data
|
||||
template kingSq(color: PieceColor): untyped = self.accumulators[color][self.current].kingSquare
|
||||
|
||||
if not move.isCastling():
|
||||
let
|
||||
newPiece = if not move.isPromotion(): piece else: move.flag().promotionToPiece()
|
||||
whiteNew = feature(White, sideToMove, newPiece, move.targetSquare, kingSq(White))
|
||||
whiteMoving = feature(White, sideToMove, piece, move.startSquare, kingSq(White))
|
||||
blackNew = feature(Black, sideToMove, newPiece, move.targetSquare, kingSq(Black))
|
||||
blackMoving = feature(Black, sideToMove, piece, move.startSquare, kingSq(Black))
|
||||
|
||||
if move.isQuiet() or (not move.isCapture() and move.isPromotion()):
|
||||
network.ft.addSub(whiteNew, whiteMoving, oldAcc(White), newAcc(White))
|
||||
network.ft.addSub(blackNew, blackMoving, oldAcc(Black), newAcc(Black))
|
||||
else:
|
||||
let
|
||||
whiteCaptured = feature(White, nonSideToMove, captured, move.captureSquare(), kingSq(White))
|
||||
blackCaptured = feature(Black, nonSideToMove, captured, move.captureSquare(), kingSq(Black))
|
||||
network.ft.addSubSub(whiteNew, whiteMoving, whiteCaptured, oldAcc(White), newAcc(White))
|
||||
network.ft.addSubSub(blackNew, blackMoving, blackCaptured, oldAcc(Black), newAcc(Black))
|
||||
else:
|
||||
network.ft.addSubAddSub(
|
||||
feature(White, sideToMove, King, move.getKingCastlingTarget(sideToMove), kingSq(White)),
|
||||
feature(White, sideToMove, King, move.startSquare, kingSq(White)),
|
||||
feature(White, sideToMove, Rook, move.getRookCastlingTarget(sideToMove), kingSq(White)),
|
||||
feature(White, sideToMove, Rook, move.targetSquare, kingSq(White)), oldAcc(White), newAcc(White))
|
||||
network.ft.addSubAddSub(
|
||||
feature(Black, sideToMove, King, move.getKingCastlingTarget(sideToMove), kingSq(Black)),
|
||||
feature(Black, sideToMove, King, move.startSquare, kingSq(Black)),
|
||||
feature(Black, sideToMove, Rook, move.getRookCastlingTarget(sideToMove), kingSq(Black)),
|
||||
feature(Black, sideToMove, Rook, move.targetSquare, kingSq(Black)), oldAcc(Black), newAcc(Black))
|
||||
|
||||
|
||||
proc undo*(self: EvalState) {.inline.} =
|
||||
## Discards the previous accumulator update
|
||||
if self.pending > 0:
|
||||
@@ -365,274 +439,322 @@ proc undo*(self: EvalState) {.inline.} =
|
||||
dec(self.current)
|
||||
|
||||
|
||||
# Logic entirely yoinked from Stormphrax. Thanks cie!
|
||||
proc forwardScalar*(self: EvalState, sideToMove: PieceColor, outputBucket: int): Score =
|
||||
## Runs a forward pass through the given output bucket of the current network,
|
||||
## using the given accumulator and side to move pair and returns the output.
|
||||
## Fully scalar implementation (i.e. slow as hell but easier to debug)
|
||||
const
|
||||
PAIR_COUNT: uint64 = L1_SIZE div 2
|
||||
L1_SHIFT = 16 + QUANT_BITS - FT_SCALE_BITS - FT_QUANT_BITS - FT_QUANT_BITS - L1_QUANT_BITS
|
||||
QUANT = 1 shl QUANT_BITS
|
||||
|
||||
var
|
||||
# Activated FT outputs (concated accumulators)
|
||||
ftOut: array[L1_SIZE, uint8]
|
||||
# Activated L1 outputs. Dual activation, so twice the outputs
|
||||
l1Out: array[L2_SIZE * (1 + DUAL_ACTIVATION.int), int32]
|
||||
# Unactivated L2 outputs
|
||||
l2Out: array[L3_SIZE, int32]
|
||||
|
||||
# Activate the FT: We do pairwise activation to reduce the size of the
|
||||
# L1 matmul in half. See https://github.com/official-stockfish/Stockfish/blob/master/src/nnue/nnue_feature_transformer.h#L239
|
||||
# for more details on this shifting business and why we use it to perform
|
||||
# quantizations instead of simple division. The TLDR is that it's faster,
|
||||
# but we are limited to quantization constants that are powers of 2. In practice
|
||||
# this limitation doesn't matter, so it's free speed at no cost
|
||||
func activatePerspective(inputs: Accumulator, outputOffset: uint64) =
|
||||
for inputIdx in 0..<PAIR_COUNT:
|
||||
var
|
||||
i1 = inputs.data[inputIdx]
|
||||
i2 = inputs.data[inputIdx + PAIR_COUNT]
|
||||
|
||||
# Use crelu activation for both values (the "squaring" will just be
|
||||
# us multiplying them together)
|
||||
i1 = clamp(i1, 0, QA)
|
||||
# We can save a max operation (hence why we don't do clamp())
|
||||
# here thanks to that stockfish trick I mentioned earlier
|
||||
i2 = min(i2, QA)
|
||||
|
||||
let
|
||||
# Divide by the scale
|
||||
s = i1 shl FT_SCALE_BITS
|
||||
# Poor man's mulhi (AVX2 intrinsic). Uses the same fast modulo reduction
|
||||
# trick that we use for indexing the transposition table!
|
||||
p = (cast[int32](s) * cast[int32](i2)) shr 16
|
||||
packed = cast[uint8](clamp(p, 0, 255))
|
||||
|
||||
ftOut[outputOffset + inputIdx] = packed
|
||||
|
||||
# Activate side-to-move accumulator into ftOut[0..L1_SIZE / 2]
|
||||
activatePerspective(self.accumulators[sideToMove][self.current], 0)
|
||||
# Activate non side-to-move accumulator into ftOut[L1_SIZE / 2..L1_SIZE]
|
||||
activatePerspective(self.accumulators[sideToMove.opposite()][self.current], PAIR_COUNT)
|
||||
|
||||
# Unactivated L1 outputs in the quantized space (FT quant * L1 quant)
|
||||
var intermediate: array[L2_SIZE, int32]
|
||||
|
||||
# This is the actual layer 1 matmul operation
|
||||
for inputIdx in 0..<L1_SIZE:
|
||||
let i = ftOut[inputIdx]
|
||||
|
||||
for outputIdx in 0..<L2_SIZE:
|
||||
# The indexing is weird instead of simply [inputIdx][outputIdx] (or
|
||||
# inputIdx * L2_SIZE + outputIdx) because dpbusd requires this ordering
|
||||
let
|
||||
weightIdx = (inputIdx - (inputIdx mod 4)) * L2_SIZE + outputIdx * 4 + (inputIdx mod 4)
|
||||
w = network.l1.weight[outputBucket][weightIdx]
|
||||
|
||||
intermediate[outputIdx] += i.int32 * w.int32
|
||||
|
||||
# Requantize, add biases and activate L1 output
|
||||
for i in 0'u64..<L2_SIZE:
|
||||
let bias = network.l1.bias[outputBucket][i]
|
||||
|
||||
var output = intermediate[i]
|
||||
|
||||
# Requantise to later layer quantization and undo FT
|
||||
# shift in one go (this is ultimately a shift down,
|
||||
# expressed as a negative shift up, so negate the
|
||||
# actual shift amount)
|
||||
|
||||
output += bias
|
||||
output = output shr -L1_SHIFT
|
||||
|
||||
when DUAL_ACTIVATION:
|
||||
# When doing dual activation we use both CReLU and
|
||||
# SCReLU
|
||||
var crelu = output
|
||||
var screlu = output
|
||||
|
||||
# ReLU + clip
|
||||
crelu = crelu.clamp(0, QUANT)
|
||||
# Shift into Q*Q space (currently Q) to match squared side
|
||||
crelu = crelu shl QUANT_BITS
|
||||
|
||||
screlu *= screlu
|
||||
# Clip in Q*Q space (we just squared this value, so we squared Q too)
|
||||
screlu = min(screlu, QUANT * QUANT)
|
||||
|
||||
l1Out[i] = crelu
|
||||
l1Out[i + L2_SIZE] = screlu
|
||||
else:
|
||||
# Use SCReLU when doing single activation
|
||||
var crelu = clamp(output, 0, QUANT)
|
||||
l1Out[i] = crelu * crelu
|
||||
|
||||
# Values are now in Q*Q space (see above)
|
||||
|
||||
for i, bias in network.l2.buckets[outputBucket].bias:
|
||||
l2Out[i] = bias
|
||||
|
||||
# Perform L2 matmul
|
||||
for inputIdx in 0..<L2_SIZE * (1 + DUAL_ACTIVATION.int):
|
||||
let i = l1Out[inputIdx]
|
||||
|
||||
for outputIdx in 0..<L3_SIZE:
|
||||
let w = network.l2.buckets[outputBucket].weight[inputIdx][outputIdx]
|
||||
|
||||
l2Out[outputIdx] += i * w
|
||||
|
||||
# Values are now in Q*Q*Q space, we just multiplied Q*Q values by Q weights
|
||||
result = network.l3.buckets[outputBucket].bias[0]
|
||||
|
||||
# Activate L2 outputs and do L3 matmul
|
||||
for inputIdx in 0..<L3_SIZE:
|
||||
var i = l2Out[inputIdx]
|
||||
|
||||
let w = network.l3.buckets[outputBucket].weight[inputIdx][0]
|
||||
|
||||
# crelu
|
||||
i = i.clamp(0, QUANT * QUANT * QUANT)
|
||||
|
||||
result += i * w
|
||||
# Values are now in Q*Q*Q*Q space
|
||||
|
||||
# Dequantise by one step before scaling to avoid overflow
|
||||
result = result div QUANT
|
||||
result *= EVAL_SCALE
|
||||
# Dequantize the rest
|
||||
result = result div (QUANT * QUANT * QUANT)
|
||||
proc updateThreats(self: EvalState, currentPositionIdx: uint64, who: PieceColor = None) =
|
||||
let before = self.board.positions[currentPositionIdx - 1]
|
||||
let after = self.board.positions[currentPositionIdx]
|
||||
var diff = collectThreatDiff(before, after)
|
||||
if who == None:
|
||||
for color in White..Black:
|
||||
diff.apply(network.threatWeights, color, self.threatAccumulators[color][self.current - 1].data, self.threatAccumulators[color][self.current].data)
|
||||
else:
|
||||
diff.apply(network.threatWeights, who, self.threatAccumulators[who][self.current - 1].data, self.threatAccumulators[who][self.current].data)
|
||||
|
||||
|
||||
when defined(simd):
|
||||
proc forwardFast*(self: EvalState, sideToMove: PieceColor, outputBucket: int): Score =
|
||||
## The same as forwardScalar but MUCH faster thanks to SIMD optimizations
|
||||
|
||||
# https://cosmo.tardis.ac/files/2024-08-17-multilayer.html
|
||||
# https://github.com/Ciekce/stoat/blob/main/src/eval/nnue.cpp
|
||||
# https://github.com/PGG106/Alexandria/blob/fuckvinny/src/nnue.cpp
|
||||
# Multilayer inference restored and wired to TI (slopped)
|
||||
when SINGLE_LAYER:
|
||||
proc forwardScalar*(self: EvalState, sideToMove: PieceColor, outputBucket: int): Score =
|
||||
## Single-layer SCReLU output from the prepared PSQ and TI accumulators.
|
||||
var sum = 0'i64
|
||||
for half, side in [sideToMove, sideToMove.opposite()]:
|
||||
for i in 0..<L1_SIZE:
|
||||
let
|
||||
value = self.accumulators[side][self.current].data[i] +% self.threatAccumulators[side][self.current].data[i]
|
||||
clipped = clamp(value, 0, QA).int64
|
||||
weight = network.output.weight[outputBucket][half * L1_SIZE + i].int64
|
||||
sum += clipped * clipped * weight
|
||||
return Score(((sum div QA + network.output.bias[outputBucket]) * EVAL_SCALE) div (QA * QB))
|
||||
|
||||
|
||||
when defined(simd):
|
||||
proc forwardFast*(self: EvalState, sideToMove: PieceColor, outputBucket: int): Score =
|
||||
## Keep the debugging head scalar; PSQ accumulator updates still use SIMD.
|
||||
self.forwardScalar(sideToMove, outputBucket)
|
||||
else:
|
||||
static:
|
||||
doAssert L1_SIZE mod 4 == 0
|
||||
|
||||
# Logic entirely yoinked from Stormphrax. Thanks cie!
|
||||
proc forwardScalar*(self: EvalState, sideToMove: PieceColor, outputBucket: int): Score =
|
||||
## Runs a forward pass through the given output bucket of the current network,
|
||||
## using the given accumulator and side to move pair and returns the output.
|
||||
## Fully scalar implementation (i.e. slow as hell but easier to debug)
|
||||
const
|
||||
PAIR_COUNT: uint64 = L1_SIZE div 2
|
||||
QUANT = 1 shl QUANT_BITS
|
||||
L1_SHIFT = 16 + QUANT_BITS - FT_SCALE_BITS - FT_QUANT_BITS - FT_QUANT_BITS - L1_QUANT_BITS
|
||||
let
|
||||
zero = vecZero16()
|
||||
one = vecSetOne16(QA)
|
||||
l1CreluOne {.used.} = vecSetOne32(QUANT)
|
||||
l1ScreluOne {.used.} = vecSetOne32(QUANT * QUANT)
|
||||
l2One {.used.} = vecSetOne32(QUANT * QUANT * QUANT)
|
||||
QUANT = 1 shl QUANT_BITS
|
||||
|
||||
var ftOut {.noinit.}: AlignedArray[L1_SIZE, uint8]
|
||||
for accNum, pov in [sideToMove, sideToMove.opposite()]:
|
||||
template accumulator: Accumulator = self.accumulators[pov][self.current]
|
||||
var
|
||||
# Activated FT outputs (concated accumulators)
|
||||
ftOut: array[L1_SIZE, uint8]
|
||||
# Activated L1 outputs. Dual activation, so twice the outputs
|
||||
l1Out: array[L2_SIZE * (1 + DUAL_ACTIVATION.int), int32]
|
||||
# Unactivated L2 outputs
|
||||
l2Out: array[L3_SIZE, int32]
|
||||
|
||||
# Activate the FT: We do pairwise activation to reduce the size of the
|
||||
# L1 matmul in half. See https://github.com/official-stockfish/Stockfish/blob/master/src/nnue/nnue_feature_transformer.h#L239
|
||||
# for more details on this shifting business and why we use it to perform
|
||||
# quantizations instead of simple division. The TLDR is that it's faster,
|
||||
# but we are limited to quantization constants that are powers of 2. In practice
|
||||
# this limitation doesn't matter, so it's free speed at no cost
|
||||
func activatePerspective(inputs, threats: Accumulator, outputOffset: uint64) =
|
||||
for inputIdx in 0..<PAIR_COUNT:
|
||||
var
|
||||
i1 = inputs.data[inputIdx] +% threats.data[inputIdx]
|
||||
i2 = inputs.data[inputIdx + PAIR_COUNT] +% threats.data[inputIdx + PAIR_COUNT]
|
||||
|
||||
# Use crelu activation for both values (the "squaring" will just be
|
||||
# us multiplying them together)
|
||||
i1 = clamp(i1, 0, QA)
|
||||
# We can save a max operation (hence why we don't do clamp())
|
||||
# here thanks to that stockfish trick I mentioned earlier
|
||||
i2 = min(i2, QA)
|
||||
|
||||
# Load input activations
|
||||
for i in countup(0'u64, PAIR_COUNT - 1, I16_CHUNK_SIZE * 2):
|
||||
let
|
||||
input0a = vecLoad(addr accumulator.data[i + 0 + 0])
|
||||
input0b = vecLoad(addr accumulator.data[i + I16_CHUNK_SIZE + 0])
|
||||
input1a = vecLoad(addr accumulator.data[i + 0 + PAIR_COUNT])
|
||||
input1b = vecLoad(addr accumulator.data[i + I16_CHUNK_SIZE + PAIR_COUNT])
|
||||
# Divide by the scale
|
||||
s = i1 shl FT_SCALE_BITS
|
||||
# Poor man's mulhi (AVX2 intrinsic). Uses the same fast modulo reduction
|
||||
# trick that we use for indexing the transposition table!
|
||||
p = (cast[int32](s) * cast[int32](i2)) shr 16
|
||||
packed = cast[uint8](clamp(p, 0, 255))
|
||||
|
||||
# Clip the inputs between 0.0 and 1.0 (well, actually between zero and QA since
|
||||
# we're in quantized space, but mathematically that's what it means)
|
||||
ftOut[outputOffset + inputIdx] = packed
|
||||
|
||||
# Activate side-to-move accumulator into ftOut[0..L1_SIZE / 2]
|
||||
activatePerspective(self.accumulators[sideToMove][self.current], self.threatAccumulators[sideToMove][self.current], 0)
|
||||
# Activate non side-to-move accumulator into ftOut[L1_SIZE / 2..L1_SIZE]
|
||||
activatePerspective(self.accumulators[sideToMove.opposite()][self.current], self.threatAccumulators[sideToMove.opposite()][self.current], PAIR_COUNT)
|
||||
|
||||
# Unactivated L1 outputs in the quantized space (FT quant * L1 quant)
|
||||
var intermediate: array[L2_SIZE, int32]
|
||||
|
||||
# This is the actual layer 1 matmul operation
|
||||
for inputIdx in 0..<L1_SIZE:
|
||||
let i = ftOut[inputIdx]
|
||||
|
||||
for outputIdx in 0..<L2_SIZE:
|
||||
# The indexing is weird instead of simply [inputIdx][outputIdx] (or
|
||||
# inputIdx * L2_SIZE + outputIdx) because dpbusd requires this ordering
|
||||
let
|
||||
clipped0a = vecMin16(vecMax16(input0a, zero), one)
|
||||
clipped0b = vecMin16(vecMax16(input0b, zero), one)
|
||||
# Here we skip the max operation for the same reason explained
|
||||
# in the scalar inference, except we actually benefit from it
|
||||
# in terms of speed
|
||||
clipped1a = vecMin16(input1a, one)
|
||||
clipped1b = vecMin16(input1b, one)
|
||||
weightIdx = l1WeightIndex(inputIdx.int, outputIdx)
|
||||
w = network.l1.weight[outputBucket][weightIdx]
|
||||
|
||||
# Multiply clipped inputs and store result. We use mulhi instead of mullo
|
||||
# because it preserves the sign (and lets us do that shifting magic from my
|
||||
# boy cj. Read the stockfish comment mentioned in scalar inference for more
|
||||
# info)
|
||||
let
|
||||
productA = vecMulhi16(vecLShift16(clipped0a, FT_SCALE_BITS.int32), clipped1a)
|
||||
productB = vecMulhi16(vecLShift16(clipped0b, FT_SCALE_BITS.int32), clipped1b)
|
||||
packed = vecPackI16toU8(productA, productB)
|
||||
intermediate[outputIdx] += i.int32 * w.int32
|
||||
|
||||
vecStore(addr ftOut.data[i + (PAIR_COUNT * accNum.uint64)], packed)
|
||||
# Requantize, add biases and activate L1 output
|
||||
for i in 0'u64..<L2_SIZE:
|
||||
let bias = network.l1.bias[outputBucket][i]
|
||||
|
||||
let ftOutI32s = cast[array[L1_SIZE div 4, int32]](ftOut.data)
|
||||
# VEPI32 is already aligned. No need to use AlignedArray
|
||||
var intermediate {.noinit.}: array[L2_SIZE div I32_CHUNK_SIZE, VEPI32]
|
||||
# L1 propagation
|
||||
for i in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
intermediate[i] = vecZero32()
|
||||
var intermediate2 {.noinit.}: array[L2_SIZE div I32_CHUNK_SIZE, VEPI32]
|
||||
for i in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
intermediate2[i] = vecZero32()
|
||||
for group in countup(0, L1_SIZE div 4 - 1, 4):
|
||||
let
|
||||
inputs0 = vecSetOne32(ftOutI32s[group])
|
||||
inputs1 = vecSetOne32(ftOutI32s[group + 1])
|
||||
inputs2 = vecSetOne32(ftOutI32s[group + 2])
|
||||
inputs3 = vecSetOne32(ftOutI32s[group + 3])
|
||||
for j in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
let
|
||||
w0 = vecLoad(addr network.l1.weight[outputBucket][group * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
w1 = vecLoad(addr network.l1.weight[outputBucket][(group + 1) * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
w2 = vecLoad(addr network.l1.weight[outputBucket][(group + 2) * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
w3 = vecLoad(addr network.l1.weight[outputBucket][(group + 3) * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
intermediate[j] = vecDpbusdx2(intermediate[j], inputs0, w0, inputs1, w1)
|
||||
intermediate2[j] = vecDpbusdx2(intermediate2[j], inputs2, w2, inputs3, w3)
|
||||
for j in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
intermediate[j] = vecAdd32(intermediate[j], intermediate2[j])
|
||||
|
||||
var l1Out {.noinit.}: AlignedArray[L2_SIZE * (1 + DUAL_ACTIVATION.int), int32]
|
||||
var output = intermediate[i]
|
||||
|
||||
# Requantize, add biases, activate
|
||||
for j in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
# Note to self: some arches do shift-then-add, some add-then shift. Something
|
||||
# to keep in mind for future potential borkage
|
||||
var output = vecRAShift32(vecAdd32(intermediate[j], vecLoad(addr network.l1.bias[outputBucket][j * I32_CHUNK_SIZE])), (-L1_SHIFT).int32)
|
||||
# Requantise to later layer quantization and undo FT
|
||||
# shift in one go (this is ultimately a shift down,
|
||||
# expressed as a negative shift up, so negate the
|
||||
# actual shift amount)
|
||||
|
||||
output += bias
|
||||
output = output shr -L1_SHIFT
|
||||
|
||||
when DUAL_ACTIVATION:
|
||||
# When doing dual activation we use both CReLU and
|
||||
# SCReLU
|
||||
var crelu = output
|
||||
var screlu = output
|
||||
|
||||
# crelu: clamp [0, QUANT], then lift into Q*Q space
|
||||
crelu = vecLShift32(vecMin32(vecMax32(crelu, vecZero32()), l1CreluOne), QUANT_BITS.int32)
|
||||
# screlu: square the *unclamped* value, then cap at QUANT^2 (no lower clamp needed)
|
||||
screlu = vecMin32(vecMullo32(screlu, screlu), l1ScreluOne)
|
||||
# ReLU + clip
|
||||
crelu = crelu.clamp(0, QUANT)
|
||||
# Shift into Q*Q space (currently Q) to match squared side
|
||||
crelu = crelu shl QUANT_BITS
|
||||
|
||||
vecStore(addr l1Out.data[j * I32_CHUNK_SIZE], crelu)
|
||||
vecStore(addr l1Out.data[L2_SIZE + j * I32_CHUNK_SIZE], screlu)
|
||||
screlu *= screlu
|
||||
# Clip in Q*Q space (we just squared this value, so we squared Q too)
|
||||
screlu = min(screlu, QUANT * QUANT)
|
||||
|
||||
l1Out[i] = crelu
|
||||
l1Out[i + L2_SIZE] = screlu
|
||||
else:
|
||||
let act = vecMin32(vecMax32(output, vecZero32()), l1CreluOne)
|
||||
vecStore(addr l1Out.data[j * I32_CHUNK_SIZE], vecMullo32(act, act))
|
||||
|
||||
# Load L2 biases, run l1Out through L2
|
||||
# Use SCReLU when doing single activation
|
||||
var crelu = clamp(output, 0, QUANT)
|
||||
l1Out[i] = crelu * crelu
|
||||
|
||||
var l2Out {.noinit.}: array[L3_SIZE div I32_CHUNK_SIZE, VEPI32]
|
||||
|
||||
for j in 0..<L3_SIZE div I32_CHUNK_SIZE:
|
||||
l2Out[j] = vecLoad(addr network.l2.buckets[outputBucket].bias[j * I32_CHUNK_SIZE])
|
||||
# Values are now in Q*Q space (see above)
|
||||
|
||||
for i in 0..<L2_SIZE * (1 + DUAL_ACTIVATION.int):
|
||||
let inputs = vecSetOne32(l1Out.data[i])
|
||||
for j in 0..<L3_SIZE div I32_CHUNK_SIZE:
|
||||
l2Out[j] = vecAdd32(l2Out[j], vecMullo32(inputs, vecLoad(addr network.l2.buckets[outputBucket].weight[i][j * I32_CHUNK_SIZE])))
|
||||
|
||||
# L3: Quantize, feed forward, activate
|
||||
for i, bias in network.l2.buckets[outputBucket].bias:
|
||||
l2Out[i] = bias
|
||||
|
||||
var sum = vecZero32()
|
||||
for j in 0..<L3_SIZE div I32_CHUNK_SIZE:
|
||||
# crelu in Q^3 space — clamp FIRST, then multiply (scalar clamps i before i * w)
|
||||
let act = vecMin32(vecMax32(l2Out[j], vecZero32()), l2One)
|
||||
let w = vecLoad(addr network.l3.buckets[outputBucket].weight[j * I32_CHUNK_SIZE][0])
|
||||
sum = vecAdd32(sum, vecMullo32(act, w))
|
||||
# Perform L2 matmul
|
||||
for inputIdx in 0..<L2_SIZE * (1 + DUAL_ACTIVATION.int):
|
||||
let i = l1Out[inputIdx]
|
||||
|
||||
# Bias + final sum
|
||||
result = Score(network.l3.buckets[outputBucket].bias[0] + vecReduceAdd32(sum))
|
||||
# Dequantize
|
||||
result = result div QUANT
|
||||
result *= EVAL_SCALE
|
||||
result = result div (QUANT * QUANT * QUANT)
|
||||
for outputIdx in 0..<L3_SIZE:
|
||||
let w = network.l2.buckets[outputBucket].weight[inputIdx][outputIdx]
|
||||
|
||||
l2Out[outputIdx] += i * w
|
||||
|
||||
# Values are now in Q*Q*Q space, we just multiplied Q*Q values by Q weights
|
||||
result = network.l3.buckets[outputBucket].bias[0]
|
||||
|
||||
# Activate L2 outputs and do L3 matmul
|
||||
for inputIdx in 0..<L3_SIZE:
|
||||
var i = l2Out[inputIdx]
|
||||
|
||||
let w = network.l3.buckets[outputBucket].weight[inputIdx][0]
|
||||
|
||||
# crelu
|
||||
i = i.clamp(0, QUANT * QUANT * QUANT)
|
||||
|
||||
result += i * w
|
||||
# Values are now in Q*Q*Q*Q space
|
||||
|
||||
# Scale in int64 and dequantize once to preserve precision without
|
||||
# overflowing the intermediate
|
||||
result = Score(result.int64 * EVAL_SCALE div (QUANT.int64 * QUANT * QUANT * QUANT))
|
||||
|
||||
|
||||
when defined(simd):
|
||||
proc forwardFast*(self: EvalState, sideToMove: PieceColor, outputBucket: int): Score {.simdKernel.} =
|
||||
when not defined(simd) or L1_SIZE mod 128 != 0 or
|
||||
L2_SIZE mod I32_CHUNK_SIZE != 0 or L3_SIZE mod I32_CHUNK_SIZE != 0:
|
||||
return self.forwardScalar(sideToMove, outputBucket)
|
||||
else:
|
||||
## The same as forwardScalar but MUCH faster thanks to SIMD optimizations
|
||||
|
||||
# https://cosmo.tardis.ac/files/2024-08-17-multilayer.html
|
||||
# https://github.com/Ciekce/stoat/blob/main/src/eval/nnue.cpp
|
||||
# https://github.com/PGG106/Alexandria/blob/fuckvinny/src/nnue.cpp
|
||||
const
|
||||
PAIR_COUNT: uint64 = L1_SIZE div 2
|
||||
QUANT = 1 shl QUANT_BITS
|
||||
L1_SHIFT = 16 + QUANT_BITS - FT_SCALE_BITS - FT_QUANT_BITS - FT_QUANT_BITS - L1_QUANT_BITS
|
||||
let
|
||||
zero = vecZero16()
|
||||
one = vecSetOne16(QA)
|
||||
l1CreluOne {.used.} = vecSetOne32(QUANT)
|
||||
l1ScreluOne {.used.} = vecSetOne32(QUANT * QUANT)
|
||||
l2One {.used.} = vecSetOne32(QUANT * QUANT * QUANT)
|
||||
|
||||
var ftOut {.noinit.}: AlignedArray[L1_SIZE, uint8]
|
||||
for accNum, pov in [sideToMove, sideToMove.opposite()]:
|
||||
template combined(offset: uint64): VEPI16 =
|
||||
vecAdd16(vecLoad(addr self.accumulators[pov][self.current].data[offset]),
|
||||
vecLoad(addr self.threatAccumulators[pov][self.current].data[offset]))
|
||||
|
||||
# Load input activations
|
||||
for packedOffset in countup(0'u64, PAIR_COUNT - 1, I16_CHUNK_SIZE * 2):
|
||||
# Emulate AVX-512 packus on every width by pairing the lower
|
||||
# and upper 32-product halves, then storing consecutively.
|
||||
let
|
||||
i = (packedOffset div 64) * 64 + (packedOffset mod 64) div 2
|
||||
input0a = combined(i)
|
||||
input0b = combined(i + 32)
|
||||
input1a = combined(i + PAIR_COUNT)
|
||||
input1b = combined(i + 32 + PAIR_COUNT)
|
||||
|
||||
# Clip the inputs between 0.0 and 1.0 (well, actually between zero and QA since
|
||||
# we're in quantized space, but mathematically that's what it means)
|
||||
let
|
||||
clipped0a = vecMin16(vecMax16(input0a, zero), one)
|
||||
clipped0b = vecMin16(vecMax16(input0b, zero), one)
|
||||
# Here we skip the max operation for the same reason explained
|
||||
# in the scalar inference, except we actually benefit from it
|
||||
# in terms of speed
|
||||
clipped1a = vecMin16(input1a, one)
|
||||
clipped1b = vecMin16(input1b, one)
|
||||
|
||||
# Multiply clipped inputs and store result. We use mulhi instead of mullo
|
||||
# because it preserves the sign (and lets us do that shifting magic from my
|
||||
# boy cj. Read the stockfish comment mentioned in scalar inference for more
|
||||
# info)
|
||||
let
|
||||
productA = vecMulhi16(vecLShift16(clipped0a, FT_SCALE_BITS.int32), clipped1a)
|
||||
productB = vecMulhi16(vecLShift16(clipped0b, FT_SCALE_BITS.int32), clipped1b)
|
||||
packed = vecPackI16toU8(productA, productB)
|
||||
|
||||
vecStore(addr ftOut.data[packedOffset + (PAIR_COUNT * accNum.uint64)], packed)
|
||||
|
||||
let ftOutI32s = cast[array[L1_SIZE div 4, int32]](ftOut.data)
|
||||
# VEPI32 is already aligned. No need to use AlignedArray
|
||||
var intermediate {.noinit.}: array[L2_SIZE div I32_CHUNK_SIZE, VEPI32]
|
||||
# L1 propagation
|
||||
for i in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
intermediate[i] = vecZero32()
|
||||
# Emulated byte dots need more temporaries: use one accumulation chain.
|
||||
# Preserve dpbusdx2 pair boundaries; only regroup wrapping int32 sums.
|
||||
const groupStep = when defined(neon) or defined(sse2): 2 else: 4
|
||||
when groupStep == 4:
|
||||
var intermediate2 {.noinit.}: array[L2_SIZE div I32_CHUNK_SIZE, VEPI32]
|
||||
for i in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
intermediate2[i] = vecZero32()
|
||||
for group in countup(0, L1_SIZE div 4 - 1, groupStep):
|
||||
let
|
||||
inputs0 = vecSetOne32(ftOutI32s[group])
|
||||
inputs1 = vecSetOne32(ftOutI32s[group + 1])
|
||||
when groupStep == 4:
|
||||
let
|
||||
inputs2 = vecSetOne32(ftOutI32s[group + 2])
|
||||
inputs3 = vecSetOne32(ftOutI32s[group + 3])
|
||||
for j in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
let
|
||||
w0 = vecLoad(addr network.l1.weight[outputBucket][group * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
w1 = vecLoad(addr network.l1.weight[outputBucket][(group + 1) * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
intermediate[j] = vecDpbusdx2(intermediate[j], inputs0, w0, inputs1, w1)
|
||||
when groupStep == 4:
|
||||
let
|
||||
w2 = vecLoad(addr network.l1.weight[outputBucket][(group + 2) * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
w3 = vecLoad(addr network.l1.weight[outputBucket][(group + 3) * 4 * L2_SIZE + j * 4 * I32_CHUNK_SIZE])
|
||||
intermediate2[j] = vecDpbusdx2(intermediate2[j], inputs2, w2, inputs3, w3)
|
||||
when groupStep == 4:
|
||||
for j in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
intermediate[j] = vecAdd32(intermediate[j], intermediate2[j])
|
||||
|
||||
var l1Out {.noinit.}: AlignedArray[L2_SIZE * (1 + DUAL_ACTIVATION.int), int32]
|
||||
|
||||
# Requantize, add biases, activate
|
||||
for j in 0..<L2_SIZE div I32_CHUNK_SIZE:
|
||||
# Note to self: some arches do shift-then-add, some add-then shift. Something
|
||||
# to keep in mind for future potential borkage
|
||||
var output = vecRAShift32(vecAdd32(intermediate[j], vecLoad(addr network.l1.bias[outputBucket][j * I32_CHUNK_SIZE])), (-L1_SHIFT).int32)
|
||||
|
||||
when DUAL_ACTIVATION:
|
||||
var crelu = output
|
||||
var screlu = output
|
||||
|
||||
# crelu: clamp [0, QUANT], then lift into Q*Q space
|
||||
crelu = vecLShift32(vecMin32(vecMax32(crelu, vecZero32()), l1CreluOne), QUANT_BITS.int32)
|
||||
# screlu: square the *unclamped* value, then cap at QUANT^2 (no lower clamp needed)
|
||||
screlu = vecMin32(vecMullo32(screlu, screlu), l1ScreluOne)
|
||||
|
||||
vecStore(addr l1Out.data[j * I32_CHUNK_SIZE], crelu)
|
||||
vecStore(addr l1Out.data[L2_SIZE + j * I32_CHUNK_SIZE], screlu)
|
||||
else:
|
||||
let act = vecMin32(vecMax32(output, vecZero32()), l1CreluOne)
|
||||
vecStore(addr l1Out.data[j * I32_CHUNK_SIZE], vecMullo32(act, act))
|
||||
|
||||
# Load L2 biases, run l1Out through L2
|
||||
|
||||
var l2Out {.noinit.}: array[L3_SIZE div I32_CHUNK_SIZE, VEPI32]
|
||||
|
||||
for j in 0..<L3_SIZE div I32_CHUNK_SIZE:
|
||||
l2Out[j] = vecLoad(addr network.l2.buckets[outputBucket].bias[j * I32_CHUNK_SIZE])
|
||||
|
||||
for i in 0..<L2_SIZE * (1 + DUAL_ACTIVATION.int):
|
||||
let inputs = vecSetOne32(l1Out.data[i])
|
||||
for j in 0..<L3_SIZE div I32_CHUNK_SIZE:
|
||||
l2Out[j] = vecAdd32(l2Out[j], vecMullo32(inputs, vecLoad(addr network.l2.buckets[outputBucket].weight[i][j * I32_CHUNK_SIZE])))
|
||||
|
||||
# L3: Quantize, feed forward, activate
|
||||
|
||||
var sum = vecZero32()
|
||||
for j in 0..<L3_SIZE div I32_CHUNK_SIZE:
|
||||
# crelu in Q^3 space — clamp FIRST, then multiply (scalar clamps i before i * w)
|
||||
let act = vecMin32(vecMax32(l2Out[j], vecZero32()), l2One)
|
||||
let w = vecLoad(addr network.l3.buckets[outputBucket].weight[j * I32_CHUNK_SIZE][0])
|
||||
sum = vecAdd32(sum, vecMullo32(act, w))
|
||||
|
||||
# Bias + final sum
|
||||
result = Score(network.l3.buckets[outputBucket].bias[0] + vecReduceAdd32(sum))
|
||||
# Match scalar rounding with a wide scaling intermediate.
|
||||
result = Score(result.int64 * EVAL_SCALE div (QUANT.int64 * QUANT * QUANT * QUANT))
|
||||
|
||||
|
||||
proc evaluate*(position: Position, state: EvalState): Score {.inline.} =
|
||||
@@ -642,16 +764,17 @@ proc evaluate*(position: Position, state: EvalState): Score {.inline.} =
|
||||
for i in 0..<state.pending:
|
||||
let update = state.updates[i]
|
||||
inc(state.current)
|
||||
for color in White..Black:
|
||||
if update.needsRefresh[color]:
|
||||
# TODO: There's a chance for an optimization here: once we find
|
||||
# an accumulator that needs a refresh, we can just refresh from
|
||||
# the last position and stop updating for that side. This would
|
||||
# allow us to get rid of the posIndex field and should be a nice
|
||||
# speedup
|
||||
state.refresh(color, state.board.positions[update.posIndex])
|
||||
else:
|
||||
state.applyUpdate(color, update.move, update.sideToMove, update.piece, update.captured)
|
||||
let nstm = update.sideToMove.opposite()
|
||||
if not update.needsRefresh:
|
||||
state.applyPSQUpdatePair(update.move, update.sideToMove, update.piece, update.captured)
|
||||
state.updateThreats(update.posIndex.uint64)
|
||||
else:
|
||||
# Only the moving king can invalidate its perspective's bucket.
|
||||
state.refreshPSQ(update.sideToMove, state.board.positions[update.posIndex])
|
||||
state.refreshThreats(update.sideToMove, state.board.positions[update.posIndex])
|
||||
# Other side gets UE'd
|
||||
state.updateThreats(update.posIndex.uint64, nstm)
|
||||
state.applyPSQUpdate(nstm, update.move, update.sideToMove, update.piece, update.captured)
|
||||
state.pending = 0
|
||||
|
||||
const divisor = 32 div NUM_OUTPUT_BUCKETS
|
||||
|
||||
@@ -24,9 +24,9 @@ from heimdall/util/numa import nil
|
||||
export bitboards, magics, pieces, moves, position, rays, board
|
||||
|
||||
|
||||
proc generatePawnMoves(self: var Position, moves: var MoveList, destinationMask: Bitboard) =
|
||||
proc generatePawnMoves(self: var Position, moves: var MoveList, destinationMask: Bitboard,
|
||||
sideToMove: static PieceColor) =
|
||||
let
|
||||
sideToMove = self.sideToMove
|
||||
nonSideToMove = sideToMove.opposite()
|
||||
pawns = self.pieces(Pawn, sideToMove)
|
||||
occupancy = self.pieces()
|
||||
@@ -38,6 +38,9 @@ proc generatePawnMoves(self: var Position, moves: var MoveList, destinationMask:
|
||||
promotionRank = sideToMove.eighthRank()
|
||||
startingRank = sideToMove.secondRank()
|
||||
friendlyKing = self.kingSquare(sideToMove)
|
||||
backwardOffset = if sideToMove == White: 8 else: -8
|
||||
backwardLeftOffset = if sideToMove == White: 7 else: -7
|
||||
backwardRightOffset = if sideToMove == White: 9 else: -9
|
||||
|
||||
# If a pawn is pinned diagonally, it cannot push forward
|
||||
let
|
||||
@@ -55,14 +58,14 @@ proc generatePawnMoves(self: var Position, moves: var MoveList, destinationMask:
|
||||
canDoublePush = canDoublePush.forward(sideToMove) and not occupancy and destinationMask
|
||||
|
||||
for pawn in singlePushes and not promotionRank:
|
||||
moves.add(createMove(pawn.toBitboard().backward(sideToMove), pawn))
|
||||
moves.add(createMove(Square(pawn.int + backwardOffset), pawn))
|
||||
|
||||
for pawn in singlePushes and promotionRank:
|
||||
for promotion in [PromotionBishop, PromotionKnight, PromotionRook, PromotionQueen]:
|
||||
moves.add(createMove(pawn.toBitboard().backward(sideToMove), pawn, promotion))
|
||||
moves.add(createMove(Square(pawn.int + backwardOffset), pawn, promotion))
|
||||
|
||||
for pawn in canDoublePush:
|
||||
moves.add(createMove(pawn.toBitboard().doubleBackward(sideToMove), pawn, DoublePush))
|
||||
moves.add(createMove(Square(pawn.int + backwardOffset * 2), pawn, DoublePush))
|
||||
|
||||
let
|
||||
canCapture = pawns and not orthogonalPins
|
||||
@@ -70,18 +73,18 @@ proc generatePawnMoves(self: var Position, moves: var MoveList, destinationMask:
|
||||
canCaptureRightUnpinned = (canCapture and not diagonalPins).forwardRight(sideToMove) and enemyPieces and destinationMask
|
||||
|
||||
for pawn in canCaptureRightUnpinned and not promotionRank:
|
||||
moves.add(createMove(pawn.toBitboard().backwardLeft(sideToMove), pawn, Capture))
|
||||
moves.add(createMove(Square(pawn.int + backwardLeftOffset), pawn, Capture))
|
||||
|
||||
for pawn in canCaptureRightUnpinned and promotionRank:
|
||||
for promotion in [CapturePromotionBishop, CapturePromotionKnight, CapturePromotionRook, CapturePromotionQueen]:
|
||||
moves.add(createMove(pawn.toBitboard().backwardLeft(sideToMove), pawn, promotion))
|
||||
moves.add(createMove(Square(pawn.int + backwardLeftOffset), pawn, promotion))
|
||||
|
||||
for pawn in canCaptureLeftUnpinned and not promotionRank:
|
||||
moves.add(createMove(pawn.toBitboard().backwardRight(sideToMove), pawn, Capture))
|
||||
moves.add(createMove(Square(pawn.int + backwardRightOffset), pawn, Capture))
|
||||
|
||||
for pawn in canCaptureLeftUnpinned and promotionRank:
|
||||
for promotion in [CapturePromotionBishop, CapturePromotionKnight, CapturePromotionRook, CapturePromotionQueen]:
|
||||
moves.add(createMove(pawn.toBitboard().backwardRight(sideToMove), pawn, promotion))
|
||||
moves.add(createMove(Square(pawn.int + backwardRightOffset), pawn, promotion))
|
||||
|
||||
# Special cases for pawns pinned diagonally that can capture their pinners
|
||||
|
||||
@@ -89,21 +92,21 @@ proc generatePawnMoves(self: var Position, moves: var MoveList, destinationMask:
|
||||
canCaptureLeft = canCapture.forwardLeft(sideToMove) and enemyPieces and destinationMask
|
||||
canCaptureRight = canCapture.forwardRight(sideToMove) and enemyPieces and destinationMask
|
||||
leftPinnedCanCapture = (canCaptureLeft and diagonalPins) and not canCaptureLeftUnpinned
|
||||
rightPinnedCanCapture = ((canCaptureRight and diagonalPins) and not canCaptureRightUnpinned) and not canCaptureRightUnpinned
|
||||
rightPinnedCanCapture = (canCaptureRight and diagonalPins) and not canCaptureRightUnpinned
|
||||
|
||||
for pawn in leftPinnedCanCapture and not promotionRank:
|
||||
moves.add(createMove(pawn.toBitboard().backwardRight(sideToMove), pawn, Capture))
|
||||
moves.add(createMove(Square(pawn.int + backwardRightOffset), pawn, Capture))
|
||||
|
||||
for pawn in leftPinnedCanCapture and promotionRank:
|
||||
for promotion in [CapturePromotionBishop, CapturePromotionKnight, CapturePromotionRook, CapturePromotionQueen]:
|
||||
moves.add(createMove(pawn.toBitboard().backwardRight(sideToMove), pawn, promotion))
|
||||
moves.add(createMove(Square(pawn.int + backwardRightOffset), pawn, promotion))
|
||||
|
||||
for pawn in rightPinnedCanCapture and not promotionRank:
|
||||
moves.add(createMove(pawn.toBitboard().backwardLeft(sideToMove), pawn, Capture))
|
||||
moves.add(createMove(Square(pawn.int + backwardLeftOffset), pawn, Capture))
|
||||
|
||||
for pawn in rightPinnedCanCapture and promotionRank:
|
||||
for promotion in [CapturePromotionBishop, CapturePromotionKnight, CapturePromotionRook, CapturePromotionQueen]:
|
||||
moves.add(createMove(pawn.toBitboard().backwardLeft(sideToMove), pawn, promotion))
|
||||
moves.add(createMove(Square(pawn.int + backwardLeftOffset), pawn, promotion))
|
||||
|
||||
let epLegality = self.isEPLegal(friendlyKing, epTarget, occupancy, pawns, sideToMove)
|
||||
if epLegality.left != nullSquare():
|
||||
@@ -200,8 +203,11 @@ proc generateKnightMoves(self: Position, moves: var MoveList, destinationMask: B
|
||||
|
||||
|
||||
proc generateCastling(self: Position, moves: var MoveList) =
|
||||
let sideToMove = self.sideToMove
|
||||
let availability = self.castlingAvailability[sideToMove]
|
||||
if availability.king == nullSquare() and availability.queen == nullSquare():
|
||||
return
|
||||
let
|
||||
sideToMove = self.sideToMove
|
||||
castlingRights = self.canCastle()
|
||||
kingSquare = self.kingSquare(sideToMove)
|
||||
if castlingRights.king != nullSquare():
|
||||
@@ -223,7 +229,10 @@ proc generateMoves*(self: var Position, moves: var MoveList, capturesOnly: bool
|
||||
# by a king move
|
||||
return
|
||||
|
||||
self.generateCastling(moves)
|
||||
# Castling is quiet and must not leak into the capture-only list used by
|
||||
# quiescence search and capture perft.
|
||||
if not capturesOnly:
|
||||
self.generateCastling(moves)
|
||||
|
||||
# We pass a mask to our move generators to remove stuff
|
||||
# like our friendly pieces from the set of possible
|
||||
@@ -249,7 +258,11 @@ proc generateMoves*(self: var Position, moves: var MoveList, capturesOnly: bool
|
||||
if capturesOnly:
|
||||
# Note: This does not cover en passant (which is OK because it's a capture)
|
||||
destinationMask = destinationMask and self.pieces(nonSideToMove)
|
||||
self.generatePawnMoves(moves, destinationMask)
|
||||
# Pawn shifts, source offsets, and rank masks are constant for each color.
|
||||
if sideToMove == White:
|
||||
self.generatePawnMoves(moves, destinationMask, White)
|
||||
else:
|
||||
self.generatePawnMoves(moves, destinationMask, Black)
|
||||
self.generateKnightMoves(moves, destinationMask)
|
||||
self.generateRookMoves(moves, destinationMask)
|
||||
self.generateBishopMoves(moves, destinationMask)
|
||||
@@ -274,102 +287,109 @@ proc doMove*(self: Chessboard, move: Move) {.gcsafe.} =
|
||||
let
|
||||
sideToMove = piece.color
|
||||
nonSideToMove = sideToMove.opposite()
|
||||
kingSideRook = self.position.castlingAvailability[sideToMove].king
|
||||
queenSideRook = self.position.castlingAvailability[sideToMove].queen
|
||||
kingSq = self.position.kingSquare(sideToMove)
|
||||
king = self.on(kingSq)
|
||||
|
||||
self.positions.add(self.position.clone())
|
||||
# Position is a POD value. Grow the stack first and copy the previous state
|
||||
# directly into its final slot. setLenUninit avoids zeroing a slot which is
|
||||
# immediately overwritten by copyMem.
|
||||
let previousPosition = self.positions.high()
|
||||
self.positions.setLenUninit(self.positions.len() + 1)
|
||||
copyMem(addr self.positions[^1], addr self.positions[previousPosition], sizeof(Position))
|
||||
# No stack resize occurs below, so keep the address instead of repeatedly
|
||||
# calculating positions[^1] for this large element type.
|
||||
let currentPosition = addr self.positions[^1]
|
||||
let previousEPTarget = currentPosition[].enPassantSquare
|
||||
|
||||
if piece.kind == Pawn or move.isCapture():
|
||||
self.positions[^1].halfMoveClock = 0
|
||||
currentPosition[].halfMoveClock = 0
|
||||
else:
|
||||
inc(self.positions[^1].halfMoveClock)
|
||||
inc(currentPosition[].halfMoveClock)
|
||||
|
||||
if piece.color == Black:
|
||||
inc(self.positions[^1].fullMoveCount)
|
||||
inc(currentPosition[].fullMoveCount)
|
||||
|
||||
if move.isDoublePush():
|
||||
self.positions[^1].enPassantSquare = move.targetSquare.toBitboard().backward(piece.color).toSquare()
|
||||
currentPosition[].enPassantSquare = move.targetSquare.toBitboard().backward(piece.color).toSquare()
|
||||
else:
|
||||
self.positions[^1].enPassantSquare = nullSquare()
|
||||
currentPosition[].enPassantSquare = nullSquare()
|
||||
|
||||
self.positions[^1].sideToMove = nonSideToMove
|
||||
self.positions[^1].fromNull = false
|
||||
currentPosition[].sideToMove = nonSideToMove
|
||||
currentPosition[].fromNull = false
|
||||
|
||||
# I HATE EN PASSANT!!!!!!
|
||||
let previousEPTarget = self.positions[^2].enPassantSquare
|
||||
if previousEPTarget != nullSquare():
|
||||
self.positions[^1].zobristKey = self.position.zobristKey xor enPassantKey(file(previousEPTarget))
|
||||
currentPosition[].zobristKey = currentPosition[].zobristKey xor enPassantKey(file(previousEPTarget))
|
||||
|
||||
if move.isCastling() or piece.kind == King:
|
||||
self.positions[^1].revokeCastling(sideToMove)
|
||||
currentPosition[].revokeCastling(sideToMove)
|
||||
|
||||
if move.isCastling():
|
||||
# Castling is encoded as king takes own rook, hence the move's
|
||||
# target square is the rook's location!
|
||||
let
|
||||
rook = self.on(move.targetSquare)
|
||||
isKingSide = move.targetSquare == kingSideRook
|
||||
king = piece
|
||||
kingSq = move.startSquare
|
||||
rook = currentPosition[].on(move.targetSquare)
|
||||
isKingSide = move.flag() == ShortCastling
|
||||
rookTarget = if isKingSide: rook.shortCastling() else: rook.longCastling()
|
||||
kingTarget = if isKingSide: king.shortCastling() else: king.longCastling()
|
||||
|
||||
self.positions[^1].remove(kingSq)
|
||||
self.positions[^1].remove(move.targetSquare)
|
||||
self.positions[^1].spawn(rookTarget, rook)
|
||||
self.positions[^1].spawn(kingTarget, king)
|
||||
currentPosition[].remove(kingSq)
|
||||
currentPosition[].remove(move.targetSquare)
|
||||
currentPosition[].spawn(rookTarget, rook)
|
||||
currentPosition[].spawn(kingTarget, king)
|
||||
|
||||
if piece.kind == Rook:
|
||||
if move.startSquare == kingSideRook:
|
||||
self.positions[^1].revokeShortCastling(sideToMove)
|
||||
let availability = currentPosition[].castlingAvailability[sideToMove]
|
||||
if move.startSquare == availability.king:
|
||||
currentPosition[].revokeShortCastling(sideToMove)
|
||||
|
||||
if move.startSquare == queenSideRook:
|
||||
self.positions[^1].revokeLongCastling(sideToMove)
|
||||
if move.startSquare == availability.queen:
|
||||
currentPosition[].revokeLongCastling(sideToMove)
|
||||
|
||||
if move.isCapture():
|
||||
# captureSquare() resolves to the target square for normal captures and
|
||||
# to the square of the captured pawn for en passant
|
||||
let capturedSquare = move.captureSquare()
|
||||
let captured = self.on(capturedSquare)
|
||||
self.positions[^1].remove(capturedSquare)
|
||||
let captured = currentPosition[].on(capturedSquare)
|
||||
currentPosition[].remove(capturedSquare)
|
||||
|
||||
if captured.kind == Rook:
|
||||
let availability = self.position.castlingAvailability[nonSideToMove]
|
||||
let availability = currentPosition[].castlingAvailability[nonSideToMove]
|
||||
|
||||
if move.targetSquare == availability.king:
|
||||
self.positions[^1].revokeShortCastling(nonSideToMove)
|
||||
currentPosition[].revokeShortCastling(nonSideToMove)
|
||||
|
||||
elif move.targetSquare == availability.queen:
|
||||
self.positions[^1].revokeLongCastling(nonSideToMove)
|
||||
currentPosition[].revokeLongCastling(nonSideToMove)
|
||||
|
||||
if not move.isCastling() and not move.isPromotion():
|
||||
self.positions[^1].move(move)
|
||||
currentPosition[].move(move)
|
||||
|
||||
if move.isPromotion():
|
||||
self.positions[^1].remove(move.startSquare)
|
||||
self.positions[^1].spawn(move.targetSquare, Piece(color: piece.color, kind: move.flag().promotionToPiece()))
|
||||
currentPosition[].remove(move.startSquare)
|
||||
currentPosition[].spawn(move.targetSquare, createPiece(color=piece.color, kind=move.flag().promotionToPiece()))
|
||||
|
||||
if move.isDoublePush():
|
||||
let
|
||||
epTarget = self.position.enPassantSquare
|
||||
pawns = self.pieces(Pawn, nonSideToMove)
|
||||
occupancy = self.pieces()
|
||||
kingSq = self.position.kingSquare(nonSideToMove)
|
||||
epTarget = currentPosition[].enPassantSquare
|
||||
pawns = currentPosition[].pieces(Pawn, nonSideToMove)
|
||||
occupancy = currentPosition[].pieces()
|
||||
kingSq = currentPosition[].kingSquare(nonSideToMove)
|
||||
# This is very minor, but technically a square is a valid en passant target only if an enemy
|
||||
# pawn can be captured by playing en passant. The only thing this changes is that we won't have
|
||||
# an ep square displayed in the FENs at every double push anymore (it should also make repetition
|
||||
# detection more reliable since we won't be considering an invalid ep target square in our zobrist
|
||||
# hashes)
|
||||
let legality = self.positions[^1].isEPLegal(kingSq, epTarget, occupancy, pawns, nonSideToMove)
|
||||
let legality = currentPosition[].isEPLegal(kingSq, epTarget, occupancy, pawns, nonSideToMove)
|
||||
if legality.left == nullSquare() and legality.right == nullSquare():
|
||||
self.positions[^1].enPassantSquare = nullSquare()
|
||||
currentPosition[].enPassantSquare = nullSquare()
|
||||
else:
|
||||
# EP is legal, update zobrist hash
|
||||
self.positions[^1].zobristKey = self.position.zobristKey xor enPassantKey(file(self.position.enPassantSquare))
|
||||
currentPosition[].zobristKey = currentPosition[].zobristKey xor enPassantKey(file(currentPosition[].enPassantSquare))
|
||||
|
||||
self.positions[^1].updateChecksAndPins()
|
||||
currentPosition[].updateChecksAndPins()
|
||||
# Swap the side to move
|
||||
self.positions[^1].zobristKey = self.position.zobristKey xor blackToMoveKey()
|
||||
currentPosition[].zobristKey = currentPosition[].zobristKey xor blackToMoveKey()
|
||||
|
||||
|
||||
proc isLegal*(self: Chessboard, move: Move): bool {.inline.} =
|
||||
@@ -396,16 +416,19 @@ proc makeNullMove*(self: Chessboard) {.inline.} =
|
||||
## to the opponent without making a move. This
|
||||
## is obviously illegal and only to be used during
|
||||
## search. The move can be undone via unmakeMove
|
||||
self.positions.add(self.position.clone())
|
||||
self.positions[^1].sideToMove = self.position.sideToMove.opposite()
|
||||
let previousEPTarget = self.positions[^2].enPassantSquare
|
||||
let previousPosition = self.positions.high()
|
||||
self.positions.setLenUninit(self.positions.len() + 1)
|
||||
copyMem(addr self.positions[^1], addr self.positions[previousPosition], sizeof(Position))
|
||||
let currentPosition = addr self.positions[^1]
|
||||
currentPosition[].sideToMove = currentPosition[].sideToMove.opposite()
|
||||
let previousEPTarget = currentPosition[].enPassantSquare
|
||||
if previousEPTarget != nullSquare():
|
||||
self.positions[^1].zobristKey = self.position.zobristKey xor enPassantKey(file(previousEPTarget))
|
||||
self.positions[^1].enPassantSquare = nullSquare()
|
||||
self.positions[^1].fromNull = true
|
||||
self.positions[^1].updateChecksAndPins()
|
||||
self.positions[^1].zobristKey = self.position.zobristKey xor blackToMoveKey()
|
||||
self.positions[^1].halfMoveClock = 0
|
||||
currentPosition[].zobristKey = currentPosition[].zobristKey xor enPassantKey(file(previousEPTarget))
|
||||
currentPosition[].enPassantSquare = nullSquare()
|
||||
currentPosition[].fromNull = true
|
||||
currentPosition[].updateChecksAndPins()
|
||||
currentPosition[].zobristKey = currentPosition[].zobristKey xor blackToMoveKey()
|
||||
currentPosition[].halfMoveClock = 0
|
||||
|
||||
|
||||
func canNullMove*(self: Chessboard): bool {.inline.} =
|
||||
@@ -461,7 +484,8 @@ proc isGameOver*(self: Chessboard): bool {.inline.} =
|
||||
proc unmakeMove*(self: Chessboard) {.inline.} =
|
||||
if self.positions.len() == 1:
|
||||
return
|
||||
discard self.positions.pop()
|
||||
# pop() also materializes and clears an unused Position return value.
|
||||
self.positions.setLen(self.positions.len() - 1)
|
||||
|
||||
|
||||
## Testing stuff
|
||||
@@ -634,7 +658,7 @@ proc basicTests* =
|
||||
doAssert game.position.orthogonalPins == newPos.orthogonalPins, &"{game.position.orthogonalPins} != {newPos.orthogonalPins}"
|
||||
doAssert game.position.diagonalPins == newPos.diagonalPins, &"{game.position.diagonalPins} != {newPos.diagonalPins}"
|
||||
doAssert game.position.zobristKey == newPos.zobristKey, &"{game.position.zobristKey} != {newPos.zobristKey}"
|
||||
for sq in Square.all():
|
||||
for sq in Square.items():
|
||||
doAssert game.position.mailbox[sq] == newPos.mailbox[sq], &"Mailbox mismatch at {sq}: {game.position.mailbox[sq]} != {newPos.mailbox[sq]}"
|
||||
except AssertionDefect:
|
||||
echo &"Test failed for {fen} -> {board.toFEN()}"
|
||||
|
||||
@@ -81,8 +81,10 @@ func `$`*(self: MoveList): string =
|
||||
|
||||
|
||||
func add*(self: var MoveList, move: Move) {.inline.} =
|
||||
self.data[self.len] = move
|
||||
inc(self.len)
|
||||
# Keep the length across the move store, avoiding an aliasing reload.
|
||||
let index = self.len
|
||||
self.data[index] = move
|
||||
self.len = index + 1
|
||||
|
||||
func clear*(self: var MoveList) {.inline.} =
|
||||
self.len = 0
|
||||
@@ -217,4 +219,4 @@ func toUCI*(self: Move): string =
|
||||
|
||||
|
||||
proc newMoveList*: MoveList {.inline, noinit.} =
|
||||
result.len = 0
|
||||
result.len = 0
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
|
||||
# Thanks @analog-hors for the contribution! The code below is heavily derived from hers :)
|
||||
import heimdall/pieces
|
||||
import heimdall/threats/index
|
||||
import heimdall/util/memory/aligned
|
||||
|
||||
import std/endians
|
||||
import std/streams
|
||||
import heimdall/util/simd_dispatch
|
||||
|
||||
|
||||
when defined(simd):
|
||||
@@ -41,14 +43,18 @@ const
|
||||
FT_QUANT_BITS* {.define: "ftQuantBits".} = 8
|
||||
QA* = (1 shl FT_QUANT_BITS) - 1
|
||||
L1_QUANT_BITS* {.define: "l1QuantBits".} = 7
|
||||
# Rescale disk biases into the pre-shift space used by inference.
|
||||
L1_BIAS_SHIFT* {.define: "l1BiasShift".} = 0
|
||||
QUANT_BITS* {.define: "quantBits".} = 6
|
||||
FT_SCALE_BITS* {.define: "ftScaleBits".} = 7
|
||||
QB* = 1 shl QUANT_BITS
|
||||
SINGLE_LAYER* {.booldefine: "singleLayer".} = false
|
||||
# Number of king input buckets
|
||||
NUM_INPUT_BUCKETS* {.define: "inputBuckets".} = 4
|
||||
NUM_OUTPUT_BUCKETS* {.define: "outputBuckets".} = 8
|
||||
MERGED_KINGS* {.booldefine: "mergedKings".} = true
|
||||
MIRRORED* {.booldefine: "horizontalMirroring".} = true
|
||||
VERBATIM_NET* {.booldefine: "verbatimNet".} = true
|
||||
VERBATIM_NET* {.booldefine: "verbatimNet".} = false
|
||||
DUAL_ACTIVATION* {.booldefine: "dualActivation".} = true
|
||||
NET_ID* {.define: "netID".} = ""
|
||||
# LUT mapping king square to buckets (it's mirrored
|
||||
@@ -81,7 +87,15 @@ when not (QA + 1).isPowerOfTwo():
|
||||
{.fatal: &"L1 quantization must be a power of 2 minus one (got {QA} instead)".}
|
||||
|
||||
|
||||
when VERBATIM_NET:
|
||||
{.fatal: "TI networks must be loaded field by field; use VERBATIM_NET=0".}
|
||||
|
||||
|
||||
type
|
||||
TransposedInt16Layer*[I, O: static[int]] = object
|
||||
weight* {.align(ALIGNMENT_BOUNDARY).}: array[O, array[I, int16]]
|
||||
bias* {.align(ALIGNMENT_BOUNDARY).}: array[O, int16]
|
||||
|
||||
Int32Layer*[I, O: static[int]] = object
|
||||
weight* {.align(ALIGNMENT_BOUNDARY).}: array[I, array[O, int32]]
|
||||
bias* {.align(ALIGNMENT_BOUNDARY).}: array[O, int32]
|
||||
@@ -97,16 +111,23 @@ type
|
||||
weight* {.align(ALIGNMENT_BOUNDARY).}: array[B, array[I * O, int8]]
|
||||
bias* {.align(ALIGNMENT_BOUNDARY).}: array[B, array[O, int32]]
|
||||
|
||||
ThreatWeights* = array[TOTAL_THREATS, array[L1_SIZE, int8]]
|
||||
|
||||
Network* = object
|
||||
ft*: Int16Layer[FT_SIZE * NUM_INPUT_BUCKETS, L1_SIZE]
|
||||
# This is ugly, but since our indexing scheme into the L1 is not
|
||||
# representable with a 2D array (the dimensions are interleaved),
|
||||
# we must sacrifice abstraction for speed. The data is ordered the
|
||||
# way dpbusd expects it to be, so we have to adapt ourselves
|
||||
l1*: BucketedL1[NUM_OUTPUT_BUCKETS, L1_SIZE, L2_SIZE]
|
||||
# We multiply the L2 size by 2 because we do dual activations
|
||||
l2*: Bucketed[NUM_OUTPUT_BUCKETS, Int32Layer[(L2_SIZE * (1 + DUAL_ACTIVATION.int)), L3_SIZE]]
|
||||
l3*: Bucketed[NUM_OUTPUT_BUCKETS, Int32Layer[L3_SIZE, 1]]
|
||||
threatWeights* {.align(ALIGNMENT_BOUNDARY).}: ThreatWeights
|
||||
when SINGLE_LAYER:
|
||||
output*: TransposedInt16Layer[L1_SIZE * 2, NUM_OUTPUT_BUCKETS]
|
||||
else:
|
||||
# This is ugly, but since our indexing scheme into the L1 is not
|
||||
# representable with a 2D array (the dimensions are interleaved),
|
||||
# we must sacrifice abstraction for speed. The data is ordered the
|
||||
# way dpbusd expects it to be, so we have to adapt ourselves
|
||||
l1*: BucketedL1[NUM_OUTPUT_BUCKETS, L1_SIZE, L2_SIZE]
|
||||
# We multiply the L2 size by 2 because we do dual activations
|
||||
l2*: Bucketed[NUM_OUTPUT_BUCKETS, Int32Layer[(L2_SIZE * (1 + DUAL_ACTIVATION.int)), L3_SIZE]]
|
||||
l3*: Bucketed[NUM_OUTPUT_BUCKETS, Int32Layer[L3_SIZE, 1]]
|
||||
|
||||
|
||||
UpdateQueue* = object
|
||||
adds: array[2, int]
|
||||
@@ -124,41 +145,66 @@ proc readLittleInt16(stream: Stream): int16 {.inline.} =
|
||||
littleEndian16(addr result, addr raw)
|
||||
|
||||
|
||||
proc writeLittleInt16(stream: Stream, value: int16) {.inline.} =
|
||||
var raw: int16
|
||||
littleEndian16(addr raw, unsafeAddr value)
|
||||
stream.writeData(addr raw, sizeof(raw))
|
||||
|
||||
|
||||
proc readLittleInt32(stream: Stream): int32 {.inline.} =
|
||||
var raw = stream.readInt32()
|
||||
littleEndian32(addr result, addr raw)
|
||||
|
||||
|
||||
|
||||
const
|
||||
FT_GROUP_PERM = block:
|
||||
when defined(simd) and defined(avx512):
|
||||
proc writeLittleInt32(stream: Stream, value: int32) {.inline.} =
|
||||
var raw: int32
|
||||
littleEndian32(addr raw, unsafeAddr value)
|
||||
stream.writeData(addr raw, sizeof(raw))
|
||||
|
||||
|
||||
when not SINGLE_LAYER:
|
||||
const
|
||||
# CJ's packing schedule lets every backend use the AVX-512 ordering.
|
||||
# These are four-byte input groups; PSQ/TI accumulator lanes stay canonical.
|
||||
# Small debugging architectures use the canonical layout and scalar head.
|
||||
FT_GROUP_PERM = when L1_SIZE mod 128 == 0:
|
||||
[0, 1, 8, 9, 2, 3, 10, 11, 4, 5, 12, 13, 6, 7, 14, 15]
|
||||
elif defined(simd) and defined(avx2):
|
||||
[0, 1, 4, 5, 2, 3, 6, 7]
|
||||
else:
|
||||
[0]
|
||||
|
||||
|
||||
# Shamelessly LLM translated from https://github.com/JonathanHallstrom/pawnocchio/blob/pp/src/nnue/outputs/multilayer.zig#L41
|
||||
# Seriously this is black magic shit
|
||||
proc transform(net: var Network, l1wDisk: var L1WeightDisk, l2wDisk: var L2WeightDisk, l3wDisk: var L3WeightDisk) =
|
||||
## Transforms Bullet's disk weight layout into the layout used for inference.
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for i in 0..<L1_SIZE div 4:
|
||||
let src = (i div FT_GROUP_PERM.len) * FT_GROUP_PERM.len + FT_GROUP_PERM[i mod FT_GROUP_PERM.len]
|
||||
for j in 0..<L2_SIZE:
|
||||
for k in 0..<4:
|
||||
net.l1.weight[bucket][i * 4 * L2_SIZE + j * 4 + k] = l1wDisk[src * 4 + k][bucket][j]
|
||||
# Shamelessly LLM translated from https://github.com/JonathanHallstrom/pawnocchio/blob/pp/src/nnue/outputs/multilayer.zig#L41
|
||||
# Seriously this is black magic shit
|
||||
proc transform(net: var Network, l1wDisk: var L1WeightDisk, l2wDisk: var L2WeightDisk, l3wDisk: var L3WeightDisk) =
|
||||
## Transforms Bullet's disk weight layout into the layout used for inference.
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for i in 0..<L1_SIZE div 4:
|
||||
let src = (i div FT_GROUP_PERM.len) * FT_GROUP_PERM.len + FT_GROUP_PERM[i mod FT_GROUP_PERM.len]
|
||||
for j in 0..<L2_SIZE:
|
||||
for k in 0..<4:
|
||||
net.l1.weight[bucket][i * 4 * L2_SIZE + j * 4 + k] = l1wDisk[src * 4 + k][bucket][j]
|
||||
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for i in 0..<L2_SIZE * (1 + DUAL_ACTIVATION.int):
|
||||
for j in 0..<L3_SIZE:
|
||||
net.l2.buckets[bucket].weight[i][j] = l2wDisk[i][bucket][j]
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for i in 0..<L2_SIZE * (1 + DUAL_ACTIVATION.int):
|
||||
for j in 0..<L3_SIZE:
|
||||
net.l2.buckets[bucket].weight[i][j] = l2wDisk[i][bucket][j]
|
||||
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for i in 0..<L3_SIZE:
|
||||
net.l3.buckets[bucket].weight[i][0] = l3wDisk[i][bucket]
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for i in 0..<L3_SIZE:
|
||||
net.l3.buckets[bucket].weight[i][0] = l3wDisk[i][bucket]
|
||||
|
||||
func l1WeightIndex*(input, output: int): int {.inline.} =
|
||||
## Map a canonical activated FT input to the packed inference weight layout.
|
||||
## Scalar inference and file export must undo the SIMD packing permutation.
|
||||
const inverse = block:
|
||||
var order: array[FT_GROUP_PERM.len, int]
|
||||
for i, source in FT_GROUP_PERM:
|
||||
order[source] = i
|
||||
order
|
||||
let group = input div 4
|
||||
let stored = (group div inverse.len) * inverse.len + inverse[group mod inverse.len]
|
||||
return stored * 4 * L2_SIZE + output * 4 + input mod 4
|
||||
|
||||
|
||||
proc loadNet*(stream: Stream): Network =
|
||||
@@ -170,44 +216,57 @@ proc loadNet*(stream: Stream): Network =
|
||||
for j in 0..<L1_SIZE:
|
||||
result.ft.weight[i][j] = stream.readLittleInt16()
|
||||
|
||||
for i in 0..<TOTAL_THREATS:
|
||||
for j in 0..<L1_SIZE:
|
||||
result.threatWeights[i][j] = stream.readInt8()
|
||||
|
||||
for i in 0..<L1_SIZE:
|
||||
result.ft.bias[i] = stream.readLittleInt16()
|
||||
|
||||
# Note: we don't multiply by 2 like for single-layer nets: normally we
|
||||
# would do that so we load in both perspective networks, but since we
|
||||
# do pairwise multiplication (which halves the matmul size), that cancels
|
||||
# it out
|
||||
var l1wDisk {.noinit.}: L1WeightDisk
|
||||
for i in 0..<L1_SIZE:
|
||||
when SINGLE_LAYER:
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for j in 0..<L2_SIZE:
|
||||
l1wDisk[i][bucket][j] = stream.readInt8()
|
||||
|
||||
for bucket in 0..<NUM_OUTPUT_BUCKETS:
|
||||
for i in 0..<L2_SIZE:
|
||||
result.l1.bias[bucket][i] = stream.readLittleInt32()
|
||||
|
||||
var l2wDisk {.noinit.}: L2WeightDisk
|
||||
# If we do dual activation for the L2, we effectively
|
||||
# have 2 of them
|
||||
for i in 0..<(L2_SIZE * (1 + DUAL_ACTIVATION.int)):
|
||||
for i in 0..<L1_SIZE * 2:
|
||||