diff --git a/build.gradle b/build.gradle index 9adc6fa..6d476c0 100644 --- a/build.gradle +++ b/build.gradle @@ -112,6 +112,13 @@ neoForge { // Include resources generated by data generators. sourceSets.main.resources { srcDir 'src/generated/resources' } +configurations { + jnaRuntime { + canBeConsumed = false + canBeResolved = true + visible = false + } +} dependencies { // Example mod dependency with JEI @@ -145,8 +152,11 @@ dependencies { // —— 不再 jarJar 嵌入。原因:Java 22 模块系统下,自嵌入的 jna jar 会形成 // "com.sun.jna" 自动模块,与其他 mod(如 Iris/Sodium 或 NeoForge 间接带) // 提供的 jna 形成两个同名模块冲突,触发 ResolutionException。 - // 缺 JNA 时由 OpenCLBackend.canExecute() 检测并 catch(Throwable) 回退到 CPU。 + // 缺 JNA 时由 OpenCLBackend 构造 catch(Throwable) 回退到 CPU。 compileOnly 'net.java.dev.jna:jna:5.14.0' + // 训练 CLI / 单元测试需要真正加载 OpenCL;玩家运行时仍由环境提供 JNA,不打进 mod JAR。 + testRuntimeOnly 'net.java.dev.jna:jna:5.14.0' + jnaRuntime 'net.java.dev.jna:jna:5.14.0' } test { @@ -159,7 +169,7 @@ tasks.register('trainGoAI', JavaExec) { description = 'Train the Go MCTS neural evaluator with self-play' mainClass = 'com.wzz.game_console.client.screens.games.gogame.GoTrainingMain' args((project.findProperty('trainArgs') ?: '').toString().trim().split("\\s+").findAll { !it.isEmpty() }) - classpath = sourceSets.main.runtimeClasspath + classpath = sourceSets.main.runtimeClasspath + configurations.jnaRuntime maxHeapSize = '4g' } @@ -176,7 +186,7 @@ tasks.register('adversarialTrain', JavaExec) { description = '对抗训练:己方 MCTS AI vs 外部 KataGo 引擎' mainClass = 'com.wzz.game_console.client.screens.games.gogame.GoAdversarialMain' args((project.findProperty('advArgs') ?: '').toString().trim().split("\\s+").findAll { !it.isEmpty() }) - classpath = sourceSets.main.runtimeClasspath + classpath = sourceSets.main.runtimeClasspath + configurations.jnaRuntime maxHeapSize = '4g' } @@ -184,7 +194,7 @@ tasks.register('gpuProbe', JavaExec) { group = 'training' description = '探测 OpenCL GPU 设备是否可用' mainClass = 'com.wzz.game_console.client.screens.games.gogame.GoGpuProbe' - classpath = sourceSets.main.runtimeClasspath + classpath = sourceSets.main.runtimeClasspath + configurations.jnaRuntime } // This block of code expands all declared replace properties in the specified resource targets. diff --git a/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java b/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java index 576d37d..0c96b2e 100644 --- a/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java @@ -24,6 +24,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; @OnlyIn(Dist.CLIENT) @@ -33,6 +34,8 @@ public class GameSelectorScreen extends Screen { private record GameEntry(String name, String icon, String description, String category, Supplier factory) {} private final List games = new ArrayList<>(); + private List filteredGames = List.of(); + private String filteredCategory; private int currentPage = 0; private int gamesPerPage = 8; private long tickCount = 0; @@ -49,6 +52,7 @@ private record GameEntry(String name, String icon, String description, String ca /** 导入消息(临时显示) */ private String importMessage = null; private long importMessageTime = 0; + private final AtomicBoolean importInProgress = new AtomicBoolean(); public GameSelectorScreen() { super(Component.literal("Game Console 游戏机")); @@ -105,12 +109,19 @@ private void initializeGames() { } private List getFilteredGames() { - if ("全部".equals(filterCategory)) return games; + if (filterCategory.equals(filteredCategory)) return filteredGames; + + filteredCategory = filterCategory; + if ("全部".equals(filterCategory)) { + filteredGames = List.copyOf(games); + return filteredGames; + } List filtered = new ArrayList<>(); for (GameEntry g : games) { if (g.category.equals(filterCategory)) filtered.add(g); } - return filtered; + filteredGames = List.copyOf(filtered); + return filteredGames; } @Override @@ -266,6 +277,7 @@ private int getCategoryColor(String category) { /** 打开文件对话框导入外部游戏设置 JSON */ private void importSettingsFromFile() { + if (!importInProgress.compareAndSet(false, true)) return; // ★ Bug修复:FileDialog.setVisible(true) 是模态阻塞调用,在 MC 主线程直接打开 // 会冻结整个游戏(停帧、无响应)。改为在后台 daemon 线程弹窗, // 用户选完后 mc.execute 回主线程执行实际导入与提示(importMessage 由 render 读取) @@ -297,9 +309,9 @@ private void importSettingsFromFile() { } Path srcPath = srcFile.toPath(); - // 回到 MC 主线程执行导入与界面提示 + // 文件读取、解析和写盘留在后台;仅界面状态在 MC 主线程更新。 + boolean success = GameSettings.importFromFile(srcPath); Minecraft.getInstance().execute(() -> { - boolean success = GameSettings.importFromFile(srcPath); importMessage = success ? "设置导入成功!" : "导入失败:文件格式不正确"; importMessageTime = System.currentTimeMillis(); }); @@ -312,6 +324,7 @@ private void importSettingsFromFile() { } finally { // ★ 修复 AWT Frame 泄漏:dispose 移入 finally,异常/用户取消路径也会释放 if (frame != null) frame.dispose(); + importInProgress.set(false); } }, "GameConsole-SettingsImport"); picker.setDaemon(true); @@ -348,12 +361,20 @@ public boolean mouseClicked(double mx, double my, int btn) { return true; } - // ─── 游戏卡片点击 ─── + // ─── 游戏卡片点击:按本次点击坐标判断,不能使用上一帧的悬停结果 ─── List filtered = getFilteredGames(); - if (hoveredIndex >= 0 && hoveredIndex < filtered.size()) { - GameEntry entry = filtered.get(hoveredIndex); - Minecraft.getInstance().setScreen(entry.factory.get()); - return true; + int cardW = Math.min(280, width - 40); + int cardX = cx - cardW / 2; + if (mx >= cardX && mx <= cardX + cardW) { + int startIdx = currentPage * gamesPerPage; + int endIdx = Math.min(startIdx + gamesPerPage, filtered.size()); + for (int i = startIdx; i < endIdx; i++) { + int cardY = 68 + (i - startIdx) * 28; + if (my >= cardY && my <= cardY + 26) { + Minecraft.getInstance().setScreen(filtered.get(i).factory.get()); + return true; + } + } } // ─── 翻页按钮 ─── diff --git a/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java index 01d3b10..1b3d276 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java @@ -229,6 +229,7 @@ private void startGame() { public void tick() { this.tickCount++; + if (!showExitConfirm) GameRenderHelper.tickParticles(this.particles); if (this.lanMode == 0 && !this.localTwoPlayer && !showExitConfirm) { if (this.state == State.PLAYING && !this.playerTurn && this.winner == 0) { this.tickAiTurn(); @@ -589,7 +590,7 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { } } - GameRenderHelper.tickAndRenderParticles(g, this.particles); + GameRenderHelper.renderParticles(g, this.particles); GameRenderHelper.drawTopHUD(g, this.width, this.height); String modeTag = this.localTwoPlayer ? " [本地双人]" : " [" + this.difficulty.label + "]"; String turnText; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java index 0087a3e..d39b39e 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java @@ -56,6 +56,7 @@ private void spawnFood() { @Override public void tick() { tickCount++; floats.removeIf(f -> { f.update(); return !f.isAlive(); }); + if (!showExitConfirm) GameRenderHelper.tickParticles(particles); if (state != State.PLAYING || showExitConfirm) return; // 弹窗期间暂停游戏 tickCounter++; int speed = Math.max(1, 4 - score / 15); @@ -196,7 +197,7 @@ private void renderPlaying(GuiGraphics g) { } // 粒子 - GameRenderHelper.tickAndRenderParticles(g, particles); + GameRenderHelper.renderParticles(g, particles); // 浮动文字 for (GameRenderHelper.FloatingText ft : floats) ft.render(g, font); @@ -232,4 +233,4 @@ private void renderGameOver(GuiGraphics g, int mx, int my) { } @Override public boolean isPauseScreen() { return false; } -} \ No newline at end of file +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java index 8d034c9..51fd5d8 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java @@ -245,6 +245,7 @@ private void initBoard() { // ══════════════ TICK ══════════════ @Override public void tick() { tickN++; + if (!showExitConfirm) GameRenderHelper.tickParticles(particles); if (lanMode != LAN_NONE) return; // ★ 关键修复:AI执黑(forWhite=false),仅黑方回合才触发 if (state==S.PLAYING && vsAI && !whiteTurn && !aiThinking && !promoPending) { @@ -727,7 +728,7 @@ private void renderBoard(GuiGraphics g, int mx, int my) { if (c==0) g.drawString(font,String.valueOf(8-r),sx+2,sy+2,light?0xFFB58863:0xFFF0D9B5); if (r==7) g.drawString(font,String.valueOf((char)('a'+c)),sx+cellSize-6,sy+cellSize-10,light?0xFFB58863:0xFFF0D9B5); } - GameRenderHelper.tickAndRenderParticles(g,particles); + GameRenderHelper.renderParticles(g,particles); GameRenderHelper.drawTopHUD(g,width,height); String ts=whiteTurn?"♔ 白方走棋":"♚ 黑方走棋"; if (vsAI&&!whiteTurn&&aiThinking) ts="AI 思考中..."; @@ -796,4 +797,4 @@ private void renderOver(GuiGraphics g, int mx, int my) { GameRenderHelper.drawSecondaryButton(g,font,"ESC - 返回",cx2-70,cy2+44,140,18,mx,my); } @Override public boolean isPauseScreen() { return false; } -} \ No newline at end of file +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialMain.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialMain.java index 5cd15de..db18787 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialMain.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialMain.java @@ -43,6 +43,12 @@ public static void main(String[] args) throws Exception { long seed = longOption(options, "seed", 0x5EEDL); Path weights = Path.of(required(options, "weights")); + String gpuVal = options.get("gpu"); + if (gpuVal != null) { + System.setProperty("go.gpu", gpuVal); + } + NeuralEvaluator.GpuStatus gpu = NeuralEvaluator.detectGpu(); + System.out.println("[GPU] " + gpu.describe()); NeuralEvaluator evaluator = new NeuralEvaluator(); if (Files.exists(weights)) { evaluator.load(weights); @@ -54,13 +60,16 @@ public static void main(String[] args) throws Exception { try { for (int gen = 0; gen < generations; gen++) { double frac = generations > 1 ? (double) gen / (generations - 1) : 0.0; - double currentLR = Math.max(learningRate * 0.5 * (1.0 + Math.cos(Math.PI * frac)), 1e-6); + double currentLR = Math.max(learningRate * 0.5 * (1.0 + Math.cos(Math.PI * frac)), + learningRate * 0.1); GoAdversarialTrainer.Result result = trainer.runGeneration( games, parallelism, epochs, currentLR, seed + gen); System.out.printf("generation=%d lr=%.6f games=%d completed=%d samples=%d ourWins=%d replay=%d meanLoss=%.8f%n", gen + 1, currentLR, result.games, result.completedGames, result.samples, result.ourWins, trainer.getReplayBufferSize(), result.meanLoss); } + evaluator.save(weights); + System.out.println("saved=" + weights.toAbsolutePath()); } catch (RuntimeException | Error t) { // ★ 崩溃保存:多代训练中途崩(OOM/GTP 异常/引擎崩溃)时, // 已训练完的各代权重不能随进程一起丢掉 @@ -71,10 +80,9 @@ public static void main(String[] args) throws Exception { System.err.println("save-on-crash failed: " + saveErr); } throw t; + } finally { + evaluator.release(); } - - evaluator.save(weights); - System.out.println("saved=" + weights.toAbsolutePath()); } private static Map parse(String[] args) { diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialTrainer.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialTrainer.java index 168ff7e..4029cc2 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialTrainer.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialTrainer.java @@ -21,7 +21,7 @@ public final class GoAdversarialTrainer { public static final class Config { public int searchTimeMillis = 300; public int maxIterations = 500; - public int maxMoves = 300; + public int maxMoves = 450; public int batchSize = 128; public double l2 = 1.0e-5; public double gradientClip = 5.0; @@ -97,8 +97,9 @@ public void clearReplayBuffer() { * @return 训练结果 */ public Result runGeneration(int games, int parallelism, int epochs, double learningRate, long seed) { - if (games < 0 || epochs < 0 || learningRate <= 0) + if (games < 0 || epochs < 0 || !Double.isFinite(learningRate) || learningRate <= 0) throw new IllegalArgumentException("Invalid generation parameters"); + NeuralEvaluator.validateTrainingParameters(learningRate, config.l2, config.gradientClip, config.momentum); synchronized (generationLock) { return runGenerationLocked(games, parallelism, epochs, learningRate, seed); } @@ -108,7 +109,6 @@ private Result runGenerationLocked(int games, int parallelism, int epochs, doubl if (games == 0 || Thread.currentThread().isInterrupted()) return new Result(games, 0, 0, 0, evaluator, 0); int workers = Math.max(1, Math.min(parallelism <= 0 ? 1 : parallelism, games)); - final NeuralEvaluator.ModelWeights snapshot = evaluator.snapshot(); ExecutorService pool = Executors.newFixedThreadPool(workers); List> futures = new ArrayList<>(workers); try { @@ -117,7 +117,7 @@ private Result runGenerationLocked(int games, int parallelism, int epochs, doubl for (; nextGame < workers; nextGame++) { final int gameIndex = nextGame; futures.add(pool.submit(() -> - playAdversarialGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex))); + playAdversarialGame(evaluator, seed + 0x9E3779B97F4A7C15L * gameIndex))); } int completed = 0; int ourWins = 0; @@ -158,7 +158,7 @@ private Result runGenerationLocked(int games, int parallelism, int epochs, doubl if (nextGame < games) { final int gameIndex = nextGame++; futures.add(pool.submit(() -> - playAdversarialGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex))); + playAdversarialGame(evaluator, seed + 0x9E3779B97F4A7C15L * gameIndex))); } } @@ -211,13 +211,13 @@ private static final class GameResult { /** * 一局对抗:MCTS AI vs KataGo,随机先后手。 */ - private GameResult playAdversarialGame(NeuralEvaluator.ModelWeights model, long seed) { + private GameResult playAdversarialGame(NeuralEvaluator sharedEvaluator, long seed) { List samples = new ArrayList<>(); Random rnd = new Random(seed); boolean ourIsBlack = rnd.nextBoolean(); // 创建己方 AI - MCTSGoAI ourAI = new MCTSGoAI(config.searchTimeMillis, config.maxIterations, 1, model); + MCTSGoAI ourAI = new MCTSGoAI(config.searchTimeMillis, config.maxIterations, 1, sharedEvaluator, false); ourAI.setRandomSeed(seed ^ 0x12345678); Process process = null; @@ -576,4 +576,4 @@ private static GoPlayer[][] applySymmetry(GoPlayer[][] board, int[] perm) { } private static double clamp(double value) { return Math.max(-1.0, Math.min(1.0, value)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGpuProbe.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGpuProbe.java index e8b9f15..df35f49 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGpuProbe.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGpuProbe.java @@ -4,10 +4,12 @@ public final class GoGpuProbe { public static void main(String[] args) { System.out.println("=== OpenCL GPU 探针 ==="); - try (OpenCLBackend backend = new OpenCLBackend()) { - if (backend.isAvailable()) { + try { + NeuralEvaluator.GpuStatus status = NeuralEvaluator.detectGpu(); + System.out.println(status.describe()); + if (status.available) { System.out.println("SUCCESS: OpenCL GPU 加速可用"); - System.out.println("设备: " + backend.getDeviceName()); + System.out.println("设备: " + status.deviceName); } else { System.out.println("FAILED: OpenCL 不可用,将回退 CPU"); } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoSelfPlayTrainer.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoSelfPlayTrainer.java index 668fbea..1d65967 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoSelfPlayTrainer.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoSelfPlayTrainer.java @@ -15,7 +15,7 @@ public static final class Config { public int searchTimeMillis = 300; public int maxIterations = 500; public int parallelism = 30; - public int maxMoves = 300; + public int maxMoves = 450; public int batchSize = 128; public double l2 = 1.0e-5; public double gradientClip = 5.0; @@ -114,7 +114,9 @@ public void clearReplayBuffer() { /** Runs one generation, then trains the shared model on the collected positions. */ public Result runGeneration(int games, int parallelism, int epochs, double learningRate, long seed) { - if (games < 0 || epochs < 0 || learningRate <= 0) throw new IllegalArgumentException("Invalid generation parameters"); + if (games < 0 || epochs < 0 || !Double.isFinite(learningRate) || learningRate <= 0) + throw new IllegalArgumentException("Invalid generation parameters"); + NeuralEvaluator.validateTrainingParameters(learningRate, config.l2, config.gradientClip, config.momentum); synchronized (generationLock) { return runGenerationLocked(games, parallelism, epochs, learningRate, seed); } @@ -126,7 +128,6 @@ private Result runGenerationLocked(int games, int parallelism, int epochs, doubl // 探索强度随训练代次衰减:gen 1→1.0, gen 41→0.2(下限 0.2) final double expScale = Math.max(0.2, 1.0 - 0.02 * (generation - 1)); int workers = Math.max(1, Math.min(parallelism <= 0 ? config.parallelism : parallelism, games)); - final NeuralEvaluator.ModelWeights snapshot = evaluator.snapshot(); ExecutorService pool = Executors.newFixedThreadPool(workers); List> futures = new ArrayList<>(workers); try { @@ -134,7 +135,7 @@ private Result runGenerationLocked(int games, int parallelism, int epochs, doubl int nextGame = 0; for (; nextGame < workers; nextGame++) { final int gameIndex = nextGame; - futures.add(pool.submit(() -> playGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); + futures.add(pool.submit(() -> playGame(evaluator, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); } int completed = 0; while (!futures.isEmpty()) { @@ -149,7 +150,7 @@ private Result runGenerationLocked(int games, int parallelism, int epochs, doubl } if (nextGame < games) { final int gameIndex = nextGame++; - futures.add(pool.submit(() -> playGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); + futures.add(pool.submit(() -> playGame(evaluator, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); } } catch (InterruptedException ie) { Thread.currentThread().interrupt(); @@ -170,7 +171,7 @@ private Result runGenerationLocked(int games, int parallelism, int epochs, doubl System.err.println("[SelfPlay] game failed: " + cause); if (nextGame < games) { final int gameIndex = nextGame++; - futures.add(pool.submit(() -> playGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); + futures.add(pool.submit(() -> playGame(evaluator, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); } } } @@ -223,10 +224,10 @@ public static double cosineLearningRate(double initialLr, int currentStep, int t return initialLr * 0.5 * (1.0 + Math.cos(Math.PI * ratio)); } - private GameSamples playGame(NeuralEvaluator.ModelWeights model, long seed, double explorationScale) { + private GameSamples playGame(NeuralEvaluator sharedEvaluator, long seed, double explorationScale) { List samples = new ArrayList<>(); GoGame game = GoGame.rulesOnly(); - MCTSGoAI ai = new MCTSGoAI(config.searchTimeMillis, config.maxIterations, 1, model); + MCTSGoAI ai = new MCTSGoAI(config.searchTimeMillis, config.maxIterations, 1, sharedEvaluator, false); ai.setRandomSeed(seed); double roundKomi = GoGame.getConfiguredKomi(); // 自对弈模式:开启根节点 Dirichlet 噪声 + 访问分布温度采样(增强探索) @@ -366,4 +367,4 @@ private static final class GameSamples { this.completed = completed; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMain.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMain.java index d2b62f0..9ee1073 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMain.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMain.java @@ -31,8 +31,11 @@ public static void main(String[] args) throws Exception { Path weights = Path.of(required(options, "weights")); int checkpointInterval = intOption(options, "checkpoint", 10); + NeuralEvaluator.GpuStatus gpu = NeuralEvaluator.detectGpu(); + System.out.println("[GPU] " + gpu.describe()); NeuralEvaluator evaluator = new NeuralEvaluator(); if (Files.exists(weights)) evaluator.load(weights); + System.out.println("[GPU] evaluator=" + (evaluator.isGpuActive() ? evaluator.gpuDeviceName() : "CPU")); GoSelfPlayTrainer trainer = new GoSelfPlayTrainer(config, evaluator); GoSelfPlayTrainer.Result result = null; try { @@ -45,7 +48,8 @@ public static void main(String[] args) throws Exception { // 余弦退火学习率调度:从初始 LR 平滑衰减 int remain = Math.max(1, generations - warmup); double frac = (double) (generation - warmup) / remain; - currentLR = Math.max(learningRate * 0.5 * (1.0 + Math.cos(Math.PI * frac)), 1e-6); + currentLR = Math.max(learningRate * 0.5 * (1.0 + Math.cos(Math.PI * frac)), + learningRate * 0.1); } result = trainer.runGeneration(games, parallelism, epochs, currentLR, seed + generation); System.out.printf("generation=%d lr=%.6f games=%d completed=%d samples=%d replay=%d meanLoss=%.8f%n", @@ -66,7 +70,11 @@ public static void main(String[] args) throws Exception { System.out.println("crash-saved=" + weights.toAbsolutePath()); } catch (Exception ignored) {} t.printStackTrace(); + // System.exit 不会执行 finally,必须先释放 OpenCL + evaluator.release(); System.exit(1); + } finally { + evaluator.release(); } } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAI.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAI.java index 1781cb4..b9c090b 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAI.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAI.java @@ -67,6 +67,8 @@ public class MCTSGoAI implements GoAI { /** 神经网络评估器 */ private final NeuralEvaluator neuralEvaluator; + /** false 时 shutdown 不释放评估器(自对弈多局共用同一实例)。 */ + private final boolean ownsEvaluator; /** Serializes searches with shutdown so evaluator native resources remain live while used. */ private final ReentrantLock lifecycleLock = new ReentrantLock(); private volatile boolean shutdown; @@ -116,19 +118,27 @@ public MCTSGoAI(int searchTime, int maxIterations, int parallelThreads) { /** Creates an AI using a private evaluator initialized from a model snapshot. */ public MCTSGoAI(int searchTime, int maxIterations, int parallelThreads, NeuralEvaluator.ModelWeights model) { + this(searchTime, maxIterations, parallelThreads, + model == null ? new NeuralEvaluator() : NeuralEvaluator.fromWeights(model), + true); + } + + /** + * 使用已有评估器。{@code ownsEvaluator=false} 时 {@link #shutdown()} 不释放 + * OpenCL,供自对弈多局共用同一 GPU 上下文和推理队列。 + */ + public MCTSGoAI(int searchTime, int maxIterations, int parallelThreads, + NeuralEvaluator evaluator, boolean ownsEvaluator) { if (searchTime < 0 || maxIterations < 0 || parallelThreads < 1) { throw new IllegalArgumentException("Invalid MCTS parameters"); } + if (evaluator == null) throw new IllegalArgumentException("evaluator"); this.baseSearchTime = searchTime; this.maxIterations = maxIterations; this.parallelThreads = parallelThreads; this.random = new Random(); - // 用 fromWeights 跳过随机 init(省去 init+apply 双重开销) - if (model != null) { - this.neuralEvaluator = NeuralEvaluator.fromWeights(model); - } else { - this.neuralEvaluator = new NeuralEvaluator(); - } + this.neuralEvaluator = evaluator; + this.ownsEvaluator = ownsEvaluator; } /** Sets the random seed for a self-play game. */ @@ -160,8 +170,12 @@ public static MCTSGoAI createFromSettings() { int iterations = GameSettings.getInt("go", "mctsIterations", DEFAULT_ITERATIONS); NeuralEvaluator trained = loadConfiguredEvaluator(); if (trained != null) { - return new MCTSGoAI(searchTime, iterations, - Math.max(1, PARALLEL_THREADS - 1), trained.snapshot()); + try { + return new MCTSGoAI(searchTime, iterations, + Math.max(1, PARALLEL_THREADS - 1), trained.snapshot()); + } finally { + trained.release(); + } } return new MCTSGoAI(searchTime, iterations); } catch (Throwable t) { @@ -237,6 +251,10 @@ else if (moveCount < 80) { time = (int)(time * 1.5); // 我方连续劣势,延长思考 } + // 自对弈按训练预算卡死,不再抬到 500ms;对局仍保留 500ms 下限。 + if (selfPlayMode) { + return Math.max(1, baseSearchTime); + } return Math.max(500, Math.min(time, baseSearchTime * 2)); // 500ms ~ 2x base } @@ -251,11 +269,8 @@ private boolean shouldTerminateEarly(long iterations) { // 快照 currentRoot.children(共享树下其他线程可能并发写入,需同步) MCTSNode root = currentRoot; if (root == null) return false; - List children; - synchronized (root) { - if (root.children == null || root.children.isEmpty()) return false; - children = new ArrayList<>(root.children); - } + MCTSNode[] children = snapshotChildren(root); + if (children == null) return false; double bestWinRate = Double.NEGATIVE_INFINITY; double secondWinRate = Double.NEGATIVE_INFINITY; @@ -371,7 +386,8 @@ private int[] getBestMoveLocked(GoGame game) { int consecutivePasses = snapshot.consecutivePasses(); int stoneCount = countStones(board); boolean passCandidate = consecutivePasses > 0 || stoneCount >= ENDGAME_STONES || validMoves.isEmpty(); - if (!passCandidate) { + // 自对弈必须走树搜索(含 PASS),杀棋/定式/战术都不会停手,会把 300 手样本整局丢掉。 + if (!selfPlayMode && !passCandidate) { // 杀棋、定式与局部战术只处理棋盘落子;进入收官或已有一手 PASS 后, // 必须交给包含 PASS 的树搜索比较继续落子和结束对局。 int[] killerMove = findKillerMove(board, currentPlayer, validMoves); @@ -390,7 +406,15 @@ private int[] getBestMoveLocked(GoGame game) { } } - if (validMoves.size() == 1 && !passCandidate) { + if (selfPlayMode && consecutivePasses > 0) { + // 对方已停手:再 pass 结束对局,避免弱网把单官填到 maxMoves。 + this.currentRoot = null; + this.lastRoot = null; + this.lastMove = null; + return null; + } + + if (validMoves.size() == 1 && !passCandidate && !selfPlayMode) { int[] m = validMoves.get(0); this.lastMove = new int[]{m[0], m[1]}; this.currentRoot = null; @@ -412,8 +436,7 @@ private int[] getBestMoveLocked(GoGame game) { return new int[]{fallback[0], fallback[1]}; } - List searchMoves = new ArrayList<>(validMoves); - if (passCandidate) searchMoves.add(PASS_MOVE.clone()); + List searchMoves = withPassMove(validMoves); // 动态计算搜索时间 int searchTime = calculateDynamicSearchTime(moveCount, validMoves.size()); @@ -428,7 +451,10 @@ private int[] getBestMoveLocked(GoGame game) { this.currentRoot.parent = null; } else { // 启发式排序候选点(getAllValidMoves 已按价值升序排好) - this.currentRoot = new MCTSNode(board, currentPlayer, null, gameLastMove, searchMoves); + // searchMoves is freshly built for this root and is not retained by + // the caller; transfer it instead of copying every candidate ref. + this.currentRoot = new MCTSNode(board, currentPlayer, null, gameLastMove, + searchMoves, false); } this.currentRoot.consecutivePasses = consecutivePasses; this.currentRoot.terminal = consecutivePasses >= 2; @@ -464,7 +490,7 @@ private int[] getBestMoveLocked(GoGame game) { } // 搜索可能因极短时间预算、线程异常或所有候选扩展被过滤而没有访问节点。 if (best == null) { - if (validMoves.isEmpty()) { + if (validMoves.isEmpty() || consecutivePasses > 0) { best = PASS_MOVE.clone(); } else { int[] fallback = validMoves.get(validMoves.size() - 1); @@ -483,14 +509,7 @@ private int[] getBestMoveLocked(GoGame game) { public double[] getVisitDistribution() { double[] dist = new double[POLICY_SIZE]; MCTSNode root = currentRoot; - List children = null; - if (root != null) { - synchronized (root) { - if (root.children != null && !root.children.isEmpty()) { - children = new ArrayList<>(root.children); - } - } - } + MCTSNode[] children = snapshotChildren(root); if (children == null) { // 无 MCTS 分布(早退路径:杀棋/定式/终局/战术/唯一走法)。 // 返回 lastMove 的 one-hot,避免全 pass 污染策略目标。 @@ -621,11 +640,8 @@ private static double gammaSample(double shape, Random rnd) { */ private double getCurrentWinRate() { double bestWinRate = Double.NEGATIVE_INFINITY; - List children; - synchronized (currentRoot) { - if (currentRoot.children == null || currentRoot.children.isEmpty()) return 0; - children = new ArrayList<>(currentRoot.children); - } + MCTSNode[] children = snapshotChildren(currentRoot); + if (children == null) return 0; for (MCTSNode child : children) { double visits; double totalScore; @@ -793,9 +809,7 @@ private MCTSNode expand(MCTSNode node) { if (needsForward) { NeuralEvaluator.ForwardResult fr; try { - fr = neuralEvaluator.forward( - neuralEvaluator.buildInputPlanes(node.board, node.player, node.move), - neuralEvaluator.extractAuxFeatures(node.board, node.player)); + fr = neuralEvaluator.forwardPosition(node.board, node.player, node.move); } catch (RuntimeException | Error e) { synchronized (node) { node.forwardInFlight = false; @@ -842,8 +856,10 @@ private MCTSNode expand(MCTSNode node) { childHash = node.hash != 0 ? node.hash : GoGame.boardHash(childBoard); terminal = childPasses >= 2; } else { - if (!simulatePlaceStone(childBoard, move[0], move[1], node.player)) return null; - childHash = GoGame.boardHash(childBoard); + SimulationHash simulation = SIMULATION_HASH.get(); + simulation.hash = node.hash != 0 ? node.hash : GoGame.boardHash(node.board); + if (!simulatePlaceStone(childBoard, move[0], move[1], node.player, simulation)) return null; + childHash = simulation.hash; if (koHistory != null && (koHistory.contains(childHash) || isAncestorKoRepeat(node, childHash))) { return null; } @@ -854,7 +870,9 @@ private MCTSNode expand(MCTSNode node) { List childMoves = terminal ? Collections.emptyList() : getSearchMoves(childHash, childBoard, nextPlayer, childPasses); - MCTSNode child = new MCTSNode(childBoard, nextPlayer, node, move, childMoves); + // childMoves is freshly allocated for this child and is not shared with + // another node; transfer ownership instead of copying every reference. + MCTSNode child = new MCTSNode(childBoard, nextPlayer, node, move, childMoves, false); child.hash = childHash; child.consecutivePasses = childPasses; child.terminal = terminal; @@ -883,6 +901,10 @@ private MCTSNode expand(MCTSNode node) { prior = (1.0 - eps) * prior + eps * (noise * PASS_INDEX); } } + if (selfPlayMode && isPass(move) + && (node.consecutivePasses > 0 || countStones(node.board) >= ENDGAME_STONES)) { + prior *= 8.0; + } child.prior = prior; } @@ -904,6 +926,128 @@ private MCTSNode expand(MCTSNode node) { private static final ThreadLocal SCRATCH_VIS_GROUP = ThreadLocal.withInitial(() -> new boolean[BOARD_SIZE * BOARD_SIZE]); private static final ThreadLocal SCRATCH_VIS_BFS = ThreadLocal.withInitial(() -> new boolean[BOARD_SIZE * BOARD_SIZE]); private static final ThreadLocal SCRATCH_SEEN = ThreadLocal.withInitial(() -> new boolean[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_TACTICAL_GROUPS = + ThreadLocal.withInitial(() -> new boolean[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_VALID_MOVES = + ThreadLocal.withInitial(ValidMoveMask::new); + private static final ThreadLocal SCRATCH_TACTICAL_REGION = + ThreadLocal.withInitial(TacticalRegion::new); + private static final ThreadLocal SCRATCH_TACTICAL_CELLS = + ThreadLocal.withInitial(() -> new int[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_TACTICAL_SEARCH = + ThreadLocal.withInitial(TacticalSearchScratch::new); + + /** Each remaining depth owns its moves and child board while descendants use lower slots. */ + private static final class TacticalSearchScratch { + private TacticalFrame[] frames = new TacticalFrame[TACTICAL_DEPTH + 1]; + + TacticalFrame frame(int depth) { + if (depth >= frames.length) frames = Arrays.copyOf(frames, depth + 1); + TacticalFrame frame = frames[depth]; + if (frame == null) frames[depth] = frame = new TacticalFrame(); + return frame; + } + } + + private static final class TacticalFrame { + final int[] points = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] priorities = new int[BOARD_SIZE * BOARD_SIZE]; + final GoPlayer[][] childBoard = new GoPlayer[BOARD_SIZE][BOARD_SIZE]; + + GoPlayer[][] copyBoard(GoPlayer[][] board) { + for (int x = 0; x < BOARD_SIZE; x++) { + System.arraycopy(board[x], 0, childBoard[x], 0, BOARD_SIZE); + } + return childBoard; + } + } + + private static final class ValidMoveMask { + final boolean[] points = new boolean[BOARD_SIZE * BOARD_SIZE]; + boolean active; + } + + private static final class TacticalRegion { + final boolean[] seen = new boolean[BOARD_SIZE * BOARD_SIZE]; + final int[] points = new int[BOARD_SIZE * BOARD_SIZE]; + int size; + + void clear() { + Arrays.fill(seen, false); + size = 0; + } + + void add(int point) { + if (!seen[point]) { + seen[point] = true; + points[size++] = point; + } + } + } + + private static final ThreadLocal SIMULATION_HASH = + ThreadLocal.withInitial(SimulationHash::new); + + private static final class SimulationHash { + long hash; + } + + private static final int LIBERTY_WORDS = (BOARD_SIZE * BOARD_SIZE + 63) / 64; + private static final ThreadLocal CANDIDATE_GROUPS = + ThreadLocal.withInitial(CandidateGroups::new); + + /** Immutable board analysis during one candidate collection; buffers belong to the caller thread. */ + private static final class CandidateGroups { + final int[] groupAt = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] sizes = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] libertyCounts = new int[BOARD_SIZE * BOARD_SIZE]; + final long[] stoneHashes = new long[BOARD_SIZE * BOARD_SIZE]; + final long[][] liberties = new long[BOARD_SIZE * BOARD_SIZE][LIBERTY_WORDS]; + final int[] queue = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] adjacent = new int[4]; + final long[] mergedLiberties = new long[LIBERTY_WORDS]; + + boolean analyze(GoPlayer[][] board) { + Arrays.fill(groupAt, -1); + int groupCount = 0; + boolean allHaveLiberty = true; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + int start = x * BOARD_SIZE + y; + GoPlayer color = board[x][y]; + if (color == GoPlayer.NONE || groupAt[start] >= 0) continue; + int group = groupCount++; + long[] libertyBits = liberties[group]; + Arrays.fill(libertyBits, 0L); + int head = 0, tail = 1; + long stoneHash = 0L; + queue[0] = start; + groupAt[start] = group; + while (head < tail) { + int point = queue[head++]; + int px = point / BOARD_SIZE, py = point % BOARD_SIZE; + stoneHash = GoGame.xorStone(stoneHash, px, py, color); + for (int[] direction : DIRS) { + int nx = px + direction[0], ny = py + direction[1]; + if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; + int neighbor = nx * BOARD_SIZE + ny; + if (board[nx][ny] == GoPlayer.NONE) { + libertyBits[neighbor >>> 6] |= 1L << (neighbor & 63); + } else if (board[nx][ny] == color && groupAt[neighbor] < 0) { + groupAt[neighbor] = group; + queue[tail++] = neighbor; + } + } + } + sizes[group] = tail; + stoneHashes[group] = stoneHash; + int count = 0; + for (long word : libertyBits) count += Long.bitCount(word); + libertyCounts[group] = count; + if (count == 0) allHaveLiberty = false; + } + return allHaveLiberty; + } + } /** 原语版 {@link #getGroup}:把 (sx,sy) 处 color 棋群的格点写入 cells,返回数量。 * 要求 board[sx][sy]==color。输出为同一格点集合(getGroup 的集合语义与遍历顺序无关)。 */ @@ -986,8 +1130,8 @@ private int countCapturesPrim(GoPlayer[][] board, int x, int y, GoPlayer player) && board[nx][ny] == opponent) { int idx = nx * BOARD_SIZE + ny; if (seen[idx]) continue; // 该棋群已被计入 - int n = scanGroup(board, nx, ny, opponent, cells); - if (!cellsHaveLiberty(board, cells, n)) { + int n = scanGroupWithoutLiberty(board, nx, ny, opponent, cells); + if (n >= 0) { captures += n; for (int i = 0; i < n; i++) seen[cells[i]] = true; } @@ -1059,6 +1203,12 @@ private int scoreFusedMove(GoPlayer[][] board, int x, int y, GoPlayer player, if (selfCount < 0) selfCount = scanGroup(board, x, y, player, cells); int groupLibs = countGroupLibertiesPrim(board, cells, selfCount); board[x][y] = GoPlayer.NONE; + return scoreMoveFromCounts(board, x, y, player, captures, oppCaptures, groupLibs); + } + + private int scoreMoveFromCounts(GoPlayer[][] board, int x, int y, GoPlayer player, + int captures, int oppCaptures, int groupLibs) { + GoPlayer opponent = opposite(player); int libs = groupLibs < 10 ? groupLibs : evaluateMoveLibertiesPrim(board, x, y, player); // 复刻 BFS 软上限过冲 int friendly = countFriendlyNeighbors(board, x, y, player); @@ -1093,25 +1243,9 @@ private int scoreFusedMove(GoPlayer[][] board, int x, int y, GoPlayer player, * 计算落子能吃的棋子数 */ private int countCaptures(GoPlayer[][] board, int x, int y, GoPlayer player) { - board[x][y] = player; - GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - int captures = 0; - // 用已计数集合去重:同一对手棋群若环绕 (x,y) 从两个方向相邻,只计一次 - Set counted = new HashSet<>(); - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { - long key = (long) nx * BOARD_SIZE + ny; - if (counted.contains(key)) continue; // 该棋群已被计入 - Set group = getGroup(board, nx, ny); - if (!hasLiberty(board, group)) { - captures += group.size(); - for (int[] pos : group) counted.add((long) pos[0] * BOARD_SIZE + pos[1]); - } - } - } - board[x][y] = GoPlayer.NONE; - return captures; + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE + || player == GoPlayer.NONE || board[x][y] != GoPlayer.NONE) return 0; + return countCapturesPrim(board, x, y, player); } /** @@ -1145,11 +1279,66 @@ private int evaluateMoveLiberties(GoPlayer[][] board, int x, int y, GoPlayer pla * 落子后是否会被打吃 */ private boolean wouldBeInAtari(GoPlayer[][] board, int x, int y, GoPlayer player) { + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE + || player == GoPlayer.NONE || board[x][y] != GoPlayer.NONE) return false; board[x][y] = player; - Set group = getGroup(board, x, y); - boolean inAtari = countGroupLiberties(board, group) == 1; - board[x][y] = GoPlayer.NONE; - return inAtari; + try { + return countGroupLibertiesUpTo(board, x, y, player, 2) == 1; + } finally { + board[x][y] = GoPlayer.NONE; + } + } + + /** Counts distinct liberties, stopping once the caller's threshold is reached. */ + private int countGroupLibertiesUpTo(GoPlayer[][] board, int x, int y, GoPlayer color, int limit) { + boolean[] visited = SCRATCH_VIS_GROUP.get(); + boolean[] liberties = SCRATCH_SEEN.get(); + Arrays.fill(visited, false); + Arrays.fill(liberties, false); + int[] stack = SCRATCH_STACK.get(); + int start = x * BOARD_SIZE + y; + int top = 1, count = 0; + stack[0] = start; + visited[start] = true; + while (top > 0) { + int point = stack[--top]; + int px = point / BOARD_SIZE, py = point % BOARD_SIZE; + for (int[] direction : DIRS) { + int nx = px + direction[0], ny = py + direction[1]; + if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; + int neighbor = nx * BOARD_SIZE + ny; + if (board[nx][ny] == GoPlayer.NONE && !liberties[neighbor]) { + liberties[neighbor] = true; + if (++count >= limit) return count; + } else if (board[nx][ny] == color && !visited[neighbor]) { + visited[neighbor] = true; + stack[top++] = neighbor; + } + } + } + return count; + } + + private int countGroupLibertiesUpTo(GoPlayer[][] board, int[] cells, int n, int limit) { + boolean[] seen = SCRATCH_SEEN.get(); + Arrays.fill(seen, false); + int count = 0; + for (int i = 0; i < n; i++) { + int point = cells[i]; + int px = point / BOARD_SIZE, py = point % BOARD_SIZE; + for (int[] dir : DIRS) { + int nx = px + dir[0], ny = py + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == GoPlayer.NONE) { + int liberty = nx * BOARD_SIZE + ny; + if (!seen[liberty]) { + seen[liberty] = true; + if (++count >= limit) return count; + } + } + } + } + return count; } /** @@ -1215,7 +1404,7 @@ public void shutdown() { private void releaseEvaluatorLocked() { if (evaluatorReleased) return; evaluatorReleased = true; - neuralEvaluator.release(); + if (ownsEvaluator) neuralEvaluator.release(); } private MCTSNode selectNode(MCTSNode node) { @@ -1237,12 +1426,13 @@ private MCTSNode selectBestChild(MCTSNode parent) { double bestValue = Double.NEGATIVE_INFINITY; double parentVisits; - // 快照 children 避免并发修改异常(expand 在加锁状态下添加子节点) - List children; + // Take the snapshot while holding only the parent lock; score children + // after releasing it, preserving the existing lock order. + MCTSNode[] children; synchronized (parent) { - if (parent.children == null || parent.children.isEmpty()) return null; + children = snapshotChildren(parent); + if (children == null) return null; parentVisits = parent.visits; - children = new ArrayList<>(parent.children); } double sqrtParentVisits = Math.sqrt(Math.max(parentVisits, 1)); @@ -1277,6 +1467,24 @@ private MCTSNode selectBestChild(MCTSNode parent) { return best; } + /** Returns a stable child snapshot, rebuilding only after the child list changes. */ + private static MCTSNode[] snapshotChildren(MCTSNode node) { + if (node == null) return null; + synchronized (node) { + if (node.children == null || node.children.isEmpty()) { + node.selectionSource = null; + node.selectionSnapshot = null; + return null; + } + if (node.selectionSource != node.children || node.selectionSnapshot == null + || node.selectionSnapshot.length != node.children.size()) { + node.selectionSnapshot = node.children.toArray(new MCTSNode[node.children.size()]); + node.selectionSource = node.children; + } + return node.selectionSnapshot; + } + } + /** * 纯神经网络模拟评估(缓存感知)。 *

@@ -1287,9 +1495,8 @@ private double simulate(MCTSNode node) { if (node.terminal) return terminalScore(node); if (node.valueCached) return node.valueCache; // 首次遇此节点:一次前向同时拿到策略+价值,避免后续重复前向 - NeuralEvaluator.ForwardResult fr = neuralEvaluator.forward( - neuralEvaluator.buildInputPlanes(node.board, node.player, node.move), - neuralEvaluator.extractAuxFeatures(node.board, node.player)); + NeuralEvaluator.ForwardResult fr = neuralEvaluator.forwardPosition( + node.board, node.player, node.move); node.valueCache = fr.value; node.valueCached = true; if (node.policyCache == null) { @@ -1363,7 +1570,11 @@ private MCTSNode tryReuseTree(GoPlayer[][] board, GoPlayer currentPlayer) { * 递归深拷贝节点及其子树,并用新棋盘状态替换根节点的棋盘 */ private MCTSNode deepCopyNode(MCTSNode node, GoPlayer[][] newBoard) { - GoPlayer[][] boardCopy = deepCopyBoard(newBoard); + return copyNodeWithOwnedBoard(node, deepCopyBoard(newBoard)); + } + + /** The board was freshly copied for this node; descendants allocate their own boards. */ + private MCTSNode copyNodeWithOwnedBoard(MCTSNode node, GoPlayer[][] boardCopy) { MCTSNode copy = new MCTSNode( boardCopy, node.player, @@ -1386,14 +1597,14 @@ private MCTSNode deepCopyNode(MCTSNode node, GoPlayer[][] newBoard) { copy.terminal = node.terminal; if (node.children != null) { - copy.children = new ArrayList<>(); + copy.children = new ArrayList<>(node.children.size()); for (MCTSNode child : node.children) { // 为每个子节点创建正确的棋盘:在父棋盘基础上应用子走法 GoPlayer[][] childBoard = deepCopyBoard(boardCopy); if (child.move != null && !isPass(child.move)) { simulatePlaceStone(childBoard, child.move[0], child.move[1], node.player); } - MCTSNode childCopy = deepCopyNode(child, childBoard); + MCTSNode childCopy = copyNodeWithOwnedBoard(child, childBoard); childCopy.parent = copy; copy.children.add(childCopy); } @@ -1403,11 +1614,8 @@ private MCTSNode deepCopyNode(MCTSNode node, GoPlayer[][] newBoard) { } private int[] getBestMCTSMove(MCTSNode root) { - List children; - synchronized (root) { - if (root.children == null || root.children.isEmpty()) return null; - children = new ArrayList<>(root.children); - } + MCTSNode[] children = snapshotChildren(root); + if (children == null) return null; // AlphaZero 标准终局选着:按访问数最大。 // 访问数对评估噪声更鲁棒:胜率均值在低访问分支方差大, @@ -1428,17 +1636,17 @@ private int[] getBestMCTSMove(MCTSNode root) { return best != null ? best.move : null; } - /** - * 自对弈模式走法选择:按访问分布采样(AlphaZero 风格温度控制)。 - * 开局 temp=1.0(按比例采样,探索充分),30 手后 temp=0.1(趋近贪心收敛)。 - * 与存盘的策略目标(访问分布)保持一致,保证训练样本分布合理。 - */ + private static final ThreadLocal SAMPLING_SCRATCH = + ThreadLocal.withInitial(SamplingScratch::new); + + private static final class SamplingScratch { + double[] weights = new double[POLICY_SIZE]; + } + + /** Samples cached visit weights; each child's visits are read once per call. */ private int[] sampleMCTSMove(MCTSNode root, int moveCount) { - List children; - synchronized (root) { - if (root.children == null || root.children.isEmpty()) return null; - children = new ArrayList<>(root.children); - } + MCTSNode[] children = snapshotChildren(root); + if (children == null) return null; // 温度随探索强度衰减(早期高探索→高温,后期低探索→低温更贪心) // ★ 修正:默认探索强度(1.0)下开局温度应为 1.0(与上方注释一致)。 // 原公式 1.5*x+0.1 在默认档得 1.6,开局采样过散,策略训练目标噪声过大 @@ -1446,30 +1654,33 @@ private int[] sampleMCTSMove(MCTSNode root, int moveCount) { double lateTemp = 0.3 * explorationScale + 0.05; double temp = moveCount < 30 ? earlyTemp : lateTemp; - List candidates = new ArrayList<>(); - List weights = new ArrayList<>(); + SamplingScratch scratch = SAMPLING_SCRATCH.get(); + if (scratch.weights.length < children.length) scratch.weights = new double[children.length]; + double[] weights = scratch.weights; double sum = 0; - for (MCTSNode child : children) { + int lastCandidate = -1; + for (int i = 0; i < children.length; i++) { + MCTSNode child = children[i]; double visits; synchronized (child) { visits = child.visits; } + weights[i] = visits > 0 ? ((temp <= 0.01) ? visits : Math.pow(visits, 1.0 / temp)) : -1; if (visits > 0) { - double w = (temp <= 0.01) ? visits : Math.pow(visits, 1.0 / temp); - weights.add(w); - candidates.add(child); - sum += w; + lastCandidate = i; + sum += weights[i]; } } - if (candidates.isEmpty() || sum <= 0) return getBestMCTSMove(root); + if (sum <= 0) return getBestMCTSMove(root); double r = random.nextDouble() * sum; double cum = 0; - for (int i = 0; i < candidates.size(); i++) { - cum += weights.get(i); - if (cum >= r) return candidates.get(i).move; + for (int i = 0; i < children.length; i++) { + if (weights[i] < 0) continue; + cum += weights[i]; + if (cum >= r) return children[i].move; } - return candidates.get(candidates.size() - 1).move; + return lastCandidate < 0 ? getBestMCTSMove(root) : children[lastCandidate].move; } private boolean movesEqual(int[] a, int[] b) { @@ -1521,27 +1732,28 @@ private int[] tacticalReading(GoPlayer[][] board, GoPlayer player, long deadline // ★ Bug修复(中盘算炸防御):原版对每个 (x,y) 都重新取棋群并搜索, // 中盘 200+ 步时濒死棋群密集,多个同色连体子被反复扫到,导致 // O(361 × α-β深度5) 的组合爆炸 → 内存/时间耗尽。 - // 1) 棋群去重:Set 没有原生 hash,把 group 序列化为 "x,y" 串后 - // 放入 visited 集合,已访问过的棋群整体跳过。 + // 1) 棋群去重:用线程局部锚点标记,已访问过的棋群整体跳过。 // 2) 候选点过载保护:collectLocalRegion 返回超过 30 个候选时直接跳过, // 避免在松散棋形上启动深度 5 α-β。 - Set visited = new HashSet<>(); + boolean[] visited = SCRATCH_TACTICAL_GROUPS.get(); + Arrays.fill(visited, false); for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] != opponent) continue; - Set group = getGroup(board, x, y); - int libs = countGroupLiberties(board, group); - if (libs <= 3 && group.size() >= 2) { - // 棋群去重(用最小 x,y 作为代表 key;group 自身按 x 升序排) - String key = groupKey(group); - if (visited.contains(key)) continue; - visited.add(key); + int point = x * BOARD_SIZE + y; + if (visited[point]) continue; + int[] group = SCRATCH_TACTICAL_CELLS.get(); + int groupSize = scanGroup(board, x, y, opponent, group); + if (!markTacticalGroup(visited, group, groupSize)) continue; + int libs = countGroupLibertiesUpTo(board, group, groupSize, 4); + if (libs <= 3 && groupSize >= 2) { // 收集局部区域 - Set region = collectLocalRegion(board, group); - if (region.isEmpty()) continue; - if (region.size() > 30) continue; // 候选过载,跳过 - int[] best = localAlphaBetaSearch(board, group, player, opponent, + TacticalRegion region = SCRATCH_TACTICAL_REGION.get(); + collectLocalRegion(board, group, groupSize, region); + if (region.size == 0) continue; + if (region.size > 30) continue; // 候选过载,跳过 + int[] best = localAlphaBetaSearch(board, group, groupSize, player, opponent, libs <= 2 ? TACTICAL_DEPTH : 3, region, deadline); if (best != null) return best; } @@ -1550,102 +1762,81 @@ private int[] tacticalReading(GoPlayer[][] board, GoPlayer player, long deadline return null; } - /** 把棋群序列化为唯一字符串 key,用于 visited 集合去重。 - * 使用相对坐标(锚点 = 棋群最左上的子)+ 字典序排序, - * 使同形状但位置不同的棋群产生不同 key,避免被错误地合并; - * 同位置同形状的棋群(递归扫描时的重复)产生相同 key,被正确去重。 */ - private static String groupKey(Set group) { - int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE; + /** Marks every stone in a group so later board scans skip the whole group. */ + private static boolean markTacticalGroup(boolean[] visited, Set group) { for (int[] p : group) { - if (p[0] < minX || (p[0] == minX && p[1] < minY)) { - minX = p[0]; minY = p[1]; - } + if (visited[p[0] * BOARD_SIZE + p[1]]) return false; } - StringBuilder sb = new StringBuilder(); - List sorted = new ArrayList<>(group); - sorted.sort((a, b) -> { - int dx = a[0] - b[0], dy = a[1] - b[1]; - return dx != 0 ? dx : dy; - }); - for (int[] p : sorted) { - if (sb.length() > 0) sb.append(';'); - sb.append(p[0] - minX).append(',').append(p[1] - minY); + for (int[] p : group) visited[p[0] * BOARD_SIZE + p[1]] = true; + return true; + } + + private static boolean markTacticalGroup(boolean[] visited, int[] group, int size) { + for (int i = 0; i < size; i++) { + if (visited[group[i]]) return false; } - return sb.toString(); + for (int i = 0; i < size; i++) visited[group[i]] = true; + return true; } /** * 收集目标棋群周围 2 格内的所有空点(局部搜索区域)。 */ - private Set collectLocalRegion(GoPlayer[][] board, Set group) { - Set region = new HashSet<>(); - for (int[] pos : group) { + private void collectLocalRegion(GoPlayer[][] board, int[] group, int groupSize, TacticalRegion region) { + region.clear(); + for (int i = 0; i < groupSize; i++) { + int point = group[i]; + int px = point / BOARD_SIZE, py = point % BOARD_SIZE; for (int[] dir : DIRS) { - int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + int nx = px + dir[0], ny = py + dir[1]; if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { - region.add(nx + "," + ny); + region.add(nx * BOARD_SIZE + ny); // 扩展一圈到 2 格半径 for (int[] d2 : DIRS) { int nx2 = nx + d2[0], ny2 = ny + d2[1]; if (nx2 >= 0 && nx2 < BOARD_SIZE && ny2 >= 0 && ny2 < BOARD_SIZE && board[nx2][ny2] == GoPlayer.NONE) - region.add(nx2 + "," + ny2); + region.add(nx2 * BOARD_SIZE + ny2); } } } } - return region; } /** * 对目标棋群做局部 α-β 搜索,返回最佳杀棋走法。 * 只有找到明确优势(评估值 > 0.3)的走法才返回。 */ - private int[] localAlphaBetaSearch(GoPlayer[][] board, Set target, + private int[] localAlphaBetaSearch(GoPlayer[][] board, + int[] targetCells, int targetSize, GoPlayer attacker, GoPlayer defender, - int maxDepth, Set region, long deadline) { - // 候选点排序(吃子优先) - List candidates = new ArrayList<>(); - for (String s : region) { - // ★ Bug修复:region 里的字符串可能为 "x,"(缺 y)或 ","(空),split 后 - // p.length<2 会抛 AIOOBE;NumberFormatException 也可能。 - // 防御性跳过,AI 线程不应因此崩 - try { - String[] p = s.split(","); - if (p.length != 2) continue; - int x = Integer.parseInt(p[0]); - int y = Integer.parseInt(p[1]); - if (!isLegalMove(board, x, y, attacker)) continue; - int priority = countCaptures(board, x, y, attacker) * 20 - + countFriendlyNeighbors(board, x, y, attacker) * 5; - candidates.add(new int[]{x, y, priority}); - } catch (NumberFormatException nfe) { - // 畸形坐标字符串,跳过 - } - } - candidates.sort((a, b) -> b[2] - a[2]); + int maxDepth, TacticalRegion region, long deadline) { + if (System.currentTimeMillis() > deadline) return null; + TacticalFrame frame = SCRATCH_TACTICAL_SEARCH.get().frame(Math.max(0, maxDepth)); + int candidateCount = collectTacticalMoves(board, attacker, region, frame, true); - int[] bestMove = null; + int bestPoint = -1; double bestScore = Double.NEGATIVE_INFINITY; - for (int[] move : candidates) { + for (int i = 0; i < candidateCount; i++) { if (System.currentTimeMillis() > deadline) break; - GoPlayer[][] next = deepCopyBoard(board); - if (!simulatePlaceStone(next, move[0], move[1], attacker)) continue; + int point = frame.points[i]; + GoPlayer[][] next = frame.copyBoard(board); + if (!simulatePlaceStone(next, point / BOARD_SIZE, point % BOARD_SIZE, attacker)) continue; - double score = -localAlphaBeta(next, target, defender, attacker, maxDepth - 1, + double score = -localAlphaBeta(next, targetCells, targetSize, defender, attacker, maxDepth - 1, Double.NEGATIVE_INFINITY, -bestScore, region, deadline); if (score > bestScore) { bestScore = score; - bestMove = new int[]{move[0], move[1]}; + bestPoint = point; } // 必胜走法,提前停止 if (score > 0.8) break; } - return bestScore > 0.3 ? bestMove : null; + return bestScore > 0.3 ? new int[]{bestPoint / BOARD_SIZE, bestPoint % BOARD_SIZE} : null; } /** @@ -1653,13 +1844,14 @@ private int[] localAlphaBetaSearch(GoPlayer[][] board, Set target, * 叶子节点用神经网络价值头评估。 * 通过 negamax 负号翻转处理交替行棋方。 */ - private double localAlphaBeta(GoPlayer[][] board, Set target, + private double localAlphaBeta(GoPlayer[][] board, + int[] targetCells, int targetSize, GoPlayer player, GoPlayer attacker, int depth, - double alpha, double beta, Set region, long deadline) { + double alpha, double beta, TacticalRegion region, long deadline) { if (System.currentTimeMillis() > deadline) return 0; // 终局:目标棋群被完全提掉 - if (targetIsCaptured(board, target)) { + if (targetIsCaptured(board, targetCells, targetSize)) { // negamax 约定:返回当前行棋方视角。 // 攻击方(player==attacker)成功提掉目标 → +1.0;防守方(player!=attacker)→ -1.0 return player == attacker ? 1.0 : -1.0; @@ -1671,27 +1863,49 @@ private double localAlphaBeta(GoPlayer[][] board, Set target, } // 生成局部合法走法 - List moves = legalMovesInRegion(board, player, region); - if (moves.isEmpty()) return 0; + TacticalFrame frame = SCRATCH_TACTICAL_SEARCH.get().frame(depth); + int moveCount = collectTacticalMoves(board, player, region, frame, false); + if (moveCount == 0) return 0; // 按吃子数排序提升剪枝效率 GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - moves.sort((a, b) -> { - int ca = countCaptures(board, a[0], a[1], player); - int cb = countCaptures(board, b[0], b[1], player); - return cb - ca; - }); - for (int[] move : moves) { - GoPlayer[][] next = deepCopyBoard(board); - if (!simulatePlaceStone(next, move[0], move[1], player)) continue; - double v = -localAlphaBeta(next, target, opponent, attacker, depth - 1, -beta, -alpha, region, deadline); + for (int i = 0; i < moveCount; i++) { + if (System.currentTimeMillis() > deadline) return 0; + int point = frame.points[i]; + GoPlayer[][] next = frame.copyBoard(board); + if (!simulatePlaceStone(next, point / BOARD_SIZE, point % BOARD_SIZE, player)) continue; + double v = -localAlphaBeta(next, targetCells, targetSize, opponent, attacker, + depth - 1, -beta, -alpha, region, deadline); if (v > alpha) alpha = v; if (alpha >= beta) break; } return alpha; } + /** Stable insertion sorting keeps the original region order for equally ranked moves. */ + private int collectTacticalMoves(GoPlayer[][] board, GoPlayer player, TacticalRegion region, + TacticalFrame frame, boolean rootPriority) { + int count = 0; + for (int i = 0; i < region.size; i++) { + int point = region.points[i]; + int x = point / BOARD_SIZE, y = point % BOARD_SIZE; + if (!isLegalMove(board, x, y, player)) continue; + int priority = countCaptures(board, x, y, player); + if (rootPriority) priority = priority * 20 + countFriendlyNeighbors(board, x, y, player) * 5; + int slot = count; + while (slot > 0 && frame.priorities[slot - 1] < priority) { + frame.points[slot] = frame.points[slot - 1]; + frame.priorities[slot] = frame.priorities[slot - 1]; + slot--; + } + frame.points[slot] = point; + frame.priorities[slot] = priority; + count++; + } + return count; + } + /** 检查目标棋群是否已被完全提掉 */ private boolean targetIsCaptured(GoPlayer[][] board, Set target) { for (int[] pos : target) { @@ -1702,19 +1916,58 @@ private boolean targetIsCaptured(GoPlayer[][] board, Set target) { /** 生成局部区域内的合法走法 */ private List legalMovesInRegion(GoPlayer[][] board, GoPlayer player, Set region) { + return legalMovesInRegion(board, player, region, false); + } + + /** Compatibility wrapper for diagnostics/tests that pass textual coordinates. */ + private List legalMovesInRegion(GoPlayer[][] board, GoPlayer player, + Set region, boolean scoreCaptures) { + TacticalRegion points = SCRATCH_TACTICAL_REGION.get(); + points.clear(); + for (String value : region) { + try { + String[] parts = value.split(","); + if (parts.length == 2) { + int x = Integer.parseInt(parts[0]); + int y = Integer.parseInt(parts[1]); + if (x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE) { + points.add(x * BOARD_SIZE + y); + } + } + } catch (NumberFormatException ignored) { + // Preserve the old defensive behavior for malformed diagnostics input. + } + } + return legalMovesInRegion(board, player, points, scoreCaptures); + } + + private List legalMovesInRegionLegacy(GoPlayer[][] board, GoPlayer player, Set region) { List moves = new ArrayList<>(); for (String s : region) { try { String[] p = s.split(","); if (p.length != 2) continue; int x = Integer.parseInt(p[0]), y = Integer.parseInt(p[1]); - // isLegalMove temporarily writes the candidate square. Calling it on - // an occupied point would overwrite the board and then reset it. if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE || board[x][y] != GoPlayer.NONE) continue; if (isLegalMove(board, x, y, player)) moves.add(new int[]{x, y}); } catch (NumberFormatException ignored) { - // Ignore malformed tactical-region coordinates. + // Preserve the defensive reflection/test entry point. + } + } + return moves; + } + + private List legalMovesInRegion(GoPlayer[][] board, GoPlayer player, + TacticalRegion region, boolean scoreCaptures) { + List moves = new ArrayList<>(); + for (int i = 0; i < region.size; i++) { + int point = region.points[i]; + int x = point / BOARD_SIZE, y = point % BOARD_SIZE; + if (isLegalMove(board, x, y, player)) { + moves.add(scoreCaptures + ? new int[]{x, y, countCaptures(board, x, y, player)} + : new int[]{x, y}); } } return moves; @@ -1728,38 +1981,51 @@ private List legalMovesInRegion(GoPlayer[][] board, GoPlayer player, Set< * 查找杀棋走法(围棋特有战术检测) */ private int[] findKillerMove(GoPlayer[][] board, GoPlayer player, List validMoves) { - // 1. 提大龙:落子能提掉对手3+子的大龙 - int[] captureMove = findBigCapture(board, player, validMoves); - if (captureMove != null) return captureMove; - - // 2. 救己方大龙:防己方被打吃 - int[] saveMove = findSaveOwnGroup(board, player, validMoves); - if (saveMove != null) return saveMove; - - // 3. 征子检测:追捕逃子 - int[] ladderMove = findLadderCapture(board, player, validMoves); - if (ladderMove != null) return ladderMove; - - // 4. 劫材价值:落子后成为劫材 - int[] koMove = findKoThreat(board, player, validMoves); - if (koMove != null) return koMove; - - // 5. 防守对手征子 - int[] defendLadder = findDefendLadder(board, player, validMoves); - if (defendLadder != null) return defendLadder; - - // 6. 杀对手大龙(气数<=2的对手棋群) - int[] killMove = findKillMove(board, player, validMoves); - if (killMove != null) return killMove; - - // 7. 防守打吃 + ValidMoveMask mask = SCRATCH_VALID_MOVES.get(); + Arrays.fill(mask.points, false); for (int[] move : validMoves) { - if (wouldPreventAtari(board, move[0], move[1], player)) { - return move; + if (move != null && move.length >= 2 + && move[0] >= 0 && move[0] < BOARD_SIZE + && move[1] >= 0 && move[1] < BOARD_SIZE) { + mask.points[move[0] * BOARD_SIZE + move[1]] = true; } } - - return null; + mask.active = true; + try { + // 1. 提大龙:落子能提掉对手3+子的大龙 + int[] captureMove = findBigCapture(board, player, validMoves); + if (captureMove != null) return captureMove; + + // 2. 救己方大龙:防己方被打吃 + int[] saveMove = findSaveOwnGroup(board, player, validMoves); + if (saveMove != null) return saveMove; + + // 3. 征子检测:追捕逃子 + int[] ladderMove = findLadderCapture(board, player, validMoves); + if (ladderMove != null) return ladderMove; + + // 4. 劫材价值:落子后成为劫材 + int[] koMove = findKoThreat(board, player, validMoves); + if (koMove != null) return koMove; + + // 5. 防守对手征子 + int[] defendLadder = findDefendLadder(board, player, validMoves); + if (defendLadder != null) return defendLadder; + + // 6. 杀对手大龙(气数<=2的对手棋群) + int[] killMove = findKillMove(board, player, validMoves); + if (killMove != null) return killMove; + + // 7. 防守打吃 + for (int[] move : validMoves) { + if (wouldPreventAtari(board, move[0], move[1], player)) { + return move; + } + } + return null; + } finally { + mask.active = false; + } } /** @@ -1782,11 +2048,16 @@ private int[] findBigCapture(GoPlayer[][] board, GoPlayer player, List va * 救己方被打吃的棋群(找其气点落子) */ private int[] findSaveOwnGroup(GoPlayer[][] board, GoPlayer player, List validMoves) { + boolean[] visited = SCRATCH_TACTICAL_GROUPS.get(); + Arrays.fill(visited, false); for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] == player) { + int point = x * BOARD_SIZE + y; + if (visited[point]) continue; Set group = getGroup(board, x, y); - if (countGroupLiberties(board, group) == 1) { + markTacticalGroup(visited, group); + if (countGroupLibertiesUpTo(board, group, 2) == 1) { for (int[] pos : group) { for (int[] dir : DIRS) { int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; @@ -1807,11 +2078,16 @@ private int[] findSaveOwnGroup(GoPlayer[][] board, GoPlayer player, List */ private int[] findLadderCapture(GoPlayer[][] board, GoPlayer player, List validMoves) { GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + boolean[] visited = SCRATCH_TACTICAL_GROUPS.get(); + Arrays.fill(visited, false); for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] == opponent) { + int point = x * BOARD_SIZE + y; + if (visited[point]) continue; Set group = getGroup(board, x, y); - if (countGroupLiberties(board, group) == 1) { + markTacticalGroup(visited, group); + if (countGroupLibertiesUpTo(board, group, 2) == 1) { int[] escape = getEscapeDirection(board, group, opponent); if (escape != null && isValid(validMoves, escape[0], escape[1])) return escape; } @@ -1825,22 +2101,29 @@ private int[] findLadderCapture(GoPlayer[][] board, GoPlayer player, List * 找棋群最靠近边角的逃生点 */ private int[] getEscapeDirection(GoPlayer[][] board, Set group, GoPlayer player) { - Set liberties = new HashSet<>(); + boolean[] seen = SCRATCH_SEEN.get(); + int[] liberties = SCRATCH_CELLS_B.get(); + Arrays.fill(seen, false); + int libertyCount = 0; for (int[] pos : group) { for (int[] dir : DIRS) { int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { - liberties.add(nx + "," + ny); + int point = nx * BOARD_SIZE + ny; + if (!seen[point]) { + seen[point] = true; + liberties[libertyCount++] = point; + } } } } - if (liberties.isEmpty()) return null; + if (libertyCount == 0) return null; int[] best = null; int bestScore = Integer.MAX_VALUE; - for (String lib : liberties) { - String[] parts = lib.split(","); - int lx = Integer.parseInt(parts[0]); - int ly = Integer.parseInt(parts[1]); + for (int i = 0; i < libertyCount; i++) { + int point = liberties[i]; + int lx = point / BOARD_SIZE; + int ly = point % BOARD_SIZE; int score = Math.min(Math.min(lx, ly), Math.min(BOARD_SIZE - 1 - lx, BOARD_SIZE - 1 - ly)); if (score < bestScore) { bestScore = score; @@ -1855,11 +2138,16 @@ private int[] getEscapeDirection(GoPlayer[][] board, Set group, GoPlayer */ private int[] findKoThreat(GoPlayer[][] board, GoPlayer player, List validMoves) { GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + boolean[] visited = SCRATCH_TACTICAL_GROUPS.get(); + Arrays.fill(visited, false); for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] == opponent) { + int point = x * BOARD_SIZE + y; + if (visited[point]) continue; Set group = getGroup(board, x, y); - if (countGroupLiberties(board, group) == 1) { + markTacticalGroup(visited, group); + if (countGroupLibertiesUpTo(board, group, 2) == 1) { for (int[] pos : group) { for (int[] dir : DIRS) { int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; @@ -1879,11 +2167,16 @@ private int[] findKoThreat(GoPlayer[][] board, GoPlayer player, List vali * 防守对手征子:己方棋群被打吃时找逃生方向 */ private int[] findDefendLadder(GoPlayer[][] board, GoPlayer player, List validMoves) { + boolean[] visited = SCRATCH_TACTICAL_GROUPS.get(); + Arrays.fill(visited, false); for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] == player) { + int point = x * BOARD_SIZE + y; + if (visited[point]) continue; Set group = getGroup(board, x, y); - if (countGroupLiberties(board, group) == 1) { + markTacticalGroup(visited, group); + if (countGroupLibertiesUpTo(board, group, 2) == 1) { int[] escape = getEscapeDirection(board, group, player); if (escape != null && isValid(validMoves, escape[0], escape[1])) return escape; } @@ -1898,11 +2191,16 @@ private int[] findDefendLadder(GoPlayer[][] board, GoPlayer player, List */ private int[] findKillMove(GoPlayer[][] board, GoPlayer player, List validMoves) { GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + boolean[] visited = SCRATCH_TACTICAL_GROUPS.get(); + Arrays.fill(visited, false); for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] == opponent) { + int point = x * BOARD_SIZE + y; + if (visited[point]) continue; Set group = getGroup(board, x, y); - if (group.size() >= 3 && countGroupLiberties(board, group) <= 2) { + markTacticalGroup(visited, group); + if (group.size() >= 3 && countGroupLibertiesUpTo(board, group, 3) <= 2) { for (int[] pos : group) { for (int[] dir : DIRS) { int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; @@ -1923,11 +2221,14 @@ private int[] findKillMove(GoPlayer[][] board, GoPlayer player, List vali */ private boolean wouldPreventAtari(GoPlayer[][] board, int x, int y, GoPlayer player) { // 检查周围己方棋群 + boolean[] visited = SCRATCH_TACTICAL_GROUPS.get(); + Arrays.fill(visited, false); for (int[] dir : DIRS) { int nx = x + dir[0], ny = y + dir[1]; if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) { Set group = getGroup(board, nx, ny); - if (countGroupLiberties(board, group) == 1) { + if (!markTacticalGroup(visited, group)) continue; + if (countGroupLibertiesUpTo(board, group, 2) == 1) { // 这个走法能救活己方被打吃的棋 return true; } @@ -2237,6 +2538,8 @@ private int[] getOpeningExpansion(GoPlayer[][] board, GoPlayer player, List moves, int x, int y) { if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return false; + ValidMoveMask mask = SCRATCH_VALID_MOVES.get(); + if (mask.active) return mask.points[x * BOARD_SIZE + y]; for (int[] m : moves) { if (m[0] == x && m[1] == y) return true; } @@ -2248,35 +2551,85 @@ private boolean isValid(List moves, int x, int y) { // ══════════════════════════════════════════════════════════════════ private boolean simulatePlaceStone(GoPlayer[][] board, int x, int y, GoPlayer player) { - if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE || board[x][y] != GoPlayer.NONE) { + return simulatePlaceStone(board, x, y, player, null); + } + + /** Updates the supplied hash only after a legal move; a failed move leaves board and hash intact. */ + private boolean simulatePlaceStone(GoPlayer[][] board, int x, int y, GoPlayer player, + SimulationHash simulation) { + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE + || player == GoPlayer.NONE || board[x][y] != GoPlayer.NONE) { return false; } board[x][y] = player; GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + long hash = simulation == null ? 0L : GoGame.xorStone(simulation.hash, x, y, player); + int[] cells = SCRATCH_CELLS_A.get(); int captured = 0; for (int[] dir : DIRS) { int nx = x + dir[0], ny = y + dir[1]; if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { - Set group = getGroup(board, nx, ny); - if (!hasLiberty(board, group)) { - for (int[] pos : group) board[pos[0]][pos[1]] = GoPlayer.NONE; - captured += group.size(); + int n = scanGroupWithoutLiberty(board, nx, ny, opponent, cells); + if (n >= 0) { + for (int i = 0; i < n; i++) { + int p = cells[i]; + board[p / BOARD_SIZE][p % BOARD_SIZE] = GoPlayer.NONE; + if (simulation != null) hash = GoGame.xorStone(hash, + p / BOARD_SIZE, p % BOARD_SIZE, opponent); + } + captured += n; } } } if (captured == 0) { - Set myGroup = getGroup(board, x, y); - if (!hasLiberty(board, myGroup)) { + if (scanGroupWithoutLiberty(board, x, y, player, cells) >= 0) { board[x][y] = GoPlayer.NONE; return false; } } + if (simulation != null) simulation.hash = hash; return true; } + private boolean targetIsCaptured(GoPlayer[][] board, int[] target, int size) { + for (int i = 0; i < size; i++) { + int point = target[i]; + if (board[point / BOARD_SIZE][point % BOARD_SIZE] != GoPlayer.NONE) return false; + } + return true; + } + + /** Returns all stones only for a group with no liberties, or -1 as soon as a liberty is found. */ + private int scanGroupWithoutLiberty(GoPlayer[][] board, int x, int y, GoPlayer color, int[] cells) { + boolean[] visited = SCRATCH_VIS_GROUP.get(); + Arrays.fill(visited, false); + int[] stack = SCRATCH_STACK.get(); + int start = x * BOARD_SIZE + y; + int top = 1, count = 1; + stack[0] = start; + cells[0] = start; + visited[start] = true; + while (top > 0) { + int point = stack[--top]; + int px = point / BOARD_SIZE, py = point % BOARD_SIZE; + for (int[] direction : DIRS) { + int nx = px + direction[0], ny = py + direction[1]; + if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; + if (board[nx][ny] == GoPlayer.NONE) return -1; + int neighbor = nx * BOARD_SIZE + ny; + if (board[nx][ny] == color && !visited[neighbor]) { + visited[neighbor] = true; + cells[count++] = neighbor; + stack[top++] = neighbor; + } + } + } + return count; + } + /** * 获取所有合法走法(带知识剪枝 + super-ko 过滤)。 * 返回 3 元素数组 [x, y, value],value 用于剪枝和排序。 @@ -2288,12 +2641,17 @@ private boolean simulatePlaceStone(GoPlayer[][] board, int x, int y, GoPlayer pl * 替代原先每个候选点的整盘深拷贝 + 361 点重哈希。 */ private List getAllValidMoves(long baseHash, GoPlayer[][] board, GoPlayer player) { - List moves = new ArrayList<>(); - collectValidMoves(baseHash, board, player, moves, true); + List moves = new ArrayList<>(BOARD_SIZE * BOARD_SIZE); + if (player == GoPlayer.NONE) return moves; + CandidateGroups groups = CANDIDATE_GROUPS.get(); + boolean analyzed = groups.analyze(board); + if (analyzed) collectAnalyzedMoves(baseHash, board, player, moves, true, groups); + else collectValidMoves(baseHash, board, player, moves, true); if (moves.isEmpty()) { // 回退到全量搜索(不剪枝,但仍过滤 super-ko) - collectValidMoves(baseHash, board, player, moves, false); + if (analyzed) collectAnalyzedMoves(baseHash, board, player, moves, false, groups); + else collectValidMoves(baseHash, board, player, moves, false); } // 按价值排序(升序,让 expand 的 remove(size-1) 取出最高分走法优先展开) @@ -2302,6 +2660,52 @@ private List getAllValidMoves(long baseHash, GoPlayer[][] board, GoPlayer return moves; } + /** Group liberties and capture hashes are computed once, so each empty point needs only four neighbors. */ + private void collectAnalyzedMoves(long baseHash, GoPlayer[][] board, GoPlayer player, + List out, boolean prune, CandidateGroups groups) { + Set history = koHistory; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] != GoPlayer.NONE) continue; + int point = x * BOARD_SIZE + y; + Arrays.fill(groups.mergedLiberties, 0L); + int adjacentCount = 0, captures = 0, oppCaptures = 0; + long hashAfter = GoGame.xorStone(baseHash, x, y, player); + for (int[] direction : DIRS) { + int nx = x + direction[0], ny = y + direction[1]; + if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; + int neighbor = nx * BOARD_SIZE + ny; + if (board[nx][ny] == GoPlayer.NONE) { + groups.mergedLiberties[neighbor >>> 6] |= 1L << (neighbor & 63); + continue; + } + int group = groups.groupAt[neighbor]; + boolean duplicate = false; + for (int i = 0; i < adjacentCount; i++) { + if (groups.adjacent[i] == group) { duplicate = true; break; } + } + if (duplicate) continue; + groups.adjacent[adjacentCount++] = group; + if (board[nx][ny] == player) { + long[] liberties = groups.liberties[group]; + for (int i = 0; i < LIBERTY_WORDS; i++) groups.mergedLiberties[i] |= liberties[i]; + if (groups.libertyCounts[group] == 1) oppCaptures += groups.sizes[group]; + } else if (groups.libertyCounts[group] == 1) { + captures += groups.sizes[group]; + hashAfter ^= groups.stoneHashes[group]; + } + } + // The placement itself ceases to be a liberty of all joined friendly groups. + groups.mergedLiberties[point >>> 6] &= ~(1L << (point & 63)); + int liberties = 0; + for (long word : groups.mergedLiberties) liberties += Long.bitCount(word); + if (captures == 0 && liberties == 0) continue; + if (history != null && history.contains(hashAfter)) continue; + // Preserve the existing pre-capture scoring and the BFS soft cap at ten liberties. + int value = prune ? scoreMoveFromCounts(board, x, y, player, captures, oppCaptures, liberties) : 0; + if (!prune || value >= -10) out.add(new int[]{x, y, value}); + } + } + /** 融合的候选收集:落子→收集提子→自杀判定→增量哈希→super-ko 过滤→(可选)知识剪枝。 * 每个候选点模拟后在原棋盘上完全恢复,与旧 isLegalMove+isKoIllegal 组合逐位等价。 * 热路径全程使用线程局部原语缓冲,不产生 HashSet/int[] 分配。 */ @@ -2369,13 +2773,25 @@ private void collectValidMoves(long baseHash, GoPlayer[][] board, GoPlayer playe } private List getSearchMoves(long baseHash, GoPlayer[][] board, GoPlayer player, int consecutivePasses) { + // getAllValidMoves() never emits pass. Child nodes own this list through + // MCTSNode's defensive copy, so append the pass in place and avoid a + // second list plus a full reference copy on every expansion. List moves = getAllValidMoves(baseHash, board, player); - if (consecutivePasses > 0 || countStones(board) >= ENDGAME_STONES || moves.isEmpty()) { - moves.add(PASS_MOVE.clone()); - } + moves.add(PASS_MOVE.clone()); return moves; } + private static List withPassMove(List moves) { + List out = new ArrayList<>(moves.size() + 1); + boolean hasPass = false; + for (int[] move : moves) { + out.add(move); + if (isPass(move)) hasPass = true; + } + if (!hasPass) out.add(PASS_MOVE.clone()); + return out; + } + /** * 评估走法是否值得搜索(负分表示应剪枝) */ @@ -2384,26 +2800,21 @@ private boolean isLegalMove(GoPlayer[][] board, int x, int y, GoPlayer player) { || board[x][y] != GoPlayer.NONE || player == GoPlayer.NONE) return false; board[x][y] = player; GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - List captured = new ArrayList<>(); - - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { - Set group = getGroup(board, nx, ny); - if (!hasLiberty(board, group)) { - for (int[] pos : group) { - board[pos[0]][pos[1]] = GoPlayer.NONE; - captured.add(pos); - } + int[] cells = SCRATCH_CELLS_A.get(); + try { + // Any captured neighboring group supplies a liberty; no removal is needed for this probe. + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == opponent + && scanGroupWithoutLiberty(board, nx, ny, opponent, cells) >= 0) { + return true; } } + return scanGroupWithoutLiberty(board, x, y, player, cells) < 0; + } finally { + board[x][y] = GoPlayer.NONE; } - - boolean legal = !captured.isEmpty() || hasLiberty(board, getGroup(board, x, y)); - - board[x][y] = GoPlayer.NONE; - for (int[] pos : captured) board[pos[0]][pos[1]] = opponent; - return legal; } /** @@ -2478,20 +2889,32 @@ private boolean hasLiberty(GoPlayer[][] board, Set group) { } private int countGroupLiberties(GoPlayer[][] board, Set group) { - Set libertySet = new HashSet<>(); + return countGroupLibertiesUpTo(board, group, Integer.MAX_VALUE); + } + + /** Counts distinct liberties and stops once the caller's threshold is reached. */ + private int countGroupLibertiesUpTo(GoPlayer[][] board, Set group, int limit) { + boolean[] seen = SCRATCH_SEEN.get(); + Arrays.fill(seen, false); + int count = 0; for (int[] pos : group) { for (int[] dir : DIRS) { int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { - libertySet.add((long) nx * BOARD_SIZE + ny); + int index = nx * BOARD_SIZE + ny; + if (!seen[index]) { + seen[index] = true; + count++; + if (count >= limit) return count; + } } } } - return libertySet.size(); + return count; } private GoPlayer[][] deepCopyBoard(GoPlayer[][] board) { - GoPlayer[][] copy = new GoPlayer[BOARD_SIZE][BOARD_SIZE]; + GoPlayer[][] copy = new GoPlayer[BOARD_SIZE][]; for (int x = 0; x < BOARD_SIZE; x++) { copy[x] = board[x].clone(); } @@ -2532,6 +2955,9 @@ private static class MCTSNode { int[] move; int[] linkedMove; List children; + /** Selection snapshots are rebuilt only when the child list changes, under this node's lock. */ + private List selectionSource; + private MCTSNode[] selectionSnapshot; List untriedMoves; /** 本节点局面的 Zobrist 哈希(用于 super-ko 全局同型检测) */ @@ -2556,11 +2982,17 @@ private static class MCTSNode { volatile boolean forwardInFlight = false; MCTSNode(GoPlayer[][] board, GoPlayer player, MCTSNode parent, int[] move, List untriedMoves) { + this(board, player, parent, move, untriedMoves, true); + } + + MCTSNode(GoPlayer[][] board, GoPlayer player, MCTSNode parent, int[] move, + List untriedMoves, boolean copyUntriedMoves) { this.board = board; this.player = player; this.parent = parent; this.move = move; - this.untriedMoves = untriedMoves != null ? new ArrayList<>(untriedMoves) : new ArrayList<>(); + this.untriedMoves = untriedMoves == null ? new ArrayList<>() + : copyUntriedMoves ? new ArrayList<>(untriedMoves) : untriedMoves; } } } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluator.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluator.java index b1efa8a..6094067 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluator.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluator.java @@ -1,8 +1,14 @@ package com.wzz.game_console.client.screens.games.gogame; import java.util.*; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.LockSupport; import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import com.wzz.game_console.util.GameSettings; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -54,6 +60,8 @@ public class NeuralEvaluator { private static final int TOP_INPUT = NUM_BLOCKS * BLOCK_HIDDEN + 24; // 576+24=600 private static final int TOP_HIDDEN = 256; private static final int POLICY_SIZE = 362; // 361 moves + pass + /** Serializes load/save of the same model path inside this JVM. */ + private static final ConcurrentHashMap MODEL_IO_LOCKS = new ConcurrentHashMap<>(); private static final int VALUE_HIDDEN = 128; // 辅助特征维度(沿用旧版) @@ -68,6 +76,49 @@ public class NeuralEvaluator { private static final int LEGACY_MODEL_MAGIC = 0x4E455632; // NEV2 private static final int LEGACY_MODEL_FORMAT = 2; private static final int MAX_CACHE_SIZE = 10000; + private static final AtomicInteger GPU_OWNER_SEQ = new AtomicInteger(1); + + /** Per-caller feature buffers. They are only reused after forward() returns. */ + private static final ThreadLocal INPUT_SCRATCH = + ThreadLocal.withInitial(InputScratch::new); + + /** Per-thread intermediate buffers; returned policies always own their storage. */ + private static final ThreadLocal CPU_FORWARD_SCRATCH = + ThreadLocal.withInitial(CpuForwardScratch::new); + + private static final ThreadLocal FEATURE_SCRATCH = + ThreadLocal.withInitial(FeatureScratch::new); + + private static final class InputScratch { + final double[][][] planes = new double[PLANES][BOARD_SIZE][BOARD_SIZE]; + final double[] aux = new double[AUX_SIZE]; + } + + private static final class CpuForwardScratch { + final double[][][] subOut = new double[NUM_BLOCKS][SUBS_PER_BLOCK][SUB_HIDDEN]; + final double[] subInput = new double[SUB_INPUT]; + final double[][] blockOut = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + final double[] blockInput = new double[BLOCK_INPUT]; + final double[] topInput = new double[TOP_INPUT]; + final double[] shared = new double[TOP_HIDDEN]; + final double[] valueHidden = new double[VALUE_HIDDEN]; + } + + /** One analysis per position, shared by the liberty, connection and separation features. */ + private static final class FeatureScratch { + final GoPlayer[] cells = new GoPlayer[BOARD_SIZE * BOARD_SIZE]; + final int[] groupAt = new int[BOARD_SIZE * BOARD_SIZE]; + final GoPlayer[] colors = new GoPlayer[BOARD_SIZE * BOARD_SIZE]; + final int[] sizes = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] liberties = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] links = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] libertySeen = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] queue = new int[BOARD_SIZE * BOARD_SIZE]; + final int[] histogram = new int[LIBERTY_HIST_BINS]; + final int[] adjacentGroups = new int[4]; + final boolean[] regionSeen = new boolean[BOARD_SIZE * BOARD_SIZE]; + int groupCount; + } // 四方向 private static final int[][] DIRS = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; @@ -80,8 +131,23 @@ public class NeuralEvaluator { private static final int[][] BLOCK_STARTS = new int[9][2]; /** 每个一级字块内 9 个二级子块的左上角偏移 (sx, sy) 相对于大块起点 */ private static final int[][] SUB_OFFSETS = new int[9][2]; + private static final double[][] INFLUENCE_WEIGHT = new double[7][7]; + private static final int[][] CELL_NEIGHBORS = new int[BOARD_SIZE * BOARD_SIZE][]; static { + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + int[] neighbors = new int[4]; + int count = 0; + for (int[] d : DIRS) { + int nx = x + d[0], ny = y + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + neighbors[count++] = nx * BOARD_SIZE + ny; + } + } + CELL_NEIGHBORS[x * BOARD_SIZE + y] = Arrays.copyOf(neighbors, count); + } + } // 一级字块起始位置 for (int bx = 0; bx < BLOCKS_PER_DIM; bx++) { for (int by = 0; by < BLOCKS_PER_DIM; by++) { @@ -98,6 +164,13 @@ public class NeuralEvaluator { SUB_OFFSETS[idx][1] = sy * SUB_STRIDE; } } + for (int dx = -3; dx <= 3; dx++) { + for (int dy = -3; dy <= 3; dy++) { + int distanceSquared = dx * dx + dy * dy; + INFLUENCE_WEIGHT[dx + 3][dy + 3] = distanceSquared == 0 + ? 1.0 : 1.0 / Math.sqrt(distanceSquared); + } + } } // ══════════════════════════════════════════════════════════════════════ @@ -127,10 +200,16 @@ public class NeuralEvaluator { private double valueB2; private final ReentrantReadWriteLock modelLock = new ReentrantReadWriteLock(); - /** Serializes forward/train with release so the native backend cannot be closed in use. */ - private final ReentrantLock lifecycleLock = new ReentrantLock(); + /** + * 与 release 互斥:前向/训练可并发持有读锁,关闭时独占写锁。 + * 原先用独占 ReentrantLock 会把 30 路自对弈的 GPU 前向串成一条。 + */ + private final ReentrantReadWriteLock lifecycleLock = new ReentrantReadWriteLock(); private volatile boolean released; private volatile long modelVersion; + /** Guarded by modelLock; checkpoint versions can repeat, device revisions cannot. */ + private long gpuWeightRevision; + private final int gpuOwnerId = GPU_OWNER_SEQ.getAndIncrement(); // 动量缓冲(惰性分配,首次 momentum > 0 训练时创建) private double[][][] vSubW1; @@ -151,8 +230,25 @@ public class NeuralEvaluator { /** OpenCL GPU 加速后端(懒初始化,失败自动回退 CPU) */ private volatile OpenCLBackend opencl; - /** OpenCL 初始化失败标记:置位后不再反复尝试加载(每次 forward 都调 ensureOpenCL) */ + /** OpenCL 初始化失败后在冷却期内直接走 CPU。 */ private volatile boolean openclDisabled; + private volatile long openclFailureAt; + /** 把并发 MCTS 前向收成一批再上 GPU;单线程时不等待。 */ + private static final int INFER_MAX_BATCH = 64; + private static final long INFER_GATHER_NS = 1_500_000L; + private final ConcurrentLinkedQueue gpuInferQueue = new ConcurrentLinkedQueue<>(); + private final AtomicInteger gpuInferWaiters = new AtomicInteger(); + private final ReentrantLock gpuInferDrain = new ReentrantLock(); + + private static final class GpuInferCall { + final double[][][] planes; + final double[] aux; + final CompletableFuture done = new CompletableFuture<>(); + GpuInferCall(double[][][] planes, double[] aux) { + this.planes = planes; + this.aux = aux; + } + } /** * 释放 OpenCL native 资源(kernel/program/queue/context)。 @@ -160,7 +256,7 @@ public class NeuralEvaluator { * MCTSGoAI 会堆积 native 句柄只能靠 GC 兜底。重复调用安全(幂等)。 */ public void release() { - lifecycleLock.lock(); + lifecycleLock.writeLock().lock(); try { if (released) return; released = true; @@ -173,7 +269,7 @@ public void release() { } } } finally { - lifecycleLock.unlock(); + lifecycleLock.writeLock().unlock(); } } @@ -277,6 +373,15 @@ private void initWeights() { */ public double[][][] buildInputPlanes(GoPlayer[][] board, GoPlayer player, int[] lastMove) { double[][][] planes = new double[PLANES][BOARD_SIZE][BOARD_SIZE]; + fillInputPlanes(planes, board, player, lastMove); + return planes; + } + + private void fillInputPlanes(double[][][] planes, GoPlayer[][] board, + GoPlayer player, int[] lastMove) { + for (double[][] plane : planes) { + for (double[] row : plane) Arrays.fill(row, 0.0); + } GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; for (int x = 0; x < BOARD_SIZE; x++) { @@ -298,7 +403,6 @@ public double[][][] buildInputPlanes(GoPlayer[][] board, GoPlayer player, int[] } } } - return planes; } /** 计算空点 x,y 周围的气数(仅看相邻空点+同色连通) */ @@ -323,22 +427,22 @@ private int countEmptyLiberties(GoPlayer[][] board, int x, int y) { */ public double[] extractAuxFeatures(GoPlayer[][] board, GoPlayer player) { double[] aux = new double[AUX_SIZE]; + fillAuxFeatures(aux, board, player); + return aux; + } + + private void fillAuxFeatures(double[] aux, GoPlayer[][] board, GoPlayer player) { + Arrays.fill(aux, 0.0); + FeatureScratch scratch = FEATURE_SCRATCH.get(); + analyzeFeatureGroups(board, scratch); GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; int BS = BOARD_SIZE; // ── 气数直方图 [0..7] ────────────────────────────────────────── - int[] libertyHist = new int[LIBERTY_HIST_BINS]; - boolean[][] visited = new boolean[BS][BS]; - for (int x = 0; x < BS; x++) { - for (int y = 0; y < BS; y++) { - if (board[x][y] != GoPlayer.NONE && !visited[x][y]) { - Set group = getGroup(board, x, y); - int libs = countGroupLiberties(board, group); - int bin = Math.min(libs, LIBERTY_HIST_BINS - 1); - libertyHist[bin]++; - for (int[] p : group) visited[p[0]][p[1]] = true; - } - } + int[] libertyHist = scratch.histogram; + Arrays.fill(libertyHist, 0); + for (int group = 0; group < scratch.groupCount; group++) { + libertyHist[Math.min(scratch.liberties[group], LIBERTY_HIST_BINS - 1)]++; } int histSum = 0; for (int v : libertyHist) histSum += v; @@ -414,8 +518,7 @@ public double[] extractAuxFeatures(GoPlayer[][] board, GoPlayer player) { for (int dx = -radius; dx <= radius; dx++) for (int dy = -radius; dy <= radius; dy++) { int nx = x + dx, ny = y + dy; if (nx >= 0 && nx < BS && ny >= 0 && ny < BS) { - double w = Math.sqrt(dx * dx + dy * dy); - w = w > 0 ? 1.0 / w : 1.0; + double w = INFLUENCE_WEIGHT[dx + 3][dy + 3]; if (board[nx][ny] == player) myInf += w; else if (board[nx][ny] == opponent) oppInf += w; } @@ -424,13 +527,12 @@ public double[] extractAuxFeatures(GoPlayer[][] board, GoPlayer player) { } } aux[17] = (myControl - oppControl) / 100.0; - aux[18] = evaluateConnections(board, player) / 20.0; - aux[19] = evaluateSeparation(board, player) / 20.0; + aux[18] = evaluateConnections(scratch, player) / 20.0; + aux[19] = evaluateSeparation(scratch, player) / 20.0; aux[20] = evaluateStrategicPoints(board, player) / 10.0; - TerritoryResult tr = evaluateTerritory(board, player); + TerritoryResult tr = evaluateTerritory(scratch, player); aux[21] = (tr.myTerritory - tr.oppTerritory) / 50.0; - return aux; } // ══════════════════════════════════════════════════════════════════════ @@ -441,19 +543,14 @@ public double[] extractAuxFeatures(GoPlayer[][] board, GoPlayer player) { private static volatile Boolean gpuEnabledCache = null; private static boolean isGpuEnabled() { + // 系统属性每次重读:训练 CLI / 测试可以在进程内切换,且优先于配置缓存 + String prop = System.getProperty("go.gpu"); + if (prop != null) return Boolean.parseBoolean(prop); Boolean v = gpuEnabledCache; if (v != null) return v; - // 1) 系统属性 -Dgo.gpu=false(训练 CLI 用) - String prop = System.getProperty("go.gpu"); - if (prop != null) { - v = Boolean.parseBoolean(prop); - gpuEnabledCache = v; - return v; - } - // 2) GameSettings 配置文件(MC 对局用) try { v = GameSettings.getBoolean("go", "gpu", true); - } catch (Exception e) { + } catch (Throwable e) { v = true; } gpuEnabledCache = v; @@ -462,87 +559,301 @@ private static boolean isGpuEnabled() { /** * 懒初始化 OpenCL 后端(GPU 启用且成功才使用,失败自动回退 CPU)。 + * 进程内共享一个上下文,避免每个评估器各自建队列。 */ private OpenCLBackend ensureOpenCL() { - if (openclDisabled || !isGpuEnabled()) return null; // GPU 可选/初始化失败:走 CPU - if (opencl == null) { - synchronized (this) { - if (opencl == null) { - try { - opencl = new OpenCLBackend(); - } catch (Throwable t) { - // ★ 兜底:JNA 缺失/UnsatisfiedLinkError 等属于 Error, - // 不能让 GPU 探测失败把整条推理路径炸掉,降级 CPU - System.err.println("[NeuralEvaluator] OpenCL 初始化失败,回退 CPU: " + t); - opencl = null; - openclDisabled = true; - } + if (released || !isGpuEnabled() || isOpenCLCoolingDown()) return null; + OpenCLBackend existing = opencl; + if (existing != null && existing.isAvailable()) return existing; + synchronized (this) { + if (released || isOpenCLCoolingDown()) return null; + existing = opencl; + if (existing != null) { + if (existing.isAvailable()) return existing; + opencl = null; + existing.close(); + } + try { + OpenCLBackend created = OpenCLBackend.acquireShared(); + if (created == null || !created.isAvailable()) { + if (created != null) created.close(); + recordOpenCLFailure(); + return null; + } + opencl = created; + openclDisabled = false; + return created; + } catch (Throwable t) { + // ★ 兜底:JNA 缺失/UnsatisfiedLinkError 等属于 Error, + // 不能让 GPU 探测失败把整条推理路径炸掉,降级 CPU + System.err.println("[NeuralEvaluator] OpenCL 初始化失败,回退 CPU: " + t); + opencl = null; + recordOpenCLFailure(); + return null; + } + } + } + + private boolean isOpenCLCoolingDown() { + return openclDisabled && System.nanoTime() - openclFailureAt < OpenCLBackend.RETRY_DELAY_NANOS; + } + + private void recordOpenCLFailure() { + openclFailureAt = System.nanoTime(); + openclDisabled = true; + } + + /** 当前评估器能否使用 GPU;失败后冷却重试,已释放的评估器不会重新获取资源。 */ + public boolean isGpuActive() { + lifecycleLock.readLock().lock(); + try { + return ensureOpenCL() != null; + } finally { + lifecycleLock.readLock().unlock(); + } + } + + public String gpuDeviceName() { + lifecycleLock.readLock().lock(); + try { + OpenCLBackend backend = ensureOpenCL(); + return backend == null ? "CPU" : backend.getDeviceName(); + } finally { + lifecycleLock.readLock().unlock(); + } + } + + /** + * 进程级 GPU 探测:打印一次设备名或失败原因,训练入口/探针共用。 + * 探测本身不长期占用引用;最后一个使用者关闭时释放上下文。 + */ + public static GpuStatus detectGpu() { + if (!isGpuEnabled()) return GpuStatus.disabled(); + try { + try (OpenCLBackend backend = OpenCLBackend.acquireShared()) { + if (backend != null && backend.isAvailable()) { + return GpuStatus.available(backend.getDeviceName()); } } + String fail = OpenCLBackend.lastSharedFailure(); + return GpuStatus.unavailable(fail == null ? "OpenCL 不可用" : fail); + } catch (Throwable t) { + return GpuStatus.unavailable(String.valueOf(t)); + } + } + + public static final class GpuStatus { + public final boolean enabled; + public final boolean available; + public final String deviceName; + public final String detail; + + private GpuStatus(boolean enabled, boolean available, String deviceName, String detail) { + this.enabled = enabled; + this.available = available; + this.deviceName = deviceName; + this.detail = detail; + } + + static GpuStatus disabled() { + return new GpuStatus(false, false, "CPU", "go.gpu=false"); + } + + static GpuStatus available(String device) { + return new GpuStatus(true, true, device, "ok"); + } + + static GpuStatus unavailable(String reason) { + return new GpuStatus(true, false, "CPU", reason == null ? "unavailable" : reason); + } + + public String describe() { + if (!enabled) return "GPU 已关闭,使用 CPU (" + detail + ")"; + if (available) return "GPU 可用: " + deviceName; + return "GPU 不可用,回退 CPU (" + detail + ")"; } - return (opencl != null && opencl.isAvailable()) ? opencl : null; } /** - * 完整前向传播:三级分块 → 双头。 + * 完整前向传播:三级分块 → 双头。GPU 可用时走 OpenCL,失败回退 CPU。 */ public ForwardResult forward(double[][][] planes, double[] auxFeatures) { try { - lifecycleLock.lockInterruptibly(); + lifecycleLock.readLock().lockInterruptibly(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return new ForwardResult(0.0, new double[POLICY_SIZE]); } try { if (released) return new ForwardResult(0.0, new double[POLICY_SIZE]); + OpenCLBackend oc = ensureOpenCL(); + if (oc != null) { + ForwardResult gpu = coalesceGpuForward(oc, planes, auxFeatures); + if (gpu != null) return gpu; + if (Thread.currentThread().isInterrupted()) { + return new ForwardResult(0.0, new double[POLICY_SIZE]); + } + } modelLock.readLock().lock(); try { + return cpuForward(planes, auxFeatures); + } finally { + modelLock.readLock().unlock(); + } + } finally { + lifecycleLock.readLock().unlock(); + } + } + + private ForwardResult coalesceGpuForward(OpenCLBackend oc, double[][][] planes, double[] auxFeatures) { + GpuInferCall call = new GpuInferCall(planes, auxFeatures); + gpuInferWaiters.incrementAndGet(); + gpuInferQueue.offer(call); + try { + while (!call.done.isDone()) { + if (gpuInferDrain.tryLock()) { + try { + flushGpuInferQueue(oc); + } finally { + gpuInferDrain.unlock(); + } + continue; + } + try { + return call.done.get(5, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException ignored) { + // 排空线程可能刚好错过本请求,下一轮自己抢锁。 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + // Removing a queued call proves no batch owns its input buffers. + if (gpuInferQueue.remove(call)) { + call.done.cancel(false); + return null; + } + // An active batch may still read thread-local inputs. Wait for + // it to finish before allowing the caller to reuse them. + return joinGpuInfer(call); + } catch (java.util.concurrent.ExecutionException e) { + return null; + } + } + return joinGpuInfer(call); + } finally { + gpuInferWaiters.decrementAndGet(); + } + } + + private static ForwardResult joinGpuInfer(GpuInferCall call) { + try { + return call.done.join(); + } catch (RuntimeException e) { + return null; + } + } + + private void flushGpuInferQueue(OpenCLBackend oc) { + while (true) { + List batch = new ArrayList<>(INFER_MAX_BATCH); + GpuInferCall next; + while (batch.size() < INFER_MAX_BATCH && (next = gpuInferQueue.poll()) != null) { + batch.add(next); + } + if (batch.isEmpty()) return; + if (batch.size() == 1 && gpuInferWaiters.get() > 1) { + long deadline = System.nanoTime() + INFER_GATHER_NS; + while (batch.size() < INFER_MAX_BATCH && System.nanoTime() < deadline) { + next = gpuInferQueue.poll(); + if (next == null) { + LockSupport.parkNanos(50_000L); + continue; + } + batch.add(next); + } + } + runGpuInferBatch(oc, batch); + } + } + + private void runGpuInferBatch(OpenCLBackend oc, List batch) { + int b = batch.size(); + modelLock.readLock().lock(); + try { + double[][][][] planes = new double[b][][][]; + double[][] aux = new double[b][]; + for (int i = 0; i < b; i++) { + GpuInferCall call = batch.get(i); + planes[i] = call.planes; + aux[i] = call.aux; + } + double[][] policyOut = new double[b][POLICY_SIZE]; + double[] valueOut = new double[b]; + boolean ok = oc.inferForward(planes, aux, b, gpuOwnerId, gpuWeightRevision, + subW1, subB1, blockW1, blockB1, topW1, topB1, + policyW, policyB, valueW1, valueB1, valueW2, valueB2, + policyOut, valueOut); + if (ok) { + for (int i = 0; i < b; i++) { + batch.get(i).done.complete(new ForwardResult(valueOut[i], policyOut[i])); + } + return; + } + } catch (Throwable t) { + System.err.println("[NeuralEvaluator] GPU 前向失败,回退 CPU: " + t); + } finally { + modelLock.readLock().unlock(); + } + modelLock.readLock().lock(); + try { + for (GpuInferCall call : batch) { + if (call.done.isDone()) continue; + try { + call.done.complete(cpuForward(call.planes, call.aux)); + } catch (Throwable t) { + call.done.completeExceptionally(t); + } + } + } finally { + modelLock.readLock().unlock(); + } + } + + private ForwardResult cpuForward(double[][][] planes, double[] auxFeatures) { + CpuForwardScratch scratch = CPU_FORWARD_SCRATCH.get(); // ── 第 1 级:二级子块 ────────────────────────────────────── // subOut[b][s][h] — 大块 b 的第 s 个子块的 16 维输出 - double[][][] subOut = new double[NUM_BLOCKS][SUBS_PER_BLOCK][SUB_HIDDEN]; + double[][][] subOut = scratch.subOut; for (int b = 0; b < NUM_BLOCKS; b++) { int bx = BLOCK_STARTS[b][0], by = BLOCK_STARTS[b][1]; - double[] w1 = null; // flatten subW1[b] for fast access double[] b1 = subB1[b]; for (int s = 0; s < SUBS_PER_BLOCK; s++) { int sx = bx + SUB_OFFSETS[s][0], sy = by + SUB_OFFSETS[s][1]; // 提取 3×3×4 = 36 个值 - double[] input = new double[SUB_INPUT]; + double[] input = scratch.subInput; int idx = 0; for (int p = 0; p < PLANES; p++) for (int dx = 0; dx < SUB_SIZE; dx++) for (int dy = 0; dy < SUB_SIZE; dy++) input[idx++] = planes[p][sx + dx][sy + dy]; // FC(36→16) + ReLU - for (int j = 0; j < SUB_HIDDEN; j++) { - double sum = b1[j]; - for (int i = 0; i < SUB_INPUT; i++) - sum += subW1[b][i][j] * input[i]; - subOut[b][s][j] = Math.max(0, sum); - } + cpuDense(subW1[b], b1, input, subOut[b][s], true); } } // ── 第 2 级:一级字块 ────────────────────────────────────── - double[][] blockOut = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + double[][] blockOut = scratch.blockOut; for (int b = 0; b < NUM_BLOCKS; b++) { // 拼接 9 个子块输出 → 144 维 - double[] input = new double[BLOCK_INPUT]; + double[] input = scratch.blockInput; int idx = 0; for (int s = 0; s < SUBS_PER_BLOCK; s++) for (int h = 0; h < SUB_HIDDEN; h++) input[idx++] = subOut[b][s][h]; // FC(144→64) + ReLU - for (int j = 0; j < BLOCK_HIDDEN; j++) { - double sum = blockB1[b][j]; - for (int i = 0; i < BLOCK_INPUT; i++) - sum += blockW1[b][i][j] * input[i]; - blockOut[b][j] = Math.max(0, sum); - } + cpuDense(blockW1[b], blockB1[b], input, blockOut[b], true); } // ── 第 3 级:顶级 ────────────────────────────────────────── - double[] topInput = new double[TOP_INPUT]; + double[] topInput = scratch.topInput; int idx = 0; for (int b = 0; b < NUM_BLOCKS; b++) for (int h = 0; h < BLOCK_HIDDEN; h++) @@ -551,23 +862,15 @@ public ForwardResult forward(double[][][] planes, double[] auxFeatures) { System.arraycopy(auxFeatures, 0, topInput, NUM_BLOCKS * BLOCK_HIDDEN, AUX_SIZE); // FC(600→256) + ReLU - double[] shared = new double[TOP_HIDDEN]; - for (int j = 0; j < TOP_HIDDEN; j++) { - double sum = topB1[j]; - for (int i = 0; i < TOP_INPUT; i++) - sum += topW1[i][j] * topInput[i]; - shared[j] = Math.max(0, sum); - } + double[] shared = scratch.shared; + cpuDense(topW1, topB1, topInput, shared, true); // ── 策略头 ────────────────────────────────────────────────── double[] policy = new double[POLICY_SIZE]; + cpuDense(policyW, policyB, shared, policy, false); double maxLogit = Double.NEGATIVE_INFINITY; for (int j = 0; j < POLICY_SIZE; j++) { - double sum = policyB[j]; - for (int i = 0; i < TOP_HIDDEN; i++) - sum += policyW[i][j] * shared[i]; - if (sum > maxLogit) maxLogit = sum; - policy[j] = sum; + if (policy[j] > maxLogit) maxLogit = policy[j]; } // softmax(数值稳定版) double sumExp = 0; @@ -579,24 +882,29 @@ public ForwardResult forward(double[][][] planes, double[] auxFeatures) { for (int j = 0; j < POLICY_SIZE; j++) policy[j] *= invSum; // ── 价值头 ────────────────────────────────────────────────── - double[] vh = new double[VALUE_HIDDEN]; - for (int j = 0; j < VALUE_HIDDEN; j++) { - double sum = valueB1[j]; - for (int i = 0; i < TOP_HIDDEN; i++) - sum += valueW1[i][j] * shared[i]; - vh[j] = Math.max(0, sum); - } + double[] vh = scratch.valueHidden; + cpuDense(valueW1, valueB1, shared, vh, true); double valueSum = valueB2; for (int i = 0; i < VALUE_HIDDEN; i++) valueSum += valueW2[i] * vh[i]; double value = Math.tanh(valueSum); return new ForwardResult(value, policy); - } finally { - modelLock.readLock().unlock(); + } + + /** Read contiguous weight rows while preserving each output's accumulation order. */ + private static void cpuDense(double[][] weights, double[] bias, double[] input, + double[] output, boolean relu) { + System.arraycopy(bias, 0, output, 0, output.length); + for (int i = 0; i < input.length; i++) { + double x = input[i]; + double[] row = weights[i]; + for (int j = 0; j < output.length; j++) { + output[j] += row[j] * x; } - } finally { - lifecycleLock.unlock(); + } + if (relu) { + for (int j = 0; j < output.length; j++) output[j] = Math.max(0, output[j]); } } @@ -605,9 +913,78 @@ public ForwardResult forward(double[][][] planes, double[] auxFeatures) { * 直接返回价值头输出(-1~1),不携带启发式偏差。 */ public double forwardValue(GoPlayer[][] board, GoPlayer player, int[] lastMove) { - double[][][] planes = buildInputPlanes(board, player, lastMove); - double[] aux = extractAuxFeatures(board, player); - return forward(planes, aux).value; + try { + lifecycleLock.readLock().lockInterruptibly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return 0.0; + } + try { + if (released) return 0.0; + // Configuration can enable GPU even when initialization failed. + // Only an available backend needs the complete GPU forward path. + if (ensureOpenCL() != null) { + double[][][] planes = buildInputPlanes(board, player, lastMove); + double[] aux = extractAuxFeatures(board, player); + return forward(planes, aux).value; + } + InputScratch input = INPUT_SCRATCH.get(); + fillInputPlanes(input.planes, board, player, lastMove); + fillAuxFeatures(input.aux, board, player); + modelLock.readLock().lock(); + try { + return cpuValueForward(input.planes, input.aux); + } finally { + modelLock.readLock().unlock(); + } + } finally { + lifecycleLock.readLock().unlock(); + } + } + + /** CPU trunk plus value head only. Caller holds modelLock.readLock(). */ + private double cpuValueForward(double[][][] planes, double[] auxFeatures) { + CpuForwardScratch scratch = CPU_FORWARD_SCRATCH.get(); + double[][][] subOut = scratch.subOut; + for (int b = 0; b < NUM_BLOCKS; b++) { + int bx = BLOCK_STARTS[b][0], by = BLOCK_STARTS[b][1]; + double[] b1 = subB1[b]; + for (int s = 0; s < SUBS_PER_BLOCK; s++) { + int sx = bx + SUB_OFFSETS[s][0], sy = by + SUB_OFFSETS[s][1]; + double[] input = scratch.subInput; + int idx = 0; + for (int p = 0; p < PLANES; p++) + for (int dx = 0; dx < SUB_SIZE; dx++) + for (int dy = 0; dy < SUB_SIZE; dy++) + input[idx++] = planes[p][sx + dx][sy + dy]; + cpuDense(subW1[b], b1, input, subOut[b][s], true); + } + } + + double[][] blockOut = scratch.blockOut; + for (int b = 0; b < NUM_BLOCKS; b++) { + double[] input = scratch.blockInput; + int idx = 0; + for (int s = 0; s < SUBS_PER_BLOCK; s++) + for (int h = 0; h < SUB_HIDDEN; h++) + input[idx++] = subOut[b][s][h]; + cpuDense(blockW1[b], blockB1[b], input, blockOut[b], true); + } + + double[] topInput = scratch.topInput; + int idx = 0; + for (int b = 0; b < NUM_BLOCKS; b++) + for (int h = 0; h < BLOCK_HIDDEN; h++) + topInput[idx++] = blockOut[b][h]; + System.arraycopy(auxFeatures, 0, topInput, NUM_BLOCKS * BLOCK_HIDDEN, AUX_SIZE); + double[] shared = scratch.shared; + cpuDense(topW1, topB1, topInput, shared, true); + + double[] valueHidden = scratch.valueHidden; + cpuDense(valueW1, valueB1, shared, valueHidden, true); + double valueSum = valueB2; + for (int i = 0; i < VALUE_HIDDEN; i++) valueSum += valueW2[i] * valueHidden[i]; + return Math.tanh(valueSum); } /** @@ -621,6 +998,16 @@ public double[] forwardPolicy(double[][][] planes, double[] auxFeatures) { // 训练(反向传播) // ══════════════════════════════════════════════════════════════════════ + static void validateTrainingParameters(double learningRate, double l2, double gradientClip, double momentum) { + if (!Double.isFinite(learningRate) || learningRate < 0 + || !Double.isFinite(l2) || l2 < 0 + || !Double.isFinite(gradientClip) || gradientClip <= 0 + || !Double.isFinite(momentum) || momentum < 0 || momentum >= 1) { + throw new IllegalArgumentException("Training requires finite learningRate/l2 >= 0, " + + "gradientClip > 0 and momentum in [0, 1)"); + } + } + /** * 训练一个 mini-batch(双头 loss:value MSE + policy cross-entropy)。 * @@ -638,11 +1025,12 @@ public double trainMiniBatch(double[][][][] planes, double[][] auxFeatures, double[] valueTargets, double[][] policyTargets, double learningRate, double l2, double gradientClip, double momentum) { + validateTrainingParameters(learningRate, l2, gradientClip, momentum); int batchSize = planes.length; if (batchSize == 0) return 0; try { - lifecycleLock.lockInterruptibly(); + lifecycleLock.writeLock().lockInterruptibly(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return 0; @@ -689,7 +1077,7 @@ public double trainMiniBatch(double[][][][] planes, double[][] auxFeatures, bPolicyOut = new double[batchSize][POLICY_SIZE]; bValueOut = new double[batchSize]; // 整个 batch 的前向在 GPU 上完成(子块→字块→顶级→策略头→价值头) - boolean ranGpu = oc.batchPass0Forward(planes, auxFeatures, batchSize, + boolean ranGpu = oc.batchPass0Forward(planes, auxFeatures, batchSize, gpuOwnerId, gpuWeightRevision, subW1, subB1, blockW1, blockB1, topW1, topB1, policyW, policyB, valueW1, valueB1, valueW2, valueB2, bSubIn, bSubZ, bBlkIn, bBlkZ, bTopIn, bShared, bShZ, @@ -954,13 +1342,14 @@ public double trainMiniBatch(double[][][][] planes, double[][] auxFeatures, } modelVersion++; + gpuWeightRevision++; synchronized (evaluationCache) { evaluationCache.clear(); } return totalLoss / batchSize; } finally { modelLock.writeLock().unlock(); } } finally { - lifecycleLock.unlock(); + lifecycleLock.writeLock().unlock(); } } @@ -1084,56 +1473,81 @@ private EyeInfo analyzeEye(GoPlayer[][] board, int x, int y) { return info; } - private double evaluateConnections(GoPlayer[][] board, GoPlayer player) { - boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; - double bonus = 0; - for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { - if (board[x][y] == player && !visited[x][y]) { - Set group = getGroup(board, x, y); - int size = group.size(); - if (size >= 5) bonus += size * 0.3; - for (int[] p : group) { - for (int[] d : DIRS) { - int nx = p[0] + d[0], ny = p[1] + d[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) - bonus += 0.5; + private static void analyzeFeatureGroups(GoPlayer[][] board, FeatureScratch scratch) { + for (int x = 0; x < BOARD_SIZE; x++) { + System.arraycopy(board[x], 0, scratch.cells, x * BOARD_SIZE, BOARD_SIZE); + } + Arrays.fill(scratch.groupAt, -1); + Arrays.fill(scratch.libertySeen, -1); + scratch.groupCount = 0; + for (int cell = 0; cell < scratch.cells.length; cell++) { + GoPlayer color = scratch.cells[cell]; + if (color == GoPlayer.NONE || scratch.groupAt[cell] >= 0) continue; + int group = scratch.groupCount++; + scratch.colors[group] = color; + int head = 0, tail = 1, liberties = 0, links = 0; + scratch.queue[0] = cell; + scratch.groupAt[cell] = group; + while (head < tail) { + int current = scratch.queue[head++]; + for (int neighbor : CELL_NEIGHBORS[current]) { + if (scratch.cells[neighbor] == GoPlayer.NONE) { + if (scratch.libertySeen[neighbor] != group) { + scratch.libertySeen[neighbor] = group; + liberties++; + } + } else if (scratch.cells[neighbor] == color) { + links++; + if (scratch.groupAt[neighbor] < 0) { + // Mark on enqueue: the queue is bounded by the board's 361 cells. + scratch.groupAt[neighbor] = group; + scratch.queue[tail++] = neighbor; + } } } - int libs = countGroupLiberties(board, group); - if (libs >= 5) bonus += 2; - for (int[] p : group) visited[p[0]][p[1]] = true; + } + scratch.sizes[group] = tail; + scratch.liberties[group] = liberties; + scratch.links[group] = links; + } + } + + private double evaluateConnections(FeatureScratch scratch, GoPlayer player) { + double bonus = 0; + for (int group = 0; group < scratch.groupCount; group++) { + if (scratch.colors[group] == player) { + int size = scratch.sizes[group]; + if (size >= 5) bonus += size * 0.3; + bonus += scratch.links[group] * 0.5; + if (scratch.liberties[group] >= 5) bonus += 2; } } return bonus; } - private double evaluateSeparation(GoPlayer[][] board, GoPlayer player) { + private double evaluateSeparation(FeatureScratch scratch, GoPlayer player) { GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; double threat = 0; - for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { - if (board[x][y] == GoPlayer.NONE) { - // getGroup 返回的 Set 中 int[] 是 identity 相等,同一棋群从多方向 - // 相邻会被 Set> 当成多个。用棋群最小坐标的 Long 键规范化去重。 - Set groupKeys = new HashSet<>(); - for (int[] d : DIRS) { - int nx = x + d[0], ny = y + d[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { - Set g = getGroup(board, nx, ny); - long minKey = Long.MAX_VALUE; - for (int[] p : g) { - long key = (long) p[0] * BOARD_SIZE + p[1]; - if (key < minKey) minKey = key; + for (int cell = 0; cell < scratch.cells.length; cell++) { + if (scratch.cells[cell] == GoPlayer.NONE) { + int groupCount = 0, minLibs = Integer.MAX_VALUE; + for (int neighbor : CELL_NEIGHBORS[cell]) { + if (scratch.cells[neighbor] == opponent) { + int group = scratch.groupAt[neighbor]; + boolean duplicate = false; + for (int i = 0; i < groupCount; i++) { + if (scratch.adjacentGroups[i] == group) { + duplicate = true; + break; + } + } + if (!duplicate) { + scratch.adjacentGroups[groupCount++] = group; + minLibs = Math.min(minLibs, scratch.liberties[group]); } - groupKeys.add(minKey); } } - if (groupKeys.size() >= 2) { - int minLibs = Integer.MAX_VALUE; - for (long key : groupKeys) { - int px = (int)(key / BOARD_SIZE), py = (int)(key % BOARD_SIZE); - int libs = countGroupLiberties(board, getGroup(board, px, py)); - minLibs = Math.min(minLibs, libs); - } + if (groupCount >= 2) { threat += minLibs <= 3 ? 3.0 : 1.0; } } @@ -1162,36 +1576,32 @@ private double evaluateStrategicPoints(GoPlayer[][] board, GoPlayer player) { private static class TerritoryResult { double myTerritory, oppTerritory; } - private TerritoryResult evaluateTerritory(GoPlayer[][] board, GoPlayer player) { + private TerritoryResult evaluateTerritory(FeatureScratch scratch, GoPlayer player) { GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; TerritoryResult result = new TerritoryResult(); - boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; - for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { - if (board[x][y] == GoPlayer.NONE && !visited[x][y]) { - List region = new ArrayList<>(); - Queue queue = new LinkedList<>(); - queue.add(new int[]{x, y}); visited[x][y] = true; - while (!queue.isEmpty()) { - int[] pos = queue.poll(); - region.add(pos); - for (int[] d : DIRS) { - int nx = pos[0] + d[0], ny = pos[1] + d[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE - && board[nx][ny] == GoPlayer.NONE && !visited[nx][ny]) { - visited[nx][ny] = true; - queue.add(new int[]{nx, ny}); + Arrays.fill(scratch.regionSeen, false); + for (int cell = 0; cell < scratch.cells.length; cell++) { + if (scratch.cells[cell] == GoPlayer.NONE && !scratch.regionSeen[cell]) { + int head = 0, tail = 1, myBorder = 0, oppBorder = 0; + scratch.queue[0] = cell; + scratch.regionSeen[cell] = true; + while (head < tail) { + int current = scratch.queue[head++]; + for (int neighbor : CELL_NEIGHBORS[current]) { + GoPlayer color = scratch.cells[neighbor]; + if (color == GoPlayer.NONE) { + if (!scratch.regionSeen[neighbor]) { + scratch.regionSeen[neighbor] = true; + scratch.queue[tail++] = neighbor; + } + } else if (color == player) { + myBorder++; + } else if (color == opponent) { + oppBorder++; } } } - int myBorder = 0, oppBorder = 0; - for (int[] p : region) for (int[] d : DIRS) { - int nx = p[0] + d[0], ny = p[1] + d[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { - if (board[nx][ny] == player) myBorder++; - else if (board[nx][ny] == opponent) oppBorder++; - } - } - double tv = region.size(); + double tv = tail; if (myBorder > oppBorder) result.myTerritory += tv; else if (oppBorder > myBorder) result.oppTerritory += tv; else { result.myTerritory += tv * 0.5; result.oppTerritory += tv * 0.5; } @@ -1235,8 +1645,7 @@ private double heuristicEvaluation(GoPlayer[][] board, GoPlayer player) { for (int dx = -2; dx <= 2; dx++) for (int dy = -2; dy <= 2; dy++) { int nx = x + dx, ny = y + dy; if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { - double w = Math.sqrt(dx * dx + dy * dy); - w = w > 0 ? 1.0 / w : 1.0; + double w = INFLUENCE_WEIGHT[dx + 3][dy + 3]; if (board[nx][ny] == player) myInf += w; else if (board[nx][ny] == opponent) oppInf += w; } @@ -1418,6 +1827,7 @@ public void apply(ModelWeights m) { // 恢复持久化版本号(避免加载 checkpoint 后版本被重置为 1, // 导致模型版本与缓存键不一致) modelVersion = m.version; + gpuWeightRevision++; synchronized (evaluationCache) { evaluationCache.clear(); } // 加载新权重后重置动量缓冲,避免旧动量污染新权重 resetVelocities(); @@ -1438,38 +1848,50 @@ private static void copyInto(double[] src, double[] dst) { public void save(Path path) throws IOException { Path absolute = path.toAbsolutePath(); - Path parent = absolute.getParent(); - if (parent != null) Files.createDirectories(parent); - Path temp = Files.createTempFile(parent == null ? Path.of(".") : parent, - absolute.getFileName().toString() + ".", ".tmp"); - try { - ModelWeights m = snapshot(); - // 缓冲流:模型 ~37 万 float 逐值写出,无缓冲时每次 writeFloat 都是一次系统调用 - try (DataOutputStream out = new DataOutputStream(new java.io.BufferedOutputStream( - Files.newOutputStream(temp, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), - 1 << 16))) { - out.writeInt(MODEL_MAGIC); - out.writeInt(MODEL_FORMAT); - out.writeLong(m.version); - // 9 套子块权重 - for (int b = 0; b < NUM_BLOCKS; b++) { - writeMatrix(out, m.subW1[b]); writeVector(out, m.subB1[b]); - } - // 9 套字块权重 - for (int b = 0; b < NUM_BLOCKS; b++) { - writeMatrix(out, m.blockW1[b]); writeVector(out, m.blockB1[b]); + synchronized (modelIoLock(absolute)) { + Path parent = absolute.getParent(); + if (parent != null) Files.createDirectories(parent); + Path temp = Files.createTempFile(parent == null ? Path.of(".") : parent, + absolute.getFileName().toString() + ".", ".tmp"); + try { + ModelWeights m = snapshot(); + // 缓冲流:模型 ~37 万 float 逐值写出,无缓冲时每次 writeFloat 都是一次系统调用 + try (DataOutputStream out = new DataOutputStream(new java.io.BufferedOutputStream( + Files.newOutputStream(temp, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), + 1 << 16))) { + out.writeInt(MODEL_MAGIC); + out.writeInt(MODEL_FORMAT); + out.writeLong(m.version); + for (int b = 0; b < NUM_BLOCKS; b++) { + writeMatrix(out, m.subW1[b]); writeVector(out, m.subB1[b]); + } + for (int b = 0; b < NUM_BLOCKS; b++) { + writeMatrix(out, m.blockW1[b]); writeVector(out, m.blockB1[b]); + } + writeMatrix(out, m.topW1); writeVector(out, m.topB1); + writeMatrix(out, m.policyW); writeVector(out, m.policyB); + writeMatrix(out, m.valueW1); writeVector(out, m.valueB1); + writeVector(out, m.valueW2); out.writeFloat((float)m.valueB2); } - writeMatrix(out, m.topW1); writeVector(out, m.topB1); - writeMatrix(out, m.policyW); writeVector(out, m.policyB); - writeMatrix(out, m.valueW1); writeVector(out, m.valueB1); - writeVector(out, m.valueW2); out.writeFloat((float)m.valueB2); + replaceFile(temp, absolute); + } finally { + Files.deleteIfExists(temp); } - replaceFile(temp, absolute); - } finally { - Files.deleteIfExists(temp); } } + /** Builds one position's features into a thread-local buffer and evaluates it. */ + public ForwardResult forwardPosition(GoPlayer[][] board, GoPlayer player, int[] lastMove) { + InputScratch scratch = INPUT_SCRATCH.get(); + fillInputPlanes(scratch.planes, board, player, lastMove); + fillAuxFeatures(scratch.aux, board, player); + return forward(scratch.planes, scratch.aux); + } + + private static Object modelIoLock(Path absolute) { + return MODEL_IO_LOCKS.computeIfAbsent(absolute.normalize(), ignored -> new Object()); + } + /** * 原子替换目标文件;Windows 上若另一线程/进程正持有目标文件的读句柄 * (JVM 流不申请 FILE_SHARE_DELETE),replace 会抛 AccessDeniedException。 @@ -1499,9 +1921,11 @@ private static void replaceFile(Path temp, Path absolute) throws IOException { } public void load(Path path) throws IOException { - // 缓冲流:与 save 对称,避免 ~37 万次逐 float 读取的系统调用开销 - try (DataInputStream in = new DataInputStream(new java.io.BufferedInputStream( - Files.newInputStream(path), 1 << 16))) { + Path absolute = path.toAbsolutePath(); + synchronized (modelIoLock(absolute)) { + // 缓冲流:与 save 对称,避免 ~37 万次逐 float 读取的系统调用开销 + try (DataInputStream in = new DataInputStream(new java.io.BufferedInputStream( + Files.newInputStream(absolute), 1 << 16))) { int magic = in.readInt(); int fmt = in.readInt(); boolean currentFormat = magic == MODEL_MAGIC && fmt == MODEL_FORMAT; @@ -1535,7 +1959,8 @@ public void load(Path path) throws IOException { ModelWeights m = new ModelWeights(lSubW1, lSubB1, lBlockW1, lBlockB1, lTopW1, lTopB1, lPolicyW, lPolicyB, lValueW1, lValueB1, lValueW2, lValueB2, ver); - apply(m); + apply(m); + } } } @@ -1566,4 +1991,4 @@ private static double readFinite(DataInputStream in, boolean isDouble) throws IO if (!Double.isFinite(value)) throw new IOException("Non-finite model weight"); return value; } -} \ No newline at end of file +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/OpenCLBackend.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/OpenCLBackend.java index 7a9b801..93025da 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/OpenCLBackend.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/OpenCLBackend.java @@ -6,31 +6,104 @@ import com.sun.jna.Pointer; import com.sun.jna.ptr.IntByReference; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; /** * OpenCL GPU 加速后端。CPU 负责构建输入,GPU 负责矩阵运算。 + *

+ * 进程内共享一个上下文:训练时 30 局自对弈各自 new NeuralEvaluator 再各自 + * new OpenCLBackend 会把 RTX 级卡打成 30 份互斥队列。共享后 MCTS 前向可以 + * 在同一设备上排队,再由 {@link #inferForward} 一次提交整批。 */ public class OpenCLBackend implements AutoCloseable { private static final int CL_MEM_READ_WRITE = 1; private static final int CL_DEVICE_EXTENSIONS = 0x1030; private static final int CL_DEVICE_DOUBLE_FP_CONFIG = 0x1032; + private static final Object SHARED_LOCK = new Object(); + private static OpenCLBackend shared; + private static String sharedFailure; + static final long RETRY_DELAY_NANOS = 30_000_000_000L; + private static long sharedFailureAt; + private volatile boolean available = false; private volatile boolean closed; /** Serializes native use with close; close waits until the active batch has left. */ private final ReentrantLock lifecycleLock = new ReentrantLock(); + private final AtomicInteger retainCount = new AtomicInteger(1); private String deviceName = "CPU"; private NativeLibrary cl; private Pointer context, queue, device, program; private final java.util.Map kernels = new java.util.concurrent.ConcurrentHashMap<>(); + private long uploadedVersion = Long.MIN_VALUE; + private int uploadedOwnerId = 0; + private Pointer dSubW, dSubB, dBlkW, dBlkB, dTopW, dTopB, dPolW, dPolB, dValW1, dValB1, dValW2; + private int actCapacity; + private Pointer aSubIn, aSubOut, aBlkIn, aBlkOut, aTopIn, aShared, aAux, aPol, aVal; public OpenCLBackend() { + this(true); + } + + // Lifecycle tests can exercise sharing without loading a native driver. + OpenCLBackend(boolean initialize) { + if (!initialize) return; // ★ catch Throwable:JNA 缺失/UnsatisfiedLinkError 是 Error 不是 Exception, // 原版 catch (Exception) 拦不住,GPU 探测失败会直接炸掉调用方 try { init(); available = true; } - catch (Throwable t) { System.err.println("[OpenCL] 初始化失败: " + t); close(); } + catch (Throwable t) { + System.err.println("[OpenCL] 初始化失败: " + t); + destroyNative(); + } + } + + /** + * 进程级共享后端。失败后冷却 30 秒再探测,避免每次推理重新加载 OpenCL。 + * 调用方必须 {@link #close()}(内部按引用计数释放)。 + */ + public static OpenCLBackend acquireShared() { + return acquireShared(OpenCLBackend::new, System.nanoTime()); + } + + static OpenCLBackend acquireShared(Supplier factory, long now) { + synchronized (SHARED_LOCK) { + if (shared != null) { + if (shared.isAvailable()) { + shared.retainCount.incrementAndGet(); + return shared; + } + shared = null; + } + if (sharedFailure != null && now - sharedFailureAt < RETRY_DELAY_NANOS) return null; + OpenCLBackend created = factory.get(); + if (!created.isAvailable()) { + sharedFailure = "unavailable"; + sharedFailureAt = now; + created.destroyNative(); + return null; + } + sharedFailure = null; + created.retainCount.set(1); + shared = created; + return created; + } + } + + public static String lastSharedFailure() { + synchronized (SHARED_LOCK) { + return sharedFailure; + } + } + + /** 只读探测:不增加引用计数,供训练入口/探针打印设备名。 */ + public static OpenCLBackend peekShared() { + synchronized (SHARED_LOCK) { + return (shared != null && shared.isAvailable()) ? shared : null; + } } - public boolean isAvailable() { return available; } + + public boolean isAvailable() { return available && !closed; } public String getDeviceName() { return deviceName; } private void init() throws Exception { @@ -64,13 +137,13 @@ private void init() throws Exception { int be = calli("clBuildProgram", program, 0, null, null, null, null); if (be != 0) { String log = getBuildLog(program); throw new Exception("编译失败: " + log); } - for (String name : "sub_fwd,block_fwd,top_fwd,policy_fwd,value_fwd".split(",")) { + for (String name : "sub_fwd,block_fwd,top_fwd,policy_fwd,value_fwd,pack_blk_in,pack_top_in".split(",")) { Pointer k = create("clCreateKernel(" + name + ")", "clCreateKernel", ec, program, name, ec); kernels.put(name, k); } // 关键内核缺失(如老驱动编译失败被吞)时必须回退 CPU, // 否则 batchPass0Forward 会拿到 null 结果被当作"成功" - if (kernels.size() < 5) throw new Exception("计算内核不足: 仅加载 " + kernels.keySet()); + if (kernels.size() < 7) throw new Exception("计算内核不足: 仅加载 " + kernels.keySet()); System.out.println("[OpenCL] " + deviceName + " 内核: " + kernels.size()); } @@ -88,7 +161,7 @@ private void init() throws Exception { * @param bShared [B][256] 顶级 ReLU 输出 * @param bShZ [B][256] 顶级 ReLU 输出(bShared 的副本,供反向掩码) */ - public boolean batchPass0Forward(double[][][][] planes, double[][] aux, int B, + public boolean batchPass0Forward(double[][][][] planes, double[][] aux, int B, int ownerId, long modelVersion, double[][][] subW, double[][] subB, double[][][] blkW, double[][] blkB, double[][] topW, double[] topB, @@ -102,41 +175,11 @@ public boolean batchPass0Forward(double[][][][] planes, double[][] aux, int B, boolean failed = false; try { if (closed || !available || Thread.currentThread().isInterrupted()) return false; - // ── 关键:子块权重需从 [9][36][16] 复制为 GPU 内核期望的 [81][36][16] - // (每大块内 9 个子块共享该块的权重,内核按子块全局索引 s=0..80 取权) - double[][][] subWGpu = new double[81][36][16]; - double[][] subBGpu = new double[81][16]; - for (int si = 0; si < 81; si++) { - int b = si / 9; - subWGpu[si] = subW[b]; - subBGpu[si] = subB[b]; - } - // ── 子块前向 ── - double[][][] subIn = extractSubInputs(planes, B); - double[][][] subOut = gpuSubFwd(subIn, subWGpu, subBGpu, B); - double[][][] blkIn = buildBlkIn(subOut, B); - double[][][] blkOut = gpuBlockFwd(blkIn, blkW, blkB, B); - double[][] topIn = buildTopIn(blkOut, aux, B); - double[][] sharedOut = gpuTopFwd(topIn, topW, topB, B); - double[][] policyOut = gpuPolicyFwd(sharedOut, polW, polB, B); - double[] valueOut = gpuValueFwd(sharedOut, valW1, valB1, valW2, valB2, B); - if (policyOut == null || valueOut == null) throw new Exception("GPU 内核未返回完整结果"); - - for (int n = 0; n < B; n++) { - for (int b = 0; b < 9; b++) { - for (int s = 0; s < 9; s++) { - System.arraycopy(subIn[b * 9 + s][n], 0, bSubIn[n][b][s], 0, 36); - System.arraycopy(subOut[b * 9 + s][n], 0, bSubZ[n][b][s], 0, 16); - } - System.arraycopy(blkIn[b][n], 0, bBlkIn[n][b], 0, 144); - System.arraycopy(blkOut[b][n], 0, bBlkZ[n][b], 0, 64); - } - System.arraycopy(topIn[n], 0, bTopIn[n], 0, 600); - System.arraycopy(sharedOut[n], 0, bShared[n], 0, 256); - System.arraycopy(sharedOut[n], 0, bShZ[n], 0, 256); - System.arraycopy(policyOut[n], 0, bPolicyOut[n], 0, 362); - } - System.arraycopy(valueOut, 0, bValueOut, 0, B); + runDeviceForward(planes, aux, B, ownerId, modelVersion, + subW, subB, blkW, blkB, topW, topB, + polW, polB, valW1, valB1, valW2, valB2, + bSubIn, bSubZ, bBlkIn, bBlkZ, bTopIn, bShared, bShZ, + bPolicyOut, bValueOut, true); return true; } catch (Throwable t) { failed = true; @@ -145,63 +188,217 @@ public boolean batchPass0Forward(double[][][][] planes, double[][] aux, int B, return false; } finally { lifecycleLock.unlock(); - if (failed) close(); + if (failed) destroyNative(); } } - // ── GPU 前向方法 ── - private double[][][] gpuSubFwd(double[][][] in, double[][][] w, double[][] b, int B) throws Exception { - return run3D("sub_fwd", in, w, b, 81, 36, 16, B); - } - private double[][][] gpuBlockFwd(double[][][] in, double[][][] w, double[][] b, int B) throws Exception { - return run3D("block_fwd", in, w, b, 9, 144, 64, B); - } - private double[][] gpuTopFwd(double[][] in, double[][] w, double[] b, int B) throws Exception { - return run2D("top_fwd", in, w, b, 600, 256, B); - } - private double[][] gpuPolicyFwd(double[][] in, double[][] w, double[] b, int B) throws Exception { - // policy_fwd 内核只使用 get_global_id(0)(每行算全部 362 个输出), - // 必须 1D launch,避免 2D launch 的 y 维度产生 368 个冗余重复计算 - Pointer k = kernels.get("policy_fwd"); if (k == null) return null; - Pointer dInG = null, dWG = null, dBG = null, dOut = null; - Memory dIn = null, dW = null, dB = null; + /** + * MCTS / 对局推理:只回填策略和价值,不拷贝中间激活。 + * 失败返回 false,调用方回退 CPU;不因单次失败关掉设备。 + */ + public boolean inferForward(double[][][][] planes, double[][] aux, int B, int ownerId, long modelVersion, + double[][][] subW, double[][] subB, + double[][][] blkW, double[][] blkB, + double[][] topW, double[] topB, + double[][] polW, double[] polB, + double[][] valW1, double[] valB1, double[] valW2, double valB2, + double[][] policyOut, double[] valueOut) { + lifecycleLock.lock(); try { - dIn = flatten2D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); - dW = flatten2D(w); dWG = alloc(dW.size()); writeG(dWG, dW); - dB = flatten1D(b); dBG = alloc(dB.size()); writeG(dBG, dB); - dOut = alloc((long)B * 362 * 8); - setPtr(k, 0, dInG); setPtr(k, 1, dWG); setPtr(k, 2, dBG); setPtr(k, 3, dOut); setInt(k, 4, B); - launch1D(k, B); - double[][] out = new double[B][362]; readBack2D(dOut, out, B, 362); - return out; + if (closed || !available || Thread.currentThread().isInterrupted()) return false; + runDeviceForward(planes, aux, B, ownerId, modelVersion, + subW, subB, blkW, blkB, topW, topB, + polW, polB, valW1, valB1, valW2, valB2, + null, null, null, null, null, null, null, + policyOut, valueOut, false); + return true; + } catch (Throwable t) { + System.err.println("[OpenCL] inferForward 失败,本批回退 CPU: " + t); + return false; } finally { - free(dInG, dWG, dBG, dOut); - if (dIn != null) dIn.close(); if (dW != null) dW.close(); if (dB != null) dB.close(); + lifecycleLock.unlock(); } } - private double[] gpuValueFwd(double[][] in, double[][] w1, double[] b1, double[] w2, double b2, int B) throws Exception { - Pointer k = kernels.get("value_fwd"); if (k == null) return null; - Pointer dInG = null, dW1G = null, dB1G = null, dW2G = null, dOut = null; - Memory dIn = null, dW1 = null, dB1 = null, dW2 = null; + + private void runDeviceForward(double[][][][] planes, double[][] aux, int B, int ownerId, long modelVersion, + double[][][] subW, double[][] subB, + double[][][] blkW, double[][] blkB, + double[][] topW, double[] topB, + double[][] polW, double[] polB, + double[][] valW1, double[] valB1, double[] valW2, double valB2, + double[][][][] bSubIn, double[][][][] bSubZ, + double[][][] bBlkIn, double[][][] bBlkZ, + double[][] bTopIn, double[][] bShared, double[][] bShZ, + double[][] policyOut, double[] valueOut, + boolean copyIntermediates) throws Exception { + ensureWeights(ownerId, modelVersion, subW, subB, blkW, blkB, topW, topB, polW, polB, valW1, valB1, valW2); + ensureActs(B); + double[][][] subIn = extractSubInputs(planes, B); + Pointer subK = kernels.get("sub_fwd"); + Pointer packBlk = kernels.get("pack_blk_in"); + Pointer blkK = kernels.get("block_fwd"); + Pointer packTop = kernels.get("pack_top_in"); + Pointer topK = kernels.get("top_fwd"); + Pointer polK = kernels.get("policy_fwd"); + Pointer valK = kernels.get("value_fwd"); + if (subK == null || packBlk == null || blkK == null || packTop == null + || topK == null || polK == null || valK == null) { + throw new Exception("GPU 内核未返回完整结果"); + } + Memory subMem = flatten3D(subIn); + Memory auxMem = flatten2D(aux); try { - dIn = flatten2D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); - dW1 = flatten2D(w1); dW1G = alloc(dW1.size()); writeG(dW1G, dW1); - dB1 = flatten1D(b1); dB1G = alloc(dB1.size()); writeG(dB1G, dB1); - dW2 = flatten1D(w2); dW2G = alloc(dW2.size()); writeG(dW2G, dW2); - dOut = alloc((long)B * 8); - setPtr(k, 0, dInG); setPtr(k, 1, dW1G); setPtr(k, 2, dB1G); - setPtr(k, 3, dW2G); setPtr(k, 4, dOut); setInt(k, 5, B); setF64(k, 6, b2); - // 内核只用 get_global_id(0),1D launch 避免 y 维度 16 倍冗余 - launch1D(k, B); - double[] out = new double[B]; readBack(dOut, out); - return out; + writeGAsync(aSubIn, subMem); + writeGAsync(aAux, auxMem); + setPtr(subK, 0, aSubIn); setPtr(subK, 1, dSubW); setPtr(subK, 2, dSubB); + setPtr(subK, 3, aSubOut); setInt(subK, 4, B); + enqueue3D(subK, 81, B, 16); + + setPtr(packBlk, 0, aSubOut); setPtr(packBlk, 1, aBlkIn); setInt(packBlk, 2, B); + enqueue3D(packBlk, 9, B, 144); + + setPtr(blkK, 0, aBlkIn); setPtr(blkK, 1, dBlkW); setPtr(blkK, 2, dBlkB); + setPtr(blkK, 3, aBlkOut); setInt(blkK, 4, B); + enqueue3D(blkK, 9, B, 64); + + setPtr(packTop, 0, aBlkOut); setPtr(packTop, 1, aAux); setPtr(packTop, 2, aTopIn); + setInt(packTop, 3, B); + enqueue2D(packTop, B, 600); + + setPtr(topK, 0, aTopIn); setPtr(topK, 1, dTopW); setPtr(topK, 2, dTopB); + setPtr(topK, 3, aShared); setInt(topK, 4, B); + enqueue2D(topK, B, 256); + + setPtr(polK, 0, aShared); setPtr(polK, 1, dPolW); setPtr(polK, 2, dPolB); + setPtr(polK, 3, aPol); setInt(polK, 4, B); + enqueue1D(polK, B); + + setPtr(valK, 0, aShared); setPtr(valK, 1, dValW1); setPtr(valK, 2, dValB1); + setPtr(valK, 3, dValW2); setPtr(valK, 4, aVal); setInt(valK, 5, B); setF64(valK, 6, valB2); + enqueue1D(valK, B); + + checkCl("clFinish(forward)", calli("clFinish", queue)); + } catch (Exception e) { + try { + if (queue != null && cl != null) calli("clFinish", queue); + } catch (Throwable ignored) { + // 失败路径仍要排空队列,才能安全释放主机缓冲。 + } + throw e; } finally { - free(dInG, dW1G, dB1G, dW2G, dOut); - if (dIn != null) dIn.close(); if (dW1 != null) dW1.close(); - if (dB1 != null) dB1.close(); if (dW2 != null) dW2.close(); + subMem.close(); + auxMem.close(); + } + readBack2D(aPol, policyOut, B, 362); + readBack(aVal, valueOut); + if (!copyIntermediates) return; + + double[][][] subOut = new double[81][B][16]; + double[][][] blkIn = new double[9][B][144]; + double[][][] blkOut = new double[9][B][64]; + double[][] topIn = new double[B][600]; + double[][] sharedOut = new double[B][256]; + readBack3D(aSubOut, subOut, 81, B, 16); + readBack3D(aBlkIn, blkIn, 9, B, 144); + readBack3D(aBlkOut, blkOut, 9, B, 64); + readBack2D(aTopIn, topIn, B, 600); + readBack2D(aShared, sharedOut, B, 256); + for (int n = 0; n < B; n++) { + for (int b = 0; b < 9; b++) { + for (int s = 0; s < 9; s++) { + System.arraycopy(subIn[b * 9 + s][n], 0, bSubIn[n][b][s], 0, 36); + System.arraycopy(subOut[b * 9 + s][n], 0, bSubZ[n][b][s], 0, 16); + } + System.arraycopy(blkIn[b][n], 0, bBlkIn[n][b], 0, 144); + System.arraycopy(blkOut[b][n], 0, bBlkZ[n][b], 0, 64); + } + System.arraycopy(topIn[n], 0, bTopIn[n], 0, 600); + System.arraycopy(sharedOut[n], 0, bShared[n], 0, 256); + System.arraycopy(sharedOut[n], 0, bShZ[n], 0, 256); + } + } + + private void ensureWeights(int ownerId, long version, double[][][] subW, double[][] subB, + double[][][] blkW, double[][] blkB, + double[][] topW, double[] topB, + double[][] polW, double[] polB, + double[][] valW1, double[] valB1, double[] valW2) throws Exception { + if (dSubW == null) { + try { + dSubW = alloc(9L * 36 * 16 * 8); + dSubB = alloc(9L * 16 * 8); + dBlkW = alloc(9L * 144 * 64 * 8); + dBlkB = alloc(9L * 64 * 8); + dTopW = alloc(600L * 256 * 8); + dTopB = alloc(256L * 8); + dPolW = alloc(256L * 362 * 8); + dPolB = alloc(362L * 8); + dValW1 = alloc(256L * 128 * 8); + dValB1 = alloc(128L * 8); + dValW2 = alloc(128L * 8); + } catch (Exception e) { + freeWeights(); + throw e; + } + uploadedVersion = Long.MIN_VALUE; + uploadedOwnerId = 0; + } + if (version == uploadedVersion && ownerId == uploadedOwnerId) return; + // Uploads overwrite buffers in place. A failed upload must invalidate the + // previous model too, even if the next request switches back to its key. + uploadedVersion = Long.MIN_VALUE; + uploadedOwnerId = 0; + // Each block's nine subregions share one weight/bias set on the device. + try (Memory m = flatten3D(subW)) { writeG(dSubW, m); } + try (Memory m = flatten2D(subB)) { writeG(dSubB, m); } + try (Memory m = flatten3D(blkW)) { writeG(dBlkW, m); } + try (Memory m = flatten2D(blkB)) { writeG(dBlkB, m); } + try (Memory m = flatten2D(topW)) { writeG(dTopW, m); } + try (Memory m = flatten1D(topB)) { writeG(dTopB, m); } + try (Memory m = flatten2D(polW)) { writeG(dPolW, m); } + try (Memory m = flatten1D(polB)) { writeG(dPolB, m); } + try (Memory m = flatten2D(valW1)) { writeG(dValW1, m); } + try (Memory m = flatten1D(valB1)) { writeG(dValB1, m); } + try (Memory m = flatten1D(valW2)) { writeG(dValW2, m); } + checkCl("clFinish(weights)", calli("clFinish", queue)); + uploadedVersion = version; + uploadedOwnerId = ownerId; + } + + private void ensureActs(int B) throws Exception { + if (aSubIn != null && B <= actCapacity) return; + freeActs(); + int cap = Math.max(B, 64); + try { + aSubIn = alloc(81L * cap * 36 * 8); + aSubOut = alloc(81L * cap * 16 * 8); + aBlkIn = alloc(9L * cap * 144 * 8); + aBlkOut = alloc(9L * cap * 64 * 8); + aTopIn = alloc((long) cap * 600 * 8); + aShared = alloc((long) cap * 256 * 8); + aAux = alloc((long) cap * 24 * 8); + aPol = alloc((long) cap * 362 * 8); + aVal = alloc((long) cap * 8); + actCapacity = cap; + } catch (Exception e) { + freeActs(); + throw e; } } + private void freeActs() { + free(aSubIn, aSubOut, aBlkIn, aBlkOut, aTopIn, aShared, aAux, aPol, aVal); + aSubIn = aSubOut = aBlkIn = aBlkOut = aTopIn = aShared = aAux = aPol = aVal = null; + actCapacity = 0; + } + + private void freeWeights() { + free(dSubW, dSubB, dBlkW, dBlkB, dTopW, dTopB, dPolW, dPolB, dValW1, dValB1, dValW2); + dSubW = dSubB = dBlkW = dBlkB = dTopW = dTopB = dPolW = dPolB = dValW1 = dValB1 = dValW2 = null; + uploadedVersion = Long.MIN_VALUE; + uploadedOwnerId = 0; + } + // ── 数据提取 ── private double[][][] extractSubInputs(double[][][][] planes, int B) { double[][][] out = new double[81][B][36]; @@ -222,61 +419,6 @@ private double[][][] extractSubInputs(double[][][][] planes, int B) { } return out; } - private double[][][] buildBlkIn(double[][][] subOut, int B) { - double[][][] out = new double[9][B][144]; - for (int b = 0; b < 9; b++) - for (int n = 0; n < B; n++) - for (int i = 0; i < 144; i++) - out[b][n][i] = subOut[b * 9 + i / 16][n][i % 16]; - return out; - } - private double[][] buildTopIn(double[][][] blkOut, double[][] aux, int B) { - double[][] out = new double[B][600]; - for (int n = 0; n < B; n++) { - int idx = 0; - for (int b = 0; b < 9; b++) { System.arraycopy(blkOut[b][n], 0, out[n], idx, 64); idx += 64; } - System.arraycopy(aux[n], 0, out[n], 576, 24); - } - return out; - } - - // ── GPU 前向辅助 ── - private double[][][] run3D(String kName, double[][][] in, double[][][] w, double[][] b, int S, int K, int N, int B) throws Exception { - Pointer k = kernels.get(kName); if (k == null) return null; - Pointer dInG = null, dWG = null, dBG = null, dOut = null; - Memory dIn = null, dW = null, dB = null; - try { - dIn = flatten3D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); - dW = flatten3D(w); dWG = alloc(dW.size()); writeG(dWG, dW); - dB = flatten2D(b); dBG = alloc(dB.size()); writeG(dBG, dB); - dOut = alloc((long)S * B * N * 8); - setPtr(k, 0, dInG); setPtr(k, 1, dWG); setPtr(k, 2, dBG); setPtr(k, 3, dOut); setInt(k, 4, B); - launch3D(k, S, B, N); - double[][][] out = new double[S][B][N]; readBack3D(dOut, out, S, B, N); - return out; - } finally { - free(dInG, dWG, dBG, dOut); - if (dIn != null) dIn.close(); if (dW != null) dW.close(); if (dB != null) dB.close(); - } - } - private double[][] run2D(String kName, double[][] in, double[][] w, double[] b, int K, int N, int B) throws Exception { - Pointer k = kernels.get(kName); if (k == null) return null; - Pointer dInG = null, dWG = null, dBG = null, dOut = null; - Memory dIn = null, dW = null, dB = null; - try { - dIn = flatten2D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); - dW = flatten2D(w); dWG = alloc(dW.size()); writeG(dWG, dW); - dB = flatten1D(b); dBG = alloc(dB.size()); writeG(dBG, dB); - dOut = alloc((long)B * N * 8); - setPtr(k, 0, dInG); setPtr(k, 1, dWG); setPtr(k, 2, dBG); setPtr(k, 3, dOut); setInt(k, 4, B); - launch(k, B, N); - double[][] out = new double[B][N]; readBack2D(dOut, out, B, N); - return out; - } finally { - free(dInG, dWG, dBG, dOut); - if (dIn != null) dIn.close(); if (dW != null) dW.close(); if (dB != null) dB.close(); - } - } // ── GPU 内存/执行 ── private Pointer alloc(long bytes) throws Exception { @@ -285,7 +427,13 @@ private Pointer alloc(long bytes) throws Exception { } private void free(Pointer... ps) { for (Pointer p : ps) if (p != null) safe("clReleaseMemObject", p); } private void writeG(Pointer dst, Memory src) throws Exception { - checkCl("clEnqueueWriteBuffer", calli("clEnqueueWriteBuffer", queue, dst, 1, 0L, src.size(), src, 0, null, null)); + writeBuffer(dst, src, 1); + } + private void writeGAsync(Pointer dst, Memory src) throws Exception { + writeBuffer(dst, src, 0); + } + private void writeBuffer(Pointer dst, Memory src, int blocking) throws Exception { + checkCl("clEnqueueWriteBuffer", calli("clEnqueueWriteBuffer", queue, dst, blocking, 0L, src.size(), src, 0, null, null)); } private void readBack(Pointer src, double[] out) throws Exception { try (Memory m = new Memory((long)out.length * 8)) { @@ -336,31 +484,25 @@ private void setF64(Pointer k, int idx, double v) throws Exception { checkCl("clSetKernelArg(double)", calli("clSetKernelArg", k, idx, 8L, m)); } } - private void launch(Pointer k, int x, int y) throws Exception { + private void enqueue1D(Pointer k, int n) throws Exception { + try (Memory g = new Memory(Native.SIZE_T_SIZE)) { + writeSizeT(g, 0, ceil(n, 256) * 256); + checkCl("clEnqueueNDRangeKernel(1D)", calli("clEnqueueNDRangeKernel", queue, k, 1, null, g, null, 0, null, null)); + } + } + private void enqueue2D(Pointer k, int x, int y) throws Exception { try (Memory g = new Memory((long) Native.SIZE_T_SIZE * 2)) { writeSizeT(g, 0, ceil(x, 16) * 16); writeSizeT(g, Native.SIZE_T_SIZE, ceil(y, 16) * 16); checkCl("clEnqueueNDRangeKernel(2D)", calli("clEnqueueNDRangeKernel", queue, k, 2, null, g, null, 0, null, null)); - checkCl("clFinish(kernel)", calli("clFinish", queue)); - } - } - private void launch1D(Pointer k, int n) throws Exception { - long gs = ceil(n, 256) * 256; - try (Memory g = new Memory(Native.SIZE_T_SIZE)) { - writeSizeT(g, 0, gs); - checkCl("clEnqueueNDRangeKernel(1D)", calli("clEnqueueNDRangeKernel", queue, k, 1, null, g, null, 0, null, null)); - checkCl("clFinish(kernel)", calli("clFinish", queue)); } } - private void launch3D(Pointer k, int x, int y, int z) throws Exception { - // 三个维度都向上取整到 16 的倍数(sub_fwd/block_fwd 内核已加 s>=S 边界检查, - // 驱动向上填充全局尺寸时多余工作项会被拦截,不会越界写)。 + private void enqueue3D(Pointer k, int x, int y, int z) throws Exception { try (Memory g = new Memory((long) Native.SIZE_T_SIZE * 3)) { writeSizeT(g, 0, ceil(x, 16) * 16); writeSizeT(g, Native.SIZE_T_SIZE, ceil(y, 16) * 16); writeSizeT(g, (long) Native.SIZE_T_SIZE * 2, ceil(z, 16) * 16); checkCl("clEnqueueNDRangeKernel(3D)", calli("clEnqueueNDRangeKernel", queue, k, 3, null, g, null, 0, null, null)); - checkCl("clFinish(kernel)", calli("clFinish", queue)); } } private static long ceil(long a, long b) { return (a + b - 1) / b; } @@ -468,6 +610,20 @@ private void safe(String fn, Pointer p) { @Override public void close() { + synchronized (SHARED_LOCK) { + if (this == shared) { + if (retainCount.decrementAndGet() > 0) return; + // The last evaluator releases device buffers and native handles. + // Finish teardown before another acquire creates a new context. + shared = null; + destroyNative(); + return; + } + } + destroyNative(); + } + + private void destroyNative() { // The same mutex guards every native batch. Do not release any handle // until the in-flight batch (including clFinish/readback) has returned. lifecycleLock.lock(); @@ -475,15 +631,17 @@ public void close() { if (closed) return; closed = true; available = false; - for (Pointer k : kernels.values()) safe("clReleaseKernel", k); - kernels.clear(); - safe("clReleaseProgram", program); - safe("clReleaseCommandQueue", queue); - safe("clReleaseContext", context); - program = null; - queue = null; - context = null; - device = null; + freeActs(); + freeWeights(); + for (Pointer k : kernels.values()) safe("clReleaseKernel", k); + kernels.clear(); + safe("clReleaseProgram", program); + safe("clReleaseCommandQueue", queue); + safe("clReleaseContext", context); + program = null; + queue = null; + context = null; + device = null; cl = null; } finally { lifecycleLock.unlock(); @@ -493,8 +651,8 @@ public void close() { private static final String KERNEL_SOURCE = "" + "__kernel void sub_fwd(__global double* in, __global double* w, __global double* b, __global double* out, int B) {\n" + " int s=get_global_id(0), r=get_global_id(1), c=get_global_id(2);\n" + - " if(s>=81||r>=B||c>=16)return; double sum=b[s*16+c];\n" + - " for(int k=0;k<36;k++) sum+=in[(s*B+r)*36+k]*w[s*36*16+k*16+c];\n" + + " if(s>=81||r>=B||c>=16)return; int block=s/9; double sum=b[block*16+c];\n" + + " for(int k=0;k<36;k++) sum+=in[(s*B+r)*36+k]*w[block*36*16+k*16+c];\n" + " out[(s*B+r)*16+c] = sum>0?sum:0;\n" + "}\n" + "__kernel void block_fwd(__global double* in, __global double* w, __global double* b, __global double* out, int B) {\n" + @@ -521,7 +679,15 @@ public void close() { " double h[128]; for(int j=0;j<128;j++){ double s=b1[j]; for(int k=0;k<256;k++) s+=in[r*256+k]*w1[k*128+j]; h[j]=s>0?s:0; }\n" + " double s=b2; for(int k=0;k<128;k++) s+=w2[k]*h[k]; out[r]=tanh(s);\n" + "}\n" + - "__kernel void sgd(__global double* w, __global double* g, double lr, double l2, int n) {\n" + - " int i=get_global_id(0); if(i>=n)return; w[i]-=lr*(g[i]+l2*w[i]);\n" + + "__kernel void pack_blk_in(__global double* subOut, __global double* blkIn, int B) {\n" + + " int b=get_global_id(0), n=get_global_id(1), i=get_global_id(2);\n" + + " if(b>=9||n>=B||i>=144)return;\n" + + " blkIn[(b*B+n)*144+i] = subOut[((b*9+i/16)*B+n)*16+(i%16)];\n" + + "}\n" + + "__kernel void pack_top_in(__global double* blkOut, __global double* aux, __global double* topIn, int B) {\n" + + " int n=get_global_id(0), i=get_global_id(1);\n" + + " if(n>=B||i>=600)return;\n" + + " if(i<576){ int b=i/64; topIn[n*600+i]=blkOut[(b*B+n)*64+(i%64)]; }\n" + + " else topIn[n*600+i]=aux[n*24+(i-576)];\n" + "}\n"; -} \ No newline at end of file +} diff --git a/src/main/java/com/wzz/game_console/util/ExternalFileManager.java b/src/main/java/com/wzz/game_console/util/ExternalFileManager.java index 1f8d13f..ea67ee8 100644 --- a/src/main/java/com/wzz/game_console/util/ExternalFileManager.java +++ b/src/main/java/com/wzz/game_console/util/ExternalFileManager.java @@ -45,6 +45,13 @@ public static void init() { if (initState != InitState.UNINITIALIZED) return; try { Path resolvedGameDir = FMLPaths.GAMEDIR.get(); + if (resolvedGameDir == null) { + gameDir = null; + rootDir = null; + initState = InitState.FAILED; + LOGGER.warn("Game directory is not ready; external file API disabled"); + return; + } Path resolvedRootDir = resolvedGameDir.resolve(ROOT_FOLDER); Files.createDirectories(resolvedRootDir); Files.createDirectories(resolvedRootDir.resolve(MUSIC_FOLDER)); diff --git a/src/main/java/com/wzz/game_console/util/GameRenderHelper.java b/src/main/java/com/wzz/game_console/util/GameRenderHelper.java index 2d6558d..2e86489 100644 --- a/src/main/java/com/wzz/game_console/util/GameRenderHelper.java +++ b/src/main/java/com/wzz/game_console/util/GameRenderHelper.java @@ -6,7 +6,8 @@ import net.neoforged.api.distmarker.OnlyIn; import java.util.List; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReferenceArray; /** * 游戏渲染工具类 - 提供所有游戏共用的美化渲染方法 @@ -30,6 +31,9 @@ public class GameRenderHelper { public static final int BTN_NORMAL = 0xFF2A3D14; public static final int BTN_HOVER = 0xFF446622; public static final int BTN_BORDER = 0xFF88CC44; + private static final int MAX_CACHED_CIRCLE_RADIUS = 128; + private static final AtomicReferenceArray CIRCLE_HALF_WIDTHS = + new AtomicReferenceArray<>(MAX_CACHED_CIRCLE_RADIUS + 1); // ═══════════════ 背景渲染 ═══════════════ @@ -212,26 +216,34 @@ public static int calcCellSize(int screenW, int screenH, int gridW, int gridH, i // ═══════════════ 绘制形状 ═══════════════ - /** 绘制填充圆 */ /** * 绘制实心圆 —— 扫描线算法,每行一次 fill,性能约为逐像素版的 1/r 倍。 * 修复:WesternChessScreen 国际象棋卡顿问题(原版每个棋子产生 ~600 次 fill 调用) */ public static void drawCircle(GuiGraphics g, int cx, int cy, int radius, int color) { + if (radius < 0) return; + int[] widths = radius <= MAX_CACHED_CIRCLE_RADIUS ? circleHalfWidths(radius) : null; int r2 = radius * radius; for (int dy = -radius; dy <= radius; dy++) { - int half = (int) Math.sqrt(r2 - dy * (long) dy); + int half = widths != null ? widths[dy + radius] : (int) Math.sqrt(r2 - dy * (long) dy); g.fill(cx - half, cy + dy, cx + half + 1, cy + dy + 1, color); } } /** 绘制圆形边框(扫描线算法) */ public static void drawCircleOutline(GuiGraphics g, int cx, int cy, int radius, int color) { + if (radius < 0) return; + int[] outerWidths = radius <= MAX_CACHED_CIRCLE_RADIUS ? circleHalfWidths(radius) : null; + int[] innerWidths = radius > 0 && radius <= MAX_CACHED_CIRCLE_RADIUS + ? circleHalfWidths(radius - 1) : null; int r2 = radius * radius; int ri2 = (radius - 1) * (radius - 1); for (int dy = -radius; dy <= radius; dy++) { - int half = (int) Math.sqrt(r2 - dy * (long) dy); - int halfI = (int) Math.sqrt(Math.max(0, ri2 - dy * (long) dy)); + int half = outerWidths != null ? outerWidths[dy + radius] + : (int) Math.sqrt(r2 - dy * (long) dy); + int halfI = innerWidths != null && Math.abs(dy) < radius + ? innerWidths[dy + radius - 1] + : (int) Math.sqrt(Math.max(0, ri2 - dy * (long) dy)); if (half > halfI) { g.fill(cx - half, cy + dy, cx - halfI, cy + dy + 1, color); g.fill(cx + halfI, cy + dy, cx + half + 1, cy + dy + 1, color); @@ -241,6 +253,18 @@ public static void drawCircleOutline(GuiGraphics g, int cx, int cy, int radius, } } + static int[] circleHalfWidths(int radius) { + int[] cached = CIRCLE_HALF_WIDTHS.get(radius); + if (cached != null) return cached; + int[] widths = new int[2 * radius + 1]; + int radiusSquared = radius * radius; + for (int dy = -radius; dy <= radius; dy++) { + widths[dy + radius] = (int) Math.sqrt(radiusSquared - dy * (long) dy); + } + return CIRCLE_HALF_WIDTHS.compareAndSet(radius, null, widths) + ? widths : CIRCLE_HALF_WIDTHS.get(radius); + } + /** 绘制分割线 */ public static void drawDivider(GuiGraphics g, int x, int y, int w, int color1, int color2) { g.fill(x, y, x + w, y + 1, color1); @@ -354,7 +378,7 @@ public void render(GuiGraphics g) { /** 在指定位置生成爆炸粒子 */ public static void spawnParticles(List particles, float x, float y, int count, int color) { - Random r = new Random(); + ThreadLocalRandom r = ThreadLocalRandom.current(); for (int i = 0; i < count; i++) { particles.add(new Particle(x, y, (r.nextFloat() - 0.5f) * 4, -r.nextFloat() * 3, @@ -364,15 +388,14 @@ public static void spawnParticles(List particles, float x, float y, in /** 仅推进粒子(应从 tick() 调用,固定 20次/秒;暂停时不调用即冻结,不再随渲染帧率变化) */ public static void tickParticles(List particles) { - particles.removeIf(p -> !p.alive); for (Particle p : particles) { p.update(); } + particles.removeIf(p -> !p.alive); } - /** 仅渲染粒子(应从 render() 调用,不再推进物理;顺带清理已死粒子) */ + /** 仅渲染粒子(应从 render() 调用,不再推进物理或修改列表) */ public static void renderParticles(GuiGraphics g, List particles) { - particles.removeIf(p -> !p.alive); for (Particle p : particles) { p.render(g); } diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/CpuInferenceRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/CpuInferenceRegressionTest.java new file mode 100644 index 0000000..b626dd2 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/CpuInferenceRegressionTest.java @@ -0,0 +1,187 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +@Timeout(30) +class CpuInferenceRegressionTest { + @Test + void unavailableGpuValuesMatchFullCpuAcrossInputsModelsThreadsAndWeightUpdates() throws Exception { + String previous = System.getProperty("go.gpu"); + NeuralEvaluator first = new NeuralEvaluator(); + NeuralEvaluator second = new NeuralEvaluator(); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + System.setProperty("go.gpu", "false"); + NeuralEvaluator.ModelWeights changed = second.snapshot(); + Arrays.fill(changed.valueB1, 0.5); + second.apply(changed); + NeuralEvaluator[] evaluators = {first, second}; + GoPlayer[][][] boards = new GoPlayer[3][19][19]; + for (GoPlayer[][] board : boards) for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.NONE); + boards[1][3][3] = GoPlayer.BLACK; + boards[1][15][15] = GoPlayer.WHITE; + for (int x = 0; x < 19; x++) for (int y = 0; y < 19; y++) { + boards[2][x][y] = (x + y) % 3 == 0 ? GoPlayer.NONE + : (x + y) % 3 == 1 ? GoPlayer.BLACK : GoPlayer.WHITE; + } + int[][] lastMoves = {null, {3, 3}, {18, 18}}; + double[][] expected = new double[2][6]; + for (int model = 0; model < 2; model++) for (int position = 0; position < 6; position++) { + GoPlayer player = position % 2 == 0 ? GoPlayer.BLACK : GoPlayer.WHITE; + expected[model][position] = evaluators[model].forward( + evaluators[model].buildInputPlanes(boards[position % 3], player, lastMoves[position % 3]), + evaluators[model].extractAuxFeatures(boards[position % 3], player)).value; + } + assertNotEquals(expected[0][1], expected[1][1]); + // Simulate a recorded initialization failure without relying on native drivers. + var disabled = NeuralEvaluator.class.getDeclaredField("openclDisabled"); + disabled.setAccessible(true); + disabled.setBoolean(first, true); + disabled.setBoolean(second, true); + var failureAt = NeuralEvaluator.class.getDeclaredField("openclFailureAt"); + failureAt.setAccessible(true); + failureAt.setLong(first, System.nanoTime()); + failureAt.setLong(second, System.nanoTime()); + System.setProperty("go.gpu", "true"); + assertFalse(first.isGpuActive()); + assertFalse(second.isGpuActive()); + List> futures = new ArrayList<>(); + for (int i = 0; i < 120; i++) { + int model = i % 2, position = (i / 2) % 6; + futures.add(pool.submit(() -> evaluators[model].forwardValue(boards[position % 3], + position % 2 == 0 ? GoPlayer.BLACK : GoPlayer.WHITE, lastMoves[position % 3]))); + } + for (int i = 0; i < futures.size(); i++) { + assertEquals(expected[i % 2][(i / 2) % 6], futures.get(i).get(10, TimeUnit.SECONDS), 1e-12); + } + first.apply(changed); + assertEquals(expected[1][1], first.forwardValue(boards[1], GoPlayer.WHITE, lastMoves[1]), 1e-12); + System.setProperty("go.gpu", "false"); + assertEquals(expected[1][1], first.forwardValue(boards[1], GoPlayer.WHITE, lastMoves[1]), 1e-12); + System.setProperty("go.gpu", "true"); + try { + Thread.currentThread().interrupt(); + assertEquals(0.0, first.forwardValue(boards[0], GoPlayer.BLACK, null)); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + first.release(); + assertEquals(0.0, first.forwardValue(boards[0], GoPlayer.BLACK, null)); + } finally { + pool.shutdownNow(); + pool.awaitTermination(10, TimeUnit.SECONDS); + first.release(); + second.release(); + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } + + @Test + void valueOnlyForwardMatchesFullForwardAndDoesNotExposeMutablePolicy() { + String previous = System.getProperty("go.gpu"); + System.setProperty("go.gpu", "false"); + NeuralEvaluator evaluator = new NeuralEvaluator(); + try (GoGame game = GoGame.rulesOnly()) { + int[][] moves = {{3, 3}, {15, 15}, {3, 15}, {10, 10}, {8, 8}}; + for (int[] move : moves) assertTrue(game.placeStone(move[0], move[1])); + GoPlayer[][] board = game.getBoardCopy(); + double[][][] planes = evaluator.buildInputPlanes(board, game.getCurrentPlayer(), game.getLastMove()); + double[] aux = evaluator.extractAuxFeatures(board, game.getCurrentPlayer()); + double expected = evaluator.forward(planes, aux).value; + for (int i = 0; i < 10; i++) { + assertEquals(expected, evaluator.forwardValue(board, game.getCurrentPlayer(), game.getLastMove()), 1e-12); + } + } finally { + evaluator.release(); + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } + + @Test + void reusedBuffersKeepResultsIndependentAcrossInputsModelsAndThreads() throws Exception { + String previous = System.getProperty("go.gpu"); + NeuralEvaluator first = new NeuralEvaluator(); + NeuralEvaluator second = new NeuralEvaluator(); + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + System.setProperty("go.gpu", "false"); + NeuralEvaluator.ModelWeights changed = second.snapshot(); + changed.policyB[0] = 2.0; + second.apply(changed); + NeuralEvaluator[] evaluators = {first, second}; + double[][][][] inputs = new double[3][4][19][19]; + double[][] aux = new double[3][24]; + Random random = new Random(853); + for (int n = 1; n < inputs.length; n++) { + for (double[][] plane : inputs[n]) { + for (double[] row : plane) { + for (int i = 0; i < row.length; i++) row[i] = random.nextDouble(); + } + } + for (int i = 0; i < aux[n].length; i++) aux[n][i] = random.nextDouble() - 0.5; + } + + NeuralEvaluator.ForwardResult[][] expected = new NeuralEvaluator.ForwardResult[2][3]; + double[][][] savedPolicies = new double[2][3][]; + for (int model = 0; model < 2; model++) { + for (int n = 0; n < 3; n++) { + expected[model][n] = evaluators[model].forward(inputs[n], aux[n]); + savedPolicies[model][n] = expected[model][n].policy.clone(); + assertEquals(1.0, Arrays.stream(savedPolicies[model][n]).sum(), 1e-12); + } + } + assertNotEquals(expected[0][0].policy[0], expected[1][0].policy[0]); + + List> futures = new ArrayList<>(); + for (int i = 0; i < 240; i++) { + int model = i % 2; + int input = i % 3; + futures.add(pool.submit(() -> evaluators[model].forward(inputs[input], aux[input]))); + } + // Wait for every call before inspecting outputs: later calls must not overwrite them. + List results = new ArrayList<>(); + for (Future future : futures) { + results.add(future.get(10, TimeUnit.SECONDS)); + } + for (int i = 0; i < results.size(); i++) { + NeuralEvaluator.ForwardResult result = results.get(i); + assertEquals(expected[i % 2][i % 3].value, result.value); + assertArrayEquals(savedPolicies[i % 2][i % 3], result.policy); + assertNotSame(expected[i % 2][i % 3].policy, result.policy); + } + for (int model = 0; model < 2; model++) { + for (int n = 0; n < 3; n++) { + assertArrayEquals(savedPolicies[model][n], expected[model][n].policy); + } + } + Arrays.fill(results.getFirst().policy, -1.0); + assertArrayEquals(savedPolicies[0][0], first.forward(inputs[0], aux[0]).policy); + + // Applying new weights must take effect even when the caller has warmed buffers. + first.apply(changed); + assertArrayEquals(savedPolicies[1][0], first.forward(inputs[0], aux[0]).policy); + } finally { + pool.shutdownNow(); + pool.awaitTermination(10, TimeUnit.SECONDS); + first.release(); + second.release(); + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoCandidateRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoCandidateRegressionTest.java new file mode 100644 index 0000000..c034c9e --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoCandidateRegressionTest.java @@ -0,0 +1,179 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +@Timeout(60) +class GoCandidateRegressionTest { + @Test + void analyzedCandidatesMatchSimulationIncludingScoresOrderAndKo() throws Exception { + MCTSGoAI ai = new MCTSGoAI(0, 0, 1); + try { + Random random = new Random(537); + List positions = new ArrayList<>(); + try (GoGame game = GoGame.rulesOnly()) { + positions.add(game.getBoardCopy()); + for (int step = 0; step < 350; step++) { + game.placeStone(random.nextInt(19), random.nextInt(19)); + if (step % 15 == 0) positions.add(game.getBoardCopy()); + } + } + for (int n = 0; n < 32; n++) { + GoPlayer[][] board = new GoPlayer[19][19]; + for (GoPlayer[] row : board) for (int y = 0; y < 19; y++) { + row[y] = random.nextDouble() < n / 31.0 + ? (random.nextBoolean() ? GoPlayer.BLACK : GoPlayer.WHITE) : GoPlayer.NONE; + } + positions.add(board); + } + // A large group touching the move on all four sides must be captured only once. + GoPlayer[][] surrounded = new GoPlayer[19][19]; + for (GoPlayer[] row : surrounded) Arrays.fill(row, GoPlayer.WHITE); + surrounded[9][9] = GoPlayer.NONE; + positions.add(surrounded); + + for (GoPlayer[][] board : positions) for (GoPlayer player : new GoPlayer[]{GoPlayer.BLACK, GoPlayer.WHITE}) { + Set banned = new HashSet<>(); + for (int x = 0; x < 19; x++) for (int y = 0; y < 19; y++) { + if (board[x][y] == GoPlayer.NONE && random.nextInt(12) == 0) { + GoPlayer[][] after = copy(board); + if ((boolean) method("simulatePlaceStone", GoPlayer[][].class, int.class, int.class, GoPlayer.class) + .invoke(ai, after, x, y, player)) banned.add(GoGame.boardHash(after)); + } + } + field("koHistory").set(ai, banned); + long hash = GoGame.boardHash(board); + GoPlayer[][] before = copy(board); + List expected = simulationCandidates(ai, board, player); + List actual = candidates(ai, board, player); + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < actual.size(); i++) { + assertArrayEquals(expected.get(i), actual.get(i), "candidate " + i); + GoPlayer[][] after = copy(board); + assertTrue((boolean) method("simulatePlaceStone", GoPlayer[][].class, int.class, int.class, GoPlayer.class) + .invoke(ai, after, actual.get(i)[0], actual.get(i)[1], player)); + assertFalse(banned.contains(GoGame.boardHash(after))); + } + for (int x = 0; x < 19; x++) assertArrayEquals(before[x], board[x]); + assertEquals(hash, GoGame.boardHash(board)); + } + } finally { + ai.shutdown(); + } + } + + @Test + void fallbackAndInvalidPlayerRemainSafe() throws Exception { + MCTSGoAI ai = new MCTSGoAI(0, 0, 1); + try { + GoPlayer[][] board = new GoPlayer[19][19]; + for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.WHITE); + board[0][0] = GoPlayer.NONE; + assertEquals(1, candidates(ai, board, GoPlayer.BLACK).size()); + assertTrue(candidates(ai, board, GoPlayer.NONE).isEmpty()); + field("koHistory").set(ai, Set.of(GoGame.xorStone(0L, 0, 0, GoPlayer.BLACK))); + assertTrue(candidates(ai, board, GoPlayer.BLACK).isEmpty()); + + // Ban every normally kept move; the isolated corner is legal but knowledge-pruned. + for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.NONE); + Set history = new HashSet<>(); + for (int x = 0; x < 19; x++) for (int y = 0; y < 19; y++) { + if (x != 0 || y != 0) history.add(GoGame.xorStone(0L, x, y, GoPlayer.BLACK)); + } + field("koHistory").set(ai, history); + List fallback = candidates(ai, board, GoPlayer.BLACK); + assertEquals(1, fallback.size()); + assertArrayEquals(new int[]{0, 0, 0}, fallback.getFirst()); + } finally { + ai.shutdown(); + } + } + + @Test + void candidateGroupScratchIsIsolatedAcrossConcurrentBoards() throws Exception { + MCTSGoAI ai = new MCTSGoAI(0, 0, 1); + ExecutorService pool = Executors.newFixedThreadPool(8); + try (GoGame game = GoGame.rulesOnly()) { + GoPlayer[][][] boards = new GoPlayer[4][][]; + Random random = new Random(758); + for (int n = 0; n < boards.length; n++) { + for (int step = 0; step < 60; step++) game.placeStone(random.nextInt(19), random.nextInt(19)); + boards[n] = game.getBoardCopy(); + } + List> expected = new ArrayList<>(); + for (GoPlayer[][] board : boards) expected.add(candidates(ai, board, GoPlayer.BLACK)); + List>> futures = new ArrayList<>(); + for (int i = 0; i < 160; i++) { + int position = i % boards.length; + futures.add(pool.submit(() -> candidates(ai, copy(boards[position]), GoPlayer.BLACK))); + } + for (int i = 0; i < futures.size(); i++) { + List result = futures.get(i).get(10, TimeUnit.SECONDS); + List reference = expected.get(i % boards.length); + assertEquals(reference.size(), result.size()); + for (int j = 0; j < result.size(); j++) assertArrayEquals(reference.get(j), result.get(j)); + } + } finally { + pool.shutdownNow(); + pool.awaitTermination(10, TimeUnit.SECONDS); + ai.shutdown(); + } + } + + @Test + void analyzedHashRejectsKoRecaptureAndAllowsItAfterThreats() throws Exception { + MCTSGoAI ai = new MCTSGoAI(0, 0, 1); + try (GoGame game = GoGame.rulesOnly()) { + int[][] moves = {{3, 5}, {3, 4}, {5, 5}, {5, 4}, {4, 6}, {4, 3}, {4, 4}, {4, 5}}; + for (int[] move : moves) assertTrue(game.placeStone(move[0], move[1])); + field("koHistory").set(ai, game.getPositionHistory()); + assertFalse(candidates(ai, game.getBoardCopy(), GoPlayer.BLACK).stream() + .anyMatch(move -> move[0] == 4 && move[1] == 4)); + assertTrue(game.placeStone(16, 16)); + assertTrue(game.placeStone(0, 0)); + field("koHistory").set(ai, game.getPositionHistory()); + assertTrue(candidates(ai, game.getBoardCopy(), GoPlayer.BLACK).stream() + .anyMatch(move -> move[0] == 4 && move[1] == 4)); + } finally { + ai.shutdown(); + } + } + + @SuppressWarnings("unchecked") + private static List candidates(MCTSGoAI ai, GoPlayer[][] board, GoPlayer player) throws Exception { + return (List) method("getAllValidMoves", long.class, GoPlayer[][].class, GoPlayer.class) + .invoke(ai, GoGame.boardHash(board), board, player); + } + + private static List simulationCandidates(MCTSGoAI ai, GoPlayer[][] board, GoPlayer player) throws Exception { + List moves = new ArrayList<>(); + Method collect = method("collectValidMoves", long.class, GoPlayer[][].class, GoPlayer.class, List.class, boolean.class); + collect.invoke(ai, GoGame.boardHash(board), board, player, moves, true); + if (moves.isEmpty()) collect.invoke(ai, GoGame.boardHash(board), board, player, moves, false); + moves.sort(Comparator.comparingInt(move -> move[2])); + return moves; + } + + private static Method method(String name, Class... types) throws Exception { + Method result = MCTSGoAI.class.getDeclaredMethod(name, types); + result.setAccessible(true); + return result; + } + + private static Field field(String name) throws Exception { + Field result = MCTSGoAI.class.getDeclaredField(name); + result.setAccessible(true); + return result; + } + + private static GoPlayer[][] copy(GoPlayer[][] board) { + return Arrays.stream(board).map(GoPlayer[]::clone).toArray(GoPlayer[][]::new); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoFeatureRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoFeatureRegressionTest.java new file mode 100644 index 0000000..02c93e2 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoFeatureRegressionTest.java @@ -0,0 +1,215 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.*; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +@Timeout(30) +class GoFeatureRegressionTest { + private static final int SIZE = 19; + private static final int[][] DIRECTIONS = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; + + @Test + void featuresMatchCollectionReferenceForDenseSparseAndLegalPositions() { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + Random random = new Random(486); + List boards = new ArrayList<>(); + boards.add(emptyBoard()); + GoPlayer[][] full = emptyBoard(); + for (GoPlayer[] row : full) Arrays.fill(row, GoPlayer.BLACK); + boards.add(full); + GoPlayer[][] oneLiberty = copy(full); + oneLiberty[9][9] = GoPlayer.NONE; + boards.add(oneLiberty); + for (int n = 0; n < 50; n++) { + GoPlayer[][] board = emptyBoard(); + for (GoPlayer[] row : board) for (int y = 0; y < SIZE; y++) { + if (random.nextDouble() < n / 49.0) { + row[y] = random.nextBoolean() ? GoPlayer.BLACK : GoPlayer.WHITE; + } + } + boards.add(board); + } + try (GoGame game = GoGame.rulesOnly()) { + for (int n = 0; n < 200; n++) { + game.placeStone(random.nextInt(SIZE), random.nextInt(SIZE)); + if (n % 20 == 0) boards.add(game.getBoardCopy()); + } + } + for (GoPlayer[][] board : boards) { + GoPlayer[][] before = copy(board); + for (GoPlayer player : new GoPlayer[]{GoPlayer.BLACK, GoPlayer.WHITE}) { + double[] expected = referenceFeatures(evaluator, board, player); + double[] actual = evaluator.extractAuxFeatures(board, player); + for (int feature : new int[]{0, 1, 2, 3, 4, 5, 6, 7, 18, 19, 21}) { + assertEquals(expected[feature], actual[feature], 1e-12, "feature " + feature); + } + } + for (int x = 0; x < SIZE; x++) assertArrayEquals(before[x], board[x]); + } + } finally { + evaluator.release(); + } + } + + @Test + void sameGroupTouchingMultipleSidesIsNotCountedAsSeparation() { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + GoPlayer[][] board = emptyBoard(); + for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.WHITE); + board[9][9] = GoPlayer.NONE; + double[] features = evaluator.extractAuxFeatures(board, GoPlayer.BLACK); + assertEquals(1.0, features[1]); // The single group has one unique liberty. + assertEquals(0.0, features[19]); + assertEquals(-1.0 / 50, features[21]); + assertEquals(1.0 / 50, evaluator.extractAuxFeatures(board, GoPlayer.WHITE)[21]); + assertArrayEquals(new double[8], Arrays.copyOf( + evaluator.extractAuxFeatures(emptyBoard(), GoPlayer.BLACK), 8)); + } finally { + evaluator.release(); + } + } + + @Test + void concurrentPositionForwardsKeepFeaturesAndPoliciesIsolated() throws Exception { + String previous = System.getProperty("go.gpu"); + NeuralEvaluator evaluator = new NeuralEvaluator(); + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + System.setProperty("go.gpu", "false"); + GoPlayer[][][] boards = {emptyBoard(), emptyBoard(), emptyBoard()}; + for (GoPlayer[] row : boards[1]) Arrays.fill(row, GoPlayer.BLACK); + boards[1][9][9] = GoPlayer.NONE; + for (int x = 0; x < SIZE; x++) for (int y = 0; y < SIZE; y++) { + boards[2][x][y] = (x + y) % 3 == 0 ? GoPlayer.NONE + : (x + y) % 3 == 1 ? GoPlayer.BLACK : GoPlayer.WHITE; + } + NeuralEvaluator.ForwardResult[] expected = new NeuralEvaluator.ForwardResult[6]; + for (int i = 0; i < expected.length; i++) { + GoPlayer player = i % 2 == 0 ? GoPlayer.BLACK : GoPlayer.WHITE; + // Allocate input features independently for the reference path. + expected[i] = evaluator.forward(evaluator.buildInputPlanes(boards[i % 3], player, null), + evaluator.extractAuxFeatures(boards[i % 3], player)); + } + List> futures = new ArrayList<>(); + for (int i = 0; i < 180; i++) { + int position = i % 6; + futures.add(pool.submit(() -> evaluator.forwardPosition(boards[position % 3], + position % 2 == 0 ? GoPlayer.BLACK : GoPlayer.WHITE, null))); + } + List outputs = new ArrayList<>(); + for (Future future : futures) { + outputs.add(future.get(10, TimeUnit.SECONDS)); + } + for (int i = 0; i < outputs.size(); i++) { + assertEquals(expected[i % 6].value, outputs.get(i).value); + assertArrayEquals(expected[i % 6].policy, outputs.get(i).policy); + } + } finally { + pool.shutdownNow(); + pool.awaitTermination(10, TimeUnit.SECONDS); + evaluator.release(); + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } + + /** Uses the original collection helpers as an independent oracle for the new analysis. */ + private static double[] referenceFeatures(NeuralEvaluator evaluator, GoPlayer[][] board, GoPlayer player) { + List> groups = new ArrayList<>(); + List liberties = new ArrayList<>(); + Set visited = new HashSet<>(); + int[] histogram = new int[8]; + double connections = 0; + for (int x = 0; x < SIZE; x++) for (int y = 0; y < SIZE; y++) { + if (board[x][y] == GoPlayer.NONE || visited.contains(x * SIZE + y)) continue; + Set original = evaluator.getGroup(board, x, y); + Set group = new HashSet<>(); + for (int[] point : original) group.add(point[0] * SIZE + point[1]); + groups.add(group); + int libs = evaluator.countGroupLiberties(board, original); + liberties.add(libs); + histogram[Math.min(libs, 7)]++; + visited.addAll(group); + if (board[x][y] == player) { + if (group.size() >= 5) connections += group.size() * 0.3; + for (int[] point : original) for (int[] d : DIRECTIONS) { + int nx = point[0] + d[0], ny = point[1] + d[1]; + if (inside(nx, ny) && board[nx][ny] == player) connections += 0.5; + } + if (libs >= 5) connections += 2; + } + } + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + double separation = 0; + for (int x = 0; x < SIZE; x++) for (int y = 0; y < SIZE; y++) { + if (board[x][y] != GoPlayer.NONE) continue; + Set adjacent = new HashSet<>(); + for (int[] d : DIRECTIONS) { + int nx = x + d[0], ny = y + d[1]; + if (inside(nx, ny) && board[nx][ny] == opponent) { + for (int g = 0; g < groups.size(); g++) { + if (groups.get(g).contains(nx * SIZE + ny)) adjacent.add(g); + } + } + } + if (adjacent.size() >= 2) { + int minLibs = adjacent.stream().mapToInt(liberties::get).min().orElseThrow(); + separation += minLibs <= 3 ? 3.0 : 1.0; + } + } + visited.clear(); + double territory = 0; + for (int x = 0; x < SIZE; x++) for (int y = 0; y < SIZE; y++) { + if (board[x][y] != GoPlayer.NONE || !visited.add(x * SIZE + y)) continue; + Set region = new HashSet<>(); + Deque pending = new ArrayDeque<>(); + pending.push(x * SIZE + y); + while (!pending.isEmpty()) { + int point = pending.pop(); + region.add(point); + for (int[] d : DIRECTIONS) { + int nx = point / SIZE + d[0], ny = point % SIZE + d[1]; + if (inside(nx, ny) && board[nx][ny] == GoPlayer.NONE && visited.add(nx * SIZE + ny)) { + pending.push(nx * SIZE + ny); + } + } + } + int border = 0; + for (int point : region) for (int[] d : DIRECTIONS) { + int nx = point / SIZE + d[0], ny = point % SIZE + d[1]; + if (inside(nx, ny)) { + if (board[nx][ny] == player) border++; + else if (board[nx][ny] == opponent) border--; + } + } + territory += Integer.signum(border) * region.size(); + } + double[] expected = new double[24]; + for (int i = 0; i < 8; i++) { + expected[i] = groups.isEmpty() ? 0.0 : histogram[i] * 2.0 / groups.size() - 1.0; + } + expected[18] = connections / 20; + expected[19] = separation / 20; + expected[21] = territory / 50; + return expected; + } + + private static boolean inside(int x, int y) { return x >= 0 && x < SIZE && y >= 0 && y < SIZE; } + + private static GoPlayer[][] emptyBoard() { + GoPlayer[][] board = new GoPlayer[SIZE][SIZE]; + for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.NONE); + return board; + } + + private static GoPlayer[][] copy(GoPlayer[][] board) { + return Arrays.stream(board).map(GoPlayer[]::clone).toArray(GoPlayer[][]::new); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoGpuStressTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoGpuStressTest.java new file mode 100644 index 0000000..58eb95b --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoGpuStressTest.java @@ -0,0 +1,241 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@Timeout(120) +class GoGpuStressTest { + private String previousGpu; + + @BeforeEach + void enableGpu() { + previousGpu = System.getProperty("go.gpu"); + System.setProperty("go.gpu", "true"); + } + + @AfterEach + void restoreGpu() { + if (previousGpu == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previousGpu); + } + + @Test + void replacingWeightsAtSameVersionRefreshesGpuCache() { + NeuralEvaluator gpu = new NeuralEvaluator(); + NeuralEvaluator cpu = new NeuralEvaluator(); + try { + assumeTrue(gpu.isGpuActive(), "OpenCL GPU unavailable"); + NeuralEvaluator.ModelWeights first = gpu.snapshot(); + NeuralEvaluator.ModelWeights second = gpu.snapshot(); + second.policyB[17] += 8.0; + second.valueB1[0] += 1.0; + assertEquals(first.version, second.version); + double[][][] planes = randomPlanes(new Random(7031)); + double[] aux = new double[24]; + for (int i = 0; i < 20; i++) { + NeuralEvaluator.ModelWeights weights = (i & 1) == 0 ? first : second; + System.setProperty("go.gpu", "false"); + cpu.apply(weights); + NeuralEvaluator.ForwardResult expected = cpu.forward(planes, aux); + System.setProperty("go.gpu", "true"); + gpu.apply(weights); + assertResult(expected, gpu.forward(planes, aux)); + assertTrue(gpu.isGpuActive()); + } + } finally { + gpu.release(); + cpu.release(); + } + } + + @Test + void failedWeightUploadCannotReusePartiallyOverwrittenCache() { + try (OpenCLBackend backend = OpenCLBackend.acquireShared()) { + assumeTrue(backend != null, "OpenCL GPU unavailable"); + NeuralEvaluator source = new NeuralEvaluator(); + try { + NeuralEvaluator.ModelWeights original = source.snapshot(); + NeuralEvaluator.ModelWeights replacement = source.snapshot(); + replacement.policyB[17] += 8.0; + double[][][][] planes = {randomPlanes(new Random(13823))}; + double[][] aux = new double[1][24]; + double[][] expectedPolicy = new double[1][362]; + double[] expectedValue = new double[1]; + assertTrue(infer(backend, original, original.valueW1, planes, aux, 1, expectedPolicy, expectedValue)); + // Fail after policy weights have reached the device, before value weights. + assertFalse(infer(backend, replacement, new double[0][], planes, aux, 2, + new double[1][362], new double[1])); + double[][] actualPolicy = new double[1][362]; + double[] actualValue = new double[1]; + assertTrue(infer(backend, original, original.valueW1, planes, aux, 1, actualPolicy, actualValue)); + assertArrayEquals(expectedPolicy[0], actualPolicy[0], 1e-10); + assertArrayEquals(expectedValue, actualValue, 1e-10); + } finally { + source.release(); + } + } + } + + @Test + void concurrentModelSwitchesKeepThreadLocalInputsAndResultsIsolated() throws Exception { + NeuralEvaluator[] evaluators = {new NeuralEvaluator(), new NeuralEvaluator()}; + ExecutorService pool = Executors.newFixedThreadPool(24); + try { + assumeTrue(evaluators[0].isGpuActive() && evaluators[1].isGpuActive(), "OpenCL GPU unavailable"); + NeuralEvaluator.ModelWeights changed = evaluators[1].snapshot(); + changed.policyB[31] += 6.0; + evaluators[1].apply(changed); + GoPlayer[][][] boards = new GoPlayer[8][19][19]; + for (int i = 0; i < boards.length; i++) { + Random random = new Random(1607 + i); + for (GoPlayer[] row : boards[i]) { + for (int y = 0; y < row.length; y++) row[y] = GoPlayer.values()[random.nextInt(3)]; + } + } + NeuralEvaluator.ForwardResult[][] expected = new NeuralEvaluator.ForwardResult[2][8]; + System.setProperty("go.gpu", "false"); + for (int model = 0; model < 2; model++) for (int board = 0; board < 8; board++) { + expected[model][board] = evaluators[model].forwardPosition(boards[board], GoPlayer.BLACK, null); + } + System.setProperty("go.gpu", "true"); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + for (int worker = 0; worker < 24; worker++) { + int index = worker; + futures.add(pool.submit(() -> { + assertTrue(start.await(5, TimeUnit.SECONDS)); + for (int iteration = 0; iteration < 128; iteration++) { + int model = (index + iteration) % 2; + int board = (index * 3 + iteration) % 8; + NeuralEvaluator.ForwardResult actual = evaluators[model].forwardPosition( + boards[board], GoPlayer.BLACK, null); + assertResult(expected[model][board], actual); + Arrays.fill(actual.policy, -1.0); // Returned policies must belong to each call. + } + return null; + })); + } + start.countDown(); + for (Future future : futures) future.get(90, TimeUnit.SECONDS); + assertTrue(evaluators[0].isGpuActive() && evaluators[1].isGpuActive()); + System.out.println("GPU stress: 24 workers, 3072 inferences, 2 models, 8 positions"); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + for (NeuralEvaluator evaluator : evaluators) evaluator.release(); + } + } + + @Test + void repeatedGpuTrainingMatchesCpuIncludingCheckpointReload() { + NeuralEvaluator gpu = new NeuralEvaluator(); + NeuralEvaluator cpu = new NeuralEvaluator(); + try { + assumeTrue(gpu.isGpuActive(), "OpenCL GPU unavailable"); + NeuralEvaluator.ModelWeights initial = gpu.snapshot(); + cpu.apply(initial); + Random random = new Random(7346); + double[][][][] planes = new double[3][][][]; + double[][] aux = new double[3][24]; + double[] targets = {1.0, -1.0, 0.0}; + double[][] policies = new double[3][362]; + for (int n = 0; n < 3; n++) { + planes[n] = randomPlanes(random); + for (int i = 0; i < 24; i++) aux[n][i] = random.nextDouble(); + policies[n][17 + n] = 1.0; + } + for (int step = 0; step < 12; step++) { + if (step == 6) { + gpu.apply(initial); + cpu.apply(initial); + } + double momentum = step < 6 ? 0.0 : 0.9; + System.setProperty("go.gpu", "false"); + double cpuLoss = cpu.trainMiniBatch(planes, aux, targets, policies, 0.01, 1e-5, 5.0, momentum); + NeuralEvaluator.ForwardResult expected = cpu.forward(planes[step % 3], aux[step % 3]); + System.setProperty("go.gpu", "true"); + double gpuLoss = gpu.trainMiniBatch(planes, aux, targets, policies, 0.01, 1e-5, 5.0, momentum); + assertEquals(cpuLoss, gpuLoss, 1e-5); + assertResult(expected, gpu.forward(planes[step % 3], aux[step % 3])); + assertEquals(cpu.getModelVersion(), gpu.getModelVersion()); + assertTrue(gpu.isGpuActive()); + } + System.out.println("GPU stress: 12 training updates match CPU, SGD/momentum, checkpoint reload"); + } finally { + gpu.release(); + cpu.release(); + } + } + + @Test + void expandingAndShrinkingBatchesSurviveRepeatedNativeTeardown() { + NeuralEvaluator cpu = new NeuralEvaluator(); + try { + System.setProperty("go.gpu", "false"); + NeuralEvaluator.ModelWeights weights = cpu.snapshot(); + int[] sizes = {1, 64, 65, 3, 129, 2}; + for (int cycle = 0; cycle < 6; cycle++) { + try (OpenCLBackend backend = OpenCLBackend.acquireShared()) { + assumeTrue(backend != null, "OpenCL GPU unavailable"); + for (int size : sizes) { + Random random = new Random(8843L + size + cycle * 1000); + double[][][][] planes = new double[size][][][]; + double[][] aux = new double[size][24]; + double[][] policy = new double[size][362]; + double[] value = new double[size]; + for (int n = 0; n < size; n++) { + planes[n] = randomPlanes(random); + for (int i = 0; i < 24; i++) aux[n][i] = random.nextDouble(); + } + assertTrue(infer(backend, weights, weights.valueW1, planes, aux, 1, policy, value)); + for (int n : new int[]{0, size / 2, size - 1}) { + NeuralEvaluator.ForwardResult expected = cpu.forward(planes[n], aux[n]); + assertResult(expected, new NeuralEvaluator.ForwardResult(value[n], policy[n])); + } + } + } + assertNull(OpenCLBackend.peekShared()); + } + System.out.println("GPU stress: 6 native context lifecycles, 36 batches, sizes 1/64/65/3/129/2"); + } finally { + cpu.release(); + } + } + + private static boolean infer(OpenCLBackend backend, NeuralEvaluator.ModelWeights weights, + double[][] valueWeights, double[][][][] planes, double[][] aux, + int owner, double[][] policy, double[] value) { + return backend.inferForward(planes, aux, planes.length, owner, weights.version, + weights.subW1, weights.subB1, weights.blockW1, weights.blockB1, + weights.topW1, weights.topB1, weights.policyW, weights.policyB, + valueWeights, weights.valueB1, weights.valueW2, weights.valueB2, policy, value); + } + + private static double[][][] randomPlanes(Random random) { + double[][][] planes = new double[4][19][19]; + for (double[][] plane : planes) for (double[] row : plane) { + for (int y = 0; y < row.length; y++) row[y] = random.nextDouble(); + } + return planes; + } + + private static void assertResult(NeuralEvaluator.ForwardResult expected, NeuralEvaluator.ForwardResult actual) { + assertEquals(expected.value, actual.value, 1e-5); + assertArrayEquals(expected.policy, actual.policy, 1e-5); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoSimulationRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoSimulationRegressionTest.java new file mode 100644 index 0000000..93e8f97 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoSimulationRegressionTest.java @@ -0,0 +1,640 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +@Timeout(60) +class GoSimulationRegressionTest { + private MCTSGoAI ai; + private String previousGpu; + private Method simulate; + private ThreadLocal simulationHashes; + private Field hashField; + private Method legalProbe; + private Method captureProbe; + private Method atariProbe; + + @BeforeEach + void createAi() throws Exception { + previousGpu = System.getProperty("go.gpu"); + System.setProperty("go.gpu", "false"); + ai = new MCTSGoAI(0, 0, 1); + simulationHashes = (ThreadLocal) field(MCTSGoAI.class, "SIMULATION_HASH").get(null); + Class hashClass = Class.forName(MCTSGoAI.class.getName() + "$SimulationHash"); + hashField = field(hashClass, "hash"); + simulate = MCTSGoAI.class.getDeclaredMethod("simulatePlaceStone", + GoPlayer[][].class, int.class, int.class, GoPlayer.class, hashClass); + simulate.setAccessible(true); + legalProbe = probeMethod("isLegalMove"); + captureProbe = probeMethod("countCaptures"); + atariProbe = probeMethod("wouldBeInAtari"); + } + + @AfterEach + void closeAi() { + if (ai != null) ai.shutdown(); + if (previousGpu == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previousGpu); + } + + @Test + void simulationAndIncrementalHashMatchRulesEngineOnEveryPoint() throws Exception { + Random random = new Random(9713); + List boards = new ArrayList<>(); + try (GoGame game = GoGame.rulesOnly()) { + boards.add(game.getBoardCopy()); + for (int step = 0; step < 500; step++) { + game.placeStone(random.nextInt(19), random.nextInt(19)); + if (step % 50 == 49) boards.add(game.getBoardCopy()); + } + } + // Also exercise imported positions that contain groups with no liberties. + for (int n = 1; n <= 8; n++) { + GoPlayer[][] board = emptyBoard(); + for (GoPlayer[] row : board) for (int y = 0; y < 19; y++) { + if (random.nextDouble() < n / 8.0) { + row[y] = random.nextBoolean() ? GoPlayer.BLACK : GoPlayer.WHITE; + } + } + boards.add(board); + } + try (GoGame oracle = GoGame.rulesOnly()) { + for (GoPlayer[][] board : boards) { + for (GoPlayer player : new GoPlayer[]{GoPlayer.BLACK, GoPlayer.WHITE}) { + for (int x = 0; x < 19; x++) for (int y = 0; y < 19; y++) { + GoPlayer[][] expected = rulesMove(oracle, board, x, y, player); + GoPlayer[][] actual = copy(board); + assertEquals(expected != null, legalProbe.invoke(ai, actual, x, y, player)); + assertBoardEquals(board, actual); + boolean legal = simulateWithHash(actual, x, y, player); + assertEquals(expected != null, legal, "move " + x + "," + y + " by " + player); + assertBoardEquals(expected == null ? board : expected, actual); + } + } + } + } + } + + @Test + void captureAndAtariProbesMatchIndependentGroupAnalysis() throws Exception { + Random random = new Random(942); + List boards = new ArrayList<>(); + for (int n = 0; n < 12; n++) { + GoPlayer[][] board = emptyBoard(); + for (GoPlayer[] row : board) for (int y = 0; y < 19; y++) { + if (random.nextDouble() < n / 11.0) { + row[y] = random.nextBoolean() ? GoPlayer.BLACK : GoPlayer.WHITE; + } + } + boards.add(board); + } + GoPlayer[][] large = emptyBoard(); + for (GoPlayer[] row : large) Arrays.fill(row, GoPlayer.WHITE); + large[9][9] = GoPlayer.NONE; + boards.add(large); + // The same sole liberty may touch several stones of a friendly group. + GoPlayer[][] sharedLiberty = emptyBoard(); + for (GoPlayer[] row : sharedLiberty) Arrays.fill(row, GoPlayer.BLACK); + sharedLiberty[9][9] = sharedLiberty[10][10] = GoPlayer.NONE; + boards.add(sharedLiberty); + for (GoPlayer[][] board : boards) { + GoPlayer[][] before = copy(board); + for (GoPlayer player : new GoPlayer[]{GoPlayer.BLACK, GoPlayer.WHITE, GoPlayer.NONE}) { + for (int x = 0; x < 19; x++) for (int y = 0; y < 19; y++) { + int[] expected = captureAndLibertyOracle(board, x, y, player); + assertEquals(expected[0], captureProbe.invoke(ai, board, x, y, player), "capture count"); + assertEquals(expected[1] == 1, atariProbe.invoke(ai, board, x, y, player), "atari"); + assertBoardEquals(before, board); + } + } + for (int[] move : new int[][]{{-1, 0}, {0, -1}, {19, 0}, {0, 19}}) { + assertEquals(0, captureProbe.invoke(ai, board, move[0], move[1], GoPlayer.BLACK)); + assertEquals(false, atariProbe.invoke(ai, board, move[0], move[1], GoPlayer.BLACK)); + assertBoardEquals(before, board); + } + } + } + + @Test + void capturesLargeAndMultipleGroupsAndPreservesRejectedMoves() throws Exception { + GoPlayer[][] surrounded = emptyBoard(); + for (GoPlayer[] row : surrounded) Arrays.fill(row, GoPlayer.WHITE); + surrounded[9][9] = GoPlayer.NONE; + assertTrue(simulateWithHash(surrounded, 9, 9, GoPlayer.BLACK)); + GoPlayer[][] singleStone = emptyBoard(); + singleStone[9][9] = GoPlayer.BLACK; + assertBoardEquals(singleStone, surrounded); // 360 stones, touching on four sides. + + GoPlayer[][] multiple = emptyBoard(); + int[][] whites = {{8, 9}, {10, 9}, {9, 8}, {9, 10}}; + for (int[] stone : whites) { + int x = stone[0], y = stone[1]; + multiple[x][y] = GoPlayer.WHITE; + for (int[] offset : new int[][]{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { + if (x + offset[0] != 9 || y + offset[1] != 9) { + multiple[x + offset[0]][y + offset[1]] = GoPlayer.BLACK; + } + } + } + assertTrue(simulateWithHash(multiple, 9, 9, GoPlayer.BLACK)); + for (int[] stone : whites) assertEquals(GoPlayer.NONE, multiple[stone[0]][stone[1]]); + + GoPlayer[][] alive = emptyBoard(); + for (GoPlayer[] row : alive) Arrays.fill(row, GoPlayer.BLACK); + alive[9][9] = alive[18][18] = GoPlayer.NONE; + assertTrue(simulateWithHash(alive, 9, 9, GoPlayer.BLACK)); + assertEquals(GoPlayer.NONE, alive[18][18]); + + GoPlayer[][] suicide = emptyBoard(); + suicide[0][1] = suicide[1][0] = GoPlayer.WHITE; + GoPlayer[][] before = copy(suicide); + assertFalse(simulateWithHash(suicide, 0, 0, GoPlayer.BLACK)); + assertFalse(simulateWithHash(suicide, 0, 1, GoPlayer.BLACK)); + assertFalse(simulateWithHash(suicide, -1, 0, GoPlayer.BLACK)); + assertFalse(simulateWithHash(suicide, 19, 0, GoPlayer.BLACK)); + assertFalse(simulateWithHash(suicide, 0, -1, GoPlayer.BLACK)); + assertFalse(simulateWithHash(suicide, 0, 19, GoPlayer.BLACK)); + assertFalse(simulateWithHash(suicide, 9, 9, GoPlayer.NONE)); + assertBoardEquals(before, suicide); + } + + @Test + void tacticalRegionStoresUniquePointsAndClearsBetweenUses() throws Exception { + Class regionType = Class.forName(MCTSGoAI.class.getName() + "$TacticalRegion"); + Method add = regionType.getDeclaredMethod("add", int.class); + Method clear = regionType.getDeclaredMethod("clear"); + Field size = regionType.getDeclaredField("size"); + add.setAccessible(true); + clear.setAccessible(true); + size.setAccessible(true); + Constructor constructor = regionType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object region = constructor.newInstance(); + add.invoke(region, 4 * 19 + 5); + add.invoke(region, 4 * 19 + 5); + add.invoke(region, 4 * 19 + 6); + assertEquals(2, size.getInt(region)); + clear.invoke(region); + assertEquals(0, size.getInt(region)); + add.invoke(region, 18 * 19 + 18); + assertEquals(1, size.getInt(region)); + } + + @Test + void escapeDirectionUsesUniqueLibertiesWithoutMutatingBoard() throws Exception { + Method escape = MCTSGoAI.class.getDeclaredMethod("getEscapeDirection", + GoPlayer[][].class, Set.class, GoPlayer.class); + escape.setAccessible(true); + GoPlayer[][] board = emptyBoard(); + board[9][9] = GoPlayer.BLACK; + board[9][10] = GoPlayer.BLACK; + Set group = new java.util.LinkedHashSet<>(List.of( + new int[]{9, 9}, new int[]{9, 10})); + GoPlayer[][] before = copy(board); + int[] selected = (int[]) escape.invoke(ai, board, group, GoPlayer.BLACK); + assertNotNull(selected); + assertEquals(GoPlayer.NONE, board[selected[0]][selected[1]]); + int selectedScore = Math.min(Math.min(selected[0], selected[1]), + Math.min(18 - selected[0], 18 - selected[1])); + int bestScore = Integer.MAX_VALUE; + for (int[] point : new int[][]{{8, 9}, {8, 10}, {10, 9}, {10, 10}, + {9, 8}, {10, 8}, {9, 11}, {8, 11}}) { + if (board[point[0]][point[1]] == GoPlayer.NONE) { + bestScore = Math.min(bestScore, Math.min(Math.min(point[0], point[1]), + Math.min(18 - point[0], 18 - point[1]))); + } + } + assertEquals(bestScore, selectedScore); + assertBoardEquals(before, board); + GoPlayer[][] blocked = emptyBoard(); + for (int[] point : group) blocked[point[0]][point[1]] = GoPlayer.BLACK; + for (int[] point : new int[][]{{8, 9}, {10, 9}, {9, 8}, {8, 10}, {10, 10}, {9, 11}}) { + blocked[point[0]][point[1]] = GoPlayer.WHITE; + } + assertNull(escape.invoke(ai, blocked, group, GoPlayer.BLACK)); + } + + @Test + void cappedLibertyCountMatchesExactCountAtAndBelowThreshold() throws Exception { + Method count = MCTSGoAI.class.getDeclaredMethod("countGroupLibertiesUpTo", + GoPlayer[][].class, Set.class, int.class); + count.setAccessible(true); + GoPlayer[][] board = emptyBoard(); + board[9][9] = GoPlayer.BLACK; + Set group = new java.util.LinkedHashSet<>(List.of(new int[]{9, 9})); + assertEquals(4, count.invoke(ai, board, group, 10)); + assertEquals(3, count.invoke(ai, board, group, 3)); + assertEquals(1, count.invoke(ai, board, group, 1)); + board[0][0] = GoPlayer.BLACK; + Set corner = new java.util.LinkedHashSet<>(List.of(new int[]{0, 0})); + assertEquals(2, count.invoke(ai, board, corner, 3)); + assertEquals(2, count.invoke(ai, board, corner, 2)); + } + + @Test + void tacticalGroupMarksEveryStoneAndKeepsSeparateGroupsDistinct() throws Exception { + Method key = MCTSGoAI.class.getDeclaredMethod("markTacticalGroup", boolean[].class, Set.class); + key.setAccessible(true); + boolean[] visited = new boolean[19 * 19]; + Set first = new java.util.LinkedHashSet<>(List.of(new int[]{2, 3}, new int[]{2, 4})); + Set second = new java.util.LinkedHashSet<>(List.of(new int[]{10, 12}, new int[]{10, 13})); + assertTrue((boolean) key.invoke(null, visited, first)); + assertTrue(visited[2 * 19 + 3]); + assertTrue(visited[2 * 19 + 4]); + assertTrue((boolean) key.invoke(null, visited, second)); + assertTrue(visited[10 * 19 + 12]); + assertTrue(visited[10 * 19 + 13]); + Set same = new java.util.LinkedHashSet<>(List.of(new int[]{2, 4}, new int[]{2, 3})); + assertFalse((boolean) key.invoke(null, visited, same)); + } + + @Test + void tacticalCandidatesCacheCaptureCountsWithoutChangingOrderOrBoard() throws Exception { + Method unscored = MCTSGoAI.class.getDeclaredMethod("legalMovesInRegion", + GoPlayer[][].class, GoPlayer.class, Set.class); + Method scored = MCTSGoAI.class.getDeclaredMethod("legalMovesInRegion", + GoPlayer[][].class, GoPlayer.class, Set.class, boolean.class); + unscored.setAccessible(true); + scored.setAccessible(true); + GoPlayer[][] board = emptyBoard(); + board[0][0] = GoPlayer.WHITE; + board[1][0] = GoPlayer.BLACK; + GoPlayer[][] before = copy(board); + Set region = new java.util.LinkedHashSet<>(List.of( + "4,4", "0,1", "1,0", "-1,0", "bad", "2,2", "19,0", "1,2,3")); + @SuppressWarnings("unchecked") + List reference = (List) unscored.invoke(ai, board, GoPlayer.BLACK, region); + @SuppressWarnings("unchecked") + List cached = (List) scored.invoke(ai, board, GoPlayer.BLACK, region, true); + assertEquals(reference.size(), cached.size()); + for (int i = 0; i < cached.size(); i++) { + int[] move = cached.get(i); + assertEquals(3, move.length); + assertArrayEquals(reference.get(i), new int[]{move[0], move[1]}); + assertEquals(captureAndLibertyOracle(board, move[0], move[1], GoPlayer.BLACK)[0], move[2]); + } + reference.sort((a, b) -> Integer.compare( + captureAndLibertyOracle(board, b[0], b[1], GoPlayer.BLACK)[0], + captureAndLibertyOracle(board, a[0], a[1], GoPlayer.BLACK)[0])); + cached.sort((a, b) -> Integer.compare(b[2], a[2])); + for (int i = 0; i < cached.size(); i++) { + assertArrayEquals(reference.get(i), new int[]{cached.get(i)[0], cached.get(i)[1]}); + } + assertBoardEquals(before, board); + } + + @Test + void tacticalSearchMatchesExhaustiveRulesSearchAcrossDepthsAndRepeatedCalls() throws Exception { + Class regionType = Class.forName(MCTSGoAI.class.getName() + "$TacticalRegion"); + Method search = MCTSGoAI.class.getDeclaredMethod("localAlphaBeta", GoPlayer[][].class, + int[].class, int.class, GoPlayer.class, GoPlayer.class, int.class, + double.class, double.class, regionType, long.class); + search.setAccessible(true); + NeuralEvaluator evaluator = (NeuralEvaluator) field(MCTSGoAI.class, "neuralEvaluator").get(ai); + int[] target = {9 * 19 + 9, 9 * 19 + 10}; + int[] points = {8 * 19 + 10, 8 * 19 + 9, 9 * 19 + 11, 8 * 19 + 11}; + Object region = tacticalRegion(points); + GoPlayer[][] board = emptyBoard(); + board[9][9] = board[9][10] = GoPlayer.WHITE; + board[10][9] = board[10][10] = board[9][8] = GoPlayer.BLACK; + GoPlayer[][] before = copy(board); + try (GoGame oracle = GoGame.rulesOnly()) { + for (int repeat = 0; repeat < 2; repeat++) { + for (int depth : new int[]{3, 1, 2}) { + for (GoPlayer player : new GoPlayer[]{GoPlayer.BLACK, GoPlayer.WHITE}) { + double expected = exhaustiveTacticalValue(oracle, evaluator, board, target, + points, player, GoPlayer.BLACK, depth); + double actual = (double) search.invoke(ai, board, target, target.length, + player, GoPlayer.BLACK, depth, Double.NEGATIVE_INFINITY, + Double.POSITIVE_INFINITY, region, Long.MAX_VALUE); + assertEquals(expected, actual, 1e-12, "depth " + depth + " player " + player); + assertBoardEquals(before, board); + } + } + } + } + assertEquals(0.0, search.invoke(ai, board, target, target.length, GoPlayer.BLACK, + GoPlayer.BLACK, 3, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, region, 0L)); + assertBoardEquals(before, board); + } + + @Test + void tacticalRootPreservesRegionOrderForEqualScoresAndReturnsIndependentMoves() throws Exception { + NeuralEvaluator evaluator = (NeuralEvaluator) field(MCTSGoAI.class, "neuralEvaluator").get(ai); + Arrays.fill((double[]) field(NeuralEvaluator.class, "valueW2").get(evaluator), 0.0); + field(NeuralEvaluator.class, "valueB2").setDouble(evaluator, -0.5); + Class regionType = Class.forName(MCTSGoAI.class.getName() + "$TacticalRegion"); + Method search = MCTSGoAI.class.getDeclaredMethod("localAlphaBetaSearch", GoPlayer[][].class, + int[].class, int.class, GoPlayer.class, GoPlayer.class, int.class, regionType, long.class); + search.setAccessible(true); + GoPlayer[][] board = emptyBoard(); + board[9][9] = GoPlayer.WHITE; + GoPlayer[][] before = copy(board); + int[] target = {9 * 19 + 9}; + int[] first = (int[]) search.invoke(ai, board, target, 1, GoPlayer.BLACK, GoPlayer.WHITE, 1, + tacticalRegion(new int[]{4 * 19 + 4, 3 * 19 + 3}), Long.MAX_VALUE); + assertArrayEquals(new int[]{4, 4}, first); + int[] second = (int[]) search.invoke(ai, board, target, 1, GoPlayer.BLACK, GoPlayer.WHITE, 1, + tacticalRegion(new int[]{3 * 19 + 3, 4 * 19 + 4}), Long.MAX_VALUE); + assertArrayEquals(new int[]{3, 3}, second); + assertArrayEquals(new int[]{4, 4}, first); + assertNotSame(first, second); + assertBoardEquals(before, board); + } + + @Test + void tacticalRootReturnsCaptureAndKeepsBuffersIsolatedAcrossThreads() throws Exception { + Class regionType = Class.forName(MCTSGoAI.class.getName() + "$TacticalRegion"); + Method search = MCTSGoAI.class.getDeclaredMethod("localAlphaBetaSearch", GoPlayer[][].class, + int[].class, int.class, GoPlayer.class, GoPlayer.class, int.class, regionType, long.class); + search.setAccessible(true); + Object region = tacticalRegion(new int[]{8 * 19 + 11, 7 * 19 + 10, 8 * 19 + 10, 7 * 19 + 11}); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> futures = new ArrayList<>(); + for (int task = 0; task < 12; task++) { + GoPlayer attacker = task % 2 == 0 ? GoPlayer.BLACK : GoPlayer.WHITE; + GoPlayer defender = attacker == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + GoPlayer[][] board = emptyBoard(); + board[9][9] = board[9][10] = defender; + for (int[] point : new int[][]{{8, 9}, {10, 9}, {9, 8}, {10, 10}, {9, 11}}) { + board[point[0]][point[1]] = attacker; + } + GoPlayer[][] before = copy(board); + futures.add(pool.submit(() -> { + for (int depth : new int[]{5, 1, 3}) { + int[] selected = (int[]) search.invoke(ai, board, new int[]{9 * 19 + 9, 9 * 19 + 10}, + 2, attacker, defender, depth, region, Long.MAX_VALUE); + assertArrayEquals(new int[]{8, 10}, selected); + assertBoardEquals(before, board); + } + assertNull(search.invoke(ai, board, new int[]{9 * 19 + 9, 9 * 19 + 10}, + 2, attacker, defender, 5, region, 0L)); + return null; + })); + } + for (Future future : futures) future.get(20, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + pool.awaitTermination(10, TimeUnit.SECONDS); + } + } + + private static Object tacticalRegion(int[] points) throws Exception { + Class type = Class.forName(MCTSGoAI.class.getName() + "$TacticalRegion"); + Constructor constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + Object region = constructor.newInstance(); + Method add = type.getDeclaredMethod("add", int.class); + add.setAccessible(true); + for (int point : points) add.invoke(region, point); + return region; + } + + /** Independent exhaustive minimax using the game rules, with no pruning or reusable boards. */ + private static double exhaustiveTacticalValue(GoGame oracle, NeuralEvaluator evaluator, + GoPlayer[][] board, int[] target, int[] region, + GoPlayer player, GoPlayer attacker, int depth) throws Exception { + boolean captured = true; + for (int point : target) { + if (board[point / 19][point % 19] != GoPlayer.NONE) captured = false; + } + if (captured) return player == attacker ? 1.0 : -1.0; + if (depth == 0) return evaluator.forwardValue(board, player, null); + double best = Double.NEGATIVE_INFINITY; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + for (int point : region) { + GoPlayer[][] next = rulesMove(oracle, board, point / 19, point % 19, player); + if (next != null) { + best = Math.max(best, -exhaustiveTacticalValue(oracle, evaluator, next, target, + region, opponent, attacker, depth - 1)); + } + } + return best == Double.NEGATIVE_INFINITY ? 0.0 : best; + } + + @Test + void concurrentSimulationsKeepScratchAndHashesIsolated() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(8); + try (GoGame oracle = GoGame.rulesOnly()) { + Random random = new Random(641); + GoPlayer[][] board = emptyBoard(); + for (int step = 0; step < 240; step++) { + oracle.placeStone(random.nextInt(19), random.nextInt(19)); + } + board = oracle.getBoardCopy(); + List> futures = new ArrayList<>(); + for (int n = 0; n < 200; n++) { + int x = random.nextInt(19), y = random.nextInt(19); + GoPlayer player = random.nextBoolean() ? GoPlayer.BLACK : GoPlayer.WHITE; + GoPlayer[][] input = copy(board); + GoPlayer[][] expected = rulesMove(oracle, input, x, y, player); + int[] expectedProbes = captureAndLibertyOracle(input, x, y, player); + futures.add(pool.submit(() -> { + for (int repeat = 0; repeat < 10; repeat++) { + GoPlayer[][] actual = copy(input); + assertEquals(expected != null, legalProbe.invoke(ai, actual, x, y, player)); + assertEquals(expectedProbes[0], captureProbe.invoke(ai, actual, x, y, player)); + assertEquals(expectedProbes[1] == 1, atariProbe.invoke(ai, actual, x, y, player)); + assertBoardEquals(input, actual); + assertEquals(expected != null, simulateWithHash(actual, x, y, player)); + assertBoardEquals(expected == null ? input : expected, actual); + } + return null; + })); + } + for (Future future : futures) future.get(10, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + pool.awaitTermination(10, TimeUnit.SECONDS); + } + } + + @Test + void expansionUsesCorrectHashAndIndependentBoardsForKnownAndUnknownParentHash() throws Exception { + for (boolean knownHash : new boolean[]{false, true}) { + GoPlayer[][] board = emptyBoard(); + board[4][4] = GoPlayer.WHITE; + List moves = List.of(new int[]{8, 8}, new int[]{10, 10}); + Object root = node(board, GoPlayer.BLACK, null, moves); + if (knownHash) field(root.getClass(), "hash").setLong(root, GoGame.boardHash(board)); + Method expand = expandMethod(root); + Object first = expand.invoke(ai, root); + Object second = expand.invoke(ai, root); + assertNotNull(first); + assertNotNull(second); + GoPlayer[][] firstBoard = (GoPlayer[][]) field(first.getClass(), "board").get(first); + GoPlayer[][] secondBoard = (GoPlayer[][]) field(second.getClass(), "board").get(second); + assertEquals(GoGame.boardHash(firstBoard), field(first.getClass(), "hash").getLong(first)); + assertEquals(GoGame.boardHash(secondBoard), field(second.getClass(), "hash").getLong(second)); + assertEquals(GoPlayer.NONE, board[10][10]); + assertEquals(GoPlayer.NONE, secondBoard[10][10]); + for (int x = 0; x < 19; x++) { + assertNotSame(board[x], firstBoard[x]); + assertNotSame(board[x], secondBoard[x]); + assertNotSame(firstBoard[x], secondBoard[x]); + } + firstBoard[4][4] = GoPlayer.NONE; + assertEquals(GoPlayer.WHITE, board[4][4]); + assertEquals(GoPlayer.WHITE, secondBoard[4][4]); + } + } + + @Test + void expansionHashesCapturesAndRejectsHistoricalAndAncestorKo() throws Exception { + try (GoGame game = GoGame.rulesOnly()) { + int[][] moves = {{3, 5}, {3, 4}, {5, 5}, {5, 4}, {4, 6}, {4, 3}, {4, 4}}; + for (int[] move : moves) assertTrue(game.placeStone(move[0], move[1])); + GoPlayer[][] beforeCapture = game.getBoardCopy(); + Object root = node(beforeCapture, GoPlayer.WHITE, null, List.of(new int[]{4, 5})); + field(root.getClass(), "hash").setLong(root, GoGame.boardHash(beforeCapture)); + Object child = expandMethod(root).invoke(ai, root); + assertNotNull(child); + assertTrue(game.placeStone(4, 5)); + assertBoardEquals(game.getBoardCopy(), (GoPlayer[][]) field(child.getClass(), "board").get(child)); + assertEquals(game.getCurrentHash(), field(child.getClass(), "hash").getLong(child)); + for (boolean ancestorOnly : new boolean[]{false, true}) { + Object recapture = node(game.getBoardCopy(), GoPlayer.BLACK, root, List.of(new int[]{4, 4})); + field(recapture.getClass(), "hash").setLong(recapture, game.getCurrentHash()); + field(MCTSGoAI.class, "koHistory").set(ai, ancestorOnly ? Set.of() : game.getPositionHistory()); + assertNull(expandMethod(recapture).invoke(ai, recapture)); + assertNull(field(recapture.getClass(), "children").get(recapture)); + assertBoardEquals(game.getBoardCopy(), (GoPlayer[][]) field(recapture.getClass(), "board").get(recapture)); + } + } + } + + private boolean simulateWithHash(GoPlayer[][] board, int x, int y, GoPlayer player) throws Exception { + Object hash = simulationHashes.get(); + long before = GoGame.boardHash(board); + hashField.setLong(hash, before); + boolean legal = (boolean) simulate.invoke(ai, board, x, y, player, hash); + assertEquals(GoGame.boardHash(board), hashField.getLong(hash), "incremental hash"); + if (!legal) assertEquals(before, hashField.getLong(hash), "rejected hash"); + return legal; + } + + private static Method probeMethod(String name) throws Exception { + Method method = MCTSGoAI.class.getDeclaredMethod(name, + GoPlayer[][].class, int.class, int.class, GoPlayer.class); + method.setAccessible(true); + return method; + } + + private static int[] captureAndLibertyOracle(GoPlayer[][] board, int x, int y, GoPlayer player) { + if (player == GoPlayer.NONE || board[x][y] != GoPlayer.NONE) return new int[]{0, 0}; + GoPlayer[][] placed = copy(board); + placed[x][y] = player; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + boolean[][] counted = new boolean[19][19]; + int captured = 0; + for (int[] offset : new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}) { + int nx = x + offset[0], ny = y + offset[1]; + if (nx < 0 || nx >= 19 || ny < 0 || ny >= 19 + || placed[nx][ny] != opponent || counted[nx][ny]) continue; + List group = oracleGroup(placed, nx, ny); + for (int[] point : group) counted[point[0]][point[1]] = true; + if (oracleLiberties(placed, group) == 0) captured += group.size(); + } + // Capture priority and atari deliberately examine the position before removing enemies. + return new int[]{captured, oracleLiberties(placed, oracleGroup(placed, x, y))}; + } + + private static List oracleGroup(GoPlayer[][] board, int x, int y) { + List group = new ArrayList<>(); + boolean[][] visited = new boolean[19][19]; + group.add(new int[]{x, y}); + visited[x][y] = true; + for (int head = 0; head < group.size(); head++) { + int[] point = group.get(head); + for (int[] offset : new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}) { + int nx = point[0] + offset[0], ny = point[1] + offset[1]; + if (nx >= 0 && nx < 19 && ny >= 0 && ny < 19 + && !visited[nx][ny] && board[nx][ny] == board[x][y]) { + visited[nx][ny] = true; + group.add(new int[]{nx, ny}); + } + } + } + return group; + } + + private static int oracleLiberties(GoPlayer[][] board, List group) { + Set liberties = new java.util.HashSet<>(); + for (int[] point : group) { + for (int[] offset : new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}) { + int nx = point[0] + offset[0], ny = point[1] + offset[1]; + if (nx >= 0 && nx < 19 && ny >= 0 && ny < 19 && board[nx][ny] == GoPlayer.NONE) { + liberties.add(nx * 19 + ny); + } + } + } + return liberties.size(); + } + + private static GoPlayer[][] rulesMove(GoGame game, GoPlayer[][] board, int x, int y, GoPlayer player) + throws Exception { + game.reset(); + field(GoGame.class, "board").set(game, copy(board)); + field(GoGame.class, "currentPlayer").set(game, player); + // Simulation validates local rules; expansion checks history separately. + ((Set) field(GoGame.class, "positionHistory").get(game)).clear(); + return game.placeStone(x, y) ? game.getBoardCopy() : null; + } + + private static Object node(GoPlayer[][] board, GoPlayer player, Object parent, List moves) + throws Exception { + Class type = Class.forName(MCTSGoAI.class.getName() + "$MCTSNode"); + Constructor constructor = type.getDeclaredConstructor( + GoPlayer[][].class, GoPlayer.class, type, int[].class, List.class); + constructor.setAccessible(true); + Object node = constructor.newInstance(board, player, parent, null, moves); + double[] policy = new double[362]; + Arrays.fill(policy, 1.0 / 362); + field(type, "policyCache").set(node, policy); + return node; + } + + private static Method expandMethod(Object node) throws Exception { + Method method = MCTSGoAI.class.getDeclaredMethod("expand", node.getClass()); + method.setAccessible(true); + return method; + } + + private static Field field(Class type, String name) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + private static GoPlayer[][] emptyBoard() { + GoPlayer[][] board = new GoPlayer[19][19]; + for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.NONE); + return board; + } + + private static GoPlayer[][] copy(GoPlayer[][] board) { + return Arrays.stream(board).map(GoPlayer[]::clone).toArray(GoPlayer[][]::new); + } + + private static void assertBoardEquals(GoPlayer[][] expected, GoPlayer[][] actual) { + for (int x = 0; x < 19; x++) assertArrayEquals(expected[x], actual[x], "row " + x); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingRegressionTest.java index 9efc8ec..d412aa2 100644 --- a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingRegressionTest.java +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingRegressionTest.java @@ -8,12 +8,70 @@ import java.io.StringReader; import java.io.StringWriter; import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import static org.junit.jupiter.api.Assertions.*; @Timeout(30) class GoTrainingRegressionTest { + @Test + void nonFiniteLearningRateCannotCorruptModel() { + String previous = System.getProperty("go.gpu"); + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + System.setProperty("go.gpu", "false"); + NeuralEvaluator.ModelWeights before = evaluator.snapshot(); + boolean rejected = false; + try { + evaluator.trainMiniBatch(new double[1][4][19][19], new double[1][24], + new double[]{1.0}, new double[1][362], Double.NaN, 1e-5, 5.0, 0.9); + } catch (IllegalArgumentException expected) { + rejected = true; + } + NeuralEvaluator.ModelWeights after = evaluator.snapshot(); + assertEquals(before.valueB2, after.valueB2, "invalid learning rate must not poison model weights"); + assertEquals(before.subW1[0][0][0], after.subW1[0][0][0]); + assertEquals(before.version, after.version); + assertTrue(rejected, "invalid learning rate must be reported to the caller"); + } finally { + evaluator.release(); + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } + + @Test + void invalidOptimizerParametersAndGenerationRatesAreRejected() { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + double[][] invalid = { + {Double.POSITIVE_INFINITY, 0, 5, 0}, {-1, 0, 5, 0}, + {0.01, Double.NaN, 5, 0}, {0.01, Double.POSITIVE_INFINITY, 5, 0}, {0.01, -1, 5, 0}, + {0.01, 0, Double.NaN, 0}, {0.01, 0, Double.POSITIVE_INFINITY, 0}, {0.01, 0, 0, 0}, + {0.01, 0, 5, Double.NaN}, {0.01, 0, 5, Double.POSITIVE_INFINITY}, + {0.01, 0, 5, -0.1}, {0.01, 0, 5, 1.0} + }; + for (double[] parameters : invalid) { + assertThrows(IllegalArgumentException.class, () -> evaluator.trainMiniBatch( + new double[1][4][19][19], new double[1][24], new double[]{1.0}, new double[1][362], + parameters[0], parameters[1], parameters[2], parameters[3])); + } + GoSelfPlayTrainer selfPlay = new GoSelfPlayTrainer(new GoSelfPlayTrainer.Config(), evaluator); + GoAdversarialTrainer adversarial = new GoAdversarialTrainer(new GoAdversarialTrainer.Config(), evaluator); + for (double rate : new double[]{Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, 0, -1}) { + assertThrows(IllegalArgumentException.class, () -> selfPlay.runGeneration(0, 1, 1, rate, 1)); + assertThrows(IllegalArgumentException.class, () -> adversarial.runGeneration(0, 1, 1, rate, 1)); + } + } finally { + evaluator.release(); + } + } + @Test void fallbackCoordinatesAndPolicyMatchExecutedMove() { try (GoGame game = GoGame.rulesOnly()) { @@ -117,6 +175,101 @@ void noLegalMoveProducesOnlyPassPolicy() throws Exception { } } + @Test + void detectGpuReportsDisabledWhenPropertyFalse() { + String previous = System.getProperty("go.gpu"); + try { + System.setProperty("go.gpu", "false"); + NeuralEvaluator.GpuStatus status = NeuralEvaluator.detectGpu(); + assertFalse(status.enabled); + assertFalse(status.available); + assertEquals("CPU", status.deviceName); + assertTrue(status.describe().contains("CPU")); + } finally { + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } + + @Test + void cpuAndGpuForwardAgreeWhenGpuAvailable() { + String previous = System.getProperty("go.gpu"); + NeuralEvaluator gpuEval = null; + NeuralEvaluator cpuEval = null; + try { + System.setProperty("go.gpu", "true"); + gpuEval = new NeuralEvaluator(); + if (!gpuEval.isGpuActive()) return; + NeuralEvaluator.ModelWeights weights = gpuEval.snapshot(); + GoPlayer[][] board = new GoPlayer[19][19]; + for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.NONE); + board[3][3] = GoPlayer.BLACK; + board[15][15] = GoPlayer.WHITE; + double[][][] planes = gpuEval.buildInputPlanes(board, GoPlayer.BLACK, new int[]{3, 3}); + double[] aux = gpuEval.extractAuxFeatures(board, GoPlayer.BLACK); + NeuralEvaluator.ForwardResult gpu = gpuEval.forward(planes, aux); + assertEquals(gpu.value, gpuEval.forwardValue(board, GoPlayer.BLACK, new int[]{3, 3}), 1e-5); + + System.setProperty("go.gpu", "false"); + cpuEval = NeuralEvaluator.fromWeights(weights); + NeuralEvaluator.ForwardResult cpu = cpuEval.forward(planes, aux); + assertEquals(cpu.value, gpu.value, 1e-5); + assertEquals(cpu.policy.length, gpu.policy.length); + for (int i = 0; i < cpu.policy.length; i++) { + assertEquals(cpu.policy[i], gpu.policy[i], 1e-5, "policy[" + i + "]"); + } + } finally { + if (gpuEval != null) gpuEval.release(); + if (cpuEval != null) cpuEval.release(); + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } + + @Test + void concurrentGpuForwardsMatchCpuWhenGpuAvailable() throws Exception { + String previous = System.getProperty("go.gpu"); + NeuralEvaluator gpuEval = null; + NeuralEvaluator cpuEval = null; + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + System.setProperty("go.gpu", "true"); + gpuEval = new NeuralEvaluator(); + if (!gpuEval.isGpuActive()) return; + NeuralEvaluator.ModelWeights weights = gpuEval.snapshot(); + GoPlayer[][] board = new GoPlayer[19][19]; + for (GoPlayer[] row : board) Arrays.fill(row, GoPlayer.NONE); + board[3][3] = GoPlayer.BLACK; + board[15][15] = GoPlayer.WHITE; + double[][][] planes = gpuEval.buildInputPlanes(board, GoPlayer.BLACK, new int[]{3, 3}); + double[] aux = gpuEval.extractAuxFeatures(board, GoPlayer.BLACK); + System.setProperty("go.gpu", "false"); + cpuEval = NeuralEvaluator.fromWeights(weights); + NeuralEvaluator.ForwardResult cpu = cpuEval.forward(planes, aux); + System.setProperty("go.gpu", "true"); + NeuralEvaluator gpuRunner = gpuEval; + + List> futures = new ArrayList<>(); + for (int i = 0; i < 16; i++) { + futures.add(pool.submit(() -> gpuRunner.forward(planes, aux))); + } + for (Future future : futures) { + NeuralEvaluator.ForwardResult gpu = future.get(); + assertEquals(cpu.value, gpu.value, 1e-5); + assertEquals(cpu.policy.length, gpu.policy.length); + for (int i = 0; i < cpu.policy.length; i++) { + assertEquals(cpu.policy[i], gpu.policy[i], 1e-5, "policy[" + i + "]"); + } + } + } finally { + pool.shutdownNow(); + if (gpuEval != null) gpuEval.release(); + if (cpuEval != null) cpuEval.release(); + if (previous == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previous); + } + } + @Test void truncatedSelfPlayGameDoesNotEnterReplayBuffer() { GoSelfPlayTrainer.Config config = new GoSelfPlayTrainer.Config(); diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAIRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAIRegressionTest.java index 23880ff..711d105 100644 --- a/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAIRegressionTest.java +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAIRegressionTest.java @@ -9,7 +9,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Random; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -88,6 +93,48 @@ void zeroIterationSearchPassesAfterOpponentPass() { } } + @Test + void selfPlayPassesImmediatelyAfterOpponentPass() { + MCTSGoAI searchAi = new MCTSGoAI(1_000, 8, 1); + try (GoGame game = GoGame.rulesOnly()) { + searchAi.setSelfPlayMode(true); + game.pass(); + assertTrue(game.canPlaceStone(0, 0)); + assertNull(searchAi.getBestMove(game)); + assertEquals(1.0, searchAi.getVisitDistribution()[361]); + GoTrainingMove.apply(game, null, searchAi.getVisitDistribution()); + assertTrue(game.isGameOver()); + } finally { + searchAi.shutdown(); + } + } + + @Test + void selfPlaySearchTimeHonorsTrainerBudget() throws Exception { + MCTSGoAI searchAi = new MCTSGoAI(300, 8, 1); + try { + var method = MCTSGoAI.class.getDeclaredMethod("calculateDynamicSearchTime", int.class, int.class); + method.setAccessible(true); + searchAi.setSelfPlayMode(true); + assertEquals(300, (int) method.invoke(searchAi, 10, 200)); + searchAi.setSelfPlayMode(false); + assertTrue((int) method.invoke(searchAi, 10, 200) >= 500); + } finally { + searchAi.shutdown(); + } + } + + @Test + void searchMovesAlwaysIncludePassInOpening() throws Exception { + var method = MCTSGoAI.class.getDeclaredMethod("getSearchMoves", + long.class, GoPlayer[][].class, GoPlayer.class, int.class); + method.setAccessible(true); + GoPlayer[][] board = emptyBoard(); + @SuppressWarnings("unchecked") + List moves = (List) method.invoke(ai, GoGame.boardHash(board), board, GoPlayer.BLACK, 0); + assertTrue(moves.stream().anyMatch(move -> move[0] < 0 && move[1] < 0)); + } + @Test void invalidLegalMoveProbeDoesNotOverwriteBoard() throws Exception { GoPlayer[][] board = emptyBoard(); @@ -155,6 +202,216 @@ void oneSearchIterationVisitsExpandedChildAndParent() throws Exception { } } + @Test + void cachedStatisticsRefreshVisitsAndNewChildrenWithoutChangingEarlyStopOrPassPolicy() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object root = node(board, null, null); + Object first = node(board, root, new int[]{3, 3}); + Object second = node(board, root, new int[]{4, 4}); + List children = new ArrayList<>(List.of(first, second)); + setField(root, "children", children); + setField(ai, "currentRoot", root); + var early = MCTSGoAI.class.getDeclaredMethod("shouldTerminateEarly", long.class); + var winRate = MCTSGoAI.class.getDeclaredMethod("getCurrentWinRate"); + var best = MCTSGoAI.class.getDeclaredMethod("getBestMCTSMove", root.getClass()); + early.setAccessible(true); + winRate.setAccessible(true); + best.setAccessible(true); + assertFalse((boolean) early.invoke(ai, 200L)); + assertEquals(0.0, winRate.invoke(ai)); + setField(first, "visits", 31.0); + setField(first, "totalScore", -31.0); + assertFalse((boolean) early.invoke(ai, 200L)); // Require enough visits to trust an advantage. + Object originalSnapshot = getField(root, "selectionSnapshot"); + setField(first, "visits", 32.0); + setField(first, "totalScore", -32.0); + assertFalse((boolean) early.invoke(ai, 49L)); + assertTrue((boolean) early.invoke(ai, 50L)); + assertEquals(1.0, winRate.invoke(ai)); + assertSame(originalSnapshot, getField(root, "selectionSnapshot")); + setField(first, "totalScore", -19.2); + assertFalse((boolean) early.invoke(ai, 200L)); // One visited branch cannot establish a margin. + setField(second, "visits", 32.0); + setField(second, "totalScore", -3.2); + assertFalse((boolean) early.invoke(ai, 100L)); + assertTrue((boolean) early.invoke(ai, 101L)); + assertArrayEquals(new int[]{3, 3}, (int[]) best.invoke(ai, root)); // Equal visits keep first. + Object pass = node(board, root, new int[]{-1, -1}); + setField(pass, "visits", 64.0); + setField(pass, "totalScore", -44.8); + synchronized (root) { children.add(pass); } + assertArrayEquals(new int[]{-1, -1}, (int[]) best.invoke(ai, root)); + assertEquals(0.7, (double) winRate.invoke(ai), 1e-12); + double[] distribution = ai.getVisitDistribution(); + assertEquals(0.25, distribution[3 * 19 + 3]); + assertEquals(0.25, distribution[4 * 19 + 4]); + assertEquals(0.5, distribution[361]); + assertNotSame(originalSnapshot, getField(root, "selectionSnapshot")); + setField(root, "children", new ArrayList<>()); + assertFalse((boolean) early.invoke(ai, 200L)); + assertEquals(0.0, winRate.invoke(ai)); + assertNull(best.invoke(ai, root)); + assertEquals(1.0, ai.getVisitDistribution()[361]); + } + + @Test + void primitiveSamplingMatchesSeededReferenceAcrossTemperaturesAndChangingCandidateCounts() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object root = node(board, null, null); + int[][] moves = {{3, 3}, {4, 4}, {-1, -1}, {5, 5}, {6, 6}}; + double[] visits = {1, 0, 16, 64, 4}; + List children = new ArrayList<>(); + for (int i = 0; i < moves.length; i++) { + Object child = node(board, root, moves[i]); + setField(child, "visits", visits[i]); + children.add(child); + } + var sample = MCTSGoAI.class.getDeclaredMethod("sampleMCTSMove", root.getClass(), int.class); + sample.setAccessible(true); + Random actualRandom = new Random(7943), referenceRandom = new Random(7943); + setField(ai, "random", actualRandom); + for (double exploration : new double[]{1.0, 0.0, 0.4}) { + setField(ai, "explorationScale", exploration); + for (int count : new int[]{5, 1, 4, 2, 5}) { + setField(root, "children", new ArrayList<>(children.subList(0, count))); + for (int moveCount : new int[]{0, 29, 30, 100}) { + double temperature = moveCount < 30 ? 0.9 * exploration + 0.1 : 0.3 * exploration + 0.05; + List indices = new ArrayList<>(); + List weights = new ArrayList<>(); + double sum = 0; + for (int i = 0; i < count; i++) { + if (visits[i] <= 0) continue; + double weight = temperature <= 0.01 ? visits[i] : Math.pow(visits[i], 1.0 / temperature); + indices.add(i); + weights.add(weight); + sum += weight; + } + for (int repeat = 0; repeat < 50; repeat++) { + double threshold = referenceRandom.nextDouble() * sum, cumulative = 0; + int expected = indices.getLast(); + for (int i = 0; i < indices.size(); i++) { + cumulative += weights.get(i); + if (cumulative >= threshold) { expected = indices.get(i); break; } + } + assertArrayEquals(moves[expected], (int[]) sample.invoke(ai, root, moveCount)); + } + } + } + } + for (Object child : children) setField(child, "visits", 0.0); + setField(root, "children", children); + assertArrayEquals(moves[0], (int[]) sample.invoke(ai, root, 0)); + assertEquals(referenceRandom.nextDouble(), actualRandom.nextDouble()); // Fallback consumes no randomness. + } + + @Test + void childSelectionPreservesScoresAndRefreshesOnlyChangedSnapshots() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object root = node(board, null, null); + Object winning = node(board, root, new int[]{3, 3}); + Object losing = node(board, root, new int[]{4, 4}); + Object unexplored = node(board, root, new int[]{5, 5}); + setField(root, "visits", 100.0); + setField(winning, "visits", 10.0); + setField(winning, "totalScore", -8.0); + setField(winning, "prior", 0.0); + setField(losing, "visits", 10.0); + setField(losing, "totalScore", 8.0); + setField(losing, "prior", 0.0); + setField(unexplored, "prior", 0.0); + setField(root, "children", new ArrayList<>(List.of(losing, winning, unexplored))); + var select = MCTSGoAI.class.getDeclaredMethod("selectBestChild", root.getClass()); + select.setAccessible(true); + assertSame(winning, select.invoke(ai, root)); + Object[] originalSnapshot = (Object[]) getField(root, "selectionSnapshot"); + setField(unexplored, "prior", 1.0); + assertSame(unexplored, select.invoke(ai, root)); + setField(unexplored, "prior", 0.0); + setField(winning, "totalScore", 8.0); + assertSame(unexplored, select.invoke(ai, root)); // Unvisited value is zero. + setField(winning, "totalScore", 0.0); + setField(losing, "totalScore", 0.0); + assertSame(losing, select.invoke(ai, root)); // First child wins equal scores. + setField(root, "visits", 0.0); + setField(unexplored, "prior", 1.0); + assertSame(unexplored, select.invoke(ai, root)); // Exploration works at zero parent visits. + assertSame(originalSnapshot, getField(root, "selectionSnapshot")); + setField(root, "children", new ArrayList<>(List.of(winning))); + assertSame(winning, select.invoke(ai, root)); + Object[] singleSnapshot = (Object[]) getField(root, "selectionSnapshot"); + assertNotSame(originalSnapshot, singleSnapshot); + setField(root, "children", new ArrayList<>(List.of(losing))); + assertSame(losing, select.invoke(ai, root)); // A replaced list can have the same size. + assertNotSame(singleSnapshot, getField(root, "selectionSnapshot")); + assertSame(winning, singleSnapshot[0]); // Older snapshots remain immutable for readers. + assertSame(unexplored, originalSnapshot[2]); + setField(root, "children", new ArrayList<>()); + assertNull(select.invoke(ai, root)); + assertNull(getField(root, "selectionSnapshot")); + assertNull(getField(root, "selectionSource")); + } + + @Test + void childSelectionSnapshotsStayIsolatedWhileOtherThreadsExpandParents() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object[] roots = {node(board, null, null), node(board, null, null)}; + Object[] winners = {node(board, roots[0], new int[]{3, 3}), node(board, roots[1], new int[]{4, 4})}; + List> children = new ArrayList<>(); + for (int n = 0; n < roots.length; n++) { + setField(roots[n], "visits", 100.0); + setField(winners[n], "visits", 1.0); + setField(winners[n], "totalScore", -1.0); + setField(winners[n], "prior", 0.0); + children.add(new ArrayList<>(List.of(winners[n]))); + setField(roots[n], "children", children.get(n)); + } + List additional = new ArrayList<>(); + for (int n = 0; n < 256; n++) { + Object child = node(board, roots[0], new int[]{n / 19, n % 19}); + setField(child, "visits", 1.0); + setField(child, "totalScore", 0.5); + setField(child, "prior", 0.0); + additional.add(child); + } + children.get(0).addAll(additional.subList(0, 64)); + var select = MCTSGoAI.class.getDeclaredMethod("selectBestChild", roots[0].getClass()); + select.setAccessible(true); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(5); + try { + List> tasks = new ArrayList<>(); + tasks.add(pool.submit(() -> { + start.await(); + for (Object child : additional.subList(64, additional.size())) { + synchronized (roots[0]) { children.get(0).add(child); } + } + return null; + })); + for (int reader = 0; reader < 4; reader++) { + tasks.add(pool.submit(() -> { + start.await(); + for (int repeat = 0; repeat < 500; repeat++) { + int parent = repeat % 2; // Alternate wide and narrow snapshots. + assertSame(winners[parent], select.invoke(ai, roots[parent])); + } + return null; + })); + } + start.countDown(); + for (Future task : tasks) task.get(10, TimeUnit.SECONDS); + assertEquals(257, children.get(0).size()); + assertEquals(1, children.get(1).size()); + for (int parent = 0; parent < roots.length; parent++) { + assertSame(winners[parent], select.invoke(ai, roots[parent])); + assertEquals(children.get(parent).size(), + ((Object[]) getField(roots[parent], "selectionSnapshot")).length); + } + } finally { + pool.shutdownNow(); + pool.awaitTermination(10, TimeUnit.SECONDS); + } + } + @Test void parallelSearchSharesTreeWithoutExceedingIterationBudget() throws Exception { MCTSGoAI searchAi = new MCTSGoAI(1_000, 8, 32); @@ -268,6 +525,81 @@ void treeReusePreservesIncomingEdgePrior() throws Exception { assertEquals(17.5, (double) getField(reused, "prior")); } + @Test + void treeCopyRebuildsCaptureAndPassBranchesWithIndependentBoardsAndMoveLists() throws Exception { + GoPlayer[][] board = emptyBoard(); + board[0][0] = GoPlayer.WHITE; + board[1][0] = GoPlayer.BLACK; + Object root = node(board, GoPlayer.BLACK, null, null, List.of(new int[]{7, 7, 0})); + GoPlayer[][] captureBoard = copy(board); + captureBoard[0][0] = GoPlayer.NONE; + captureBoard[0][1] = GoPlayer.BLACK; + Object captured = node(captureBoard, GoPlayer.WHITE, root, new int[]{0, 1}, + List.of(new int[]{8, 8, 0}, new int[]{-1, -1, 0})); + GoPlayer[][] replyBoard = copy(captureBoard); + replyBoard[4][4] = GoPlayer.WHITE; + Object reply = node(replyBoard, GoPlayer.BLACK, captured, new int[]{4, 4}, + List.of(new int[]{9, 9, 0})); + Object passed = node(copy(board), GoPlayer.WHITE, root, new int[]{-1, -1}, List.of()); + setField(root, "children", new ArrayList<>(List.of(captured, passed))); + setField(captured, "children", new ArrayList<>(List.of(reply))); + setField(captured, "visits", 12.0); + setField(captured, "totalScore", -3.0); + setField(captured, "prior", 2.5); + setField(captured, "hash", 123L); // Copies must compute hashes from rebuilt boards. + setField(captured, "valueCache", 0.25); + setField(captured, "valueCached", true); + double[] policy = new double[362]; + policy[7] = 0.75; + setField(captured, "policyCache", policy); + setField(passed, "consecutivePasses", 1); + setField(reply, "terminal", true); + + var clone = MCTSGoAI.class.getDeclaredMethod("deepCopyNode", root.getClass(), GoPlayer[][].class); + clone.setAccessible(true); + Object copied = clone.invoke(ai, root, board); + List children = (List) getField(copied, "children"); + Object copiedCapture = children.get(0), copiedPass = children.get(1); + Object copiedReply = ((List) getField(copiedCapture, "children")).getFirst(); + assertNull(getField(copied, "parent")); + assertSame(copied, getField(copiedCapture, "parent")); + assertSame(copied, getField(copiedPass, "parent")); + assertSame(copiedCapture, getField(copiedReply, "parent")); + assertEquals(12.0, getField(copiedCapture, "visits")); + assertEquals(-3.0, getField(copiedCapture, "totalScore")); + assertEquals(2.5, getField(copiedCapture, "prior")); + assertEquals(0.25, getField(copiedCapture, "valueCache")); + assertEquals(true, getField(copiedCapture, "valueCached")); + assertSame(policy, getField(copiedCapture, "policyCache")); + assertEquals(1, getField(copiedPass, "consecutivePasses")); + assertEquals(true, getField(copiedReply, "terminal")); + + List originals = List.of(root, captured, passed, reply); + List copies = List.of(copied, copiedCapture, copiedPass, copiedReply); + Set rows = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + for (Object original : originals) { + for (GoPlayer[] row : (GoPlayer[][]) getField(original, "board")) assertTrue(rows.add(row)); + } + for (int i = 0; i < copies.size(); i++) { + GoPlayer[][] actual = (GoPlayer[][]) getField(copies.get(i), "board"); + assertBoardEquals((GoPlayer[][]) getField(originals.get(i), "board"), actual); + assertEquals(GoGame.boardHash(actual), getField(copies.get(i), "hash")); + for (GoPlayer[] row : actual) assertTrue(rows.add(row), "board rows must be independent"); + List originalMoves = (List) getField(originals.get(i), "untriedMoves"); + List copiedMoves = (List) getField(copies.get(i), "untriedMoves"); + assertNotSame(originalMoves, copiedMoves); + int originalSize = originalMoves.size(); + assertEquals(originalSize, copiedMoves.size()); + copiedMoves.clear(); + assertEquals(originalSize, originalMoves.size()); + } + ((GoPlayer[][]) getField(copiedCapture, "board"))[1][0] = GoPlayer.NONE; + assertEquals(GoPlayer.BLACK, board[1][0]); + assertEquals(GoPlayer.BLACK, captureBoard[1][0]); + assertEquals(GoPlayer.BLACK, ((GoPlayer[][]) getField(copiedPass, "board"))[1][0]); + assertEquals(GoPlayer.BLACK, ((GoPlayer[][]) getField(copiedReply, "board"))[1][0]); + } + @Test void treeReuseRejectsSameBoardWithWrongPlayerToMove() throws Exception { GoPlayer[][] board = emptyBoard(); diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/OpenCLLifecycleRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/OpenCLLifecycleRegressionTest.java new file mode 100644 index 0000000..68fb29e --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/OpenCLLifecycleRegressionTest.java @@ -0,0 +1,283 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Field; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@Timeout(15) +class OpenCLLifecycleRegressionTest { + private Object previousShared; + private Object previousFailure; + private Object previousFailureAt; + private String previousGpu; + + @BeforeEach + void isolateSharedBackend() throws Exception { + previousShared = backendField("shared").get(null); + previousFailure = backendField("sharedFailure").get(null); + previousFailureAt = backendField("sharedFailureAt").get(null); + backendField("shared").set(null, null); + backendField("sharedFailure").set(null, null); + previousGpu = System.getProperty("go.gpu"); + System.setProperty("go.gpu", "true"); + } + + @AfterEach + void restoreSharedBackend() throws Exception { + OpenCLBackend backend = (OpenCLBackend) backendField("shared").get(null); + if (backend != null) { + var destroy = OpenCLBackend.class.getDeclaredMethod("destroyNative"); + destroy.setAccessible(true); + destroy.invoke(backend); + } + backendField("shared").set(null, previousShared); + backendField("sharedFailure").set(null, previousFailure); + backendField("sharedFailureAt").set(null, previousFailureAt); + if (previousGpu == null) System.clearProperty("go.gpu"); + else System.setProperty("go.gpu", previousGpu); + } + + @Test + void failedAcquisitionRetriesOnlyAfterCooldownAndClearsFailure() throws Exception { + OpenCLBackend unavailable = new OpenCLBackend(false); + OpenCLBackend recovered = availableBackend(); + AtomicInteger attempts = new AtomicInteger(); + Supplier factory = () -> attempts.incrementAndGet() == 1 ? unavailable : recovered; + long start = System.nanoTime(); + assertNull(OpenCLBackend.acquireShared(factory, start)); + assertNotNull(OpenCLBackend.lastSharedFailure()); + assertTrue(backendField("closed").getBoolean(unavailable)); + for (int i = 0; i < 20; i++) { + assertNull(OpenCLBackend.acquireShared(factory, start + OpenCLBackend.RETRY_DELAY_NANOS - 1)); + } + assertEquals(1, attempts.get()); + assertSame(recovered, OpenCLBackend.acquireShared(factory, start + OpenCLBackend.RETRY_DELAY_NANOS)); + assertEquals(2, attempts.get()); + assertNull(OpenCLBackend.lastSharedFailure()); + recovered.close(); + } + + @Test + void existingEvaluatorRecoversAndReleasedEvaluatorCannotReacquire() throws Exception { + long start = System.nanoTime(); + assertNull(OpenCLBackend.acquireShared(() -> new OpenCLBackend(false), start)); + NeuralEvaluator evaluator = new NeuralEvaluator(); + OpenCLBackend recovered = availableBackend(); + try { + assertFalse(evaluator.isGpuActive()); + assertSame(recovered, OpenCLBackend.acquireShared(() -> recovered, + start + OpenCLBackend.RETRY_DELAY_NANOS)); + assertFalse(evaluator.isGpuActive(), "the evaluator must respect its own cooldown"); + evaluatorField("openclFailureAt").setLong(evaluator, + System.nanoTime() - OpenCLBackend.RETRY_DELAY_NANOS); + assertTrue(evaluator.isGpuActive()); + assertEquals("test GPU", evaluator.gpuDeviceName()); + evaluator.release(); + assertTrue(recovered.isAvailable(), "the pool's other owner still holds a reference"); + assertFalse(evaluator.isGpuActive()); + assertEquals("CPU", evaluator.gpuDeviceName()); + assertNull(evaluatorField("opencl").get(evaluator)); + recovered.close(); + assertFalse(recovered.isAvailable()); + } finally { + evaluator.release(); + recovered.close(); + } + } + + @Test + void lastOwnerFreesBackendAndLaterAcquisitionCreatesNewContext() throws Exception { + OpenCLBackend first = availableBackend(); + assertSame(first, OpenCLBackend.acquireShared(() -> first, 0)); + assertSame(first, OpenCLBackend.acquireShared(() -> fail("unexpected reinitialization"), 0)); + first.close(); + assertTrue(first.isAvailable()); + assertSame(first, OpenCLBackend.peekShared()); + first.close(); + assertFalse(first.isAvailable()); + assertTrue(backendField("closed").getBoolean(first)); + assertNull(OpenCLBackend.peekShared()); + OpenCLBackend second = availableBackend(); + assertSame(second, OpenCLBackend.acquireShared(() -> second, 0)); + first.close(); + assertTrue(second.isAvailable(), "closing an old context must not close its replacement"); + second.close(); + assertFalse(second.isAvailable()); + } + + @Test + void finalCloseWaitsForActiveNativeBatch() throws Exception { + OpenCLBackend backend = availableBackend(); + OpenCLBackend.acquireShared(() -> backend, 0); + ReentrantLock nativeLock = (ReentrantLock) backendField("lifecycleLock").get(backend); + CountDownLatch started = new CountDownLatch(1); + FutureTask closing = new FutureTask<>(() -> { + started.countDown(); + backend.close(); + return null; + }); + Thread closer = new Thread(closing, "test-opencl-close"); + nativeLock.lock(); + try { + closer.start(); + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + assertTrue(backend.isAvailable()); + } finally { + nativeLock.unlock(); + closer.join(2_000); + } + closing.get(2, TimeUnit.SECONDS); + assertFalse(backend.isAvailable()); + assertNull(OpenCLBackend.peekShared()); + } + + @Test + void interruptedInFlightRequestKeepsInputsAliveAndReturnsCompletedResult() throws Exception { + exerciseInterruptedRequest(true); + } + + @Test + void interruptedQueuedRequestIsRemovedWithoutWaitingForAnotherBatch() throws Exception { + exerciseInterruptedRequest(false); + } + + @SuppressWarnings("unchecked") + private void exerciseInterruptedRequest(boolean inFlight) throws Exception { + NeuralEvaluator evaluator = new NeuralEvaluator(); + ReentrantLock drain = (ReentrantLock) evaluatorField("gpuInferDrain").get(evaluator); + ConcurrentLinkedQueue queue = (ConcurrentLinkedQueue) evaluatorField("gpuInferQueue").get(evaluator); + var coalesce = NeuralEvaluator.class.getDeclaredMethod("coalesceGpuForward", + OpenCLBackend.class, double[][][].class, double[].class); + coalesce.setAccessible(true); + double[][][] planes = new double[4][19][19]; + planes[0][0][0] = 1.0; + AtomicBoolean interruptPreserved = new AtomicBoolean(); + FutureTask pending = new FutureTask<>(() -> { + var result = (NeuralEvaluator.ForwardResult) coalesce.invoke(evaluator, null, planes, new double[24]); + interruptPreserved.set(Thread.currentThread().isInterrupted()); + planes[0][0][0] = 2.0; // Caller reuses its thread-local input after returning. + return result; + }); + Thread caller = new Thread(pending, "test-gpu-waiter"); + caller.setDaemon(true); + CompletableFuture completion = null; + drain.lock(); + try { + caller.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (queue.isEmpty() && System.nanoTime() < deadline) Thread.sleep(1); + Object call = inFlight ? queue.poll() : queue.peek(); + assertNotNull(call, "request was not enqueued"); + var doneField = call.getClass().getDeclaredField("done"); + doneField.setAccessible(true); + completion = (CompletableFuture) doneField.get(call); + caller.interrupt(); + if (inFlight) { + assertThrows(TimeoutException.class, () -> pending.get(100, TimeUnit.MILLISECONDS)); + assertFalse(completion.isDone(), "interrupt must not overwrite the consumer's result"); + assertEquals(1.0, planes[0][0][0], "input must remain owned by the batch"); + var expected = new NeuralEvaluator.ForwardResult(0.75, new double[362]); + completion.complete(expected); + assertSame(expected, pending.get(2, TimeUnit.SECONDS)); + } else { + assertNull(pending.get(2, TimeUnit.SECONDS)); + assertTrue(completion.isCancelled()); + assertTrue(queue.isEmpty()); + } + assertTrue(interruptPreserved.get()); + assertEquals(2.0, planes[0][0][0]); + assertEquals(0, ((AtomicInteger) evaluatorField("gpuInferWaiters").get(evaluator)).get()); + } finally { + if (completion != null) completion.complete(null); + drain.unlock(); + caller.join(2_000); + evaluator.release(); + } + } + + @Test + void compactSubWeightsMatchCpuAcrossBatchSizesAndModelSwitchesOnGpu() { + try (OpenCLBackend backend = OpenCLBackend.acquireShared()) { + assumeTrue(backend != null, "OpenCL GPU unavailable; native parity requires a device"); + System.setProperty("go.gpu", "false"); + NeuralEvaluator first = new NeuralEvaluator(); + NeuralEvaluator second = new NeuralEvaluator(); + try { + Random random = new Random(72831); + for (int pass = 0; pass < 4; pass++) { + NeuralEvaluator cpu = pass == 1 ? second : first; + NeuralEvaluator.ModelWeights weights = cpu.snapshot(); + if (pass == 3) { + for (int block = 0; block < 9; block++) { + weights.subB1[block][block] += 0.2 * (block + 1); + } + cpu.apply(weights); + weights = cpu.snapshot(); + } + int size = pass + 1; + double[][][][] planes = new double[size][4][19][19]; + double[][] aux = new double[size][24]; + NeuralEvaluator.ForwardResult[] expected = new NeuralEvaluator.ForwardResult[size]; + for (int n = 0; n < size; n++) { + for (double[][] plane : planes[n]) for (double[] row : plane) { + for (int y = 0; y < row.length; y++) row[y] = random.nextDouble(); + } + for (int i = 0; i < 24; i++) aux[n][i] = random.nextDouble(); + expected[n] = cpu.forward(planes[n], aux[n]); + } + double[][] policy = new double[size][362]; + double[] value = new double[size]; + assertTrue(backend.inferForward(planes, aux, size, pass == 1 ? 2 : 1, pass == 3 ? 1 : 0, + weights.subW1, weights.subB1, weights.blockW1, weights.blockB1, + weights.topW1, weights.topB1, weights.policyW, weights.policyB, + weights.valueW1, weights.valueB1, weights.valueW2, weights.valueB2, policy, value), + "native inference must succeed, without a hidden CPU fallback"); + for (int n = 0; n < size; n++) { + assertEquals(expected[n].value, value[n], 1e-5); + assertArrayEquals(expected[n].policy, policy[n], 1e-5); + } + } + } finally { + first.release(); + second.release(); + } + } + } + + private static OpenCLBackend availableBackend() throws Exception { + OpenCLBackend backend = new OpenCLBackend(false); + backendField("available").setBoolean(backend, true); + backendField("deviceName").set(backend, "test GPU"); + return backend; + } + + private static Field backendField(String name) throws Exception { + Field field = OpenCLBackend.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + private static Field evaluatorField(String name) throws Exception { + Field field = NeuralEvaluator.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } +} diff --git a/src/test/java/com/wzz/game_console/util/GameRenderHelperTest.java b/src/test/java/com/wzz/game_console/util/GameRenderHelperTest.java new file mode 100644 index 0000000..dff15fc --- /dev/null +++ b/src/test/java/com/wzz/game_console/util/GameRenderHelperTest.java @@ -0,0 +1,15 @@ +package com.wzz.game_console.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class GameRenderHelperTest { + @Test + void cachedCircleScanlinesKeepTheOriginalPixelShape() { + assertArrayEquals(new int[]{0}, GameRenderHelper.circleHalfWidths(0)); + assertArrayEquals(new int[]{0, 2, 2, 3, 2, 2, 0}, GameRenderHelper.circleHalfWidths(3)); + assertSame(GameRenderHelper.circleHalfWidths(3), GameRenderHelper.circleHalfWidths(3)); + } +}