Skip to content

Latest commit

 

History

History
491 lines (348 loc) · 20.4 KB

CONTRIBUTING.md

File metadata and controls

491 lines (348 loc) · 20.4 KB

Contributing

This guide provides information for developers working on the chess engine codebase, including development setup, architecture, code standards, and optimization workflows.

Development Setup

One-Time Setup

make setup

This command does these steps:

  1. Installs the Rust toolchain that rust-toolchain.toml pins, with rustfmt and clippy. This step needs rustup 1.28 or later.
  2. Installs a pinned nightly toolchain with rustfmt. make fmt and make lint use it to group and merge imports. make toolchains does steps 1 and 2 only.
  3. Adds llvm-tools-preview to the stable toolchain (for make coverage), and miri and rust-src to the nightly toolchain (for make miri). make test-toolchains does this step only.
  4. Installs the cargo tools that the Makefile uses (samply, cargo-nextest, cargo-machete, cargo-deny, typos-cli, taplo-cli, lychee, cargo-llvm-cov, cargo-mutants). It uses cargo binstall if it is available, and cargo install if not.
  5. Sets core.hooksPath to .githooks, so that the pre-commit hook runs.

make docs also needs these tools, which make setup does not install:

  • Node.js 22 or later. make docs runs npm ci, which installs markdownlint-cli2 at the versions in package-lock.json.
  • Vale 3.24 or later, on your PATH.

Building and Running

# Build release version (required for performance testing)
cargo build --release

# Install the binary
cargo install --path .

# Run the engine
chess --help

IMPORTANT: Always use the installed chess binary (available via which chess) rather than ./target/release/chess. The binary is installed at ~/.cargo/bin/chess and should be in your PATH.

Testing and Quality

# Run all tests
cargo test

# Run benchmarks (requires release profile)
cargo bench

# Format code (required before commit)
cargo fmt

# Lint code (required before commit)
make lint

# Check perft at depth 5 (move generation correctness)
make perft

# Check for source files that no module declares, or that have no //! doc
make modules

# Build the API docs and fail on rustdoc warnings
make rustdoc

# Check dependencies: unused crates (cargo-machete), and advisories, bans,
# licenses, and sources (cargo-deny, configured in deny.toml).
# This needs network access, because cargo-deny downloads the advisory database.
make deps

# Lint the Markdown docs: markdownlint, Vale, and local links
make docs

# Test coverage (HTML report in target/llvm-cov/html)
make coverage

# Mutation tests: tracked changes since main, or one file (slow).
# Add new files to git (git add -N) first, so that the diff includes them.
make mutants
make mutants FILE=src/move_generator/rules.rs

# Concurrent transposition table test under Miri (the first run builds the Miri
# sysroot). This fails now: the
# lock-free table has a data race, which is undefined behavior in Rust.
make miri

# Run the same checks as CI (lint, rustdoc, docs, deps, test, perft)
make ci

# Profile perft at depth 6 with samply
make profile

Common Subcommands

# Play against the engine
chess play --depth 4 --color white

# Calculate best move from FEN position
chess best-move --fen "1Q6/8/8/8/8/k1K5/8/8 w - - 0 1"

# Count positions (performance test)
chess dev perft --depth 6 --strategy alpha-beta

# Test against Stockfish
chess dev elo --depth 6 --starting-elo 2000

Architecture

Core Design Philosophy

The engine separates concerns into three layers:

  1. Generic alpha-beta search (alpha_beta_searcher/) - Game-agnostic algorithm using traits
  2. Chess-specific implementations (chess_search/) - Chess logic implementing the search traits
  3. Board representation & move generation (board/, move_generator/) - Low-level chess mechanics

Key Architectural Patterns

Trait-Based Search: The alpha-beta algorithm is implemented generically using traits (MoveGenerator, Evaluator, MoveOrderer). Chess-specific implementations live in chess_search/implementation.rs. This separation enables:

  • Independent testing of search algorithm logic
  • Clean separation between algorithm and domain logic
  • Type-safe abstraction boundaries

Bitboard Representation: Board state uses 64-bit integers (Bitboard) for efficient bitwise operations. Individual squares are represented as newtype-wrapped u8 indices (Square). This enables CPU-level optimizations for move generation and attack calculations.

Move Type Hierarchy: Chess moves are represented as an enum (ChessMove) with variants for different move types:

  • StandardChessMove - Regular piece moves and captures
  • CastleChessMove - King and rook castling
  • EnPassantChessMove - Special pawn capture
  • PawnPromotionChessMove - Pawn reaching back rank

Each variant implements the ChessMoveEffect trait for applying/unapplying moves to board state.

State Management: Board maintains a StateStack for efficient undo operations. Move application pushes state, move reversal pops state. This enables the search algorithm to explore game trees without expensive cloning.

