Compare commits

...

79 Commits

Author SHA1 Message Date
a570d38c2b Pass Microsoft-style syntax when building under MSVC on Windows (bench 5551733) 2026-09-14 22:15:39 +02:00
12903ac97a Restructure README documentation (bench 5551733)
Move build, testing, UCI and TUI details into focused guides while keeping the README concise and preserving the engine overview, ratings and credits.

Co-authored-by: GPT 5.6 Luna <codex@openai.com>
2026-09-12 22:15:49 +02:00
baa5e82c16 Mark runtime SIMD target declarations as used (bench 5551733) 2026-09-12 22:15:48 +02:00
14a248638b Respect node limits after verification searches (bench 5551733) 2026-09-12 22:15:48 +02:00
a12eacea82 Guard InvalidCmpOp pragma on older Nim compilers (bench 5551733) 2026-09-12 22:15:48 +02:00
872dba5219 Explicitly silence or resolve all unnecessary hints/warnings 2026-09-12 19:59:42 +02:00
d602d7e2c1 Clarify universal binaries are available after 1.5 (bench 5551733) 2026-09-12 02:42:08 +02:00
5dde49ed0c Simplify universal executable selection guide (bench 5551733) 2026-09-12 02:34:11 +02:00
59699841f5 Add universal runtime SIMD binaries (bench 5551733)
Select SIMD kernels at startup and share the AVX-512 packing layout across
scalar, x86 and NEON inference using CJ's packing schedule. Preserve the
canonical network file format and provide backend diagnostics and overrides.

Add universal release targets, including a combined macOS executable. Tag
releases publish only universal binaries; individual SIMD variants remain
available through manual workflow runs.

Validate native x86 backends, Windows/Wine and AArch64/QEMU, older CPU and
OS vector-state fallback, network export/reload, NNUE state and UCI searches.
Tyrfing passes 17731 state comparisons per native backend and the full bench.
AVX-512/VNNI execution and native macOS validation require suitable runners.
2026-09-12 02:30:32 +02:00
b182e53f83 Optimize SIMD row updates and NEON dots (bench 5551733)
Unroll threat updates and rebuilds across four vectors on every SIMD
backend. Add a shared paired widening-load helper with full-register NEON
loads and a fallback using existing x86 primitives.

Use NEON SADALP and saturating even/odd byte products while preserving the
existing arithmetic contract. Reduce dot-loop register pressure on SSE2
and NEON to eliminate accumulator spills. Keep explicit PSQ unrolling on
SSE2, AVX2 and NEON, where measurements and code generation support it.

Add exhaustive byte-product and paired-load checks, PSQ wrapping/tail
oracles, and documentation of backend choices and arithmetic contracts.

Validation: shared correctness suites on scalar, SSE2, SSSE3, SSE4.1,
AVX2 and NEON under QEMU; tail-width checks; all selected engine builds;
AVX-512/VNNI compile and assembly checks; make dev and 49 Python/UCI tests.
Depth-13 bench remains 5551733 nodes. Eight paired fixed-node UCI runs on
a Ryzen 5900X show a 7.84% native AVX2 NPS gain (95% interval 7.37-8.32%)
with matching per-position nodes, depths and best moves. ARM is not timed
natively and AVX-512 is not executed on this host.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-12 01:43:58 +02:00
8a812ae6b3 Add Mac targets and independent release jobs (bench 5551733)
Add native Intel macOS/SSE2 and Apple Silicon/NEON Make targets with a
macOS 11 deployment target and Apple ld stack settings.

Build each release artifact in its own CI job. Manual dispatch selects one
target or platform and can publish it to an existing release tag, using
current workflow tooling with the exact tagged source. Give every target
its own checksum and archive so uploads leave other release assets intact.

Document source selection, Mac builds and adding artifacts to an existing
release without moving its tag.

