mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:55:42 +00:00
Compare commits
1
Commits
227abf5102
...
prune
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda962679f |
@@ -0,0 +1,177 @@
|
||||
# Transposition Table Pruning Optimization
|
||||
|
||||
## Overview
|
||||
This document describes an optimization to prune duplicate game states during AI search, preventing redundant exploration of positions we've already seen in the current search path.
|
||||
|
||||
## The Problem
|
||||
Currently, when the AI encounters the same game state through different move sequences (a transposition), it may explore the same subtree multiple times. This wastes computational resources.
|
||||
|
||||
## The Solution
|
||||
Implement **transposition pruning** - when we encounter a game state that's already in our current search path, we immediately return without further exploration.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Add Path Tracking
|
||||
We need to track which game states are currently being explored in the search tree:
|
||||
|
||||
```cpp
|
||||
// Add to AIScoreCalculator.cpp
|
||||
thread_local std::unordered_set<uint64_t> t_currentSearchPath;
|
||||
|
||||
// RAII helper to manage path tracking
|
||||
class SearchPathGuard {
|
||||
uint64_t hash;
|
||||
bool added;
|
||||
public:
|
||||
SearchPathGuard(uint64_t h) : hash(h), added(false) {
|
||||
auto [_, inserted] = t_currentSearchPath.insert(hash);
|
||||
added = inserted;
|
||||
}
|
||||
~SearchPathGuard() {
|
||||
if (added) {
|
||||
t_currentSearchPath.erase(hash);
|
||||
}
|
||||
}
|
||||
bool wasAlreadyInPath() const { return !added; }
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Modify BasicLookaheadCalculator
|
||||
Add duplicate detection at the start of the function:
|
||||
|
||||
```cpp
|
||||
auto BasicLookaheadCalculator(...) {
|
||||
// Get hash of current state
|
||||
uint64_t stateHash = hashGameState(innerEngine->GetCurrentGameState());
|
||||
|
||||
// Check if we're already exploring this state
|
||||
SearchPathGuard pathGuard(stateHash);
|
||||
if (pathGuard.wasAlreadyInPath()) {
|
||||
// State is already being explored - return neutral value
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(0.0); // Or return current evaluation
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
// Continue with existing transposition table check...
|
||||
auto cachedScore = g_transpositionTable.probe(...);
|
||||
// ... rest of function
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Modify CalcOne
|
||||
Similar check when creating new engine states:
|
||||
|
||||
```cpp
|
||||
auto CalcOne(...) {
|
||||
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
|
||||
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
|
||||
|
||||
// Check for duplicate state after applying move
|
||||
uint64_t stateHash = hashGameState(innerEngine->GetCurrentGameState());
|
||||
if (t_currentSearchPath.count(stateHash) > 0) {
|
||||
// This move leads to a state we're already exploring
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(AIScoreCalculator::GuessedStateScore(...)); // Return static eval
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
// Continue with normal evaluation...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add Metrics
|
||||
Track how often pruning occurs:
|
||||
|
||||
```cpp
|
||||
struct PruningStats {
|
||||
std::atomic<uint64_t> duplicatesDetected{0};
|
||||
std::atomic<uint64_t> branchesPruned{0};
|
||||
std::atomic<uint64_t> nodesExplored{0};
|
||||
|
||||
void print() const {
|
||||
printf("Pruning Stats: %lu duplicates, %lu pruned, %.2f%% pruning rate\n",
|
||||
duplicatesDetected.load(), branchesPruned.load(),
|
||||
100.0 * branchesPruned.load() / nodesExplored.load());
|
||||
}
|
||||
};
|
||||
|
||||
static PruningStats g_pruningStats;
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Reduced Computation**: Avoid exploring identical positions multiple times
|
||||
2. **Better Depth**: Can search deeper with the same time budget
|
||||
3. **Cache Efficiency**: Better use of transposition table space
|
||||
4. **Deterministic Results**: More consistent evaluations
|
||||
|
||||
## Potential Issues
|
||||
|
||||
1. **Hash Collisions**: Need robust hashing to avoid false positives
|
||||
2. **Thread Safety**: Path tracking must be thread-local
|
||||
3. **Memory Usage**: Set of visited states grows with search depth
|
||||
4. **Evaluation Consistency**: Need to handle different depths appropriately
|
||||
|
||||
## Alternative Approaches
|
||||
|
||||
### Option 1: Store "In Progress" Flag in Transposition Table
|
||||
Instead of a separate set, mark entries in the transposition table as "currently being explored":
|
||||
|
||||
```cpp
|
||||
struct TTEntry {
|
||||
// ... existing fields ...
|
||||
std::atomic<bool> in_progress; // Flag for current exploration
|
||||
std::atomic<std::thread::id> exploring_thread; // Which thread is exploring
|
||||
};
|
||||
```
|
||||
|
||||
### Option 2: Depth-Limited Path Tracking
|
||||
Only track states from the last N moves to limit memory usage:
|
||||
|
||||
```cpp
|
||||
thread_local std::deque<uint64_t> t_recentStates;
|
||||
constexpr size_t MAX_PATH_HISTORY = 10;
|
||||
```
|
||||
|
||||
### Option 3: Bloom Filter for Approximate Detection
|
||||
Use a Bloom filter for memory-efficient approximate duplicate detection:
|
||||
|
||||
```cpp
|
||||
class BloomFilter {
|
||||
std::bitset<65536> filter;
|
||||
// Multiple hash functions for low false positive rate
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Correctness Tests**:
|
||||
- Verify same evaluation with and without pruning
|
||||
- Test with known transposition-heavy positions
|
||||
- Check thread safety with concurrent searches
|
||||
|
||||
2. **Performance Tests**:
|
||||
- Measure nodes explored with/without pruning
|
||||
- Time to depth comparisons
|
||||
- Memory usage monitoring
|
||||
|
||||
3. **Regression Tests**:
|
||||
- Ensure no degradation in playing strength
|
||||
- Verify deterministic behavior
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Phase 1**: Basic path tracking with thread-local set
|
||||
2. **Phase 2**: Add metrics and logging
|
||||
3. **Phase 3**: Optimize memory usage if needed
|
||||
4. **Phase 4**: Consider more sophisticated approaches if beneficial
|
||||
|
||||
## Expected Impact
|
||||
|
||||
Based on typical game tree structures, we expect:
|
||||
- 10-30% reduction in nodes explored
|
||||
- 15-25% increase in achievable search depth
|
||||
- Minimal memory overhead (< 1MB per thread)
|
||||
- More consistent move selection in transposition-heavy positions
|
||||
@@ -0,0 +1,90 @@
|
||||
# Testing the Transposition Pruning Optimization
|
||||
|
||||
## What We've Implemented
|
||||
|
||||
The transposition pruning optimization adds the following features to AIScoreCalculator:
|
||||
|
||||
1. **Thread-local path tracking** (`t_currentSearchPath`) - tracks game state hashes currently being explored
|
||||
2. **SearchPathGuard** - RAII class to automatically manage path insertion/removal
|
||||
3. **Duplicate detection** in both `BasicLookaheadCalculator` and `CalcOne`
|
||||
4. **Pruning statistics** - tracks how often duplicates are detected and branches are pruned
|
||||
5. **Public API functions**:
|
||||
- `resetSearchPath()` - clear path at start of search
|
||||
- `printPruningStats()` - display statistics
|
||||
- `resetPruningStats()` - reset counters
|
||||
|
||||
## How It Works
|
||||
|
||||
### In BasicLookaheadCalculator:
|
||||
```cpp
|
||||
// Check if we're already exploring this state
|
||||
SearchPathGuard pathGuard(stateHash);
|
||||
if (pathGuard.wasAlreadyInPath()) {
|
||||
// Return current utility, increment pruning stats
|
||||
return future_with_current_utility;
|
||||
}
|
||||
// Continue with normal transposition table check and search...
|
||||
```
|
||||
|
||||
### In CalcOne:
|
||||
```cpp
|
||||
// After applying a move, check if resulting state is already being explored
|
||||
if (t_currentSearchPath.count(newStateHash) > 0) {
|
||||
// Return static evaluation, don't search further
|
||||
return static_evaluation;
|
||||
}
|
||||
// Continue with normal lookahead search...
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Prevents infinite loops** - cycles in the game tree are detected and cut short
|
||||
2. **Reduces redundant computation** - same positions aren't explored multiple times
|
||||
3. **Enables deeper search** - saved time can be used for exploring new positions
|
||||
4. **Maintains correctness** - returns reasonable evaluations (current/static eval) for pruned branches
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
The optimization includes comprehensive statistics:
|
||||
- `duplicatesDetected` - how many times we found a duplicate state
|
||||
- `branchesPruned` - how many subtrees were cut short
|
||||
- `nodesExplored` - total nodes considered (for pruning rate calculation)
|
||||
|
||||
## Thread Safety
|
||||
|
||||
- Uses `thread_local` storage for path tracking, so each thread has its own search path
|
||||
- No synchronization needed between threads
|
||||
- Statistics use atomic counters for safe concurrent updates
|
||||
|
||||
## Usage
|
||||
|
||||
To use the optimization:
|
||||
|
||||
```cpp
|
||||
// At start of AI search
|
||||
AIScoreCalculator::resetSearchPath();
|
||||
AIScoreCalculator::resetPruningStats();
|
||||
|
||||
// Run normal AI calculations...
|
||||
auto score = AIScoreCalculator::CommandScore(...);
|
||||
|
||||
// At end of search
|
||||
AIScoreCalculator::printPruningStats();
|
||||
```
|
||||
|
||||
## Expected Results
|
||||
|
||||
In game positions with transpositions (same position reachable via different move sequences), we should see:
|
||||
- Non-zero `duplicatesDetected` count
|
||||
- Significant pruning rate (5-20% in complex positions)
|
||||
- No change in final move selection quality
|
||||
- Potentially faster search times or deeper achievable search depths
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Correctness**: Run existing tests to ensure no regressions
|
||||
2. **Functionality**: Create positions known to have transpositions
|
||||
3. **Performance**: Measure search time and depth with/without optimization
|
||||
4. **Statistics**: Verify counters increment appropriately
|
||||
|
||||
The optimization is conservative - it only prunes when absolutely safe (duplicate state in current path) and returns reasonable fallback evaluations.
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <future>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "TranspositionTable.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
|
||||
@@ -251,6 +252,53 @@ using Unit = fb::Unit;
|
||||
static const std::vector _averageSequence = {0.5};
|
||||
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
|
||||
|
||||
// Thread-local tracking of game states currently being explored in the search tree
|
||||
// This prevents infinite loops and redundant exploration of transpositions
|
||||
thread_local std::unordered_set<uint64_t> t_currentSearchPath;
|
||||
|
||||
// RAII helper to manage search path tracking
|
||||
class SearchPathGuard {
|
||||
uint64_t hash;
|
||||
bool added;
|
||||
|
||||
public:
|
||||
SearchPathGuard(uint64_t h) : hash(h), added(false) {
|
||||
auto [_, inserted] = t_currentSearchPath.insert(hash);
|
||||
added = inserted;
|
||||
}
|
||||
~SearchPathGuard() {
|
||||
if (added) { t_currentSearchPath.erase(hash); }
|
||||
}
|
||||
bool wasAlreadyInPath() const { return !added; }
|
||||
};
|
||||
|
||||
// Statistics for transposition pruning optimization
|
||||
struct PruningStats {
|
||||
std::atomic<uint64_t> duplicatesDetected{0};
|
||||
std::atomic<uint64_t> branchesPruned{0};
|
||||
std::atomic<uint64_t> nodesExplored{0};
|
||||
|
||||
void print() const {
|
||||
uint64_t explored = nodesExplored.load();
|
||||
uint64_t pruned = branchesPruned.load();
|
||||
if (explored > 0) {
|
||||
printf("Transposition Pruning: %llu duplicates detected, %llu branches pruned (%.2f%% "
|
||||
"pruning rate)\n",
|
||||
(unsigned long long)duplicatesDetected.load(),
|
||||
(unsigned long long)pruned,
|
||||
100.0 * pruned / explored);
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
duplicatesDetected = 0;
|
||||
branchesPruned = 0;
|
||||
nodesExplored = 0;
|
||||
}
|
||||
};
|
||||
|
||||
static PruningStats g_pruningStats;
|
||||
|
||||
static auto CommandSorter(const IndexAndScore &l, const IndexAndScore &r) -> bool {
|
||||
if (l.lookaheadScore < r.lookaheadScore) return true;
|
||||
if (l.lookaheadScore > r.lookaheadScore) return false;
|
||||
@@ -921,6 +969,24 @@ auto BasicLookaheadCalculator(
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache,
|
||||
std::chrono::steady_clock::time_point deadline) -> std::future<ScoreValue> {
|
||||
g_pruningStats.nodesExplored++;
|
||||
|
||||
// Get hash of current state for duplicate detection
|
||||
uint64_t stateHash = innerEngine->GetCurrentGameState().ComputeFNV1aHash();
|
||||
|
||||
// Check if we're already exploring this state in the current search path
|
||||
SearchPathGuard pathGuard(stateHash);
|
||||
if (pathGuard.wasAlreadyInPath()) {
|
||||
// This state is already being explored higher up in the search tree
|
||||
// Return the current utility to avoid infinite loops and redundant work
|
||||
g_pruningStats.duplicatesDetected++;
|
||||
g_pruningStats.branchesPruned++;
|
||||
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(currentUtility); // Return current evaluation
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
// Check transposition table before expensive computation
|
||||
auto cachedScore =
|
||||
g_transpositionTable.probe(innerEngine->GetCurrentGameState(), remainingLookahead, pid);
|
||||
@@ -1032,6 +1098,29 @@ auto CalcOne(
|
||||
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
|
||||
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
|
||||
|
||||
// Check if this move leads to a state we're already exploring
|
||||
uint64_t newStateHash = innerEngine->GetCurrentGameState().ComputeFNV1aHash();
|
||||
if (t_currentSearchPath.count(newStateHash) > 0) {
|
||||
// This move creates a transposition to a state already in our search path
|
||||
// Return the static evaluation to avoid redundant exploration
|
||||
g_pruningStats.duplicatesDetected++;
|
||||
|
||||
auto staticEval = AIScoreCalculator::GuessedStateScore(
|
||||
isDefender,
|
||||
innerEngine->GetCurrentGameState(),
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
settingsGetter,
|
||||
apdCache,
|
||||
alCache);
|
||||
|
||||
returnValue.immediateScore = staticEval;
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(staticEval);
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
auto innerUtility = AIScoreCalculator::GuessedStateScore(
|
||||
isDefender,
|
||||
innerEngine->GetCurrentGameState(),
|
||||
@@ -1475,4 +1564,10 @@ auto EvaluateCommand(
|
||||
return std::move(result.lookaheadScore);
|
||||
}
|
||||
|
||||
void AIScoreCalculator::resetSearchPath() { t_currentSearchPath.clear(); }
|
||||
|
||||
void AIScoreCalculator::printPruningStats() { g_pruningStats.print(); }
|
||||
|
||||
void AIScoreCalculator::resetPruningStats() { g_pruningStats.reset(); }
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -57,6 +57,15 @@ public:
|
||||
const ALCache &alCache,
|
||||
size_t commandIndex,
|
||||
std::chrono::steady_clock::time_point deadline) -> std::future<ScoreValue>;
|
||||
|
||||
// Reset search path for a new search (call at start of each AI search)
|
||||
static void resetSearchPath();
|
||||
|
||||
// Print transposition pruning statistics
|
||||
static void printPruningStats();
|
||||
|
||||
// Reset pruning statistics
|
||||
static void resetPruningStats();
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
Reference in New Issue
Block a user