Compare commits

...
Author SHA1 Message Date
admin 8387ec41a2 analysis to md file 2025-08-22 22:01:09 -07:00
admin e5d7abd96e Merge fix-race branch to address ShardokEngine shared state issues 2025-08-22 21:53:27 -07:00
adminandClaude 7822824657 Re-add ThreadPool implementation with FIFO queue and deadline support
This commit re-adds the ThreadPool functionality that was reverted, incorporating
changes from PRs 4340, 4342, and 4343:

* ThreadPool with TaskResult wrapper for deadline support (PR 4342)
* Simplified FIFO queue instead of priority system (PR 4343)
* Deadline parameter threading through AI functions (PR 4340)
* Comprehensive metrics and session tracking capabilities
* Proper error handling for deadline exceeded scenarios

The implementation maintains compatibility with the transposition table
caching system and provides improved performance monitoring.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 18:24:16 -07:00
adminandGitHub e2720911c9 cache GameState scores (#4344)
* cache GameState scores

* fix

* more infinite recursion checks

* fix the bug and improve logging

* small fixes

* rename

* null checks etc

* fix the build

* no change

* remove the null checks

* fix the build

* fix from comment
2025-08-22 17:45:54 -07:00
adminandGitHub 1b731c2080 oops (#4352) 2025-08-22 17:45:43 -07:00
54c7ae4a10 Add deadline parameter to AIScoreCalculator::CommandScore (#4351)
Pipes deadline through all AI scoring functions to enable timeout handling:
- Add deadline parameter to CommandScore, CalcOne, BestCommandIndex, EvaluateCommand, BasicLookaheadCalculator
- Add deadline checking in CalcOne to return early if timeout exceeded
- Update IterativeDeepeningAI to compute deadline from time budget
- No ThreadPool changes - uses original async/deferred approach

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-22 17:18:44 -07:00
adminandGitHub ca5c67158d Revert "Pipe deadline to AIScoreCalculator and use the thread pool (#4340)" (#4350)
This reverts commit 3b25ba3f97.
2025-08-22 16:57:33 -07:00
adminandGitHub 06835671a6 Revert "don't use a sentinel value (#4341)" (#4349)
This reverts commit a542361ae5.
2025-08-22 16:55:55 -07:00
adminandGitHub 563fd07036 Revert "add some metrics to the threadpool and use thread pools for lower dep…" (#4348)
This reverts commit f896d2d517.
2025-08-22 16:54:37 -07:00
adminandGitHub 427e284ac8 Revert "just use a queue (#4343)" (#4347)
This reverts commit c59aecf0b8.
2025-08-22 16:52:52 -07:00
14 changed files with 580 additions and 512 deletions
+10
View File
@@ -84,6 +84,16 @@ find . -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
find . -name "*.cs" | xargs clang-format -i
```
### Static Analysis
```bash
# Run clang-tidy static analysis on C++ files
# Note: This may show some header include errors but will still analyze the main file
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' <file_path> -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++20
# Example for AI files:
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' /Users/dancrosby/CodingProjects/github/eagle0/src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.cpp -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++20
```
## Language-Specific Patterns
**Scala (Strategic Layer):**
+150
View File
@@ -0,0 +1,150 @@
# Race Condition Analysis for Eagle0
## Summary
This document provides an analysis of potential race conditions in the Eagle0 codebase after merging the `fix-race` branch and re-adding ThreadPool functionality from PRs 4340, 4342, and 4343.
## Fixed Issues
### ✅ ShardokEngine Shared State Issue (FIXED by fix-race branch)
The `fix-race` branch successfully addressed a major race condition by changing `ShardokEngine` from being passed as `shared_ptr<ShardokEngine>` to being passed by value (copy).
**Key changes:**
- `BasicLookaheadCalculator` now takes `const ShardokEngine innerEngine` (by value)
- `CalcOne` creates a local copy: `auto innerEngine = ShardokEngine(guessedEngine, false)`
- All engine method calls changed from `innerEngine->` to `innerEngine.`
This ensures each thread works with its own independent copy of the engine state, eliminating concurrent access to shared mutable state.
## Remaining Potential Race Conditions
### 1. Transposition Table Global Access (HIGH RISK)
**Location:** `TranspositionTable.cpp`, global instance `g_transpositionTable`
**Issues:**
- Global shared state accessed by multiple threads simultaneously
- Hash collisions possible under high concurrency
- Depth-based storage logic when threads work at different depths
- Potential ABA problems in compare-and-swap operations
**Impact:** Most likely culprit for remaining crashes given it was added recently
### 2. Global Random Generator (MEDIUM RISK)
**Location:** `AIScoreCalculator.cpp`
```cpp
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
```
**Issues:**
- Shared across all threads
- If `SequenceRandomGenerator` isn't thread-safe internally, concurrent access could corrupt state
- No synchronization around access to this shared generator
### 3. ActionPointDistances Cache (MEDIUM RISK)
**Location:** `ActionPointDistancesCache.cpp`
**Issues:**
- Thread-local caching mechanism may have synchronization issues
- Cache invalidation across threads could be problematic
- Global cache updates might race with thread-local cache access
### 4. Performance Logging Atomics (LOW RISK)
**Location:** `AIScoreCalculator.cpp`, `AttackerScorePerformanceLogger`
```cpp
intervalTime.fetch_add(duration); // duration is a double
```
**Issues:**
- Atomic operations on doubles aren't guaranteed lock-free on all platforms
- Could cause performance degradation or incorrect metrics
### 5. ThreadPool Task Ordering (MEDIUM RISK)
**Location:** `ThreadPool.hpp`
**Issues:**
- FIFO queue (deque) could have ordering dependencies
- Deadline handling might create races if tasks timeout while processing
- Session-based metrics collection adds new shared state
- Task cancellation and cleanup could race with execution
### 6. Future Aggregation (LOW RISK)
**Location:** `AIScoreCalculator.cpp`, `BestCommandIndex` function
**Issues:**
- Futures collected and results aggregated
- Unexpected completion order could cause issues
- Error states might not be handled consistently
## Recommendations
### Immediate Actions
1. **Make Random Generators Thread-Local**
```cpp
thread_local auto t_randomGenerator =
std::make_shared<SequenceRandomGenerator>(_averageSequence);
```
2. **Add Defensive Checks**
- Validate game state after engine operations
- Check for NaN/infinity before storing scores
- Assert transposition table entry consistency
3. **Improve Transposition Table Locking**
- Consider sharding with separate locks per shard
- Investigate lock-free data structures
- Add more granular locking around critical sections
### Investigation Steps
1. **Run Thread Sanitizer**
```bash
bazel test --config=tsan //src/test/cpp/net/eagle0/shardok/ai:all
```
2. **Add Detailed Logging**
- Log all transposition table store/probe operations with thread IDs
- Track random generator access patterns
- Monitor ThreadPool task lifecycle
3. **Verify Thread Safety**
- Audit `SequenceRandomGenerator` for thread safety
- Review `ShardokEngine` copy constructor for deep copy completeness
- Check all global/static variables for proper synchronization
### Long-term Improvements
1. **Redesign Transposition Table**
- Implement thread-local transposition tables with periodic merging
- Use concurrent hash map implementation (e.g., Intel TBB concurrent_hash_map)
- Add versioning to prevent ABA problems
2. **Eliminate Global State**
- Pass random generators explicitly rather than using globals
- Consider dependency injection for caches and tables
- Make performance loggers thread-local
3. **Improve ThreadPool Robustness**
- Add task dependency tracking
- Implement proper cancellation tokens
- Add timeout recovery mechanisms
## Testing Recommendations
1. **Stress Testing**
- Run AI calculations with many threads simultaneously
- Use different random seeds to expose timing-dependent bugs
- Test with various game states and board configurations
2. **Reproducibility**
- Add deterministic mode with fixed seeds
- Log thread scheduling information
- Create minimal test cases that reproduce crashes
3. **Monitoring**
- Add metrics for lock contention
- Track task completion times and timeout rates
- Monitor memory usage patterns
## Conclusion
While the `fix-race` branch addressed the critical ShardokEngine shared state issue, several race condition risks remain. The transposition table and global random generator are the most likely culprits for any remaining crashes. Implementing the recommended immediate actions should significantly improve stability, while the long-term improvements will make the system more robust and maintainable.
@@ -88,19 +88,11 @@ cc_library(
],
)
cc_library(
name = "task_result",
hdrs = ["TaskResult.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
)
cc_library(
name = "thread_pool",
hdrs = ["ThreadPool.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
deps = [":task_result"],
)
cc_library(
+23 -148
View File
@@ -16,18 +16,25 @@
#include <thread>
#include <vector>
#include "TaskResult.hpp"
namespace eagle0::common {
// Metrics structure for ThreadPool session statistics
struct ThreadPoolMetrics {
size_t tasks_enqueued = 0;
size_t tasks_succeeded = 0;
size_t tasks_deadline_exceeded = 0;
size_t tasks_cancelled = 0;
double average_thread_load = 0.0; // Average percentage of threads busy over time
std::chrono::milliseconds session_duration{0};
enum class TaskStatus { SUCCESS = 0, DEADLINE_EXCEEDED = 1, CANCELLED = 2 };
template<typename T>
struct TaskResult {
T value;
TaskStatus status;
TaskResult() : value{}, status(TaskStatus::SUCCESS) {}
TaskResult(T val) : value(std::move(val)), status(TaskStatus::SUCCESS) {}
TaskResult(T val, TaskStatus stat) : value(std::move(val)), status(stat) {}
// NO implicit conversion - this was causing infinite recursion
// Use .value or .get() instead
T get() const { return value; }
bool succeeded() const { return status == TaskStatus::SUCCESS; }
bool deadlineExceeded() const { return status == TaskStatus::DEADLINE_EXCEEDED; }
};
class ThreadPool {
@@ -53,19 +60,6 @@ private:
std::condition_variable condition;
std::atomic<bool> stop{false};
// Metrics tracking
mutable std::mutex metrics_mutex; // mutable for const methods like isSessionActive()
bool session_active = false;
TimePoint session_start;
std::atomic<size_t> tasks_enqueued{0};
std::atomic<size_t> tasks_succeeded{0};
std::atomic<size_t> tasks_deadline_exceeded{0};
std::atomic<size_t> tasks_cancelled{0};
std::atomic<size_t> active_threads{0};
// Thread load tracking
std::vector<std::pair<TimePoint, size_t>> thread_load_samples; // (timestamp, active_count)
public:
explicit ThreadPool(size_t num_threads = std::thread::hardware_concurrency()) {
for (size_t i = 0; i < num_threads; ++i) {
@@ -87,31 +81,12 @@ public:
}
// Execute the task (deadline checking is now handled inside the task)
if (task.function) {
// Track thread activity
active_threads++;
recordThreadLoadSample();
task.function();
active_threads--;
recordThreadLoadSample();
}
if (task.function) { task.function(); }
}
});
}
}
private:
// Helper to record thread load samples
void recordThreadLoadSample() {
if (session_active) {
std::lock_guard<std::mutex> lock(metrics_mutex);
thread_load_samples.emplace_back(Clock::now(), active_threads.load());
}
}
public:
// Enqueue a task without deadline
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
@@ -122,19 +97,8 @@ public:
auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
auto task = std::make_shared<std::packaged_task<result_type()>>(
[this, actualTask = std::move(actualTask)]() mutable -> result_type {
result_type res = result_type(actualTask());
// Track completion status
if (session_active) {
switch (res.status) {
case TaskStatus::SUCCESS: tasks_succeeded++; break;
case TaskStatus::DEADLINE_EXCEEDED: tasks_deadline_exceeded++; break;
case TaskStatus::CANCELLED: tasks_cancelled++; break;
}
}
return res;
[actualTask = std::move(actualTask)]() mutable -> result_type {
return result_type(actualTask());
});
std::future<result_type> result = task->get_future();
@@ -143,8 +107,6 @@ public:
std::unique_lock<std::mutex> lock(queue_mutex);
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
tasks.emplace_back([task]() { (*task)(); }, TimePoint{}, false);
if (session_active) { tasks_enqueued++; }
}
condition.notify_one();
@@ -161,24 +123,11 @@ public:
auto actualTask = std::forward<F>(f);
auto task = std::make_shared<std::packaged_task<result_type()>>(
[this, actualTask = std::move(actualTask), deadline]() mutable -> result_type {
result_type res;
[actualTask = std::move(actualTask), deadline]() mutable -> result_type {
if (Clock::now() > deadline) {
res = result_type(return_type{}, TaskStatus::DEADLINE_EXCEEDED);
} else {
res = result_type(actualTask());
return result_type(return_type{}, TaskStatus::DEADLINE_EXCEEDED);
}
// Track completion status
if (session_active) {
switch (res.status) {
case TaskStatus::SUCCESS: tasks_succeeded++; break;
case TaskStatus::DEADLINE_EXCEEDED: tasks_deadline_exceeded++; break;
case TaskStatus::CANCELLED: tasks_cancelled++; break;
}
}
return res;
return result_type(actualTask());
});
std::future<result_type> result = task->get_future();
@@ -187,8 +136,6 @@ public:
std::unique_lock<std::mutex> lock(queue_mutex);
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
tasks.emplace_back([task]() { (*task)(); }, deadline, true);
if (session_active) { tasks_enqueued++; }
}
condition.notify_one();
@@ -221,78 +168,6 @@ public:
}
}
// Start a new metrics session
void beginSession() {
std::lock_guard<std::mutex> lock(metrics_mutex);
session_active = true;
session_start = Clock::now();
// Reset all metrics
tasks_enqueued = 0;
tasks_succeeded = 0;
tasks_deadline_exceeded = 0;
tasks_cancelled = 0;
thread_load_samples.clear();
// Record initial thread load
thread_load_samples.emplace_back(session_start, active_threads.load());
}
// End the current session and return metrics
ThreadPoolMetrics endSession() {
std::lock_guard<std::mutex> lock(metrics_mutex);
if (!session_active) {
return ThreadPoolMetrics{}; // Return empty metrics if no session active
}
auto session_end = Clock::now();
session_active = false;
// Record final thread load
thread_load_samples.emplace_back(session_end, active_threads.load());
// Calculate metrics
ThreadPoolMetrics metrics;
metrics.tasks_enqueued = tasks_enqueued.load();
metrics.tasks_succeeded = tasks_succeeded.load();
metrics.tasks_deadline_exceeded = tasks_deadline_exceeded.load();
metrics.tasks_cancelled = tasks_cancelled.load();
metrics.session_duration =
std::chrono::duration_cast<std::chrono::milliseconds>(session_end - session_start);
// Calculate average thread load
if (thread_load_samples.size() >= 2 && workers.size() > 0) {
double total_load_time = 0.0;
auto total_duration =
std::chrono::duration<double>(
thread_load_samples.back().first - thread_load_samples.front().first)
.count();
for (size_t i = 1; i < thread_load_samples.size(); ++i) {
auto duration =
std::chrono::duration<double>(
thread_load_samples[i].first - thread_load_samples[i - 1].first)
.count();
auto load = static_cast<double>(thread_load_samples[i - 1].second) / workers.size();
total_load_time += load * duration;
}
metrics.average_thread_load =
(total_duration > 0) ? (total_load_time / total_duration) : 0.0;
} else {
metrics.average_thread_load = 0.0;
}
return metrics;
}
// Check if a session is currently active
bool isSessionActive() const {
std::lock_guard<std::mutex> lock(metrics_mutex);
return session_active;
}
~ThreadPool() {
stop.store(true);
condition.notify_all();
@@ -4,6 +4,7 @@
#include "AIAttackGroups.hpp"
#include <cstdlib>
#include <iterator>
#include <ranges>
#include <unordered_map>
@@ -4,16 +4,14 @@
#include "AIScoreCalculator.hpp"
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <future>
#include <limits>
#include "TranspositionTable.hpp"
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/common/TaskResult.hpp"
#include "src/main/cpp/net/eagle0/common/ThreadPool.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
@@ -30,54 +28,6 @@
namespace shardok {
// Maximum depth for which we pre-allocate thread pools (depths 2-6)
constexpr int MAX_DEPTH_POOLS = 5;
constexpr int MAX_SUPPORTED_DEPTH = 6;
static auto GetAIThreadPool() -> std::shared_ptr<eagle0::common::ThreadPool> {
static auto pool =
std::make_shared<eagle0::common::ThreadPool>(std::thread::hardware_concurrency());
return pool;
}
static auto GetAIThreadPoolForDepth(int depth) -> std::shared_ptr<eagle0::common::ThreadPool> {
// Use depth 1 pool for depth 1 (backwards compatibility with metrics)
if (depth <= 1) { return GetAIThreadPool(); }
// Create separate thread pools for each depth (2-6) to avoid deadlocks
// while avoiding std::launch::async thread creation overhead
static std::array<std::shared_ptr<eagle0::common::ThreadPool>, MAX_DEPTH_POOLS> depth_pools =
[]() {
std::array<std::shared_ptr<eagle0::common::ThreadPool>, MAX_DEPTH_POOLS> pools;
for (size_t i = 0; i < MAX_DEPTH_POOLS; ++i) {
pools[i] = std::make_shared<eagle0::common::ThreadPool>(
std::thread::hardware_concurrency());
}
return pools;
}();
if (depth >= 2 && depth <= MAX_SUPPORTED_DEPTH) {
return depth_pools[depth - 2]; // depth 2 -> index 0, depth 3 -> index 1, etc.
}
// We should never reach depths higher than 6 in realistic scenarios
printf("FATAL: ThreadPool depth limit exceeded (depth=%d, max=%d). This indicates a "
"configuration error or infinite recursion. The program will terminate.\n",
depth,
MAX_SUPPORTED_DEPTH);
std::abort();
}
void AIScoreCalculator::BeginMetricsSession() {
auto pool = GetAIThreadPool();
pool->beginSession();
}
eagle0::common::ThreadPoolMetrics AIScoreCalculator::EndMetricsSession() {
auto pool = GetAIThreadPool();
return pool->endSession();
}
struct IndexAndScore {
size_t index;
CommandType type;
@@ -87,7 +37,7 @@ struct IndexAndScore {
struct ImmediateAndLookaheadScore {
ScoreValue immediateScore;
future<eagle0::common::TaskResult<ScoreValue>> lookaheadScore;
future<ScoreValue> lookaheadScore;
};
[[nodiscard]] static auto BasicLookaheadCalculator(
@@ -102,8 +52,7 @@ struct ImmediateAndLookaheadScore {
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
std::chrono::steady_clock::time_point deadline)
-> std::future<eagle0::common::TaskResult<ScoreValue>>;
std::chrono::steady_clock::time_point deadline) -> std::future<ScoreValue>;
[[nodiscard]] static auto BestCommandIndex(
PlayerId pid,
@@ -119,14 +68,6 @@ struct ImmediateAndLookaheadScore {
const ALCache &alCache,
std::chrono::steady_clock::time_point deadline) -> std::future<IndexAndScore>;
/// Core command evaluation function
///
/// Internal implementation uses ScoreValue (double) with NaN as sentinel value for timeouts.
/// This allows existing algorithms and data structures to work unchanged while providing
/// explicit timeout semantics at the API boundary via TaskResult conversion.
///
/// @param deadline Absolute time point when evaluation should stop
/// @return ImmediateAndLookaheadScore where lookaheadScore future contains NaN if timed out
[[nodiscard]] static auto CalcOne(
PlayerId pid,
bool isDefender,
@@ -180,7 +121,7 @@ struct ImmediateAndLookaheadScore {
struct CommandEvaluationResult {
ScoreValue immediateScore;
future<eagle0::common::TaskResult<ScoreValue>> lookaheadScore;
future<ScoreValue> lookaheadScore;
};
[[nodiscard]] static auto EvaluateCommand(
@@ -435,6 +376,7 @@ auto AttackerUnitsScore(
const auto *gameStateRawPtr = gameState.Get();
const auto *cachedUnits = gameStateRawPtr->units();
const auto *cachedHexMap = gameStateRawPtr->hex_map();
const int16_t cachedRowCount = cachedHexMap->row_count();
const int16_t cachedColumnCount = cachedHexMap->column_count();
const int cachedCurrentRound = gameStateRawPtr->current_round();
@@ -682,7 +624,10 @@ auto AttackerUnitsScore(
auto FleeStrategyScoreForState(const GameStateW &gameState, const PlayerId playerId) -> ScoreValue {
ScoreValue scoreValue = 0.0;
for (const auto *unit : *gameState->units()) {
const auto *gameStatePtr = gameState.Get();
const auto *units = gameStatePtr->units();
for (const auto *unit : *units) {
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
if (unit->player_id() == playerId &&
@@ -705,21 +650,25 @@ auto DefenderScatterStrategyScoreForState(
const SettingsGetter &settings,
const ALCache &alCache,
const APDCache &apdCache) -> ScoreValue {
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
const auto *gameStatePtr = gameState.Get();
const auto *status = gameStatePtr->status();
if (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
const auto *winningIds = status->winning_shardok_ids();
const auto *playerInfos = gameStatePtr->player_infos();
for (const PlayerId winningPid : *winningIds) {
if (winningPid < 0) continue;
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MAX;
if (playerInfos->Get(winningPid)->is_defender()) return INT_MAX;
return INT_MIN;
}
return INT_MAX;
}
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
return 0;
}
if (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) { return 0; }
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
const auto *hexMap = gameStatePtr->hex_map();
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
const auto unitsTotal = -AttackerUnitsScore(
gameState,
@@ -971,10 +920,31 @@ auto BasicLookaheadCalculator(
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
std::chrono::steady_clock::time_point deadline)
-> std::future<eagle0::common::TaskResult<ScoreValue>> {
std::chrono::steady_clock::time_point deadline) -> std::future<ScoreValue> {
// Check transposition table before expensive computation
auto cachedScore =
g_transpositionTable.probe(innerEngine.GetCurrentGameState(), remainingLookahead, pid);
if (cachedScore.has_value()) {
// Return cached result immediately
std::promise<ScoreValue> p;
p.set_value(*cachedScore);
return p.get_future();
}
const auto nextUtility = currentUtility;
// Check if we've reached the depth limit before making recursive calls
if (remainingLookahead <= 0) {
// Store the current utility in the transposition table and return it
// Note: Store with depth 1 since depth 0 indicates an empty entry in the transposition
// table
g_transpositionTable.store(innerEngine.GetCurrentGameState(), 1, pid, nextUtility);
std::promise<ScoreValue> p;
p.set_value(nextUtility);
return p.get_future();
}
if (const CommandListSPtr nextCommands = innerEngine.GetAvailableCommandsForAIPlayer(pid);
nextCommands && !nextCommands->empty()) {
// Get the future from BestCommandIndex without calling .get()
@@ -998,24 +968,38 @@ auto BasicLookaheadCalculator(
[bestCommandFuture = std::move(bestCommandFuture),
innerEngine,
pid,
nextUtility]() mutable -> eagle0::common::TaskResult<ScoreValue> {
nextUtility,
remainingLookahead]() mutable -> ScoreValue {
const auto [index, type, lookaheadScore, immediateScore] =
bestCommandFuture.get();
ScoreValue resultScore;
if (auto &nextCommand =
innerEngine.GetAvailableCommandsForAIPlayer(pid)->at(index);
nextCommand->GetCommandType() !=
net::eagle0::shardok::common::END_TURN_COMMAND) {
return eagle0::common::TaskResult<ScoreValue>::Success(immediateScore);
resultScore = immediateScore;
} else {
resultScore = nextUtility;
}
return eagle0::common::TaskResult<ScoreValue>::Success(nextUtility);
// Store in transposition table before returning
g_transpositionTable.store(
innerEngine.GetCurrentGameState(),
remainingLookahead,
pid,
resultScore);
return resultScore;
});
}
// No commands available, return the current utility as a future
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
p.set_value(eagle0::common::TaskResult<ScoreValue>::Success(nextUtility));
// No commands available, store and return the current utility as a future
g_transpositionTable
.store(innerEngine.GetCurrentGameState(), remainingLookahead, pid, nextUtility);
std::promise<ScoreValue> p;
p.set_value(nextUtility);
return p.get_future();
}
@@ -1037,10 +1021,10 @@ auto CalcOne(
// Check if we've exceeded the deadline
if (std::chrono::steady_clock::now() > deadline) {
// Return TaskResult with DEADLINE_EXCEEDED status
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
p.set_value(eagle0::common::TaskResult<ScoreValue>::DeadlineExceeded(0.0));
returnValue.immediateScore = 0.0; // Will be ignored since lookahead indicates timeout
// Return with a default score and an empty future that resolves immediately
std::promise<ScoreValue> p;
p.set_value(0.0); // Default timeout score
returnValue.immediateScore = 0.0;
returnValue.lookaheadScore = p.get_future();
return returnValue;
}
@@ -1060,84 +1044,46 @@ auto CalcOne(
returnValue.immediateScore = innerUtility;
if (remainingLookahead <= 0) {
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
std::promise<ScoreValue> p;
returnValue.lookaheadScore = p.get_future();
p.set_value(eagle0::common::TaskResult<ScoreValue>::Success(innerUtility));
p.set_value(innerUtility);
} else {
auto lookaheadLambda = [pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
attackerStrategy,
innerUtility,
&settingsGetter,
&allCastleCoords,
&apdCache,
&alCache,
deadline]() -> ScoreValue {
auto lookaheadFuture = BasicLookaheadCalculator(
pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
innerUtility,
attackerStrategy,
settingsGetter,
allCastleCoords,
apdCache,
alCache,
deadline);
return lookaheadFuture.get();
};
#if MULTITHREAD
// Revert to original strategy for now - always use ThreadPool for depth 1
// and deferred for higher depths
auto lookaheadLambda = [pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
attackerStrategy,
innerUtility,
&settingsGetter,
&allCastleCoords,
&apdCache,
&alCache,
deadline]() -> ScoreValue {
auto lookaheadFuture = BasicLookaheadCalculator(
pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
innerUtility,
attackerStrategy,
settingsGetter,
allCastleCoords,
apdCache,
alCache,
deadline);
auto result = lookaheadFuture.get();
return result.succeeded() ? result.value : 0.0;
};
// Use depth-specific ThreadPool to avoid thread creation overhead
// while preventing deadlocks (depth N threads only wait on depth N+1 pools)
auto pool = GetAIThreadPoolForDepth(remainingLookahead);
auto taskFuture = pool->enqueue_with_deadline(
lookaheadLambda, // Pass the lambda directly - it returns ScoreValue
deadline);
// ThreadPool already returns future<TaskResult<ScoreValue>>, so use it directly
returnValue.lookaheadScore = std::move(taskFuture);
auto launchPolicy = remainingLookahead == 1 ? std::launch::async : std::launch::deferred;
returnValue.lookaheadScore = std::async(launchPolicy, lookaheadLambda);
#else
auto lookaheadLambda = [pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
attackerStrategy,
innerUtility,
&settingsGetter,
&allCastleCoords,
&apdCache,
&alCache,
deadline]() -> ScoreValue {
auto lookaheadFuture = BasicLookaheadCalculator(
pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
innerUtility,
attackerStrategy,
settingsGetter,
allCastleCoords,
apdCache,
alCache,
deadline);
auto result = lookaheadFuture.get();
return result.succeeded() ? result.value : 0.0;
};
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
std::promise<ScoreValue> p;
returnValue.lookaheadScore = p.get_future();
auto lambdaResult = lookaheadLambda();
p.set_value(eagle0::common::TaskResult<ScoreValue>::Success(lambdaResult));
p.set_value(lambdaResult);
#endif
}
@@ -1222,7 +1168,7 @@ auto CalcOne(
size_t index;
CommandType type;
ScoreValue immediateScore;
std::vector<std::future<eagle0::common::TaskResult<ScoreValue>>> lookaheadFutures;
std::vector<std::future<ScoreValue>> lookaheadFutures;
};
std::vector<CommandEvaluation> commandEvaluations(commandCount);
@@ -1236,9 +1182,9 @@ auto CalcOne(
commandEvaluations[index].type = guessedCommandType;
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
std::promise<ScoreValue> p;
commandEvaluations[index].lookaheadFutures.push_back(p.get_future());
p.set_value(eagle0::common::TaskResult<ScoreValue>::Success(currentUtility));
p.set_value(currentUtility);
commandEvaluations[index].immediateScore = currentUtility;
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] =
@@ -1305,19 +1251,8 @@ auto CalcOne(
auto failureSF = failureLookaheadScore.share();
commandEvaluations[index].lookaheadFutures.push_back(std::async(
std::launch::deferred,
[successSF, failureSF, successChance]()
-> eagle0::common::TaskResult<ScoreValue> {
auto failureResult = failureSF.get();
auto successResult = successSF.get();
// If either result failed, return failure
if (!failureResult.succeeded() || !successResult.succeeded()) {
return eagle0::common::TaskResult<ScoreValue>::DeadlineExceeded();
}
auto interpolatedValue =
std::lerp(failureResult.value, successResult.value, successChance);
return eagle0::common::TaskResult<ScoreValue>::Success(interpolatedValue);
[successSF, failureSF, successChance]() -> double {
return std::lerp(failureSF.get(), successSF.get(), successChance);
}));
} else {
ScoreValue sum = 0.0;
@@ -1358,20 +1293,13 @@ auto CalcOne(
// Wait for all futures and compute final scores
for (auto &eval : commandEvaluations) {
ScoreValue totalLookaheadScore = 0.0;
int successCount = 0;
for (auto &future : eval.lookaheadFutures) {
auto result = future.get();
if (result.succeeded()) {
totalLookaheadScore += result.value;
successCount++;
}
totalLookaheadScore += future.get();
}
ScoreValue avgLookaheadScore =
(eval.lookaheadFutures.empty() || successCount == 0)
eval.lookaheadFutures.empty()
? eval.immediateScore
: totalLookaheadScore / successCount;
: totalLookaheadScore / eval.lookaheadFutures.size();
allResults.push_back(IndexAndScore{
.index = eval.index,
@@ -1402,8 +1330,8 @@ auto EvaluateCommand(
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
if (commandIndex >= guessedDescriptors->size()) {
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
p.set_value(eagle0::common::TaskResult<ScoreValue>::Success(currentUtility));
std::promise<ScoreValue> p;
p.set_value(currentUtility);
return {currentUtility, p.get_future()};
}
@@ -1411,8 +1339,8 @@ auto EvaluateCommand(
if (const auto guessedCommandType = guessedDescriptor->GetCommandType();
guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
p.set_value(eagle0::common::TaskResult<ScoreValue>::Success(currentUtility));
std::promise<ScoreValue> p;
p.set_value(currentUtility);
return {currentUtility, p.get_future()};
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] =
@@ -1472,27 +1400,13 @@ auto EvaluateCommand(
return {std::lerp(failureImmediateScore, successImmediateScore, successChance),
std::async(
std::launch::deferred,
[successSF, failureSF, successChance]()
-> eagle0::common::TaskResult<ScoreValue> {
auto failureResult = failureSF.get();
auto successResult = successSF.get();
// If either result failed, return failure
if (!failureResult.succeeded() || !successResult.succeeded()) {
return eagle0::common::TaskResult<ScoreValue>::DeadlineExceeded();
}
auto interpolatedValue = std::lerp(
failureResult.value,
successResult.value,
successChance);
return eagle0::common::TaskResult<ScoreValue>::Success(
interpolatedValue);
[successSF, failureSF, successChance]() -> double {
return std::lerp(failureSF.get(), successSF.get(), successChance);
})};
} else {
// For non-deterministic commands without odds, use multiple attempts
ScoreValue totalImmediateScore = 0.0;
std::vector<std::future<eagle0::common::TaskResult<ScoreValue>>> lookaheadFutures;
std::vector<std::future<ScoreValue>> lookaheadFutures;
lookaheadFutures.reserve(maxRepeatCount);
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
@@ -1522,25 +1436,10 @@ auto EvaluateCommand(
std::async(
std::launch::deferred,
[lookaheadFutures = std::move(lookaheadFutures),
maxRepeatCount]() mutable -> eagle0::common::TaskResult<ScoreValue> {
maxRepeatCount]() mutable -> double {
ScoreValue total = 0.0;
int successCount = 0;
for (auto &future : lookaheadFutures) {
auto result = future.get();
if (result.succeeded()) {
total += result.value;
successCount++;
}
}
// If no futures succeeded, return deadline exceeded
if (successCount == 0) {
return eagle0::common::TaskResult<ScoreValue>::DeadlineExceeded();
}
return eagle0::common::TaskResult<ScoreValue>::Success(
total / maxRepeatCount);
for (auto &future : lookaheadFutures) { total += future.get(); }
return total / maxRepeatCount;
})};
}
}
@@ -1558,8 +1457,7 @@ auto EvaluateCommand(
const APDCache &apdCache,
const ALCache &alCache,
const size_t commandIndex,
std::chrono::steady_clock::time_point deadline)
-> std::future<eagle0::common::TaskResult<ScoreValue>> {
std::chrono::steady_clock::time_point deadline) -> std::future<ScoreValue> {
auto result = EvaluateCommand(
pid,
isDefender,
@@ -1574,8 +1472,6 @@ auto EvaluateCommand(
apdCache,
alCache,
deadline);
// Return the TaskResult future directly - no conversion needed
return std::move(result.lookaheadScore);
}
@@ -8,8 +8,6 @@
#include <chrono>
#include <future>
#include "src/main/cpp/net/eagle0/common/TaskResult.hpp"
#include "src/main/cpp/net/eagle0/common/ThreadPool.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
@@ -33,11 +31,6 @@ using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
class AIScoreCalculator {
public:
// Start a new metrics collection session
static void BeginMetricsSession();
// End the current session and return metrics
static eagle0::common::ThreadPoolMetrics EndMetricsSession();
// Evaluate the score of a guessed game state based on the current AI strategy. DOES NOT perform
// or evaluate any commands.
[[nodiscard]] static auto GuessedStateScore(
@@ -63,8 +56,7 @@ public:
const APDCache &apdCache,
const ALCache &alCache,
size_t commandIndex,
std::chrono::steady_clock::time_point deadline)
-> std::future<eagle0::common::TaskResult<ScoreValue>>;
std::chrono::steady_clock::time_point deadline) -> std::future<ScoreValue>;
};
} // namespace shardok
@@ -5,6 +5,7 @@
#include "AIUnitScoreCalculator.hpp"
#include <algorithm>
#include <cstdlib>
#include "AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -342,7 +343,8 @@ auto UnitValue(
unit->battalion().type() == net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD;
const int coordsIndex = location.row() * map->column_count() + location.column();
const auto &terrain = map->terrain()->Get(coordsIndex);
const auto *terrain = map->terrain()->Get(coordsIndex);
double castleMultiplier = 1.0;
// Only give a multiplier for being in a castle if the castle is useful, and the unit is not
// undead
@@ -358,8 +360,8 @@ auto UnitValue(
{
for (const auto adjacentCoords = HexMapUtils::GetAdjacentCoords(map, location);
const auto &c : adjacentCoords) {
if (const auto &adjTerrain = GetTerrain(map, c);
adjTerrain->modifier().fire().present()) {
if (const auto *adjTerrain = GetTerrain(map, c);
adjTerrain && adjTerrain->modifier().fire().present()) {
onFireMultiplier *= kAdjacentFireMultiplier;
}
}
@@ -414,7 +416,7 @@ auto UnitValue(
if (const auto commandingUnitId = unit->commanding_unit_id(); commandingUnitId != -1) {
const Unit *commandingUnit = nullptr;
for (const Unit *attackerUnit : attackerUnits) {
if (attackerUnit->unit_id() == commandingUnitId) {
if (attackerUnit && attackerUnit->unit_id() == commandingUnitId) {
commandingUnit = attackerUnit;
break;
}
@@ -422,7 +424,7 @@ auto UnitValue(
if (commandingUnit == nullptr) {
for (const Unit *defenderUnit : defenderUnits) {
if (defenderUnit->unit_id() == commandingUnitId) {
if (defenderUnit && defenderUnit->unit_id() == commandingUnitId) {
commandingUnit = defenderUnit;
break;
}
+15 -3
View File
@@ -168,6 +168,20 @@ cc_library(
],
)
cc_library(
name = "transposition_table",
srcs = ["TranspositionTable.cpp"],
hdrs = ["TranspositionTable.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
],
)
cc_library(
name = "ai_score_calculator",
srcs = ["AIScoreCalculator.cpp"],
@@ -182,9 +196,8 @@ cc_library(
":ai_command_filter",
":ai_unit_score_calculator",
":ai_victory_condition_score_calculator",
":transposition_table",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//src/main/cpp/net/eagle0/common:task_result",
"//src/main/cpp/net/eagle0/common:thread_pool",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/view_filters:game_state_guesser",
],
@@ -310,7 +323,6 @@ cc_library(
":ai_score_calculator",
":ai_time_budget",
":ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/common:task_result",
"//src/main/cpp/net/eagle0/common:time_utils",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
@@ -5,15 +5,13 @@
#include "IterativeDeepeningAI.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#include <numeric>
#include <utility>
#include "AIAttackerStrategySelector.hpp"
#include "AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/common/TaskResult.hpp"
#include "src/main/cpp/net/eagle0/common/TimeUtils.hpp"
#include "TranspositionTable.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
namespace shardok {
@@ -45,34 +43,25 @@ auto IterativeDeepeningAI::IterativeSearch(
const auto initialBudgetMs = initialBudget.remainingBudget;
SearchResult result;
// Start ThreadPool metrics session
AIScoreCalculator::BeginMetricsSession();
// Increment TT age for replacement strategy (new search)
g_transpositionTable.incrementAge();
// DEBUG: Clear TT to see if that's causing the suspicious depth reaching
// g_transpositionTable.clear(); // Uncomment to test without cross-search caching
if (commands.empty()) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Commands are empty, returning early\n");
#endif
result.searchCompleted = true;
// End session and print metrics (only if session was long enough to be interesting)
auto metrics = AIScoreCalculator::EndMetricsSession();
if (metrics.session_duration.count() >= 100) {
printf("ThreadPool Metrics (empty commands):\n");
printf(" Tasks enqueued: %zu\n", metrics.tasks_enqueued);
printf(" Tasks succeeded: %zu\n", metrics.tasks_succeeded);
printf(" Tasks deadline exceeded: %zu\n", metrics.tasks_deadline_exceeded);
printf(" Average thread load: %.1f%%\n", metrics.average_thread_load * 100.0);
printf(" Session duration: %lldms\n", metrics.session_duration.count());
}
return result;
}
// Check if we're in SET_UP phase
// Check if we're in SET_UP phase and enforce maximum depth limit
bool isSetupPhase =
(state->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP);
size_t maxDepth = isSetupPhase ? 2 : std::numeric_limits<int>::max();
// Limit depth to prevent thread pool exhaustion and keep search reasonable
size_t maxDepth = isSetupPhase ? 2 : 8;
// Calculate current utility and create engine once for all command evaluations
const auto& settingsGetter = settings->GetGetter();
@@ -100,12 +89,6 @@ auto IterativeDeepeningAI::IterativeSearch(
// Main iterative deepening loop
while ((currentDepth == 1 || !IsTimeExpired(timeBudget)) && currentDepth <= maxDepth) {
// Track depth timing
auto depthStartTime = std::chrono::steady_clock::now();
auto elapsedSinceStart =
std::chrono::duration_cast<std::chrono::milliseconds>(depthStartTime - startTime);
printf("ID AI: Starting depth %zu at %lldms\n", currentDepth, elapsedSinceStart.count());
// Get command indices sorted by best score from previous depth
std::vector<size_t> sortedIndices = GetCommandsSortedByPreviousDepth(
currentDepth,
@@ -132,46 +115,29 @@ auto IterativeDeepeningAI::IterativeSearch(
maxRepeatCount,
commands,
cmdIndex,
currentDepth,
currentDepth, // Pass current iteration depth as desired search depth
currentUtility,
timeBudget);
futures.emplace_back(cmdIndex, std::move(future));
}
auto afterTaskSubmission = std::chrono::steady_clock::now();
auto submissionTime = std::chrono::duration_cast<std::chrono::milliseconds>(
afterTaskSubmission - depthStartTime);
printf("ID AI: Submitted %zu tasks for depth %zu (took %lldms)\n",
futures.size(),
currentDepth,
submissionTime.count());
// Now wait for all futures and collect results
printf("ID AI: Waiting for %zu futures at depth %zu\n", futures.size(), currentDepth);
auto waitStartTime = std::chrono::steady_clock::now();
for (auto& [cmdIndex, future] : futures) {
auto cmdResult = future.get();
// Only record results for successfully completed evaluations
if (cmdResult.searchCompleted) {
// Ensure scoresByDepth[cmdIndex] has enough space
if (scoresByDepth[cmdIndex].size() <= currentDepth) {
scoresByDepth[cmdIndex].resize(currentDepth + 1);
}
scoresByDepth[cmdIndex][currentDepth] = cmdResult.bestScore;
highestDepthCompleted[cmdIndex] = currentDepth;
evaluatedCount++;
// Ensure scoresByDepth[cmdIndex] has enough space
if (scoresByDepth[cmdIndex].size() <= currentDepth) {
scoresByDepth[cmdIndex].resize(currentDepth + 1);
}
scoresByDepth[cmdIndex][currentDepth] = cmdResult.bestScore;
highestDepthCompleted[cmdIndex] = currentDepth;
evaluatedCount++;
// Check if this command is not END_TURN_COMMAND
if (commands[cmdIndex].type() != net::eagle0::shardok::common::END_TURN_COMMAND) {
allEndTurnCommands = false;
}
// Check if this command is not END_TURN_COMMAND
if (commands[cmdIndex].type() != net::eagle0::shardok::common::END_TURN_COMMAND) {
allEndTurnCommands = false;
}
// If searchCompleted is false, we don't increment evaluatedCount or update
// highestDepthCompleted This means the iterative deepening logic will correctly handle
// incomplete evaluations
}
// Find the best command at current depth and check if it changed
@@ -209,24 +175,6 @@ auto IterativeDeepeningAI::IterativeSearch(
previousBestCommand = currentBestCommand;
}
// Log depth completion timing
auto depthEndTime = std::chrono::steady_clock::now();
auto depthDuration = std::chrono::duration_cast<std::chrono::milliseconds>(
depthEndTime - depthStartTime);
auto waitDuration =
std::chrono::duration_cast<std::chrono::milliseconds>(depthEndTime - waitStartTime);
auto totalElapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(depthEndTime - startTime);
printf("ID AI: Completed depth %zu at %lldms (depth took %lldms, wait took %lldms, "
"evaluated %zu/%zu)\n",
currentDepth,
totalElapsed.count(),
depthDuration.count(),
waitDuration.count(),
evaluatedCount,
sortedIndices.size());
// Only proceed to next depth if we completed all commands at current depth
if (!allEvaluated) {
completionReason = EvaluationCompletionReason::RAN_OUT_OF_TIME;
@@ -270,9 +218,8 @@ auto IterativeDeepeningAI::IterativeSearch(
}
// Check if we've used more than 50% of total budget
auto totalElapsedCheck = std::chrono::steady_clock::now() - startTime;
auto totalElapsedMs =
std::chrono::duration_cast<std::chrono::milliseconds>(totalElapsedCheck);
auto totalElapsed = std::chrono::steady_clock::now() - startTime;
auto totalElapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(totalElapsed);
double budgetUsedPercent = static_cast<double>(totalElapsedMs.count()) /
static_cast<double>(initialBudgetMs.count());
@@ -312,28 +259,8 @@ auto IterativeDeepeningAI::IterativeSearch(
result.availableCommandCount);
}
// End session and print ThreadPool metrics (only for sessions >= 100ms)
auto metrics = AIScoreCalculator::EndMetricsSession();
if (metrics.session_duration.count() >= 100) {
printf("ThreadPool Metrics (depth %zu, %s):\n",
result.depthAchieved,
completionReason == EvaluationCompletionReason::RAN_OUT_OF_TIME ? "timeout"
: completionReason == EvaluationCompletionReason::RAN_OUT_OF_COMMANDS ? "complete"
: completionReason == EvaluationCompletionReason::NOT_ENOUGH_TIME_TO_CONTINUE
? "no_time"
: "unknown");
printf(" Tasks enqueued: %zu\n", metrics.tasks_enqueued);
printf(" Tasks succeeded: %zu\n", metrics.tasks_succeeded);
printf(" Tasks deadline exceeded: %zu\n", metrics.tasks_deadline_exceeded);
printf(" Tasks cancelled: %zu\n", metrics.tasks_cancelled);
printf(" Average thread load: %.1f%%\n", metrics.average_thread_load * 100.0);
printf(" Session duration: %lldms\n", metrics.session_duration.count());
printf(" Tasks per ms: %.2f\n",
metrics.session_duration.count() > 0 ? static_cast<double>(metrics.tasks_enqueued) /
metrics.session_duration.count()
: 0.0);
}
// Print TranspositionTable statistics
g_transpositionTable.printStats();
return result;
}
@@ -347,12 +274,12 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
const int maxRepeatCount,
const std::vector<CommandProto>& commands,
const size_t commandIndex,
const int depth,
const int desiredDepth,
const ScoreValue currentUtility,
AITimeBudget& timeBudget) const -> std::future<SearchResult> {
SearchResult result;
result.bestCommandIndex = commandIndex;
result.depthAchieved = depth;
result.depthAchieved = desiredDepth;
result.searchCompleted = true;
result.minimumDepthCompleted = true;
result.availableCommandCount = commands.size();
@@ -374,10 +301,14 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
const auto deadline = startTime + timeBudget.remainingBudget;
// Get the future from CommandScore - don't wait yet
// Note: CommandScore expects remainingLookahead, not desiredDepth
// desiredDepth 1 = evaluate immediate (remainingLookahead 0)
// desiredDepth 2 = look 1 move ahead (remainingLookahead 1)
// desiredDepth N = look N-1 moves ahead (remainingLookahead N-1)
auto commandScoreFuture = AIScoreCalculator::CommandScore(
playerId,
isDefender,
depth,
desiredDepth - 1, // Convert desiredDepth to remainingLookahead
maxRepeatCount,
guessedEngine,
strategy,
@@ -391,7 +322,7 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
// Calculate time and adjust budget before waiting
// This is needed because we need to update timeBudget synchronously
const auto commandResult = commandScoreFuture.get();
const auto commandScore = commandScoreFuture.get();
const auto elapsed = std::chrono::steady_clock::now() - startTime;
const int concurrentCount = AIEvaluationCounter::GetCurrentCount();
@@ -402,15 +333,7 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
// Deduct adjusted time from remaining budget
timeBudget.remainingBudget -= adjustedElapsedMs;
// Check if we got a valid result or if evaluation failed/timed out
if (!commandResult.succeeded()) {
// Command evaluation failed or timed out - mark as incomplete
result.bestScore = 0.0;
result.searchCompleted = false;
result.minimumDepthCompleted = false;
} else {
result.bestScore = commandResult.value;
}
result.bestScore = commandScore;
} catch (const std::exception& e) {
// If evaluation fails, return a neutral score rather than crashing
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
@@ -439,9 +362,19 @@ auto IterativeDeepeningAI::GetCommandsSortedByPreviousDepth(
// Sort by score at previous depth
const size_t prevDepth = currentDepth - 1;
std::ranges::sort(indices, [&](const size_t a, const size_t b) {
// Only consider commands that were evaluated at previous depth
// Bounds check - if indices are out of range, or inner vectors are too small, treat as not
// evaluated
if (a >= scoresByDepth.size() || b >= scoresByDepth.size() ||
a >= highestDepthCompleted.size() || b >= highestDepthCompleted.size()) {
return a < b; // Maintain stable order for out-of-bounds indices
}
// Check if the scores for previous depth exist
if (highestDepthCompleted[a] >= prevDepth && highestDepthCompleted[b] >= prevDepth) {
return scoresByDepth[a][prevDepth] > scoresByDepth[b][prevDepth];
// Additional safety check for inner vector size
if (scoresByDepth[a].size() > prevDepth && scoresByDepth[b].size() > prevDepth) {
return scoresByDepth[a][prevDepth] > scoresByDepth[b][prevDepth];
}
}
// Commands not evaluated at prev depth go to the end
return highestDepthCompleted[a] >= prevDepth;
@@ -91,7 +91,7 @@ private:
int maxRepeatCount,
const std::vector<CommandProto>& commands,
size_t commandIndex,
int depth,
int desiredDepth,
ScoreValue currentUtility,
AITimeBudget& timeBudget) const;
@@ -0,0 +1,113 @@
//
// TranspositionTable.cpp - Implementation of game state evaluation cache
//
#include "TranspositionTable.hpp"
#include <cstdio>
#include <cstring>
namespace shardok {
// Global instance
TranspositionTable g_transpositionTable;
TranspositionTable::TranspositionTable() : table(TABLE_SIZE) {
// Initialize all entries to zero
clear();
}
uint64_t TranspositionTable::hashGameState(const GameStateW& state) const {
// The FlatBuffer is contiguous in memory and units are sorted by ID,
// so we can just hash the raw bytes for order-independent hashing
// Use ComputeFNV1aHash to avoid creating a string copy
return state.ComputeFNV1aHash();
}
std::optional<ScoreValue>
TranspositionTable::probe(const GameStateW& state, int depth, PlayerId player) {
stats.probes++;
uint64_t hash = hashGameState(state);
size_t index = hash & INDEX_MASK;
const auto& entry = table[index];
// Check if this entry matches our position using FULL hash
uint64_t stored_hash = entry.hash_full.load(std::memory_order_relaxed);
uint8_t stored_depth = entry.depth.load(std::memory_order_relaxed);
uint8_t stored_player = entry.player_id.load(std::memory_order_relaxed);
if (stored_hash == hash && stored_depth >= depth && stored_player == player) {
stats.hits++;
float score = entry.score.load(std::memory_order_relaxed);
return static_cast<ScoreValue>(score);
}
// Track collisions (different position mapped to same index)
// Note: We use depth==0 to indicate empty entries, not hash==0
if (stored_depth != 0 && stored_hash != hash) { stats.collisions++; }
return std::nullopt;
}
void TranspositionTable::store(
const GameStateW& state,
int depth,
PlayerId player,
ScoreValue score) {
stats.stores++;
uint64_t hash = hashGameState(state);
size_t index = hash & INDEX_MASK;
auto& entry = table[index];
// Simple replacement strategy: always replace if:
// 1. Entry is from an older search (different age)
// 2. New search is deeper
// 3. Entry is empty (depth == 0)
uint16_t stored_age = entry.age.load(std::memory_order_relaxed);
uint8_t stored_depth = entry.depth.load(std::memory_order_relaxed);
bool should_replace = (stored_depth == 0) || // Empty entry (depth 0 means unused)
(stored_age != current_age) || // Old entry
(depth >= stored_depth); // Deeper or equal search
if (should_replace) {
// Store all fields with relaxed ordering (TT races are benign)
entry.hash_full.store(hash, std::memory_order_relaxed);
entry.score.store(static_cast<float>(score), std::memory_order_relaxed);
entry.depth.store(static_cast<uint8_t>(depth), std::memory_order_relaxed);
entry.player_id.store(static_cast<uint8_t>(player), std::memory_order_relaxed);
entry.age.store(current_age, std::memory_order_relaxed);
}
}
void TranspositionTable::clear() {
// Reset all entries
for (auto& entry : table) {
entry.hash_full.store(0, std::memory_order_relaxed);
entry.score.store(0.0f, std::memory_order_relaxed);
entry.depth.store(0, std::memory_order_relaxed);
entry.player_id.store(0, std::memory_order_relaxed);
entry.age.store(0, std::memory_order_relaxed);
}
stats.reset();
current_age = 0;
}
void TranspositionTable::printStats() const {
printf("TranspositionTable Stats:\n");
printf(" Probes: %llu\n", stats.probes.load());
printf(" Hits: %llu (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %llu\n", stats.stores.load());
printf(" Collisions: %llu\n", stats.collisions.load());
printf(" Table size: %zu entries (%.1f MB)\n",
TABLE_SIZE,
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
}
} // namespace shardok
@@ -0,0 +1,91 @@
//
// TranspositionTable.hpp - Cache for game state evaluations to avoid redundant calculations
//
#ifndef EAGLE0_TRANSPOSITIONTABLE_HPP
#define EAGLE0_TRANSPOSITIONTABLE_HPP
#include <atomic>
#include <cstdint>
#include <optional>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
namespace shardok {
using ScoreValue = double;
// PlayerId already defined in ShardokCTypes.h
class TranspositionTable {
public:
// Statistics for monitoring effectiveness
struct Stats {
std::atomic<uint64_t> probes{0};
std::atomic<uint64_t> hits{0};
std::atomic<uint64_t> stores{0};
std::atomic<uint64_t> collisions{0};
double hitRate() const {
uint64_t p = probes.load();
return p > 0 ? (100.0 * hits.load() / p) : 0.0;
}
void reset() {
probes = 0;
hits = 0;
stores = 0;
collisions = 0;
}
};
private:
// Compact entry structure (actual size is greater than 16 bytes due to atomics and alignment)
struct TTEntry {
std::atomic<uint64_t> hash_full; // Full hash for validation
std::atomic<float> score; // Score as float to save space
std::atomic<uint8_t> depth; // Search depth (0-255)
std::atomic<uint8_t> player_id; // Player who is to move
std::atomic<uint16_t> age; // For replacement strategy
};
static constexpr size_t TABLE_SIZE_BITS = 22; // 2^22 entries
static constexpr size_t TABLE_SIZE = 1ULL << TABLE_SIZE_BITS; // 4M entries = 64MB
static constexpr size_t INDEX_MASK = TABLE_SIZE - 1;
std::vector<TTEntry> table;
Stats stats;
std::atomic<uint16_t> current_age{0};
// Hash function for FlatBuffer game state
uint64_t hashGameState(const GameStateW& state) const;
public:
TranspositionTable();
// Probe the table for a cached evaluation
std::optional<ScoreValue> probe(const GameStateW& state, int depth, PlayerId player);
// Store an evaluation in the table
void store(const GameStateW& state, int depth, PlayerId player, ScoreValue score);
// Clear the entire table
void clear();
// Increment age for replacement strategy (call at start of each search)
void incrementAge() { current_age++; }
// Get statistics
const Stats& getStats() const { return stats; }
// Print statistics to stdout
void printStats() const;
};
// Global instance for the AI to use
extern TranspositionTable g_transpositionTable;
} // namespace shardok
#endif // EAGLE0_TRANSPOSITIONTABLE_HPP
@@ -7,6 +7,7 @@
#include <flatbuffers/flatbuffers.h>
#include <cstdlib>
#include <utility>
#include "src/main/cpp/net/eagle0/common/ByteHasher.hpp"