Validation: actionlint, 13 release tests, all 15 target build plans and
both Mac convenience targets. Single-target Linux builds from current
and 1.5.1-dev source both matched bench 5551733. Native Mac compilation
still needs validation on GitHub runners.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-12 00:13:58 +02:00
9e16ed867d Run SIMD correctness CI only on manual dispatch (bench 5551733)
Remove push and pull-request triggers from the correctness workflow and
document how to start it manually. Release workflows retain their tag
triggers.

Validation: actionlint and git diff --check. Engine code is unchanged.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-11 23:51:12 +02:00
660fea4a56 Add SSE2, SSSE3, SSE4.1 and NEON backends (bench 5551733)
Extend the existing vec* API with a shared x86 128-bit backend and a small
AArch64 NEON adapter, preserving integer arithmetic and inference layout.
SSE4.1 selects native int32 min/max and multiplication intrinsics.

Use feature-set names for Make targets and release artifacts, with CPU
tuning selected separately through TUNE. Preserve the historical download
guide in the README and scope the new names to versions after 1.5.0.

Add shared SIMD and inference tests, native ARM CI, minimum x86 CPU checks,
and Linux ARM release builds. Check full v3/v4 requirements before running
release benches.

Validation: all six scalar/SSE2/SSSE3/SSE4.1/AVX2/NEON correctness suites,
41 Python tests, production bench 5551733, workflow lint and release dry
runs. NEON also passes under QEMU Cortex-A53, including full-engine perft
and single-/four-thread searches.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-11 23:50:26 +02:00
b7bfe4c8e6 Bound history updates without widening tables (bench 5551733)
Clamp depth-scaled bonuses and compute complete history updates in int32,
narrowing only the final value to int16. Apply continuation-history gravity
to each individual entry so its value remains bounded. Keep table layouts
unchanged and use the same helper for quiet and capture histories.

Add the matetrack overflow position as a UCI regression. It fails on the
old checked binary; the fixed engine also completes a 1M-node reproduction.
Checked and optimized make dev builds pass, as do all 36 Python tests and
a checked 280-position matetrack run without runtime failures.

Eight paired timed UCI runs measured -0.06% NPS change, with a 95% bootstrap
interval of [-1.98%, +1.92%]. Playing strength has not been tested.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-11 09:56:11 +02:00
d72809cf69 Always clamp variation count to the number of legal moves (bench 5247438) 2026-09-11 01:29:27 +02:00
e8a2611a88 Ensure MultiPV searches don't exclude best moves across ID iterations (bench 5247438) 2026-09-11 01:24:52 +02:00
7c418f2f9b Print lowerbound and upperbound mates correctly (bench 5247438) 2026-09-11 01:18:41 +02:00
5f2b6dca57 Clean up some of the more inconsequential AI assistance notes (bench 5247438) 2026-09-10 14:20:48 +02:00
6a313a2c41 Windows is fucking weird bro (bench 5247438) 2026-09-10 10:48:50 +02:00
fa63f28c19 Switch to TI net (tyrfing) (bench 5247438) 2026-09-10 10:35:14 +02:00
17f8102a9b Finalize TI inference (SIMD & scalar). Currently using pawnocchio's arch (bench 5834033) 2026-09-09 21:53:51 +02:00
1e2d2b0dc8 Add notes on AI usage (bench 5834033) 2026-09-09 14:28:26 +02:00
6370fb5941 Update license headers, clean up README, clarify licensing of third party content (bench 5834033) 2026-09-09 14:20:22 +02:00
97aab4aea2 Complete threat inputs indexing and refresh logic (bench 5834033) 2026-09-09 14:09:55 +02:00
c7b02fbe1a Initial work on threat inputs indexing (bench 5834033) 2026-09-09 00:32:50 +02:00
00b1950b87 Pack pieces into one byte (bench 5834033)
Store piece kind and color in a private uint8 payload, add construction and field access helpers, and migrate all constructors. Add packed representation tests while preserving defaults and sentinels.

Validated with make dev, packed-piece, move-generation and NNUE tests, and all 35 Python regression tests.

