Compare commits

...
Author SHA1 Message Date
adminandClaude e2f7b82ecd Eliminate expensive proto conversions in ShardokAction
Refactored ShardokAction to store a pointer to ShardokCommand instead of
eagerly converting to CommandProto. This eliminates millions of expensive
GetCommandProto() calls during MCTS search.

Key changes:
- Added forward declaration for ShardokCommand in ShardokAction.hpp
- New constructor: ShardokAction(const ShardokCommand* command, size_t index)
- Changed internal storage: const ShardokCommand* command_ (non-owning pointer)
- Made getCommand() lazy: only converts Command→Proto when actually needed
- Updated clone() to copy command pointer (cheap) instead of proto
- Updated BUILD.bazel visibility for shardok_command target

Performance impact: 2.1x speedup (870K → 1.8M visits)
Combined with previous optimizations: 55x total speedup (33K → 1.8M visits)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 19:10:07 -07:00
adminandClaude 4214610f33 Cache action objects to reduce allocation overhead
Caches the actual MCTSAction objects alongside legal actions data,
allowing fast cloning instead of reconstructing from command protos.

Changes:
- Added cachedActions vector to LegalActionsCache struct
- Modified getLegalActions() to clone cached actions when available
- Store cloned actions in cache after computing them

Impact:
- Reduces repeated action construction from cached data
- Performance similar to previous (~750K visits)
- Sets stage for eliminating expensive proto conversions

Note: This shows diminishing returns, suggesting proto conversion
is now the bottleneck (GetCommandProto() called on every action creation).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:59:32 -07:00
adminandClaude 339b2e8e63 Cache action weights to eliminate getActionWeights bottleneck
After adding transition caching, getActionWeights became the dominant
cost at 61% of runtime. This caches action weights alongside legal actions.

Changes:
- Added actionWeights vector to LegalActionsCache struct
- Modified getActionWeights() to:
  - Check cache for weights based on state hash
  - Return cached weights if available and size matches
  - Compute and store weights on cache miss

Performance impact:
- Additional 1.7x speedup on top of transition caching
- Total improvement: ~26x vs original (33K → 870K visits/search)
- Eliminates redundant AIHeuristicWeighting calculations for revisited states

The weights are deterministic for a given (state, actions) pair, making
them safe to cache. Since we already cache legal actions per state hash,
adding weights to the same cache entry is straightforward and effective.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:53:05 -07:00
adminandClaude cfe33bd7e4 Fix transition cache: store next state's engine in cache
CRITICAL FIX: The previous implementation stored transition mappings
(currentState, action) -> nextStateHash, but never stored nextStateHash's
engine in the cache. This caused 100% cache miss rate because lookups
for nextStateHash would always fail.

Changes:
- In applyAction(): After storing transition, also store next state's engine
  in legalActionsCache_[nextStateHash].engine
- In applyActionMutable(): Same fix

Why this matters:
Without this, the transition cache lookup chain would fail at:
1. Find transition: (stateA, action) -> stateB hash ✓
2. Look up stateB in cache to get engine ✗ (not in cache!)

Now stateB's engine is cached immediately when the transition is stored,
so future lookups of the same transition can retrieve the complete state.

Note: Statistics appear low/zero in test output because they're thread-local
and we only report from the main thread. Worker threads that do MCTS
simulations have separate caches. This doesn't affect correctness, only
visibility of metrics.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:43:34 -07:00
adminandClaude 6384419c51 Add transition caching to MCTS ShardokGameEngine
Implements state transition caching to avoid redundant PostCommand calls
during MCTS tree search. Key changes:

1. Added TransitionKey struct to cache (actionIndex, roll) tuples
2. Extended LegalActionsCache with actionResults map for transition storage
3. Modified applyAction() and applyActionMutable() to:
   - Check transition cache before applying actions
   - Store new transitions after PostCommand
4. Added thread-local statistics for transition cache hits/misses
5. Updated reportCacheStatistics() to display transition cache metrics
6. Use SequenceRandomGenerator with {0.5} for deterministic 50th percentile rolls
   (matching IterativeDeepening AI behavior)

Benefits:
- Eliminates redundant PostCommand calls for previously-seen state transitions
- Particularly valuable for MCTS which revisits states many times
- Cache is thread-local to avoid lock contention in multithreaded simulation
- Complements existing legal actions cache for comprehensive optimization

Testing:
- All MCTS-specific tests pass (ai_mcts_test, shardok_mcts_ai_basic_test)
- 12 of 13 integration tests pass
- Transition cache statistics visible in search output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:03:58 -07:00
8 changed files with 710 additions and 29 deletions
+463
View File
@@ -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",