Skip to content

Commit f4f787c

Browse files
committed
feat: v0.4.0
Implemented capture history and better LMR. Ready for release.
1 parent c5b4946 commit f4f787c

File tree

7 files changed

+116
-16
lines changed

7 files changed

+116
-16
lines changed

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,28 @@ This is a toy chess engine I developed over two weeks as part of my winter break
44

55
This software **does not** come with a GUI, so if you want to play it you will need to download one from Internet. I recommend [en-croissant](https://github.com/franciscoBSalgueiro/en-croissant).
66

7-
This engine is still **WIP** and will be updated from time to time. Currently I am refactoring and optimizing and hope to hit 1600 Elo soon.
7+
This engine is still **WIP** and will be updated from time to time.
88

99
## Compiling
1010

1111
Use the CMake to compile the engine.
1212

13+
## Technical Details
14+
15+
This engine is based on traditional *alpha-beta search* algorithm with simple optimizations and pruning methods. Features implemented:
16+
- Transposition table
17+
- Iterative deepening
18+
- Null move pruning
19+
- Quiescence search
20+
- Razoring
21+
- Late move reduction
22+
- History heuristics
23+
- A simple NNUE for evaluation
24+
25+
Since v0.4.0 the engine uses **NNUE** for evaluation.
26+
- Uses naive `768->128->1` architecture. No accumulators, king buckets, subnetworks - so in theory this is not actually *efficiently updated*. However, with proper auto-vectorization this runs even *faster* than my previous handcrafted evaluation function.
27+
- Positions used to train this NNUE are collected from Lichess database. About 30% of the data is from chess960 variant. All positions are first evaluated with my own engine (v0.3.0) at depth 8, and then adjusted based on the actual outcome and the "imbalanceness" of the position. Thus, this network is purely original. So far, only 1.5M filtered positions are used to train this reasonably good network.
28+
1329
## License
1430

1531
This project is licensed under the MIT License.

src/eval.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
#include "types.h"
44
#include <cstring>
55

6+
constexpr Value PIECE_VALUE[7] = {100, 300, 330, 550, 900, 10000, 0};
7+
68
/**
79
* Checks if the game is over and returns the appropriate score.
810
*/

src/history.h

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,53 @@ class SearchHistory {
4949
return data[(int) stm][move.from().index()][move.to().index()];
5050
}
5151
inline void update(const Color stm, const Move& move, int16_t bonus) {
52-
int16_t& ref = data[(int) stm][move.from().index()][move.to().index()];
53-
int newScore = bonus + ref;
52+
int16_t& ref = data[(int) stm][move.from().index()][move.to().index()];
53+
int diff = bonus - ref * std::abs(bonus) / MAX_HISTORY_SCORE;
5454

55-
ref = std::clamp(newScore, (int) MIN_HISTORY_SCORE, (int) MAX_HISTORY_SCORE);
55+
ref = std::clamp(ref + diff, (int) MIN_HISTORY_SCORE, (int) MAX_HISTORY_SCORE);
5656
}
5757
};
5858

59-
public:
60-
KillerTable killerTable[MAX_PLY];
61-
QuietHistoryTable qHistoryTable;
59+
struct CaptureHistoryTable {
60+
int16_t data[2][6][64][6];
61+
62+
void clear() { std::memset(data, 0, sizeof(data)); }
63+
64+
inline int16_t get(const Color stm, const Move& move, const Position& pos) {
65+
const Square to = move.to();
66+
const auto aggressor = pos.at(move.from()).type();
67+
const auto victim = pos.at(to).type();
68+
assert(aggressor != PieceType::NONE);
69+
if (victim == PieceType::NONE) { // en passant
70+
return 0;
71+
}
72+
return data[(int) stm][(int) aggressor][to.index()][(int) victim];
73+
}
74+
75+
inline void update(const Color stm, const Move& move, const Position& pos, int16_t bonus) {
76+
const Square to = move.to();
77+
const auto aggressor = pos.at(move.from()).type();
78+
const auto victim = pos.at(to).type();
79+
assert(aggressor != PieceType::NONE);
80+
if (victim == PieceType::NONE) { // en passant
81+
return;
82+
}
83+
int16_t& ref = data[(int) stm][(int) aggressor][to.index()][(int) victim];
84+
int diff = bonus - ref * std::abs(bonus) / MAX_HISTORY_SCORE;
85+
86+
ref = std::clamp(ref + diff, (int) MIN_HISTORY_SCORE, (int) MAX_HISTORY_SCORE);
87+
}
88+
};
89+
90+
KillerTable killerTable[MAX_PLY];
91+
QuietHistoryTable qHistoryTable;
92+
CaptureHistoryTable capHistoryTable;
6293