Co-authored-by: GPT-6 Astra <codex@openai.com>
2026-09-08 18:32:38 +02:00
f4156c9c00 Update README (bench 5834033) 2026-09-08 15:08:56 +02:00
6a73b1ec86 Make distinct board iterators Nim 2.2 compatible (bench 5834033)
Use explicit safe iterators for File, Rank, and Square traversal so compile-time generation remains compatible with Nim 2.2.2 and newer. Relax the Nimble and README version requirements accordingly.

Co-authored-by: Codex (GPT-6) <codex@openai.com>
2026-09-08 11:44:42 +02:00
35293bafaa Add DFRC genfens starts and randomize default opening length (bench 5834033)
Use the seeded generator for independent DFRC back ranks and an equal choice of 8 or 9 plies per opening. Preserve explicit lengths and use doMove for known legal moves. Document the options and add regression coverage.

Co-authored-by: Codex (GPT-6) <codex@openai.com>
2026-09-07 18:23:05 +02:00
899f031976 Support configurable L1 bias scaling (bench 5834033)
Rescale loaded L1 biases with L1_BIAS_SHIFT for networks that add
biases after requantization. Keep the default shift at zero for
existing networks and document bias compatibility and dual activation.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-07 15:35:01 +02:00
79fd2b4a9c Add OpenBench genfens support (bench 5834033)
Co-authored-by: Codex (GPT-5.6 Luna) <codex@openai.com>
2026-09-07 15:34:30 +02:00
e110f2419e Bump version to 1.5.1 (bench 5834033) 2026-09-07 10:34:30 +02:00
344fed5589 docs: record carryover experiments and final validation (bench 5834033)
Document retained limiter, NNUE and PGO improvements, rejected prefetch trials, correctness findings, measured intervals, regression coverage, and reproduction commands.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-06 13:10:03 +02:00
1fdb162c76 build: add opt-in profile-guided native builds (bench 5834033)
Train a separate instrumented executable on configurable node and time workloads, merge its profiles, and rebuild with profile use. Preserve EXE overrides, reject standalone-test training, and leave default dev and OpenBench behavior unchanged.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-06 13:09:46 +02:00
708803afd7 feat(bench): add paired UCI workload measurements (bench 5834033)
Run normalized FEN subsets with real node or time budgets, recording final search summaries and optional perf counters. Require per-position node/depth/best-move agreement for deterministic comparisons and cover the driver with regression tests.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-06 13:09:42 +02:00
96d6d73c92 perf(nnue): share move decoding across accumulator perspectives (bench 5834033)
Reuse the existing scalar and SIMD update kernels with one move decode for both perspectives, retaining the separate king-refresh path. Extend fresh-versus-incremental checks to all Chess960 castling arrangements, including stationary pieces and king/rook swaps.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-06 13:08:53 +02:00
7fe35dcf29 perf(search): skip unnecessary hard-limit dispatch (bench 5834033)
Cache active limit kinds and bypass limit-list scans when no hard budget can expire. Keep node limits unconditional and preserve the 1024-node clock sampling cadence. Correctness and limiter regressions are introduced separately.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-06 13:08:30 +02:00
f2befa5a4d fix(search): complete all MultiPV lines at the depth limit (bench 5834033)
Check depth limits between whole iterative-deepening iterations instead of interrupting later PV lines. Preserve real node and time cutoffs, including mixed budgets, and cover limiter state transitions with mandatory diagnostics.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-06 13:07:49 +02:00
27b192eee2 fix(search): keep aspiration retries above quiescence (bench 5834033)
Clamp root retry depth to one so repeated fail-high reductions cannot return an empty quiescence PV and stop before the node budget. Strengthen the warm-TT and late-worker regression.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-06 13:07:04 +02:00
e5e52a4946 docs: record audit results and validation workflows (bench 5834033)
Document retained and reverted experiments from both audit passes, aggregate measurements, correctness coverage, limitations and reproduction commands.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 22:07:33 +02:00
6e2f8df714 perf(search): pre-index continuation-history rows (bench 5834033)
Transpose continuation history for sibling locality and resolve the shared 1/2/4-ply row pointers once per move list. Keep all score/update semantics and omit quiet-history code from capture-only scoring.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 22:07:08 +02:00
6c42b7a8dd perf(movegen): specialize pawn generation by color (bench 5834033)
Select a compile-time pawn generator for each color so shifts, source offsets and rank masks are constants. Preserve move ordering and legality masks.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 22:06:43 +02:00
7cc48030f9 perf(movegen): defer special-move metadata loads (bench 5834033)
Read king/rook metadata only in the branches that need it, and obtain the previous en-passant target from the copied position. Avoid keeping unused values live across position-stack growth.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 22:06:19 +02:00
f2fb343556 perf(movegen): simplify castling targets and clearance masks (bench 5834033)
Use direct home-rank king/rook destinations and one union occupancy test per side. Preserve Chess960 occupancy removal and attack-path checks.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 22:05:47 +02:00
2f430bd5cb perf(search): specialize quiescence move scoring (bench 5834033)
Make the picker/scorer's quiescence mode static and skip killer/countermove probes that cannot match captures. Depends on capture-only generation excluding quiet castling.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 22:05:17 +02:00
f2657d025f fix(movegen): exclude castling from capture-only generation (bench 5834033)
Restore the capture-only contract used by quiescence. Add capture-list/state/hash/undo/clone invariants, all special-move flags, and cleared castling paths for all 960 arrangements and both colors.

