mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 07:15:58 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f7b82ecd | ||
|
|
4214610f33 | ||
|
|
339b2e8e63 | ||
|
|
cfe33bd7e4 | ||
|
|
6384419c51 |
@@ -0,0 +1,463 @@
|
|||||||
|
# MCTS Transposition Table Enhancement: Caching State Transitions
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Goal:** Avoid redundant `PostCommand` calls by caching state transitions (stateHash, actionIndex, roll) → nextStateHash.
|
||||||
|
|
||||||
|
**Key Findings:**
|
||||||
|
1. ✅ MCTS already has thread-local cache (`legalActionsCache_`) with 70-90% hit rates
|
||||||
|
2. ✅ **DETERMINISM SOLUTION:** Cache (action, roll) tuples instead of just action
|
||||||
|
- Initial implementation: always use roll=50
|
||||||
|
- Future-proof: supports chance nodes with multiple rolls (10, 30, 50, 70, 90)
|
||||||
|
- Architecturally solves the non-determinism problem
|
||||||
|
3. ✅ Enhancement is straightforward: add `actionResults` map to existing `LegalActionsCache` struct
|
||||||
|
4. ✅ Expected benefit: Eliminate PostCommand overhead on cache hits (could save 5-15 microseconds per hit)
|
||||||
|
|
||||||
|
**Recommendation:** Implement using (action, roll) tuple keys. Initial implementation uses roll=50 for all transitions, but data structure supports future expansion to chance nodes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
**IMPORTANT:** MCTS does NOT use the TranspositionTable.hpp/cpp files. Those are for IterativeDeepening AI.
|
||||||
|
|
||||||
|
MCTS uses a thread-local cache in `ShardokGameEngine` (src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Thread-local cache (line 20-21)
|
||||||
|
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache> legalActionsCache_;
|
||||||
|
|
||||||
|
struct LegalActionsCache {
|
||||||
|
std::shared_ptr<ShardokEngine> engine; // Cached engine with populated command cache
|
||||||
|
std::vector<size_t> filteredIndices; // Filtered action indices
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current cache mapping:**
|
||||||
|
```
|
||||||
|
stateHash -> LegalActionsCache {
|
||||||
|
ShardokEngine engine, // Engine with command cache populated
|
||||||
|
vector<size_t> filteredIndices // Filtered action indices
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Avoid recalculating GetAvailableCommands and FilterCommands for states we've seen before.
|
||||||
|
|
||||||
|
**Performance:** Already achieving ~70-90% hit rates in typical searches (see reportCacheStatistics() output).
|
||||||
|
|
||||||
|
## Proposed Enhancement
|
||||||
|
|
||||||
|
Extend the `LegalActionsCache` struct to also cache state transitions using (action, roll) tuples:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Key for transition cache: (actionIndex, roll)
|
||||||
|
struct TransitionKey {
|
||||||
|
size_t actionIndex;
|
||||||
|
int roll;
|
||||||
|
|
||||||
|
bool operator==(const TransitionKey& other) const {
|
||||||
|
return actionIndex == other.actionIndex && roll == other.roll;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hash function for TransitionKey
|
||||||
|
struct TransitionKeyHash {
|
||||||
|
size_t operator()(const TransitionKey& key) const {
|
||||||
|
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LegalActionsCache {
|
||||||
|
std::shared_ptr<ShardokEngine> engine; // Existing: cached engine
|
||||||
|
std::vector<size_t> filteredIndices; // Existing: filtered action indices
|
||||||
|
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW: (action, roll) -> next_state_hash
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Avoid re-applying actions (PostCommand calls) for (state, action, roll) tuples we've already evaluated.
|
||||||
|
|
||||||
|
**Why (action, roll) tuples?**
|
||||||
|
- Handles determinism explicitly: different rolls produce different next states
|
||||||
|
- Initial implementation uses roll=50 for all transitions
|
||||||
|
- Future-proof: supports chance nodes with multiple roll values (10, 30, 50, 70, 90)
|
||||||
|
- Architecturally cleaner than requiring PostCommand to always use the same roll
|
||||||
|
|
||||||
|
**Location:** Modify `ShardokGameEngine::applyAction()` (line 50-99 in ShardokGameEngine.cpp)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### During MCTS Exploration/Simulation:
|
||||||
|
|
||||||
|
1. **Before applying an action:**
|
||||||
|
- Look up current state hash in cache
|
||||||
|
- If found, check if `actionResults` contains the (action_index, roll) tuple we want to apply
|
||||||
|
- If yes, retrieve the next state's hash from the map
|
||||||
|
- Look up that hash in the cache to get the next state's engine directly
|
||||||
|
- **Skip PostCommand entirely** - we already know the result!
|
||||||
|
|
||||||
|
2. **When applying a new action:**
|
||||||
|
- Apply action normally via `engine.PostCommand(playerId, actionIndex, roll)`
|
||||||
|
- Hash the resulting state
|
||||||
|
- Store the mapping: `actionResults[{action_index, roll}] = next_state_hash`
|
||||||
|
- Store the next state in the cache (if not already present)
|
||||||
|
|
||||||
|
### Example Flow:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Current state hash: 0xABCD
|
||||||
|
// Want to apply action index 5 with roll 50
|
||||||
|
|
||||||
|
const int roll = 50; // Fixed roll for initial implementation
|
||||||
|
auto it = legalActionsCache_.find(0xABCD);
|
||||||
|
if (it != legalActionsCache_.end()) {
|
||||||
|
TransitionKey key{5, roll};
|
||||||
|
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
|
||||||
|
// We've applied this (action, roll) before!
|
||||||
|
uint64_t nextHash = resIt->second;
|
||||||
|
if (auto nextIt = legalActionsCache_.find(nextHash); nextIt != legalActionsCache_.end()) {
|
||||||
|
// We have the complete next state cached
|
||||||
|
// Clone the cached engine and return - No PostCommand needed!
|
||||||
|
return createStateFromCachedEngine(nextIt->second.engine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haven't seen this (state, action, roll) tuple before, apply normally
|
||||||
|
engine.PostCommand(playerId, 5, roll);
|
||||||
|
uint64_t nextHash = HashGameState(engine.GetCurrentGameState());
|
||||||
|
legalActionsCache_[0xABCD].actionResults[{5, roll}] = nextHash;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Eliminates redundant PostCommand calls**
|
||||||
|
- PostCommand involves creating new GameStateW, potentially allocating memory
|
||||||
|
- Combat resolution, unit updates, state validation all skipped when cached
|
||||||
|
|
||||||
|
2. **Particularly valuable for MCTS**
|
||||||
|
- MCTS revisits states many times during tree search
|
||||||
|
- Same state-action pairs explored in multiple simulations
|
||||||
|
- Deeper trees mean more opportunities for cache hits
|
||||||
|
|
||||||
|
3. **Compounds with existing optimizations**
|
||||||
|
- Already caching score calculations
|
||||||
|
- Already caching available commands
|
||||||
|
- Now also caching state transitions
|
||||||
|
- All three together significantly reduce per-simulation cost
|
||||||
|
|
||||||
|
## Potential Issues & Solutions
|
||||||
|
|
||||||
|
### 1. Memory Usage
|
||||||
|
**Issue:** Storing (action, roll)->hash mappings for every visited state could consume significant memory.
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- Only store recently used entries (already done - thread-local cache per search)
|
||||||
|
- Cache is automatically cleared between searches
|
||||||
|
- Monitor memory usage in production
|
||||||
|
|
||||||
|
**Analysis:**
|
||||||
|
- Each cache entry: `TransitionKey{size_t actionIndex, int roll}` + `uint64_t nextHash`
|
||||||
|
- Size: ~24 bytes per entry (8 + 4 + 8, plus hash map overhead)
|
||||||
|
- For 10,000 states × 10 actions × 1 roll = 100,000 entries ≈ 2.4 MB
|
||||||
|
- With chance nodes (5 rolls per action): 10,000 × 10 × 5 = 500,000 entries ≈ 12 MB
|
||||||
|
- **Reasonable** for modern systems, especially since it's thread-local and cleared per search
|
||||||
|
|
||||||
|
### 2. Determinism Requirements
|
||||||
|
**Issue:** PostCommand results depend on the roll parameter, which affects combat outcomes and random events.
|
||||||
|
|
||||||
|
**ARCHITECTURAL SOLUTION:** Cache (action, roll) tuples instead of just actions!
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Cache key includes BOTH action and roll
|
||||||
|
TransitionKey key{actionIndex, roll};
|
||||||
|
actionResults[key] = nextStateHash;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this solves the problem:**
|
||||||
|
- Each (action, roll) combination gets its own cache entry
|
||||||
|
- If we call PostCommand(5, 50), we cache the result for (5, 50)
|
||||||
|
- If we later call PostCommand(5, 70), it's a different cache key - no collision!
|
||||||
|
- No need to enforce determinism at the PostCommand level
|
||||||
|
- Data structure naturally supports multiple rolls per action
|
||||||
|
|
||||||
|
**Initial Implementation:**
|
||||||
|
- Use fixed roll=50 for all transitions (matching IterativeDeepening)
|
||||||
|
- All cache entries will have roll=50
|
||||||
|
- Simple and deterministic
|
||||||
|
|
||||||
|
**Future Enhancement:**
|
||||||
|
- Implement chance nodes by exploring multiple rolls (10, 30, 50, 70, 90)
|
||||||
|
- Each roll becomes a separate child in the MCTS tree
|
||||||
|
- Cache naturally handles this: (action=5, roll=10), (action=5, roll=50), (action=5, roll=90) are distinct
|
||||||
|
- This models uncertainty without requiring code changes to the cache structure
|
||||||
|
|
||||||
|
**Required Changes:**
|
||||||
|
1. **Change PostCommand call in `applyAction()` (line 82):**
|
||||||
|
```cpp
|
||||||
|
// OLD:
|
||||||
|
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
||||||
|
// NEW:
|
||||||
|
const int roll = 50; // Fixed roll for initial implementation
|
||||||
|
engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Change PostCommand call in `applyActionMutable()` (line 133):**
|
||||||
|
```cpp
|
||||||
|
// Same change - use roll=50
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Store the roll used when caching:**
|
||||||
|
```cpp
|
||||||
|
TransitionKey key{actionIndex, roll};
|
||||||
|
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:** ✅ SOLVED ARCHITECTURALLY - No determinism issues with this design.
|
||||||
|
|
||||||
|
### 3. Hash Collisions
|
||||||
|
**Issue:** Two different states might hash to the same value.
|
||||||
|
|
||||||
|
**Current situation:** Already a risk with existing transposition table.
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- Use 64-bit hashes (current implementation) - collision probability very low
|
||||||
|
- Could add verification: store state size/checksum alongside hash
|
||||||
|
- Could add debug mode that does full state comparison
|
||||||
|
|
||||||
|
### 4. Action Index Stability
|
||||||
|
**Issue:** Action indices must be stable (same action always has same index for a given state).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- ShardokEngine::GetAvailableCommandProtos() must return actions in deterministic order
|
||||||
|
- Need to verify this is true
|
||||||
|
- If not, would need to hash actions themselves, not just use indices
|
||||||
|
|
||||||
|
**Risk:** LOW - game engine likely returns actions in consistent order
|
||||||
|
|
||||||
|
### 5. State Ownership & Copying
|
||||||
|
**Issue:** GameStateW contains FlatBufferBuilder, careful with copying/references.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- TranspositionTable already stores complete ShardokEngine (which contains GameStateW)
|
||||||
|
- No additional complexity beyond existing implementation
|
||||||
|
- Just need to ensure we're cloning engines appropriately
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Define TransitionKey and Extend Data Structure
|
||||||
|
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`
|
||||||
|
|
||||||
|
1. **Add TransitionKey struct (before LegalActionsCache):**
|
||||||
|
```cpp
|
||||||
|
// Key for transition cache: (actionIndex, roll)
|
||||||
|
struct TransitionKey {
|
||||||
|
size_t actionIndex;
|
||||||
|
int roll;
|
||||||
|
|
||||||
|
bool operator==(const TransitionKey& other) const {
|
||||||
|
return actionIndex == other.actionIndex && roll == other.roll;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hash function for TransitionKey
|
||||||
|
struct TransitionKeyHash {
|
||||||
|
size_t operator()(const TransitionKey& key) const {
|
||||||
|
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update `LegalActionsCache` struct:**
|
||||||
|
```cpp
|
||||||
|
struct LegalActionsCache {
|
||||||
|
std::shared_ptr<ShardokEngine> engine;
|
||||||
|
std::vector<size_t> filteredIndices;
|
||||||
|
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add cache statistics fields:**
|
||||||
|
```cpp
|
||||||
|
// Add to class members:
|
||||||
|
thread_local static uint64_t transitionCacheHits_;
|
||||||
|
thread_local static uint64_t transitionCacheMisses_;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Integrate with applyAction
|
||||||
|
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`
|
||||||
|
|
||||||
|
Modify `ShardokGameEngine::applyAction()` (lines 50-99):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||||
|
const MCTSGameState& state,
|
||||||
|
const MCTSAction& action) const {
|
||||||
|
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
|
||||||
|
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
|
||||||
|
if (!shardokState || !shardokAction) { return nullptr; }
|
||||||
|
|
||||||
|
const uint64_t currentStateHash = shardokState->hash();
|
||||||
|
const size_t actionIndex = shardokAction->getIndex();
|
||||||
|
const int roll = 50; // Fixed roll for initial implementation
|
||||||
|
|
||||||
|
// NEW: Check if we've already applied this (action, roll) to this state
|
||||||
|
TransitionKey key{actionIndex, roll};
|
||||||
|
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
|
||||||
|
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
|
||||||
|
// We have the next state hash cached!
|
||||||
|
uint64_t nextStateHash = resIt->second;
|
||||||
|
|
||||||
|
// Look up the next state in the cache
|
||||||
|
if (auto nextIt = legalActionsCache_.find(nextStateHash); nextIt != legalActionsCache_.end()) {
|
||||||
|
// CACHE HIT: Clone the cached engine and return the state
|
||||||
|
transitionCacheHits_++;
|
||||||
|
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
|
||||||
|
return std::make_unique<ShardokGameState>(
|
||||||
|
engine->GetCurrentGameState(),
|
||||||
|
scoreCalculator_,
|
||||||
|
gameSettings_.get(),
|
||||||
|
isDefender_,
|
||||||
|
strategy_,
|
||||||
|
castleCoords_,
|
||||||
|
*apdCache_,
|
||||||
|
*alCache_,
|
||||||
|
criticalTileCoords_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CACHE MISS: Apply action normally...
|
||||||
|
transitionCacheMisses_++;
|
||||||
|
|
||||||
|
// (existing code from lines 58-98, but change line 82:)
|
||||||
|
// OLD: engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
||||||
|
// NEW: engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
|
||||||
|
|
||||||
|
// After applying, store the transition:
|
||||||
|
uint64_t nextStateHash = newState->hash();
|
||||||
|
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Similar changes for `applyActionMutable()`** (lines 100-150)
|
||||||
|
|
||||||
|
### Phase 3: Testing & Validation (Critical)
|
||||||
|
1. Add unit tests verifying:
|
||||||
|
- Cached transitions match actual transitions
|
||||||
|
- Performance improvement measurable
|
||||||
|
- No correctness regressions
|
||||||
|
|
||||||
|
2. Add debug assertions:
|
||||||
|
- Verify determinism: cached result == recomputed result (sample check)
|
||||||
|
- Detect hash collisions (optional, performance cost)
|
||||||
|
|
||||||
|
3. Add performance tracking:
|
||||||
|
- Count cache hits vs misses
|
||||||
|
- Measure time saved
|
||||||
|
- Monitor memory usage
|
||||||
|
|
||||||
|
### Phase 4: Optimization (Optional)
|
||||||
|
1. Tune cache eviction policy if memory becomes issue
|
||||||
|
2. Consider bloom filter to quickly reject cache misses
|
||||||
|
3. Profile to ensure cache lookups aren't dominating cost
|
||||||
|
|
||||||
|
## Performance Expectations
|
||||||
|
|
||||||
|
**Conservative estimate:**
|
||||||
|
- PostCommand might take ~10-50 microseconds (allocations, state updates)
|
||||||
|
- Hash table lookup takes ~100-500 nanoseconds
|
||||||
|
- If we get 30% cache hit rate, save ~3-15 microseconds per simulation step
|
||||||
|
- With 100,000 simulations, save 0.3-1.5 seconds per search
|
||||||
|
|
||||||
|
**Best case estimate:**
|
||||||
|
- In dense search trees, might get 70%+ cache hit rate
|
||||||
|
- Could save 5-10 seconds per search in complex positions
|
||||||
|
|
||||||
|
**Measurement needed:** Profile actual PostCommand cost and cache hit rates.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
**HIGH RISK:**
|
||||||
|
- Non-determinism in PostCommand would cause silent correctness bugs
|
||||||
|
- Must thoroughly test determinism before enabling in production
|
||||||
|
|
||||||
|
**MEDIUM RISK:**
|
||||||
|
- Memory usage could grow large in long-running games
|
||||||
|
- Need monitoring and eviction policy
|
||||||
|
|
||||||
|
**LOW RISK:**
|
||||||
|
- Implementation complexity moderate but manageable
|
||||||
|
- Can be feature-flagged and disabled if problems arise
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**This optimization appears sound IF PostCommand is deterministic.**
|
||||||
|
|
||||||
|
**Suggested approach:**
|
||||||
|
1. First, verify PostCommand determinism with extensive testing
|
||||||
|
2. Implement with feature flag (can disable if issues found)
|
||||||
|
3. Add comprehensive debug assertions
|
||||||
|
4. Profile to verify performance improvement justifies complexity
|
||||||
|
5. Monitor memory usage in production
|
||||||
|
|
||||||
|
**Key verification needed before proceeding:**
|
||||||
|
- Confirm PostCommand has no randomness
|
||||||
|
- Confirm action indices are stable
|
||||||
|
- Measure baseline PostCommand performance cost
|
||||||
|
|
||||||
|
## Alternative Considered: Lighter-Weight Caching
|
||||||
|
|
||||||
|
Instead of storing full state transitions, just cache:
|
||||||
|
```cpp
|
||||||
|
unordered_map<pair<uint64_t, size_t>, uint64_t> globalTransitionCache;
|
||||||
|
// Maps (state_hash, action_index) -> next_state_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Simpler data structure
|
||||||
|
- Easier to implement cache eviction
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires two hash lookups per transition (this map, then transposition table)
|
||||||
|
- Doesn't integrate as cleanly with existing transposition table
|
||||||
|
|
||||||
|
**Verdict:** Proposed approach (extending LegalActionsCache) is cleaner and integrates with existing infrastructure.
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Phase 1: Data Structure
|
||||||
|
1. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`**
|
||||||
|
- Add `TransitionKey` struct with equality operator
|
||||||
|
- Add `TransitionKeyHash` struct for hashing
|
||||||
|
- Modify `LegalActionsCache` to use `gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults;`
|
||||||
|
- Add thread-local statistics: `transitionCacheHits_`, `transitionCacheMisses_`
|
||||||
|
|
||||||
|
### Phase 2: Implementation
|
||||||
|
2. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`**
|
||||||
|
- Initialize thread-local statistics variables
|
||||||
|
- Modify `applyAction()`:
|
||||||
|
- Line 82: Change `nullptr` to `50` for roll parameter
|
||||||
|
- Add transition cache lookup using `TransitionKey{actionIndex, 50}`
|
||||||
|
- Add transition cache storage after PostCommand
|
||||||
|
- Modify `applyActionMutable()`:
|
||||||
|
- Line 133: Change `nullptr` to `50` for roll parameter
|
||||||
|
- Add same transition cache logic
|
||||||
|
- Update `reportCacheStatistics()` to include transition cache hit/miss stats
|
||||||
|
|
||||||
|
### Phase 3: Testing
|
||||||
|
3. **`src/test/cpp/net/eagle0/shardok/ai/mcts/ShardokMCTSAI_test.cpp`** (or create new test file)
|
||||||
|
- Add unit tests for determinism verification (same search → same result)
|
||||||
|
- Add unit tests for transition cache correctness
|
||||||
|
- Add performance benchmarks comparing with/without transition cache
|
||||||
|
- Test with multiple rolls to verify tuple caching works correctly
|
||||||
|
|
||||||
|
## Related Work
|
||||||
|
|
||||||
|
This optimization is related to:
|
||||||
|
- **Zobrist hashing** in chess engines (incremental hash updates)
|
||||||
|
- **Transposition tables with move ordering** in minimax search
|
||||||
|
- **Memoization** in dynamic programming
|
||||||
|
|
||||||
|
Shardok's approach is most similar to transposition tables in chess engines, but applied to MCTS instead of minimax.
|
||||||
@@ -11,6 +11,7 @@ cc_library(
|
|||||||
],
|
],
|
||||||
deps = [
|
deps = [
|
||||||
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_action",
|
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_action",
|
||||||
|
"//src/main/cpp/net/eagle0/shardok/library:shardok_command",
|
||||||
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
|
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
|
||||||
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
|
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
|
||||||
|
|
||||||
#pragma clang diagnostic push
|
#pragma clang diagnostic push
|
||||||
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
||||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||||
@@ -14,28 +16,50 @@
|
|||||||
namespace shardok {
|
namespace shardok {
|
||||||
namespace mcts {
|
namespace mcts {
|
||||||
|
|
||||||
ShardokAction::ShardokAction(const CommandProto& command, size_t index)
|
// New constructor: store pointer to command (preferred - no proto conversion!)
|
||||||
|
ShardokAction::ShardokAction(const ShardokCommand* command, size_t index)
|
||||||
: command_(command),
|
: command_(command),
|
||||||
commandIndex_(index) {}
|
commandIndex_(index) {}
|
||||||
|
|
||||||
ShardokAction::ShardokAction(CommandProto&& command, size_t index)
|
// Legacy constructors for compatibility
|
||||||
: command_(std::move(command)),
|
ShardokAction::ShardokAction(const CommandProto& command, size_t index)
|
||||||
|
: command_(nullptr),
|
||||||
|
commandProto_(command),
|
||||||
commandIndex_(index) {}
|
commandIndex_(index) {}
|
||||||
|
|
||||||
int ShardokAction::getType() const { return static_cast<int>(command_.type()); }
|
ShardokAction::ShardokAction(CommandProto&& command, size_t index)
|
||||||
|
: command_(nullptr),
|
||||||
|
commandProto_(std::move(command)),
|
||||||
|
commandIndex_(index) {}
|
||||||
|
|
||||||
|
// Lazy getter - converts to proto only when needed
|
||||||
|
const ShardokAction::CommandProto& ShardokAction::getCommand() const {
|
||||||
|
if (command_ != nullptr) {
|
||||||
|
// Lazily convert Command → Proto (only once)
|
||||||
|
if (commandProto_.type() == net::eagle0::shardok::common::UNKNOWN_COMMAND) {
|
||||||
|
commandProto_ = command_->GetCommandProto();
|
||||||
|
}
|
||||||
|
return commandProto_;
|
||||||
|
}
|
||||||
|
// Already have proto from legacy constructor
|
||||||
|
return commandProto_;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ShardokAction::getType() const { return static_cast<int>(getCommand().type()); }
|
||||||
|
|
||||||
std::string ShardokAction::getDescription() const {
|
std::string ShardokAction::getDescription() const {
|
||||||
|
const auto& cmd = getCommand();
|
||||||
std::stringstream ss;
|
std::stringstream ss;
|
||||||
|
|
||||||
// Show player
|
// Show player
|
||||||
ss << "P" << static_cast<int>(command_.player()) << " ";
|
ss << "P" << static_cast<int>(cmd.player()) << " ";
|
||||||
|
|
||||||
ss << net::eagle0::shardok::common::CommandType_Name(command_.type());
|
ss << net::eagle0::shardok::common::CommandType_Name(cmd.type());
|
||||||
|
|
||||||
if (command_.has_actor()) { ss << " Unit:" << command_.actor().value(); }
|
if (cmd.has_actor()) { ss << " Unit:" << cmd.actor().value(); }
|
||||||
|
|
||||||
if (command_.has_target()) {
|
if (cmd.has_target()) {
|
||||||
const auto& coord = command_.target();
|
const auto& coord = cmd.target();
|
||||||
ss << " @(" << coord.row() << "," << coord.column() << ")";
|
ss << " @(" << coord.row() << "," << coord.column() << ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,28 +67,39 @@ std::string ShardokAction::getDescription() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int ShardokAction::getActorId() const {
|
int ShardokAction::getActorId() const {
|
||||||
if (command_.has_actor()) { return command_.actor().value(); }
|
const auto& cmd = getCommand();
|
||||||
|
if (cmd.has_actor()) { return cmd.actor().value(); }
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::pair<int, int> ShardokAction::getTarget() const {
|
std::pair<int, int> ShardokAction::getTarget() const {
|
||||||
if (command_.has_target()) {
|
const auto& cmd = getCommand();
|
||||||
const auto& coord = command_.target();
|
if (cmd.has_target()) {
|
||||||
|
const auto& coord = cmd.target();
|
||||||
return std::make_pair(coord.row(), coord.column());
|
return std::make_pair(coord.row(), coord.column());
|
||||||
}
|
}
|
||||||
return std::make_pair(-1, -1);
|
return std::make_pair(-1, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<MCTSAction> ShardokAction::clone() const {
|
std::unique_ptr<MCTSAction> ShardokAction::clone() const {
|
||||||
return std::make_unique<ShardokAction>(command_, commandIndex_);
|
// Clone by copying command pointer (cheap!) instead of proto
|
||||||
|
if (command_ != nullptr) { return std::make_unique<ShardokAction>(command_, commandIndex_); }
|
||||||
|
// Fallback: clone proto
|
||||||
|
return std::make_unique<ShardokAction>(commandProto_, commandIndex_);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ShardokAction::equals(const MCTSAction& other) const {
|
bool ShardokAction::equals(const MCTSAction& other) const {
|
||||||
const auto* shardokOther = dynamic_cast<const ShardokAction*>(&other);
|
const auto* shardokOther = dynamic_cast<const ShardokAction*>(&other);
|
||||||
if (!shardokOther) { return false; }
|
if (!shardokOther) { return false; }
|
||||||
|
|
||||||
|
// Fast path: compare by command pointer
|
||||||
|
if (command_ != nullptr && shardokOther->command_ != nullptr) {
|
||||||
|
return commandIndex_ == shardokOther->commandIndex_ && command_ == shardokOther->command_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: compare protos
|
||||||
return commandIndex_ == shardokOther->commandIndex_ &&
|
return commandIndex_ == shardokOther->commandIndex_ &&
|
||||||
command_.SerializeAsString() == shardokOther->command_.SerializeAsString();
|
getCommand().SerializeAsString() == shardokOther->getCommand().SerializeAsString();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace mcts
|
} // namespace mcts
|
||||||
|
|||||||
@@ -16,12 +16,19 @@
|
|||||||
#pragma clang diagnostic pop
|
#pragma clang diagnostic pop
|
||||||
|
|
||||||
namespace shardok {
|
namespace shardok {
|
||||||
|
// Forward declaration
|
||||||
|
class ShardokCommand;
|
||||||
|
|
||||||
namespace mcts {
|
namespace mcts {
|
||||||
|
|
||||||
class ShardokAction : public MCTSAction {
|
class ShardokAction : public MCTSAction {
|
||||||
public:
|
public:
|
||||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||||
|
|
||||||
|
// New constructor: store pointer to Command instead of copying proto
|
||||||
|
ShardokAction(const ShardokCommand* command, size_t index);
|
||||||
|
|
||||||
|
// Legacy constructors for compatibility (creates internal proto copy)
|
||||||
ShardokAction(const CommandProto& command, size_t index);
|
ShardokAction(const CommandProto& command, size_t index);
|
||||||
ShardokAction(CommandProto&& command, size_t index);
|
ShardokAction(CommandProto&& command, size_t index);
|
||||||
|
|
||||||
@@ -36,11 +43,13 @@ public:
|
|||||||
[[nodiscard]] int getActorId() const;
|
[[nodiscard]] int getActorId() const;
|
||||||
[[nodiscard]] std::pair<int, int> getTarget() const;
|
[[nodiscard]] std::pair<int, int> getTarget() const;
|
||||||
|
|
||||||
// Shardok-specific accessor
|
// Shardok-specific accessor - lazily converts to proto if needed
|
||||||
[[nodiscard]] const CommandProto& getCommand() const { return command_; }
|
[[nodiscard]] const CommandProto& getCommand() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
CommandProto command_;
|
// Store pointer to command (preferred) OR proto copy (legacy)
|
||||||
|
const ShardokCommand* command_ = nullptr; // Non-owning pointer to cached command
|
||||||
|
mutable CommandProto commandProto_; // Lazy proto copy (only used if command_ is null)
|
||||||
size_t commandIndex_;
|
size_t commandIndex_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "ShardokAction.hpp"
|
#include "ShardokAction.hpp"
|
||||||
#include "ShardokGameState.hpp"
|
#include "ShardokGameState.hpp"
|
||||||
|
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
|
||||||
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
|
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
|
||||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
|
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
|
||||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.hpp"
|
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.hpp"
|
||||||
@@ -17,11 +18,18 @@
|
|||||||
|
|
||||||
namespace shardok::mcts {
|
namespace shardok::mcts {
|
||||||
|
|
||||||
|
// Deterministic random generator for predictable combat rolls (50th percentile)
|
||||||
|
// This matches the behavior used in IterativeDeepening AI
|
||||||
|
static const std::vector<double> kAverageSequence = {0.5};
|
||||||
|
static const auto kAverageGenerator = std::make_shared<SequenceRandomGenerator>(kAverageSequence);
|
||||||
|
|
||||||
// Thread-local cache for legal actions (avoids lock contention in multithreaded simulation)
|
// Thread-local cache for legal actions (avoids lock contention in multithreaded simulation)
|
||||||
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache>
|
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache>
|
||||||
ShardokGameEngine::legalActionsCache_;
|
ShardokGameEngine::legalActionsCache_;
|
||||||
thread_local uint64_t ShardokGameEngine::cacheHits_ = 0;
|
thread_local uint64_t ShardokGameEngine::cacheHits_ = 0;
|
||||||
thread_local uint64_t ShardokGameEngine::cacheMisses_ = 0;
|
thread_local uint64_t ShardokGameEngine::cacheMisses_ = 0;
|
||||||
|
thread_local uint64_t ShardokGameEngine::transitionCacheHits_ = 0;
|
||||||
|
thread_local uint64_t ShardokGameEngine::transitionCacheMisses_ = 0;
|
||||||
thread_local uint64_t ShardokGameEngine::timeInHashComputation_ = 0;
|
thread_local uint64_t ShardokGameEngine::timeInHashComputation_ = 0;
|
||||||
thread_local uint64_t ShardokGameEngine::timeInLegalActionsComputation_ = 0;
|
thread_local uint64_t ShardokGameEngine::timeInLegalActionsComputation_ = 0;
|
||||||
|
|
||||||
@@ -57,6 +65,42 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
|||||||
if (!shardokState || !shardokAction) { return nullptr; }
|
if (!shardokState || !shardokAction) { return nullptr; }
|
||||||
|
|
||||||
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
|
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
|
||||||
|
const uint64_t currentStateHash = shardokState->hash();
|
||||||
|
const size_t actionIndex = shardokAction->getIndex();
|
||||||
|
const int roll = 50; // Fixed roll for deterministic transitions
|
||||||
|
|
||||||
|
// Check transition cache: have we applied this (action, roll) to this state before?
|
||||||
|
TransitionKey transitionKey{actionIndex, roll};
|
||||||
|
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
|
||||||
|
if (auto transIt = it->second.actionResults.find(transitionKey);
|
||||||
|
transIt != it->second.actionResults.end()) {
|
||||||
|
// We've applied this transition before - get the next state hash
|
||||||
|
uint64_t nextStateHash = transIt->second;
|
||||||
|
|
||||||
|
// Look up the next state in the cache
|
||||||
|
if (auto nextIt = legalActionsCache_.find(nextStateHash);
|
||||||
|
nextIt != legalActionsCache_.end()) {
|
||||||
|
// TRANSITION CACHE HIT: We have the complete next state cached!
|
||||||
|
transitionCacheHits_++;
|
||||||
|
|
||||||
|
// Clone the cached engine and return the state
|
||||||
|
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
|
||||||
|
return std::make_unique<ShardokGameState>(
|
||||||
|
engine->GetCurrentGameState(),
|
||||||
|
scoreCalculator_,
|
||||||
|
gameSettings_.get(),
|
||||||
|
isDefender_,
|
||||||
|
strategy_,
|
||||||
|
castleCoords_,
|
||||||
|
*apdCache_,
|
||||||
|
*alCache_,
|
||||||
|
criticalTileCoords_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRANSITION CACHE MISS: Apply action normally
|
||||||
|
transitionCacheMisses_++;
|
||||||
|
|
||||||
// Use cached engine if available (avoids recomputing GetAvailableCommands for same state)
|
// Use cached engine if available (avoids recomputing GetAvailableCommands for same state)
|
||||||
std::shared_ptr<ShardokEngine> engine;
|
std::shared_ptr<ShardokEngine> engine;
|
||||||
@@ -80,7 +124,7 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
|||||||
engine = std::make_shared<ShardokEngine>(*engine);
|
engine = std::make_shared<ShardokEngine>(*engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
engine->PostCommand(currentPlayer, actionIndex, kAverageGenerator);
|
||||||
|
|
||||||
// Create and return the new state (don't cache the mutated engine)
|
// Create and return the new state (don't cache the mutated engine)
|
||||||
auto newState = std::make_unique<ShardokGameState>(
|
auto newState = std::make_unique<ShardokGameState>(
|
||||||
@@ -94,6 +138,15 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
|||||||
*alCache_,
|
*alCache_,
|
||||||
criticalTileCoords_);
|
criticalTileCoords_);
|
||||||
|
|
||||||
|
// Store the transition in cache for future lookups
|
||||||
|
const uint64_t nextStateHash = newState->hash();
|
||||||
|
legalActionsCache_[currentStateHash].actionResults[transitionKey] = nextStateHash;
|
||||||
|
|
||||||
|
// CRITICAL: Also store the next state's engine in the cache
|
||||||
|
// Without this, transition cache lookups will always miss because the next state won't exist
|
||||||
|
// Clone the engine so we have an independent cached copy
|
||||||
|
legalActionsCache_[nextStateHash].engine = std::make_shared<ShardokEngine>(*engine);
|
||||||
|
|
||||||
// Don't pre-compute hash - let it be computed lazily on first use
|
// Don't pre-compute hash - let it be computed lazily on first use
|
||||||
// Many states (especially in simulation) never need their hash computed
|
// Many states (especially in simulation) never need their hash computed
|
||||||
return newState;
|
return newState;
|
||||||
@@ -112,6 +165,36 @@ void ShardokGameEngine::applyActionMutable(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auto currentPlayer = static_cast<PlayerId>(state->currentPlayerId());
|
const auto currentPlayer = static_cast<PlayerId>(state->currentPlayerId());
|
||||||
|
const uint64_t currentStateHash = shardokState->hash();
|
||||||
|
const size_t actionIndex = shardokAction->getIndex();
|
||||||
|
const int roll = 50; // Fixed roll for deterministic transitions
|
||||||
|
|
||||||
|
// Check transition cache: have we applied this (action, roll) to this state before?
|
||||||
|
TransitionKey transitionKey{actionIndex, roll};
|
||||||
|
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
|
||||||
|
if (auto transIt = it->second.actionResults.find(transitionKey);
|
||||||
|
transIt != it->second.actionResults.end()) {
|
||||||
|
// We've applied this transition before - get the next state hash
|
||||||
|
uint64_t nextStateHash = transIt->second;
|
||||||
|
|
||||||
|
// Look up the next state in the cache
|
||||||
|
if (auto nextIt = legalActionsCache_.find(nextStateHash);
|
||||||
|
nextIt != legalActionsCache_.end()) {
|
||||||
|
// TRANSITION CACHE HIT: We have the complete next state cached!
|
||||||
|
transitionCacheHits_++;
|
||||||
|
|
||||||
|
// Clone the cached engine and update the current state to match
|
||||||
|
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
|
||||||
|
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
|
||||||
|
shardokState->setCachedEngine(engine);
|
||||||
|
shardokState->invalidateHashCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRANSITION CACHE MISS: Apply action normally
|
||||||
|
transitionCacheMisses_++;
|
||||||
|
|
||||||
// Use cached engine if available
|
// Use cached engine if available
|
||||||
std::shared_ptr<ShardokEngine> engine;
|
std::shared_ptr<ShardokEngine> engine;
|
||||||
@@ -131,11 +214,20 @@ void ShardokGameEngine::applyActionMutable(
|
|||||||
engine = std::make_shared<ShardokEngine>(*engine);
|
engine = std::make_shared<ShardokEngine>(*engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
engine->PostCommand(currentPlayer, actionIndex, kAverageGenerator);
|
||||||
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
|
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
|
||||||
// Clear the cached engine and hash since the state has been mutated
|
// Clear the cached engine and hash since the state has been mutated
|
||||||
shardokState->setCachedEngine(nullptr);
|
shardokState->setCachedEngine(nullptr);
|
||||||
shardokState->invalidateHashCache();
|
shardokState->invalidateHashCache();
|
||||||
|
|
||||||
|
// Store the transition in cache for future lookups
|
||||||
|
const uint64_t nextStateHash = shardokState->hash();
|
||||||
|
legalActionsCache_[currentStateHash].actionResults[transitionKey] = nextStateHash;
|
||||||
|
|
||||||
|
// CRITICAL: Also store the next state's engine in the cache
|
||||||
|
// Without this, transition cache lookups will always miss because the next state won't exist
|
||||||
|
// Clone the engine so we have an independent cached copy
|
||||||
|
legalActionsCache_[nextStateHash].engine = std::make_shared<ShardokEngine>(*engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||||
@@ -171,20 +263,30 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
|||||||
// Use cached engine
|
// Use cached engine
|
||||||
shardokState->setCachedEngine(it->second.engine);
|
shardokState->setCachedEngine(it->second.engine);
|
||||||
|
|
||||||
// Get commands from the cached engine (Engine already caches these internally)
|
// If we have cached actions, clone and return them (fastest path)
|
||||||
|
if (!it->second.cachedActions.empty()) {
|
||||||
|
std::vector<std::unique_ptr<MCTSAction>> actions;
|
||||||
|
actions.reserve(it->second.cachedActions.size());
|
||||||
|
for (const auto& action : it->second.cachedActions) {
|
||||||
|
actions.push_back(action->clone());
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: reconstruct actions from cached engine (shouldn't happen often)
|
||||||
const CommandListSPtr commands =
|
const CommandListSPtr commands =
|
||||||
it->second.engine->GetAvailableCommandsForAIPlayer(currentPlayer);
|
it->second.engine->GetAvailableCommandsForAIPlayer(currentPlayer);
|
||||||
|
|
||||||
if (!commands || commands->empty()) { return {}; }
|
if (!commands || commands->empty()) { return {}; }
|
||||||
|
|
||||||
// Convert to MCTSActions using stored filtered indices
|
|
||||||
std::vector<std::unique_ptr<MCTSAction>> actions;
|
std::vector<std::unique_ptr<MCTSAction>> actions;
|
||||||
actions.reserve(it->second.filteredIndices.size());
|
actions.reserve(it->second.filteredIndices.size());
|
||||||
|
|
||||||
for (const size_t origIdx : it->second.filteredIndices) {
|
for (const size_t origIdx : it->second.filteredIndices) {
|
||||||
if (origIdx < commands->size()) {
|
if (origIdx < commands->size()) {
|
||||||
const auto& cmd = commands->at(origIdx);
|
const auto& cmd = commands->at(origIdx);
|
||||||
actions.push_back(std::make_unique<ShardokAction>(cmd->GetCommandProto(), origIdx));
|
// Use new constructor: pass command pointer instead of converting to proto
|
||||||
|
actions.push_back(std::make_unique<ShardokAction>(cmd.get(), origIdx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,13 +327,14 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Convert only filtered commands to MCTSActions
|
// Convert only filtered commands to MCTSActions
|
||||||
|
// Use new constructor: pass command pointer instead of converting to proto (major speedup!)
|
||||||
std::vector<std::unique_ptr<MCTSAction>> actions;
|
std::vector<std::unique_ptr<MCTSAction>> actions;
|
||||||
actions.reserve(filteredIndices.size());
|
actions.reserve(filteredIndices.size());
|
||||||
|
|
||||||
for (const size_t idx : filteredIndices) {
|
for (const size_t idx : filteredIndices) {
|
||||||
if (idx < commands->size()) {
|
if (idx < commands->size()) {
|
||||||
const auto& cmd = commands->at(idx);
|
const auto& cmd = commands->at(idx);
|
||||||
actions.push_back(std::make_unique<ShardokAction>(cmd->GetCommandProto(), idx));
|
actions.push_back(std::make_unique<ShardokAction>(cmd.get(), idx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,11 +344,12 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
|||||||
.count();
|
.count();
|
||||||
|
|
||||||
// Store in transposition table for future lookups
|
// Store in transposition table for future lookups
|
||||||
// Note: We only store filtered indices and the engine (which caches commands internally)
|
|
||||||
// This avoids duplicating heavy protocol buffer objects
|
|
||||||
LegalActionsCache entry;
|
LegalActionsCache entry;
|
||||||
entry.filteredIndices = filteredIndices;
|
entry.filteredIndices = filteredIndices;
|
||||||
entry.engine = engine;
|
entry.engine = engine;
|
||||||
|
// Clone actions to cache them (allows fast retrieval without reconstruction)
|
||||||
|
entry.cachedActions.reserve(actions.size());
|
||||||
|
for (const auto& action : actions) { entry.cachedActions.push_back(action->clone()); }
|
||||||
legalActionsCache_[stateHash] = std::move(entry);
|
legalActionsCache_[stateHash] = std::move(entry);
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
@@ -279,7 +383,17 @@ std::vector<double> ShardokGameEngine::getActionWeights(
|
|||||||
"indicates a type mismatch in the MCTS adapter layer");
|
"indicates a type mismatch in the MCTS adapter layer");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use AIHeuristicWeighting for fast O(1) context-aware command weighting
|
// Check cache for action weights
|
||||||
|
const uint64_t stateHash = shardokState->hash();
|
||||||
|
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
|
||||||
|
if (!it->second.actionWeights.empty() &&
|
||||||
|
it->second.actionWeights.size() == actions.size()) {
|
||||||
|
// Cache hit! Return cached weights
|
||||||
|
return it->second.actionWeights;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss: compute action weights using AIHeuristicWeighting
|
||||||
std::vector<double> weights;
|
std::vector<double> weights;
|
||||||
weights.reserve(actions.size());
|
weights.reserve(actions.size());
|
||||||
|
|
||||||
@@ -301,6 +415,9 @@ std::vector<double> ShardokGameEngine::getActionWeights(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store weights in cache for future lookups
|
||||||
|
legalActionsCache_[stateHash].actionWeights = weights;
|
||||||
|
|
||||||
return weights;
|
return weights;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +488,30 @@ void ShardokGameEngine::reportCacheStatistics() const {
|
|||||||
savings,
|
savings,
|
||||||
(timeWithoutCache - timeWithCache) / 1000.0);
|
(timeWithoutCache - timeWithCache) / 1000.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report transition cache statistics
|
||||||
|
const uint64_t totalTransitions = transitionCacheHits_ + transitionCacheMisses_;
|
||||||
|
if (totalTransitions > 0) {
|
||||||
|
const double transitionHitRate =
|
||||||
|
static_cast<double>(transitionCacheHits_) / static_cast<double>(totalTransitions);
|
||||||
|
|
||||||
|
printf("\nTransition Cache Stats:\n");
|
||||||
|
printf(" Transitions: %llu hits, %llu misses, %.1f%% hit rate\n",
|
||||||
|
static_cast<unsigned long long>(transitionCacheHits_),
|
||||||
|
static_cast<unsigned long long>(transitionCacheMisses_),
|
||||||
|
transitionHitRate * 100.0);
|
||||||
|
|
||||||
|
// Calculate total number of transition entries across all states
|
||||||
|
size_t totalTransitionEntries = 0;
|
||||||
|
for (const auto& [stateHash, cache] : legalActionsCache_) {
|
||||||
|
totalTransitionEntries += cache.actionResults.size();
|
||||||
|
}
|
||||||
|
printf(" Total transition entries: %zu (avg %.1f per state)\n",
|
||||||
|
totalTransitionEntries,
|
||||||
|
totalTransitionEntries > 0 ? static_cast<double>(totalTransitionEntries) /
|
||||||
|
static_cast<double>(legalActionsCache_.size())
|
||||||
|
: 0.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ShardokGameEngine::resetCacheStatistics() {
|
void ShardokGameEngine::resetCacheStatistics() {
|
||||||
@@ -378,6 +519,8 @@ void ShardokGameEngine::resetCacheStatistics() {
|
|||||||
cacheMisses_ = 0;
|
cacheMisses_ = 0;
|
||||||
timeInHashComputation_ = 0;
|
timeInHashComputation_ = 0;
|
||||||
timeInLegalActionsComputation_ = 0;
|
timeInLegalActionsComputation_ = 0;
|
||||||
|
transitionCacheHits_ = 0;
|
||||||
|
transitionCacheMisses_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace shardok::mcts
|
} // namespace shardok::mcts
|
||||||
|
|||||||
@@ -93,11 +93,34 @@ public:
|
|||||||
void resetCacheStatistics();
|
void resetCacheStatistics();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Key for transition cache: (actionIndex, roll)
|
||||||
|
// Caches state transitions to avoid redundant PostCommand calls
|
||||||
|
struct TransitionKey {
|
||||||
|
size_t actionIndex;
|
||||||
|
int roll;
|
||||||
|
|
||||||
|
bool operator==(const TransitionKey& other) const {
|
||||||
|
return actionIndex == other.actionIndex && roll == other.roll;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hash function for TransitionKey
|
||||||
|
struct TransitionKeyHash {
|
||||||
|
size_t operator()(const TransitionKey& key) const {
|
||||||
|
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Transposition table entry for caching legal actions
|
// Transposition table entry for caching legal actions
|
||||||
// Note: We don't store command protos since the Engine already caches them
|
|
||||||
struct LegalActionsCache {
|
struct LegalActionsCache {
|
||||||
std::vector<size_t> filteredIndices;
|
std::vector<size_t> filteredIndices;
|
||||||
std::shared_ptr<ShardokEngine> engine; // Engine with populated command cache
|
std::shared_ptr<ShardokEngine> engine; // Engine with populated command cache
|
||||||
|
// NEW: Cache state transitions (action, roll) -> nextStateHash
|
||||||
|
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults;
|
||||||
|
// NEW: Cache action weights to avoid recomputing heuristics
|
||||||
|
std::vector<double> actionWeights;
|
||||||
|
// NEW: Cache the actual action objects to avoid repeated allocation/construction
|
||||||
|
std::vector<std::unique_ptr<MCTSAction>> cachedActions;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AIScoreCalculator* scoreCalculator_;
|
const AIScoreCalculator* scoreCalculator_;
|
||||||
@@ -116,6 +139,10 @@ private:
|
|||||||
static thread_local uint64_t cacheHits_;
|
static thread_local uint64_t cacheHits_;
|
||||||
static thread_local uint64_t cacheMisses_;
|
static thread_local uint64_t cacheMisses_;
|
||||||
|
|
||||||
|
// Transition cache statistics
|
||||||
|
static thread_local uint64_t transitionCacheHits_;
|
||||||
|
static thread_local uint64_t transitionCacheMisses_;
|
||||||
|
|
||||||
// Performance timing (in microseconds)
|
// Performance timing (in microseconds)
|
||||||
static thread_local uint64_t timeInHashComputation_;
|
static thread_local uint64_t timeInHashComputation_;
|
||||||
static thread_local uint64_t timeInLegalActionsComputation_;
|
static thread_local uint64_t timeInLegalActionsComputation_;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
|||||||
pi.is_defender(),
|
pi.is_defender(),
|
||||||
e->GetCurrentGameState()->hex_map(),
|
e->GetCurrentGameState()->hex_map(),
|
||||||
e->GetGameSettings()->GetGetter(),
|
e->GetGameSettings()->GetGetter(),
|
||||||
AIAlgorithmType::ITERATIVE_DEEPENING,
|
AIAlgorithmType::MCTS,
|
||||||
ScoringCalculatorType::MCTS_OPTIMIZED,
|
ScoringCalculatorType::MCTS_OPTIMIZED,
|
||||||
mctsConfig);
|
mctsConfig);
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,10 @@ cc_library(
|
|||||||
srcs = [],
|
srcs = [],
|
||||||
hdrs = ["ShardokCommand.hpp"],
|
hdrs = ["ShardokCommand.hpp"],
|
||||||
copts = COPTS,
|
copts = COPTS,
|
||||||
visibility = ["//src/main/cpp/net/eagle0/shardok/library:__subpackages__"],
|
visibility = [
|
||||||
|
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
|
||||||
|
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
|
||||||
|
],
|
||||||
deps = [
|
deps = [
|
||||||
":action_cost",
|
":action_cost",
|
||||||
":shardok_action",
|
":shardok_action",
|
||||||
|
|||||||
Reference in New Issue
Block a user