Zobrist Hashing: Hash tables generated at compile time by precompile/ build script enable incremental hashing for caching move generation and transposition table lookups.

Move Ordering: The ChessMoveOrderer prioritizes moves to improve alpha-beta pruning efficiency:

  1. PV move from transposition table
  2. Killer moves (from sibling beta cutoffs at the same ply)
  3. Captures (MVV-LVA ordering)
  4. Promotions
  5. Quiet moves (history heuristic, then piece type)

Interior nodes use incremental selection (pick-best), not a full sort. The PV move and the killer moves go first. Then MoveOrderer::pick_next() selects each remaining move when the search needs it. Root nodes and quiescence nodes use a full sort. Better ordering gives more pruning, and more pruning gives a faster search.

Module Organization

common/ - Shared types between engine and precompiler

  • bitboard/bitboard.rs - 64-bit integer set operations
  • bitboard/square.rs - Newtype-wrapped u8 for board squares

precompile/ - Build-time code generation (runs at compile time)

  • zobrist/ - Generates Zobrist hash tables
  • magic/ - Computes magic bitboard numbers for sliding piece attacks
  • book/ - Generates opening book from PGN data

src/ - Main engine implementation

  • prelude.rs - Common type re-exports (Board, Color, Piece, ChessMove, Bitboard, Square)
  • alpha_beta_searcher/ - Generic search algorithm with traits
    • search.rs - Core alpha-beta with iterative deepening, aspiration windows, quiescence, NMP, RFP, futility pruning, LMR, check extensions
    • traits.rs - Game-agnostic trait definitions (MoveGenerator, Evaluator, MoveOrderer)
    • transposition_table.rs - Fixed-size, lock-free table with two slots per bucket (depth-preferred and always-replace)
    • killer_moves.rs - Thread-local killer move storage for parallel search
    • tests.rs - Comprehensive search algorithm tests
  • chess_search/ - Chess-specific search implementations
    • implementation.rs - Implements MoveGenerator, Evaluator traits for chess
    • move_orderer.rs - Move ordering heuristics (MVV-LVA, killer moves, PV)
    • history_table.rs - History heuristic for move ordering
    • tests.rs - Chess search tests
  • board/ - Board state representation
    • board.rs - Main Board struct with piece placement and game state
    • piece_set.rs - Per-color piece bitboards
    • state_stack.rs - Undo stack for move reversal
    • color.rs, piece.rs - Color and piece type definitions
    • castle_rights.rs, halfmove_clock.rs, fullmove_number.rs - Game state newtypes
    • display.rs, error.rs, move_info.rs, position_info.rs - Board utilities
    • fen.rs, fen_serialize.rs - FEN notation parsing and serialization
    • tests.rs - Board state tests
  • chess_move/ - Move types and application
    • chess_move.rs - ChessMove enum with all move variants
    • chess_move_effect.rs - Trait for applying/unapplying moves to board
    • standard.rs, castle.rs, en_passant.rs, pawn_promotion.rs, capture.rs - Move implementations
    • algebraic_notation.rs - Display moves in standard notation
    • traits.rs - Move-related traits
  • move_generator/ - Legal move generation
    • generator.rs - Main move generation logic with caching
    • targets.rs - Calculate attack/movement targets for pieces
    • magic_table.rs - Magic bitboards for sliding pieces (rooks, bishops, queens)
    • rules.rs - Check, checkmate, and game ending rules
  • evaluate/ - Position scoring
    • evaluation.rs - Board evaluation heuristic (material + position)
    • evaluation_tables.rs - Piece-square tables for positional scoring
  • game/ - Game loop and I/O
    • loop.rs - Main game loop using InputSource and GameRenderer traits
    • engine.rs - Computer player using alpha-beta search
    • input_source.rs, renderer.rs - Trait abstractions for I/O
    • display.rs, action.rs, mode.rs - Game utilities
    • menu_input.rs - End-of-game menu input
    • session.rs - Runs games and switches between play, watch, and pvp
  • book/ - Opening book
    • book.rs - Opening book lookup for move suggestions
  • input_handler/ - Move input parsing
    • input.rs - User move input handling
  • cli/ - Command-line interface (clap 4)
    • args.rs - Top-level commands and dispatch, including the hidden old command names
    • game.rs - play, watch, pvp
    • analyze.rs - best-move
    • uci.rs - uci
    • dev.rs - dev perft, dev search-bench, dev elo, dev puzzles
  • tools/ - Code for the developer tools
    • position_counter.rs, alpha_beta_benchmark.rs, stockfish_elo.rs, puzzle_suite.rs
  • uci/ - UCI protocol implementation
    • protocol.rs - UCI protocol state machine
    • command_parser.rs - UCI command parsing
    • response_formatter.rs - UCI response formatting
  • tui/ - Terminal user interface
    • app.rs - Main TUI application state and rendering
    • board_widget.rs - Chess board widget for ratatui
    • theme.rs - Color theme management
  • diagnostics/ - Performance diagnostics
    • memory_profiler.rs - Memory allocation tracking and profiling