6394
void clear() {
6495
for (int i = 0; i < MAX_PLY; i++) {
6596
killerTable[i].clear();
6697
}
98+
qHistoryTable.clear();
99+
capHistoryTable.clear();
67100
}
68101
};

src/movepick.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ void MovePicker::generateNoisyMoves() {
184184
} else { // a losing capture
185185
score = mvvlva - 1000;
186186
}
187+
// Use capture history heuristic
188+
const int16_t hist = history.capHistoryTable.get(pos.sideToMove(), move, pos);
189+
if (hist > 0) {
190+
score = score / 2 + hist / 8;
191+
}
187192

188193
noisyBuffer.emplace_back(ScoredMove {move.move(), score});
189194
}
@@ -209,7 +214,7 @@ void MovePicker::generateQuietMoves() {
209214
if (pos.at(move.from()).type() != PieceType::PAWN &&
210215
(attacks::pawn(pos.sideToMove(), move.to()) &
211216
pos.pieces(PieceType::PAWN, ~pos.sideToMove()))) {
212-
score -= 900;
217+
score -= 1200;
213218
}
214219
// Assign score from quiet history
215220
int historyScore = history.qHistoryTable.get(pos.sideToMove(), move);

src/search.cpp

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ using SearchStack = std::array<SearchStackEntry, MAX_PLY>;
4343

4444
std::thread searchThread;
4545

46+
// LMR Table ============================================================================
47+
int8_t LMRTable[256][256];
48+
void computeLMRTable() {
49+
for (int depth = 1; depth < 256; ++depth) {
50+
for (int moveIndex = 1; moveIndex < 256; ++moveIndex) {
51+
LMRTable[depth][moveIndex] =
52+
(int8_t) std::round(0.9f + std::sqrt(depth) * std::sqrt(moveIndex) / 3.0f);
53+
}
54+
}
55+
}
56+
4657
// Global variables =====================================================================
4758
SearchStats searchStats;
4859
SearchStack searchStack;
@@ -104,7 +115,8 @@ Value qsearch(Position& pos, int depth, int ply, Value alpha, Value beta) {
104115
}
105116

106117
// Delta Pruning
107-
if (!pos.inCheck() && standPat + Value(1000) < alpha) {
118+
Value delta = PIECE_VALUE[pos.at(m.to()).type()] + Value(200);
119+
if (!pos.inCheck() && standPat + delta < alpha) {
108120
continue;
109121
}
110122

@@ -207,9 +219,21 @@ Value negamax(Position& pos, int depth, int ply, Value alpha, Value beta, bool c
207219
currSS->staticEval = staticEval;
208220

209221
// Pre-move-loop pruning
222+
210223
// If static evaluation is a fail-high or fail-low, we can likely prune
211224
// without doing any further work.
212225
if (!isPV && !inCheck) {
226+
// Reverse Futility Pruning
227+
const Value futilityMargin = Value(200) + Value(100) * depth;
228+
if (depth <= 9 && !alpha.isMate() && staticEval - futilityMargin > beta) {
229+
return beta + (staticEval - beta) / 4;
230+
}
231+
232+
// Razoring
233+
if (staticEval < alpha - Value(500) - Value(100) * depth) {
234+
return qsearch(pos, depth - 1, ply + 1, alpha, beta);
235+
}
236+
213237
// Null move pruning
214238
if (depth >= 6 // enough depth
215239
&& currSS->canNullMove // prev move not null move
@@ -248,17 +272,32 @@ Value negamax(Position& pos, int depth, int ply, Value alpha, Value beta, bool c
248272
moveSearched++;
249273

250274
// todo reductions and prunings
275+
int reduction = 0;
276+
277+
const int lmrMinDepth = isPV ? 4 : 3;
278+
if (moveSearched >= 3 && depth >= lmrMinDepth && !inCheck) {
279+
reduction = LMRTable[depth][moveSearched];
280+
if (!cutnode) {
281+
reduction--;
282+
}
283+
if (isPV) {
284+
reduction--;
285+
}
286+
}
251287

252288
// Principal variation search
289+
reduction = std::clamp(reduction, 0, depth - 1);
290+
int searchDepth = depth - reduction - 1;
291+
253292
Value score;
254293
pos.makeMove(m);
255294
if (moveSearched == 1) {
256-
score = -negamax<isPV>(pos, depth - 1, ply + 1, -beta, -alpha, false);
295+
score = -negamax<isPV>(pos, searchDepth, ply + 1, -beta, -alpha, false);
257296
} else {
258-
score = -negamax<false>(pos, depth - 1, ply + 1, -alpha - 1, -alpha, true);
297+
score = -negamax<false>(pos, searchDepth, ply + 1, -alpha - 1, -alpha, true);
259298
// If it improves alpha, re-search with full window
260299
if (score > alpha && isPV) {
261-
score = -negamax<true>(pos, depth - 1, ply + 1, -beta, -alpha, false);
300+
score = -negamax<true>(pos, searchDepth, ply + 1, -beta, -alpha, false);
262301
}
263302
}
264303
pos.unmakeMove(m);
@@ -283,6 +322,9 @@ Value negamax(Position& pos, int depth, int ply, Value alpha, Value beta, bool c
283322
if (!pos.isCapture(bestMove)) {
284323
searchHistory.killerTable[ply].add(bestMove);
285324
searchHistory.qHistoryTable.update(pos.sideToMove(), bestMove, depth * depth);
325+
} else {
326+
searchHistory.capHistoryTable.update(
327+
pos.sideToMove(), bestMove, pos, depth * depth);
286328
}
287329
break;
288330
}
@@ -302,9 +344,9 @@ Value negamax(Position& pos, int depth, int ply, Value alpha, Value beta, bool c
302344

303345
void searchWorker(SearchParams params, Position pos) {
304346
g_stopRequested.store(false);
305-
searchStats = SearchStats();
306347
searchStack.fill(SearchStackEntry {});
307348
tt.incGeneration();
349+
computeLMRTable();
308350

309351
g_timeControl = TimeControl(pos.sideToMove(), params, TimeControl::now());
310352
int maxDepth = params.depth > 0 ? (int) params.depth : 64;
@@ -316,6 +358,7 @@ void searchWorker(SearchParams params, Position pos) {
316358
Value windowLower = 20;
317359

318360
for (int depth = 1; depth <= maxDepth; ++depth) {
361+
searchStats = SearchStats();
319362
if (g_stopRequested.load())
320363
break;
321364
if (g_timeControl.hitSoftLimit(depth, (int) searchStats.nodes, 0))
@@ -343,7 +386,7 @@ void searchWorker(SearchParams params, Position pos) {
343386
}
344387
}
345388

346-
auto pv = extractPv(pos, depth);
389+
auto pv = extractPv(pos, depth / 2 + 1);
347390
if (!pv.empty())
348391
rootBestMove = pv.front();
349392
rootBestScore = score;

src/uci.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#include <sstream>
55
#include <string>
66

7-
#define ENGINE_VERSION "0.3.0"
7+
#define ENGINE_VERSION "0.4.0"
88

99
namespace uci {
1010

testengine.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ def elo_mode(args):
3434
# Output
3535
cmd += [
3636
"-pgnout", f'elo_{time.strftime("%Y%m%d-%H%M%S", time.localtime())}.pgn',
37-
"-output", "format=fastchess"
37+
"-output", "format=fastchess",
38+
# "-log", "level=err"
3839
]
3940

4041
run_fastchess(cmd)

0 commit comments

Comments
 (0)