15 KiB
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 checkto verify changes. Verify Nim changes by compiling through the Makefile, usingmake devunder normal circumstances. - Use
make devwhen all Makefile dependencies are already satisfied locally, so no network downloads need to happen. It invokes the native build withSKIP_DEPS=1, skipping dependency installation and network fetching, and writes the engine tobin/heimdall(.exeon Windows). - Local prerequisites include Nim 2.2.2 or greater (required by
heimdall.nimble; CI uses 2.2.6), the declared Nimble packages, Clang, the platform linker (LLD on Linux/Windows, Apple ld on macOS), and the actual NNUE weights selected byEVALFILE. Multilayer TI inference is the default and requires an explicitEVALFILE. UseSINGLE_LAYER=1for the localthreans.bindebugging fixture; the old PSQ-only production network is not a compatible TI file. make nativeshould almost never be needed. Use it only ifmake devfails for reasons related to missing dependencies. Inspect the failure first:make nativerunsnimble 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 buildis 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: buildsbin/testdallwithIS_TEST=1and runs a depth-9 bench.- After
make dev, runpython -m unittest discover -s tests -p 'test_*.py'for UCI and Python test-tool regressions. To enable runtime checks, build withmake dev IS_TEST=1 EXE_BASE=bin/testdalland setHEIMDALL=bin/testdallwhen running the Python tests. make test-suite: runs deeper benches and perft comparisons. This is slow and requires Python and Stockfish onPATH; seepython tests/suite.py -hfor explicit binary paths.make bench: builds withmake devand runs the engine's search benchmark.
Focused correctness tests
The production architecture is multilayer. Select the single-layer debug build explicitly when using the toy fixture:
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:
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:
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 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:
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:
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:
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.nimowns the state wrappers;refreshThreats(self, side, position)andrefreshPSQboth 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.