This correctness fix changes search nodes; throughput measurements use a separately saved capture-correct baseline.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:23:50 +02:00
4477560e9e fix(bench): use elapsed time for multithreaded throughput (bench 5725647)
Keep CPU timing for single-thread benchmarks and use monotonic wall time for multithreaded NPS instead of summed process CPU time.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:23:14 +02:00
4ba8d3b2f9 perf(nnue): reuse worker state and copy only live frames (bench 5725647)
Introduce idle-state copyFrom, retain worker allocations across setup/network changes, and copy only live accumulator/update prefixes. Extend NNUE regressions to cover reused storage and self-rebinding.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:22:41 +02:00
7954d5010a fix(nnue): allocate an accumulator for the deepest search ply (bench 5725647)
Reserve the root frame plus all 255 search plies. Add incremental/full-refresh checks covering delayed updates, clone undo, special moves and eager/lazy depth boundaries; mandatory checks use doAssert.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:22:09 +02:00
7f66a42a4c perf(see): specialize the evaluation context at compile time (bench 5725647)
Remove runtime selection of SEE weight arrays by making the caller's context static.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:21:26 +02:00
1018f5c1a4 fix(memory): preserve alignment and advise full huge-page ranges (bench 5725647)
Round allocations to their required alignment, advise the entire allocation, and retain aligned allocation/free when THP is unavailable. Add a forced noTHP path and alignment regressions.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:21:01 +02:00
14ded78eab perf(position): keep position stacks plain-data (bench 5725647)
Remove the custom copy hook so shrinking a position stack does not clear popped entries. Enforce the raw-copy contract with a compile-time doAssert.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:20:35 +02:00
70edfdcdfc perf(movegen): retain the move-list append index (bench 5725647)
Keep the list length across the move store to avoid an aliasing reload.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:20:09 +02:00
5ef4974993 fix(search): clear stale worker node totals before dispatch (bench 5725647)
Reset idle worker counters before enqueuing Go so short node-limited searches cannot observe previous-search totals. Add node-limit, worker-restart and affinity-based regressions.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:19:32 +02:00
61d82a67d8 fix(uci): avoid reallocating an unchanged hash size (bench 5725647)
Compare configured MiB with configured MiB instead of comparing MiB with the transposition-table entry count. Cover repeated size settings.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:19:03 +02:00
297963ee3f fix(uci): reject unsupported searches before worker dispatch (bench 5725647)
Validate unsupported time controls and pondering synchronously so rejected requests cannot strand the busy flag or block stop/wait. Test rejected requests and allowed time-control exemptions.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:18:35 +02:00
3a3d37a563 fix(uci): validate command arguments and preserve token boundaries (bench 5725647)
Guard missing arguments and invalid depths, recognize whitespace and multiword options, and allow searchmoves before limits. Add parser integration regressions.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:18:08 +02:00
79784c33d8 fix(tests): reject incomplete perft results and propagate failures (bench 5725647)
Validate perft summaries, repair imported verbose output and crash reporting, and return reliable suite exit statuses in serial and parallel modes. Include test-tool regressions.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:17:51 +02:00
25073310c7 build: support focused audit tests and paired benchmarks (bench 5725647)
Allow standalone MAIN targets and extra Nim flags through make dev. Add paired search/perft measurements and focused NNUE inference and worker-setup benchmarks.

