mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:35:42 +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 = [
|
||||
"//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/common:command_type_cc_proto",
|
||||
],
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#include <sstream>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
@@ -14,28 +16,50 @@
|
||||
namespace shardok {
|
||||
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),
|
||||
commandIndex_(index) {}
|
||||
|
||||
ShardokAction::ShardokAction(CommandProto&& command, size_t index)
|
||||
: command_(std::move(command)),
|
||||
// Legacy constructors for compatibility
|
||||
ShardokAction::ShardokAction(const CommandProto& command, size_t index)
|
||||
: command_(nullptr),
|
||||
commandProto_(command),
|
||||
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 {
|
||||
const auto& cmd = getCommand();
|
||||
std::stringstream ss;
|
||||
|
||||
// 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()) {
|
||||
const auto& coord = command_.target();
|
||||
if (cmd.has_target()) {
|
||||
const auto& coord = cmd.target();
|
||||
ss << " @(" << coord.row() << "," << coord.column() << ")";
|
||||
}
|
||||
|
||||
@@ -43,28 +67,39 @@ std::string ShardokAction::getDescription() 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;
|
||||
}
|
||||
|
||||
std::pair<int, int> ShardokAction::getTarget() const {
|
||||
if (command_.has_target()) {
|
||||
const auto& coord = command_.target();
|
||||
const auto& cmd = getCommand();
|
||||
if (cmd.has_target()) {
|
||||
const auto& coord = cmd.target();
|
||||
return std::make_pair(coord.row(), coord.column());
|
||||
}
|
||||
return std::make_pair(-1, -1);
|
||||
}
|
||||
|
||||
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 {
|
||||
const auto* shardokOther = dynamic_cast<const ShardokAction*>(&other);
|
||||
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_ &&
|
||||
command_.SerializeAsString() == shardokOther->command_.SerializeAsString();
|
||||
getCommand().SerializeAsString() == shardokOther->getCommand().SerializeAsString();
|
||||
}
|
||||
|
||||
} // namespace mcts
|
||||
|
||||
@@ -16,12 +16,19 @@
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
namespace shardok {
|
||||
// Forward declaration
|
||||
class ShardokCommand;
|
||||
|
||||
namespace mcts {
|
||||
|
||||
class ShardokAction : public MCTSAction {
|
||||
public:
|
||||
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(CommandProto&& command, size_t index);
|
||||
|
||||
@@ -36,11 +43,13 @@ public:
|
||||
[[nodiscard]] int getActorId() const;
|
||||
[[nodiscard]] std::pair<int, int> getTarget() const;
|
||||
|
||||
// Shardok-specific accessor
|
||||
[[nodiscard]] const CommandProto& getCommand() const { return command_; }
|
||||
// Shardok-specific accessor - lazily converts to proto if needed
|
||||
[[nodiscard]] const CommandProto& getCommand() const;
|
||||
|
||||
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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "ShardokAction.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/shardok/ai/AICommandFilter.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.hpp"
|
||||
@@ -17,11 +18,18 @@
|
||||
|
||||
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 gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache>
|
||||
ShardokGameEngine::legalActionsCache_;
|
||||
thread_local uint64_t ShardokGameEngine::cacheHits_ = 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::timeInLegalActionsComputation_ = 0;
|
||||
|
||||
@@ -57,6 +65,42 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
if (!shardokState || !shardokAction) { return nullptr; }
|
||||
|
||||
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)
|
||||
std::shared_ptr<ShardokEngine> engine;
|
||||
@@ -80,7 +124,7 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
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)
|
||||
auto newState = std::make_unique<ShardokGameState>(
|
||||
@@ -94,6 +138,15 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
*alCache_,
|
||||
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
|
||||
// Many states (especially in simulation) never need their hash computed
|
||||
return newState;
|
||||
@@ -112,6 +165,36 @@ void ShardokGameEngine::applyActionMutable(
|
||||
}
|
||||
|
||||
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
|
||||
std::shared_ptr<ShardokEngine> engine;
|
||||
@@ -131,11 +214,20 @@ void ShardokGameEngine::applyActionMutable(
|
||||
engine = std::make_shared<ShardokEngine>(*engine);
|
||||
}
|
||||
|
||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
||||
engine->PostCommand(currentPlayer, actionIndex, kAverageGenerator);
|
||||
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
|
||||
// Clear the cached engine and hash since the state has been mutated
|
||||
shardokState->setCachedEngine(nullptr);
|
||||
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(
|
||||
@@ -171,20 +263,30 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
// Use cached 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 =
|
||||
it->second.engine->GetAvailableCommandsForAIPlayer(currentPlayer);
|
||||
|
||||
if (!commands || commands->empty()) { return {}; }
|
||||
|
||||
// Convert to MCTSActions using stored filtered indices
|
||||
std::vector<std::unique_ptr<MCTSAction>> actions;
|
||||
actions.reserve(it->second.filteredIndices.size());
|
||||
|
||||
for (const size_t origIdx : it->second.filteredIndices) {
|
||||
if (origIdx < commands->size()) {
|
||||
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
|
||||
// Use new constructor: pass command pointer instead of converting to proto (major speedup!)
|
||||
std::vector<std::unique_ptr<MCTSAction>> actions;
|
||||
actions.reserve(filteredIndices.size());
|
||||
|
||||
for (const size_t idx : filteredIndices) {
|
||||
if (idx < commands->size()) {
|
||||
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();
|
||||
|
||||
// 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;
|
||||
entry.filteredIndices = filteredIndices;
|
||||
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);
|
||||
|
||||
return actions;
|
||||
@@ -279,7 +383,17 @@ std::vector<double> ShardokGameEngine::getActionWeights(
|
||||
"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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -371,6 +488,30 @@ void ShardokGameEngine::reportCacheStatistics() const {
|
||||
savings,
|
||||
(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() {
|
||||
@@ -378,6 +519,8 @@ void ShardokGameEngine::resetCacheStatistics() {
|
||||
cacheMisses_ = 0;
|
||||
timeInHashComputation_ = 0;
|
||||
timeInLegalActionsComputation_ = 0;
|
||||
transitionCacheHits_ = 0;
|
||||
transitionCacheMisses_ = 0;
|
||||
}
|
||||
|
||||
} // namespace shardok::mcts
|
||||
|
||||
@@ -93,11 +93,34 @@ public:
|
||||
void resetCacheStatistics();
|
||||
|
||||
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
|
||||
// Note: We don't store command protos since the Engine already caches them
|
||||
struct LegalActionsCache {
|
||||
std::vector<size_t> filteredIndices;
|
||||
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_;
|
||||
@@ -116,6 +139,10 @@ private:
|
||||
static thread_local uint64_t cacheHits_;
|
||||
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)
|
||||
static thread_local uint64_t timeInHashComputation_;
|
||||
static thread_local uint64_t timeInLegalActionsComputation_;
|
||||
|
||||
@@ -79,7 +79,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
||||
pi.is_defender(),
|
||||
e->GetCurrentGameState()->hex_map(),
|
||||
e->GetGameSettings()->GetGetter(),
|
||||
AIAlgorithmType::ITERATIVE_DEEPENING,
|
||||
AIAlgorithmType::MCTS,
|
||||
ScoringCalculatorType::MCTS_OPTIMIZED,
|
||||
mctsConfig);
|
||||
|
||||
|
||||
@@ -150,7 +150,10 @@ cc_library(
|
||||
srcs = [],
|
||||
hdrs = ["ShardokCommand.hpp"],
|
||||
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 = [
|
||||
":action_cost",
|
||||
":shardok_action",
|
||||
|
||||
Reference in New Issue
Block a user