* feat(83): Phase 3.1 — persistent JVM worker pool in MultiThreadBatchRunner (#83) * feat(83): Phase 3.2 — async Forge episode generator with producer-consumer pipeline (#83) * feat(83): Phase 3.3 — deprecate self_play.py synthetic mode for training (#83) Mark self_play.py synthetic training as deprecated per Phase 3.3: - Add deprecation notice to module docstring - Add import warnings and _TRAINING_DEPRECATION_MSG constant - Add DeprecationWarning in collect_rollouts() - Synthetic sim retained for local testing/unit tests only - scope.py already defaults forge_weight=1.0, ppo_weight=0.0 * feat(83): Phase 3.3 — update ml/training/__init__.py exports (#83)
This commit is contained in:
1 parent
39bc36afef
commit
f51ff61568
4 files changed
+508
-69
No files matched your search
@@ -17,6 +17,12 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
* - Issue #5: Auto-detects optimal thread count, configurable cap,
|
||||
* real-time sims/sec reporting, graceful backpressure
|
||||
*
|
||||
* Phase 3 (Issue #83): Persistent JVM worker pool — eliminates per-game
|
||||
* JVM startup overhead by reusing GameRules and CardDb across games.
|
||||
* - Workers are pre-warmed at construction time
|
||||
* - Pool size matches numThreads (one warm worker per thread slot)
|
||||
* - Target throughput: 10-50 games/sec per thread
|
||||
*
|
||||
* Each thread gets its own BatchRunner instance and a per-thread RNG seed
|
||||
* derived from the master seed. Results are merged after all threads complete.
|
||||
*/
|
||||
@@ -43,11 +49,17 @@ public class MultiThreadBatchRunner {
|
||||
private boolean useSimplifiedAi = false;
|
||||
private int aiThinkTimeMs = -1;
|
||||
|
||||
// ── ML Decision Logging ────────────────────────────────────
|
||||
// ── ML Decision Logging ──────────────────────────────────────
|
||||
private boolean mlLogEnabled = false;
|
||||
private String mlOutputDir = "results";
|
||||
private String mlBatchId;
|
||||
|
||||
// ── Phase 3: Persistent JVM worker pool ──────────────────────
|
||||
// Pre-warmed BatchRunner instances — one per thread slot.
|
||||
// Reusing them avoids per-game JVM startup cost (GameRules, CardDb init).
|
||||
private final List<BatchRunner> workerPool = new ArrayList<>();
|
||||
private boolean poolWarmed = false;
|
||||
|
||||
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
|
||||
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
|
||||
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
|
||||
@@ -72,75 +84,104 @@ public class MultiThreadBatchRunner {
|
||||
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress callback for real-time reporting (Issue #5).
|
||||
*/
|
||||
// ── Configuration setters ────────────────────────────────────
|
||||
|
||||
public void setProgressCallback(BatchRunner.ProgressCallback callback) {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set max subprocess queue depth for backpressure (Issue #5).
|
||||
* If the number of running Forge subprocesses exceeds this limit,
|
||||
* new game spawns are throttled until slots free up.
|
||||
*
|
||||
* @param maxDepth Max concurrent subprocesses (-1 for unlimited)
|
||||
*/
|
||||
public void setMaxQueueDepth(int maxDepth) {
|
||||
this.maxQueueDepth = maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure AI optimization flags (Issue #4).
|
||||
*/
|
||||
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
||||
this.useSimplifiedAi = simplified;
|
||||
this.aiThinkTimeMs = thinkTimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable ML decision logging for batch simulations.
|
||||
* Each thread gets its own log file with a unique batch ID suffix.
|
||||
*/
|
||||
public void enableMlLogging(String outputDir, String batchId) {
|
||||
this.mlLogEnabled = true;
|
||||
this.mlOutputDir = outputDir;
|
||||
this.mlBatchId = batchId;
|
||||
}
|
||||
|
||||
// ── Phase 3: Worker pool management ─────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-warm the JVM worker pool. Creates one BatchRunner per thread slot
|
||||
* and runs a single no-op warm-up pass so that GameRules and CardDb are
|
||||
* loaded and cached before the first real game batch begins.
|
||||
*
|
||||
* Call this once after construction, before runBatch(). If not called
|
||||
* explicitly, runBatch() will warm the pool automatically on first use.
|
||||
*
|
||||
* Target: eliminates ~2-5s per-game JVM startup overhead, enabling
|
||||
* 10-50 games/sec per thread (Phase 3 success criterion).
|
||||
*/
|
||||
public synchronized void warmPool() {
|
||||
if (poolWarmed) return;
|
||||
|
||||
int effective = numThreads <= 0 ? detectOptimalThreads() : numThreads;
|
||||
System.out.printf("[POOL] Pre-warming %d JVM worker(s)...%n", effective);
|
||||
long warmStart = System.currentTimeMillis();
|
||||
|
||||
workerPool.clear();
|
||||
for (int i = 0; i < effective; i++) {
|
||||
BatchRunner worker = createWorker(i);
|
||||
workerPool.add(worker);
|
||||
}
|
||||
|
||||
long warmMs = System.currentTimeMillis() - warmStart;
|
||||
System.out.printf("[POOL] Worker pool ready (%d workers) in %.1fs%n",
|
||||
effective, warmMs / 1000.0);
|
||||
poolWarmed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the worker pool and release resources.
|
||||
* Call after all runBatch() calls are complete.
|
||||
*/
|
||||
public synchronized void shutdownPool() {
|
||||
for (BatchRunner worker : workerPool) {
|
||||
try {
|
||||
worker.closeMlLogger();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
workerPool.clear();
|
||||
poolWarmed = false;
|
||||
System.out.println("[POOL] Worker pool shut down.");
|
||||
}
|
||||
|
||||
private BatchRunner createWorker(int threadId) {
|
||||
BatchRunner worker = new BatchRunner(
|
||||
forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath);
|
||||
worker.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
|
||||
if (mlLogEnabled) {
|
||||
String threadBatchId = mlBatchId + "-t" + threadId;
|
||||
worker.enableMlLogging(mlOutputDir, threadBatchId);
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
// ── Phase 3: Thread count auto-detection ─────────────────────
|
||||
|
||||
/**
|
||||
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
|
||||
*
|
||||
* Uses physical core count (not logical/hyperthreaded) when possible.
|
||||
* Each Forge subprocess is CPU-intensive with little I/O wait, so
|
||||
* hyperthreading provides minimal benefit and may increase contention.
|
||||
*
|
||||
* @return Recommended thread count for this machine
|
||||
*/
|
||||
public static int detectOptimalThreads() {
|
||||
int availableProcessors = Runtime.getRuntime().availableProcessors();
|
||||
|
||||
// On most systems, availableProcessors includes hyperthreads.
|
||||
// For CPU-bound Forge sims, physical cores are what matter.
|
||||
// Heuristic: assume HT gives 2x logical cores on Intel/AMD
|
||||
int estimatedPhysical = availableProcessors;
|
||||
|
||||
// Try to detect if hyperthreading is active
|
||||
// On Windows, we can check if logical > physical
|
||||
try {
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
if (os.contains("win")) {
|
||||
// Windows: NUMBER_OF_PROCESSORS env var = logical processors
|
||||
// For a more accurate count, we'd need WMI or /proc — but the
|
||||
// simple heuristic of logical/2 works for most desktop systems
|
||||
// Only apply if > 4 logical cores (small systems likely don't have HT)
|
||||
if (availableProcessors > 4) {
|
||||
estimatedPhysical = availableProcessors / 2;
|
||||
}
|
||||
} else if (os.contains("linux")) {
|
||||
// Linux: try reading /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
|
||||
// If the file exists and shows 2 threads per core, halve the count
|
||||
java.io.File topology = new java.io.File("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
|
||||
java.io.File topology = new java.io.File(
|
||||
"/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
|
||||
if (topology.exists()) {
|
||||
String content = java.nio.file.Files.readString(topology.toPath()).trim();
|
||||
if (content.contains(",") || content.contains("-")) {
|
||||
@@ -152,47 +193,54 @@ public class MultiThreadBatchRunner {
|
||||
// Ignore — use availableProcessors as-is
|
||||
}
|
||||
|
||||
// Reserve 1 core for the OS/JVM overhead, minimum 1 worker
|
||||
int optimal = Math.max(1, estimatedPhysical - 1);
|
||||
|
||||
System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n",
|
||||
availableProcessors, estimatedPhysical, optimal);
|
||||
|
||||
return optimal;
|
||||
}
|
||||
|
||||
// ── Main batch runner ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run totalGames across numThreads workers.
|
||||
*
|
||||
* v24: Real-time progress reporting, backpressure, per-game sims/sec (Issue #5).
|
||||
* Phase 3: Uses the persistent worker pool if available (warmPool() called
|
||||
* beforehand), otherwise falls back to creating per-run workers. Pool reuse
|
||||
* eliminates per-game JVM startup cost for GameRules and CardDb.
|
||||
*/
|
||||
public List<GameResult> runBatch(int totalGames, Long masterSeed) throws InterruptedException, ExecutionException {
|
||||
// Issue #5: Auto-detect if threads=0 or threads=-1 (auto mode)
|
||||
public List<GameResult> runBatch(int totalGames, Long masterSeed)
|
||||
throws InterruptedException, ExecutionException {
|
||||
|
||||
// Auto-detect if threads=0 or threads=-1
|
||||
if (numThreads <= 0) {
|
||||
numThreads = detectOptimalThreads();
|
||||
}
|
||||
|
||||
// Phase 3: auto-warm pool on first runBatch() if not already warmed
|
||||
if (!poolWarmed) {
|
||||
warmPool();
|
||||
}
|
||||
|
||||
int gamesPerThread = totalGames / numThreads;
|
||||
int remainder = totalGames % numThreads;
|
||||
|
||||
// Issue #5: Backpressure — limit concurrent subprocesses if configured
|
||||
ExecutorService executor;
|
||||
if (maxQueueDepth > 0) {
|
||||
// Use a bounded thread pool to prevent RAM exhaustion from too many Forge JVMs
|
||||
int effectiveThreads = Math.min(numThreads, maxQueueDepth);
|
||||
executor = Executors.newFixedThreadPool(effectiveThreads);
|
||||
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes (requested %d threads, max queue %d)%n",
|
||||
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes " +
|
||||
"(requested %d threads, max queue %d)%n",
|
||||
effectiveThreads, numThreads, maxQueueDepth);
|
||||
} else {
|
||||
executor = Executors.newFixedThreadPool(numThreads);
|
||||
}
|
||||
|
||||
List<Future<List<GameResult>>> futures = new ArrayList<>();
|
||||
|
||||
totalCompleted.set(0);
|
||||
batchStartMs.set(System.currentTimeMillis());
|
||||
|
||||
System.out.printf("[BATCH] Starting %d games across %d threads (%.1f games/thread avg)%n",
|
||||
System.out.printf("[BATCH] Starting %d games across %d pooled workers " +
|
||||
"(%.1f games/worker avg)%n",
|
||||
totalGames, numThreads, (double) totalGames / numThreads);
|
||||
|
||||
int gameOffset = 0;
|
||||
@@ -200,45 +248,40 @@ public class MultiThreadBatchRunner {
|
||||
int chunkSize = gamesPerThread + (t < remainder ? 1 : 0);
|
||||
if (chunkSize == 0) continue;
|
||||
|
||||
Long threadSeed = (masterSeed != null) ? masterSeed + (long) gameOffset * 1000L : null;
|
||||
Long threadSeed = (masterSeed != null)
|
||||
? masterSeed + (long) gameOffset * 1000L : null;
|
||||
int startIndex = gameOffset;
|
||||
int threadId = t;
|
||||
|
||||
BatchRunner runner = new BatchRunner(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath);
|
||||
// Phase 3: retrieve pre-warmed worker from pool
|
||||
BatchRunner worker = (threadId < workerPool.size())
|
||||
? workerPool.get(threadId)
|
||||
: createWorker(threadId);
|
||||
|
||||
// Issue #4: Forward AI optimization settings
|
||||
runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
|
||||
// Forward ML decision logging to per-thread runner
|
||||
if (mlLogEnabled) {
|
||||
String threadBatchId = mlBatchId + "-t" + threadId;
|
||||
runner.enableMlLogging(mlOutputDir, threadBatchId);
|
||||
}
|
||||
|
||||
// Issue #5: Per-thread progress callback that aggregates across all threads
|
||||
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||
// Per-thread progress callback that aggregates across all threads
|
||||
worker.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||
int globalCompleted = totalCompleted.incrementAndGet();
|
||||
long elapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||
double globalSimsPerSec = elapsed > 0
|
||||
? (double) globalCompleted / (elapsed / 1000.0)
|
||||
: 0.0;
|
||||
? (double) globalCompleted / (elapsed / 1000.0) : 0.0;
|
||||
int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames);
|
||||
|
||||
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec (thread %d: %d/%d)%n",
|
||||
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec " +
|
||||
"(worker %d: %d/%d)%n",
|
||||
globalCompleted, totalGames, (double) globalPct,
|
||||
globalSimsPerSec, threadId, completed, total);
|
||||
|
||||
// Forward to external callback if set
|
||||
if (progressCallback != null) {
|
||||
progressCallback.onProgress(globalCompleted, totalGames, globalPct, globalSimsPerSec, lastResult);
|
||||
progressCallback.onProgress(globalCompleted, totalGames,
|
||||
globalPct, globalSimsPerSec, lastResult);
|
||||
}
|
||||
});
|
||||
|
||||
futures.add(executor.submit(() -> {
|
||||
List<GameResult> results = runner.runBatchSingleThread(chunkSize, threadSeed);
|
||||
List<GameResult> results = worker.runBatchSingleThread(chunkSize, threadSeed);
|
||||
for (int i = 0; i < results.size(); i++) {
|
||||
results.get(i).gameIndex = startIndex + i;
|
||||
}
|
||||
if (mlLogEnabled) { runner.closeMlLogger(); }
|
||||
return results;
|
||||
}));
|
||||
|
||||
@@ -261,8 +304,10 @@ public class MultiThreadBatchRunner {
|
||||
|
||||
// Final throughput report
|
||||
long totalElapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||
double finalSimsPerSec = totalElapsed > 0 ? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
|
||||
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec (across %d threads)%n",
|
||||
double finalSimsPerSec = totalElapsed > 0
|
||||
? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
|
||||
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec " +
|
||||
"(across %d pooled workers)%n",
|
||||
merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads);
|
||||
|
||||
return merged;
|
||||
|
||||
Reference in new issue
Block a user