From 6e946abc65a7b1459942aa31a3de7a6c6fb8751d Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 12:42:01 +0800 Subject: [PATCH 1/4] perf(gb-bot): replace tileNumber/isHonor string parsing with lookup tables GbRoundSupport.tileNumber() allocated a new String via name().substring(1,2) and parsed it with Integer.parseInt on every call. In the GB bot discard decision hot loop (GbBotDecisionService.discardPreference), tileNumber and isHonor are called once per candidate tile per hand tile, producing repeated short-lived String allocations that escape into parseInt and cannot be escape-analyzed away. Precompute two ordinal-indexed arrays once at class init: - TILE_NUMBERS: the 1-9 value for suited tiles, 0 for honors/flowers/unknown - TILE_HONORS: the EAST..RED_DRAGON honor range as a boolean mask tileNumber() and isHonor() now perform a single array load instead of string allocation + parsing. Behavior is unchanged: the same control flow (null/flower/honor guard) is preserved, and the lookup values are computed from the original expressions during static init. Target profile: gb-bot (must_pass: gb.duplicate.time, gb.mixed.time, gb.unique.time). --- .../table/core/round/GbRoundSupport.java | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java b/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java index 3dbbaf6..e7a688d 100644 --- a/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java +++ b/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java @@ -118,15 +118,55 @@ static boolean sameKind(MahjongTile left, MahjongTile right) { return leftBase == rightBase; } + // Precomputed lookup tables indexed by MahjongTile.ordinal(). Eliminates the + // per-call String allocation (name().substring(1,2)) and Integer.parseInt + // parsing inside hot loops such as GbBotDecisionService.discardPreference. + private static final int[] TILE_NUMBERS = buildTileNumbers(); + private static final boolean[] TILE_HONORS = buildTileHonors(); + + private static int[] buildTileNumbers() { + MahjongTile[] values = MahjongTile.values(); + int[] numbers = new int[values.length]; + int east = MahjongTile.EAST.ordinal(); + int redDragon = MahjongTile.RED_DRAGON.ordinal(); + for (MahjongTile tile : values) { + int ord = tile.ordinal(); + if (tile.isFlower() || (ord >= east && ord <= redDragon)) { + numbers[ord] = 0; + continue; + } + // UNKNOWN and any other non-suited tile would fail parseInt; leave 0. + // Callers never pass such tiles to tileNumber() (they guard with + // isFlower()/isHonor() or only pass suited tiles from hands/walls). + try { + numbers[ord] = Integer.parseInt(tile.name().substring(1, 2)); + } catch (NumberFormatException ignored) { + numbers[ord] = 0; + } + } + return numbers; + } + + private static boolean[] buildTileHonors() { + MahjongTile[] values = MahjongTile.values(); + boolean[] honors = new boolean[values.length]; + int east = MahjongTile.EAST.ordinal(); + int redDragon = MahjongTile.RED_DRAGON.ordinal(); + for (int i = 0; i < values.length; i++) { + honors[i] = i >= east && i <= redDragon; + } + return honors; + } + static boolean isHonor(MahjongTile tile) { - return tile.ordinal() >= MahjongTile.EAST.ordinal() && tile.ordinal() <= MahjongTile.RED_DRAGON.ordinal(); + return TILE_HONORS[tile.ordinal()]; } static int tileNumber(MahjongTile tile) { if (tile == null || tile.isFlower() || isHonor(tile)) { return 0; } - return Integer.parseInt(tile.name().substring(1, 2)); + return TILE_NUMBERS[tile.ordinal()]; } static MahjongTile offsetTile(MahjongTile tile, int delta) { From 0a3e879c9fcb1809ad189fa809697dc3475eb3c9 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 13:14:10 +0800 Subject: [PATCH 2/4] perf(gb-bot): reuse tingMemo array via ThreadLocal to avoid per-call allocation bestDiscardChoice allocated a 45-element GbTingResponse[] on every call to memoize ting evaluations by tile ordinal. On the duplicate-hand hot path measured by GbBotDecisionBenchmark.duplicateHand the memo only ever writes a single slot, so the array allocation dominates the per-iteration allocation footprint without delivering reuse benefit. Replace the per-call allocation with a ThreadLocal buffer that is cleared via Arrays.fill at the start of each call. Semantics are unchanged: the array is indexed by MahjongTile.ordinal(), fully reset before use, and never escapes the method. Each worker thread gets its own buffer, so there is no contention or cross-thread visibility concern. This eliminates ~376 bytes of heap allocation per discard decision (object header + 45 pointer slots), which should push the duplicateHand improvement above the 3% AB gate threshold that the lookup-table-only commit could not clear on its own. --- .../table/core/round/GbBotDecisionService.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java index 7045301..2014755 100644 --- a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java +++ b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java @@ -9,6 +9,7 @@ import top.ellan.mahjong.riichi.ReactionResponse; import top.ellan.mahjong.riichi.ReactionResponses; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Locale; import kotlin.Pair; @@ -16,6 +17,17 @@ final class GbBotDecisionService { private static final int TILE_KIND_COUNT = MahjongTile.values().length; + /** + * Per-thread reuse buffer for the {@code tingMemo} array used by + * {@link #bestDiscardChoice}. The array is indexed by tile ordinal and is + * fully cleared ({@link Arrays#fill}) at the start of every call, so no + * state leaks across invocations. Reuse eliminates a 45-element heap + * allocation on every discard decision, which is the dominant allocation + * on the duplicate-hand hot path measured by {@code GbBotDecisionBenchmark}. + */ + private static final ThreadLocal TING_MEMO_BUFFER = + ThreadLocal.withInitial(() -> new GbTingResponse[TILE_KIND_COUNT]); + private final int minimumFan; GbBotDecisionService(int minimumFan) { @@ -189,7 +201,8 @@ private DiscardChoice bestDiscardChoice( int bestIndex = -1; long bestReadyScore = 0; int bestDiscardPreference = 0; - GbTingResponse[] tingMemo = new GbTingResponse[TILE_KIND_COUNT]; + GbTingResponse[] tingMemo = TING_MEMO_BUFFER.get(); + Arrays.fill(tingMemo, null); MahjongTile previousDiscarded = null; int previousDiscardPreference = 0; for (int i = 0; i < hand.size(); i++) { From c6d1d01209570d99a95dbfacdb194402930aa0f4 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 13:26:36 +0800 Subject: [PATCH 3/4] Revert "perf(gb-bot): reuse tingMemo array via ThreadLocal to avoid per-call allocation" This reverts commit 0a3e879c9fcb1809ad189fa809697dc3475eb3c9. --- .../table/core/round/GbBotDecisionService.java | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java index 2014755..7045301 100644 --- a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java +++ b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java @@ -9,7 +9,6 @@ import top.ellan.mahjong.riichi.ReactionResponse; import top.ellan.mahjong.riichi.ReactionResponses; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Locale; import kotlin.Pair; @@ -17,17 +16,6 @@ final class GbBotDecisionService { private static final int TILE_KIND_COUNT = MahjongTile.values().length; - /** - * Per-thread reuse buffer for the {@code tingMemo} array used by - * {@link #bestDiscardChoice}. The array is indexed by tile ordinal and is - * fully cleared ({@link Arrays#fill}) at the start of every call, so no - * state leaks across invocations. Reuse eliminates a 45-element heap - * allocation on every discard decision, which is the dominant allocation - * on the duplicate-hand hot path measured by {@code GbBotDecisionBenchmark}. - */ - private static final ThreadLocal TING_MEMO_BUFFER = - ThreadLocal.withInitial(() -> new GbTingResponse[TILE_KIND_COUNT]); - private final int minimumFan; GbBotDecisionService(int minimumFan) { @@ -201,8 +189,7 @@ private DiscardChoice bestDiscardChoice( int bestIndex = -1; long bestReadyScore = 0; int bestDiscardPreference = 0; - GbTingResponse[] tingMemo = TING_MEMO_BUFFER.get(); - Arrays.fill(tingMemo, null); + GbTingResponse[] tingMemo = new GbTingResponse[TILE_KIND_COUNT]; MahjongTile previousDiscarded = null; int previousDiscardPreference = 0; for (int i = 0; i < hand.size(); i++) { From 1b6b83bac93d674e3be4da009a9b3b12450d009f Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 13:27:34 +0800 Subject: [PATCH 4/4] perf(gb-bot): avoid DiscardChoice allocation on suggestedDiscardIndex hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suggestedDiscardIndex only needs the discard index, but went through bestDiscardChoice which always allocates a DiscardChoice record (int index, long readyScore, int discardPreference) just to immediately extract .index() and discard the record. On the duplicateHand hot path measured by GbBotDecisionBenchmark.duplicateHand this is a per-iteration allocation that the caller never benefits from. Add a bestDiscardIndex variant that returns primitive int directly, avoiding the record allocation. The loop body is identical to bestDiscardChoice; only the return shape differs. bestDiscardChoice is retained unchanged for the reaction/kan callers that need the full (index, readyScore, discardPreference) triple. This is a pure allocation elimination — no behavior change, no ThreadLocal overhead, no shared mutable state. Pairs with the lookup-table optimization in the previous commit to push duplicateHand above the 3% AB gate threshold. --- .../core/round/GbBotDecisionService.java | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java index 7045301..cd3298e 100644 --- a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java +++ b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java @@ -30,8 +30,7 @@ int suggestedDiscardIndex( if (hand == null || hand.isEmpty()) { return -1; } - DiscardChoice best = this.bestDiscardChoice(hand, melds, tingEvaluator); - return best == null ? hand.size() - 1 : best.index(); + return this.bestDiscardIndex(hand, melds, tingEvaluator); } ReactionResponse suggestedReaction( @@ -178,6 +177,54 @@ private long readyScoreForBestDiscard( return best == null ? 0 : best.readyScore(); } + /** + * Index-only variant of {@link #bestDiscardChoice} for the + * {@link #suggestedDiscardIndex} hot path. Avoids allocating a + * {@link DiscardChoice} record per call when the caller only needs the + * discard index. The loop body is identical; only the return shape + * differs (primitive {@code int} vs record). + */ + private int bestDiscardIndex( + List hand, + List melds, + TingEvaluator tingEvaluator + ) { + int bestIndex = -1; + long bestReadyScore = 0; + int bestDiscardPreference = 0; + GbTingResponse[] tingMemo = new GbTingResponse[TILE_KIND_COUNT]; + MahjongTile previousDiscarded = null; + int previousDiscardPreference = 0; + for (int i = 0; i < hand.size(); i++) { + MahjongTile discarded = hand.get(i); + int discardedOrdinal = discarded.ordinal(); + GbTingResponse ting = tingMemo[discardedOrdinal]; + boolean tingMemoHit = ting != null; + if (ting == null) { + List remaining = new ArrayList<>(hand); + remaining.remove(i); + ting = tingEvaluator.evaluate(remaining, melds); + if (ting != null) { + tingMemo[discardedOrdinal] = ting; + } + } + long candidateReadyScore = readyScore(ting); + int candidateDiscardPreference = tingMemoHit && discarded == previousDiscarded + ? previousDiscardPreference + : discardPreference(hand, discarded); + previousDiscarded = discarded; + previousDiscardPreference = candidateDiscardPreference; + if (bestIndex < 0 + || candidateReadyScore > bestReadyScore + || (candidateReadyScore == bestReadyScore && candidateDiscardPreference > bestDiscardPreference)) { + bestIndex = i; + bestReadyScore = candidateReadyScore; + bestDiscardPreference = candidateDiscardPreference; + } + } + return bestIndex < 0 ? hand.size() - 1 : bestIndex; + } + private DiscardChoice bestDiscardChoice( List hand, List melds,