Important Implementation Details

chess_position! Macro: Instantiates board state from ASCII art. Used extensively in tests:

let board = chess_position! {
    rnbqkbnr
    pppppppp
    ........
    ........
    ........
    ........
    PPPPPPPP
    RNBQKBNR
};

Build Script: Cargo.toml specifies build = "precompile/src/main.rs". This runs the precompiler before compilation to generate Zobrist tables and magic numbers.

Release Profile Optimizations: Critical for performance testing (see Cargo.toml):

  • lto = true - Link-time optimization
  • codegen-units = 1 - Single codegen unit for better optimization
  • opt-level = 3 - Maximum optimization

Code Standards

Module Structure

The codebase follows idiomatic Rust module organization practices:

  • mod.rs files: Only module declarations and re-exports, no implementation
  • Implementation files: Dedicated .rs files (e.g., board/board.rs, not in mod.rs)
  • Module docstrings: Required //! comment at top of all module and implementation files. make modules checks it.
  • Doc sections: A public function that returns Result needs a # Errors section. A public function that can panic needs a # Panics section. Clippy checks both.

Import Organization

All imports follow this ordering standard. make fmt applies it, and make lint checks it. Both use nightly rustfmt with group_imports = "StdExternalCrate" and imports_granularity = "Module". Nightly rustfmt also wraps comments at 100 columns:

  1. Standard library (std::, core::, alloc::)
  2. External crates (for example common, rayon, smallvec)
  3. Local imports: self::, then super::, then crate::, with no blank line between them

Put a blank line between groups. Each use statement imports from one module. rustfmt sorts the statements in each group.

Example:

use std::io;
use std::str::FromStr;

use common::bitboard::Square;
use rayon::prelude::*;

use super::helper::Helper;
use crate::board::Board;
use crate::chess_move::ChessMove;

Pre-Commit Hook

The hook is .githooks/pre-commit. make setup installs it. It runs make lint before each commit:

  • Nightly cargo fmt --all -- --check - Verify Rust formatting and import order in all packages
  • taplo fmt --check - Verify TOML formatting (configured in .taplo.toml)
  • cargo clippy --workspace ... -D warnings - Enforce zero warnings in all packages, including tests, with default features and with all features. [workspace.lints] in the root Cargo.toml sets the extra lints.
  • typos - Check spelling (configured in _typos.toml)
  • scripts/check-orphans.sh - Find source files that no module declares
  • scripts/check-module-docs.sh - Check that each source file starts with a //! doc

If a check fails, git rejects the commit. Fix the issues, then commit again.

The hook checks the working tree, not only the staged changes.

Continuous Integration

GitHub Actions runs .github/workflows/ci.yml on each push to main and on each pull request. It runs make lint, make rustdoc, make docs, make deps, make test, and make perft with the pinned toolchain.

Code Quality Enforcement

  • rustfmt.toml - Configuration file documenting the import style standard and other formatting rules.
  • rust-toolchain.toml - Pins the Rust version, so that rustfmt and clippy give the same results on every machine.
  • Pre-commit hook (.githooks/pre-commit) - Runs make lint before each commit.
  • CI (.github/workflows/ci.yml) - Runs lint, docs checks, dependency checks, tests, and perft on each push to main and on each pull request. .github/workflows/links.yml checks all links once a week.
  • .markdownlint-cli2.jsonc, .vale.ini, .vale/styles/ - Docs linters. Vale fails on em dashes and on sentences longer than 25 words (ASD-STE100). make emdash (part of make lint) fails on an em dash in any tracked file.
  • deny.toml - cargo-deny configuration. Each ignored advisory has a reason. Remove the entry when the reason is gone.

Profiling and Optimization

This section provides a systematic approach to profiling and optimizing codepaths in the chess engine. Use this as a template when optimizing any component.

Profiling Tools

CPU Profiling (Text-Based)

macOS:

chess dev perft --depth 6 2>&1 &
CHESS_PID=$!
sleep 1
sample $CHESS_PID 30 -file /tmp/profile.txt
wait $CHESS_PID
tail -n 100 /tmp/profile.txt  # See "Sort by top of stack" summary

Linux:

chess dev perft --depth 6 2>&1 &
CHESS_PID=$!
sleep 1
perf record -p $CHESS_PID sleep 30
perf report

Analyze the "Sort by top of stack" section to identify hot functions.

Memory Profiler (Built-In)