Co-authored-by: Codex (GPT-6 Astra) <codex@openai.com>
2026-09-05 21:17:20 +02:00
c8ad8b2806 Add AGENTS.md (bench 5725647) 2026-09-05 09:54:21 +02:00
01948cb2fe The most important change ever in the history of heimdall (autism go brrrr) (bench 5725647) 2026-09-03 20:48:02 +02:00
2acfb53bfa Add dummy handler for register command (bench 5725647) 2026-09-03 20:45:52 +02:00
f41874315d Keep track of and print lines that failed high/low, make pretty logs even prettier (lov u codex) (bench 5725647) 2026-09-03 20:16:25 +02:00
dcc7d7c614 Ensure at least 8MiB of stack space are available on all platforms (bench 5725647) 2026-09-03 19:38:35 +02:00
ef22de4886 Use incremental NNUE updates in policy eval (bench 5725647) 2026-08-29 13:59:53 +02:00
52b610a101 Add static evaluation mode to relabeler (bench 5725647) 2026-08-29 13:56:28 +02:00
50353a4631 Compute threats lazily (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:38:31 +02:00
5d46ea5dac Reduce position stack overhead (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:12:12 +02:00
556c9c8a6e Simplify transposition table ownership (bench 5725647) 2026-08-25 11:12:12 +02:00
e191ee8ac2 Speed up parallel relabelling (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:12:12 +02:00
f37f0ffd3d Make relabel depth optional (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:12:12 +02:00
94af695bda Print relabel progress in place (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:12:11 +02:00
c1e0f7a708 Report live relabel chunk progress (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:12:11 +02:00
11dc969671 Add relabel progress and ETA reporting (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:12:11 +02:00
9a598e40e3 Add parallel viriformat relabel command (bench 5725647)
Co-authored-by: Codex <codex@openai.com>
2026-08-25 11:12:10 +02:00
b607a75fd9 Top 40 babyyy (bench 5725647) 2026-07-14 20:08:03 +02:00
d6a3386753 Speed up move generation and position updates (bench 5725647)
Co-authored-by: GPT 5.6 Sol <codex@openai.com>
2026-07-09 22:04:00 +02:00
c010a37702 Update ratings (bench 5725647) 2026-07-07 12:47:00 +02:00
d8ec13e049 Remove dead lists, add row for 1.5, format tables (bench 5725647) 2026-06-29 10:27:17 +02:00
131 changed files with 10604 additions and 1700 deletions

View File

@@ -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
View File

@@ -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.

View 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"

View 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"

View File

@@ -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
View 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
View 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

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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:

View File

@@ -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
View 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.

View File

@@ -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
View File

@@ -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
View File

@@ -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.
-->
![Heimdall](Heimdall_logo_v2.png "Heimdall")
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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
View 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"

View File

@@ -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
View 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
View 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
View 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.

View 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
View 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
View 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
View 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
View 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
View 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.

View File

@@ -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"

View File

@@ -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.

View File

@@ -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]

View File

@@ -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()

View 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()

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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()

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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()}"

View File

@@ -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

View File

@@ -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: