Overview
Chessops is designed for high performance in JavaScript environments, particularly web browsers. This guide covers the performance characteristics of the library and best practices for optimization.
Bitboard Advantages
Chessops uses bitboards (64-bit integers represented as two 32-bit numbers) for representing sets of squares. This provides significant performance benefits:
Memory Efficiency
Traditional array-based representation:
// 64 squares × 4 bytes per boolean = 256 bytes minimum
const squares: boolean[] = new Array(64).fill(false);
Bitboard representation:
// 2 × 4 bytes = 8 bytes total
class SquareSet {
readonly lo: number; // 4 bytes
readonly hi: number; // 4 bytes
}
Bitboards use 32x less memory than array-based representations, improving cache locality and reducing garbage collection pressure.
Operation Speed
Bitwise operations are extremely fast on modern CPUs:
// Union of two square sets: Single OR operation
const combined = set1.union(set2);
// Equivalent to: new SquareSet(set1.lo | set2.lo, set1.hi | set2.hi)
// Intersection: Single AND operation
const overlap = set1.intersect(set2);
// Equivalent to: new SquareSet(set1.lo & set2.lo, set1.hi & set2.hi)
// Check if empty: Two comparisons
const isEmpty = set.isEmpty();
// Equivalent to: set.lo === 0 && set.hi === 0
These operations are typically 1-2 CPU cycles, compared to array iterations that would require 64 iterations.
Parallelism
Bitwise operations process multiple squares simultaneously:
// Check if any of 64 squares overlap between two sets
// Array-based: O(n) iterations
function hasOverlapArray(set1: boolean[], set2: boolean[]): boolean {
for (let i = 0; i < 64; i++) {
if (set1[i] && set2[i]) return true;
}
return false;
}
// Bitboard: O(1) operation
const hasOverlap = set1.intersects(set2);
// Processes all 64 squares in parallel via bitwise AND
Hyperbola Quintessence vs Magic Bitboards
Chessops uses Hyperbola Quintessence for computing sliding piece attacks. Here’s why:
Hyperbola Quintessence:
- ✅ Minimal initialization time (~instant)
- ✅ Small memory footprint (~4KB attack tables)
- ✅ Predictable performance
- ⚠️ Slightly slower lookup (but still very fast)
Magic Bitboards:
- ❌ Initialization overhead (several milliseconds)
- ❌ Large memory footprint (hundreds of KB)
- ✅ Marginally faster lookups
- ⚠️ Complex implementation
For web applications, initialization time matters. Users notice the delay before interaction, but a few nanoseconds difference in move generation is imperceptible.
Attack Computation Benchmarks
Typical performance characteristics (modern JavaScript engine):
import { kingAttacks, knightAttacks, bishopAttacks } from 'chessops/attacks';
// Non-sliding pieces: ~1-2 nanoseconds (table lookup)
const king = kingAttacks(square); // O(1) array access
const knight = knightAttacks(square); // O(1) array access
const pawn = pawnAttacks('white', square); // O(1) array access
// Sliding pieces: ~10-20 nanoseconds (bitwise operations)
const bishop = bishopAttacks(square, occupied); // O(1) with Hyperbola Quintessence
const rook = rookAttacks(square, occupied); // O(1) with Hyperbola Quintessence
const queen = queenAttacks(square, occupied); // O(1), combines bishop + rook
Actual performance depends on JavaScript engine, CPU architecture, and JIT optimization. These are relative comparisons.
SquareSet Operations
Fast Operations (O(1))
These operations are pure bitwise manipulations:
import { SquareSet } from 'chessops/squareSet';
const set1 = SquareSet.fromRank(0);
const set2 = SquareSet.fromFile(0);
// All O(1) bitwise operations:
set1.union(set2) // OR
set1.intersect(set2) // AND
set1.diff(set2) // AND NOT
set1.xor(set2) // XOR
set1.complement() // NOT
set1.isEmpty() // Zero check
set1.nonEmpty() // Non-zero check
set1.has(square) // Bit test
Iterator Operations
Iterating over squares in a set:
const squares = SquareSet.fromRank(0);
// Fast: Direct iteration (O(k) where k = number of set bits)
for (const square of squares) {
// Process each square
// Uses bit scanning (bsf/bsr operations)
}
// Also fast: Built-in methods
squares.size() // Population count (popcnt instruction)
squares.first() // Bit scan forward
squares.last() // Bit scan reverse
Population count implementation:
// From squareSet.ts
const popcnt32 = (n: number): number => {
n = n - ((n >>> 1) & 0x5555_5555);
n = (n & 0x3333_3333) + ((n >>> 2) & 0x3333_3333);
return Math.imul((n + (n >>> 4)) & 0x0f0f_0f0f, 0x0101_0101) >> 24;
};
This uses the Hamming weight algorithm for counting set bits in O(1) time.
Prefer iterating directly over SquareSet rather than converting to arrays. The iterator uses efficient bit scanning.
Move Generation Optimization
Best Practices
❌ Inefficient: Regenerating moves repeatedly
// DON'T: Compute legal moves multiple times
function isMoveLegal(pos: Position, move: Move): boolean {
const legalMoves = Array.from(pos.allLegalMoves());
return legalMoves.some(m => m.from === move.from && m.to === move.to);
}
✅ Efficient: Check legality directly
// DO: Use the direct legality check
function isMoveLegal(pos: Position, move: Move): boolean {
return pos.isLegal(move);
}
✅ Efficient: Cache destinations when needed
// DO: Compute once, use multiple times
const ctx = pos.ctx();
const dests = new Map();
for (const [from, squares] of pos.allDests(ctx)) {
dests.set(from, squares);
}
// Now use cached destinations
const e2Dests = dests.get(makeSquare('e2'));
Context Reuse
Compute position context once:
import { Chess } from 'chessops/chess';
const pos = Chess.default();
// ❌ DON'T: Recompute context for each operation
for (const move of pos.allLegalMoves()) {
// pos.allLegalMoves() computes context internally each time
}
// ✅ DO: Compute context once
const ctx = pos.ctx();
for (const [from, dests] of pos.allDests(ctx)) {
for (const to of dests) {
// Process move
}
}
Immutability and Memory
Chessops uses immutable data structures, which has both benefits and costs.
Benefits
// Safe to share without copying
const pos1 = Chess.default();
const pos2 = pos1.play(move);
// pos1 is unchanged, pos2 is new position
// Great for undo/redo
const history: Position[] = [];
let pos = Chess.default();
for (const move of moves) {
history.push(pos);
pos = pos.play(move);
}
// Can go back to any historical position instantly
Memory Considerations
// Each position is ~100-200 bytes
// For a 50-move game with full history:
// 100 positions × 200 bytes = 20KB (acceptable)
// ❌ DON'T: Keep unnecessary position copies
const positions = [];
for (let i = 0; i < 10000; i++) {
positions.push(pos.play(move)); // 2MB of positions!
}
// ✅ DO: Only keep what you need
let pos = Chess.default();
for (let i = 0; i < 10000; i++) {
pos = pos.play(move); // Previous positions get garbage collected
}
Modern JavaScript engines optimize immutable structures well. The garbage collector efficiently reclaims unused positions.
Optimized Parsing
import { parseFen, makeFen } from 'chessops/fen';
// Parsing is fast but not free
const setup = parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1');
// ~1-5 microseconds on modern hardware
// ✅ DO: Parse once, reuse
const startingSetup = parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1').unwrap();
function newGame() {
return Chess.fromSetup(startingSetup).unwrap();
}
// ❌ DON'T: Parse repeatedly in loops
for (let i = 0; i < 1000; i++) {
const setup = parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1');
// Wasted parsing time
}
FEN Generation
// Making FEN is also fast but not instant
const fen = makeFen(pos.toSetup());
// ~1-5 microseconds
// ✅ DO: Cache FEN when displaying same position multiple times
let cachedFen: string | undefined;
function getFen(pos: Position): string {
if (!cachedFen) {
cachedFen = makeFen(pos.toSetup());
}
return cachedFen;
}
// Invalidate cache when position changes
function playMove(pos: Position, move: Move): Position {
cachedFen = undefined;
return pos.play(move);
}
Benchmarking Your Code
import { Chess } from 'chessops/chess';
import { parseUci } from 'chessops';
function benchmark() {
const iterations = 10000;
const pos = Chess.default();
const move = parseUci('e2e4')!;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
pos.play(move);
}
const end = performance.now();
console.log(`${iterations} moves in ${end - start}ms`);
console.log(`${(end - start) / iterations}ms per move`);
}
benchmark();
Perft Testing
Perft (performance test) counts leaf nodes at a given depth:
function perft(pos: Position, depth: number): number {
if (depth === 0) return 1;
let nodes = 0;
const ctx = pos.ctx();
for (const [from, dests] of pos.allDests(ctx)) {
for (const to of dests) {
const move = { from, to };
if (pos.isLegal(move, ctx)) {
const newPos = pos.play(move);
nodes += perft(newPos, depth - 1);
}
}
}
return nodes;
}
// Benchmark move generation speed
const start = performance.now();
const nodes = perft(Chess.default(), 4);
const end = performance.now();
console.log(`Nodes: ${nodes}`);
console.log(`Time: ${end - start}ms`);
console.log(`Nodes/sec: ${(nodes / (end - start) * 1000).toFixed(0)}`);
Perft is the standard benchmark for chess engines. Compare your results with known perft values to verify correctness.
Web Worker Optimization
For expensive computations, use Web Workers:
// worker.ts
import { Chess } from 'chessops/chess';
import { parseFen } from 'chessops/fen';
self.onmessage = (e) => {
const { fen, depth } = e.data;
const setup = parseFen(fen).unwrap();
const pos = Chess.fromSetup(setup).unwrap();
// Expensive computation in worker
const result = analyzePosition(pos, depth);
self.postMessage(result);
};
// main.ts
const worker = new Worker('worker.ts');
worker.onmessage = (e) => {
console.log('Analysis complete:', e.data);
};
worker.postMessage({
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
depth: 6
});
Web Workers allow expensive chess computations without blocking the UI thread, keeping your application responsive.
General Tips
- Use direct methods:
pos.isLegal(move) is faster than generating all legal moves and searching
- Cache when appropriate: Context, destinations, and FEN strings can be cached
- Prefer SquareSet operations: Native bitboard operations are faster than arrays
- Reuse objects: Don’t parse the same FEN repeatedly
- Profile your code: Use browser DevTools to find actual bottlenecks
- Consider Web Workers: For deep analysis that might freeze the UI
Premature optimization is the root of all evil. Profile first, optimize second. Chessops is already fast for most use cases.