The engine includes instrumentation for tracking allocations:

  • Tracks board clones and MoveGenerator allocations in builds with --features instrumentation
  • Shows the counts after the dev perft command in those builds
  • Add custom counters using MemoryProfiler::record_*() methods in src/diagnostics/memory_profiler.rs

Visual Profiling

samply:

make profile

This command builds a release binary and records perft at depth 6. samply then opens the result in the Firefox Profiler. It does not need sudo on macOS. On Linux, set kernel.perf_event_paranoid to 1 or lower first.

The image below is an older flamegraph capture of the pvp_benchmark benchmark:

Flamegraph of the pvp_benchmark benchmark

Debug Logging for Lock Contention

The engine includes debug-level logging for monitoring lock contention in critical sections. These messages are useful for identifying synchronization bottlenecks:

Enable debug logging:

RUST_LOG=debug chess play --depth 4

Key debug messages:

  • Slow killer get lock - Indicates when acquiring the killer moves lock takes >100µs
  • Slow killer store lock - Indicates when acquiring the killer moves lock for storage takes >100µs

These messages help identify when thread contention on the killer_moves mutex is impacting performance. High frequency of these messages suggests the parallel search strategy may need adjustment or the killer move storage mechanism should be optimized.

Note: These messages are logged at debug level by default and won't appear in normal operation. Use RUST_LOG=debug to enable them during profiling.

Latency Instrumentation (Built-In)

The engine includes compile-time instrumentation to measure function-level latency. This shows exactly where time is spent across all hot codepaths.

Build with instrumentation:

cargo build --release --features instrumentation

Run benchmark:

./target/release/chess dev search-bench --depth 4

Output shows:

  • Function call counts (e.g., generate_moves: 8.3M calls)
  • Total time per function (e.g., quiescence_search: 4,868s)
  • Average time per call (e.g., apply: 1.22µs average)

Example output:

Function                                        Calls   Total (ms)     Avg (µs)
--------------------------------------------------------------------------------
quiescence_search                              536220   4867809.61      9078.01
alpha_beta_minimax                             216718   4803848.15     22166.36
generate_moves                                8317003   1275467.43       153.36
remove_invalid_moves                          8317003   1216735.59       146.29
generate_attack_targets                     336648619    454444.55         1.35
apply                                       321011382    392248.92         1.22

Overhead: Instrumentation adds ~13x overhead. Use this for understanding bottlenecks, not measuring absolute performance. Disable instrumentation for normal builds.

Optimization Workflow

1. Establish Baseline

Measure performance before making changes:

chess dev perft --depth 5 | tail -n 1

Record key metrics: positions/second, duration, memory profiler stats.

Quick feedback loops are essential: Use shallow depths (depth 4-5) during development for fast iteration. Deep profiling (depth 6+) should be reserved for final validation, as it takes significantly longer. The dev perft command provides immediate feedback on performance changes, allowing you to iterate quickly and validate optimizations before investing time in comprehensive profiling.

2. Profile to Identify Bottlenecks

Use CPU profiling to find:

  • Thread synchronization overhead (e.g., __psynch_cvwait on macOS)
  • Excessive allocations (board clones, object creations)
  • Algorithmic bottlenecks (not just hot functions)

3. Focus on Algorithmic Improvements

Prioritize optimizations that:

  • Reduce operation counts: Eliminate redundant allocations, avoid unnecessary cloning
  • Improve parallelization strategy: Multi-depth parallelization, conditional cloning
  • Share resources: Pass references instead of creating new instances

4. Measure After Each Change

Critical: Always verify correctness before measuring performance:

cargo test  # Must pass before measuring
chess dev perft --depth 5 | tail -n 1

Compare results to baseline and verify position counts match exactly.

Maintain quick feedback cycles: After each optimization, run tests and a quick performance check. This allows you to:

  • Catch regressions immediately
  • Validate that optimizations actually improve performance
  • Iterate rapidly without waiting for long-running benchmarks
  • Build confidence before investing in deeper profiling

Only run comprehensive profiling (depth 6+, full CPU profiling) after you've validated improvements with quick checks.

Key Lessons

  • High sample counts ≠ slow functions: Functions called frequently may show high counts but be fast
  • Compiler optimizations: Modern compilers with LTO already optimize many micro-operations
  • Real bottlenecks: Often in resource allocation patterns, parallelization strategy, or algorithmic complexity
  • Instrumentation is essential: Add counters to track allocations, clones, and operation counts
  • System variability: ±10% variation is normal; measure multiple times for small improvements

Performance Considerations

  • Always test performance changes with release builds (cargo build --release)
  • Use benchmarks (cargo bench) to measure impact of optimizations
  • The pvp_benchmark simulates engine-vs-engine gameplay for realistic profiling