diff --git a/.github/workflows/performance-ab.yml b/.github/workflows/performance-ab.yml index 7876ad5..184644a 100644 --- a/.github/workflows/performance-ab.yml +++ b/.github/workflows/performance-ab.yml @@ -40,6 +40,10 @@ on: - layout - scheduler-reflection - ray-proxy + - riichi-reaction + - sichuan-hu + - region-apply + - layout-cache - infra forks: description: Trusted CI override for JMH forks (blank uses base config) @@ -157,6 +161,10 @@ jobs: "performance-layout": "layout", "performance-scheduler-reflection": "scheduler-reflection", "performance-ray-proxy": "ray-proxy", + "performance-riichi-reaction": "riichi-reaction", + "performance-sichuan-hu": "sichuan-hu", + "performance-region-apply": "region-apply", + "performance-layout-cache": "layout-cache", } selected = [profile for label, profile in choices.items() if label in labels] if len(selected) != 1: diff --git a/perf/ab/gate-config.json b/perf/ab/gate-config.json index f438505..11457ec 100644 --- a/perf/ab/gate-config.json +++ b/perf/ab/gate-config.json @@ -189,6 +189,88 @@ "top/ellan/mahjong/runtime/ServerScheduler.class" ] }, + "riichi-reaction": { + "include": "^top\\.ellan\\.mahjong\\.riichi\\.RiichiReactionBenchmark\\.(?:structurallyImpossibleCall|structurallyImpossibleRonOnly|firstPossiblePonAnalysis)$", + "benchmark_ids": [ + "riichi.impossible.time", + "riichi.impossible.alloc", + "riichi.ron-only.time", + "riichi.ron-only.alloc", + "riichi.pon.time", + "riichi.pon.alloc" + ], + "minimum_passes": 2, + "must_pass": [ + "riichi.impossible.time", + "riichi.ron-only.time" + ], + "required_classes": [ + "top/ellan/mahjong/riichi/RiichiPlayerAnalysisState.class", + "top/ellan/mahjong/riichi/RiichiPlayerState.class" + ] + }, + "sichuan-hu": { + "include": "^top\\.ellan\\.mahjong\\.table\\.core\\.round\\.SichuanHuEvaluatorBenchmark\\.(?:waitingTilesStandardHand|evaluateStandardWin|canWinWithFixedMeld)$", + "benchmark_ids": [ + "sichuan.waiting.time", + "sichuan.waiting.alloc", + "sichuan.evaluate.time", + "sichuan.evaluate.alloc", + "sichuan.can-win.time", + "sichuan.can-win.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "sichuan.waiting.time", + "sichuan.evaluate.time", + "sichuan.can-win.time" + ], + "required_classes": [ + "top/ellan/mahjong/table/core/round/SichuanHuEvaluator.class" + ] + }, + "region-apply": { + "include": "^top\\.ellan\\.mahjong\\.table\\.render\\.RegionRenderApplyBenchmark\\.(?:planAndDeferRepresentativeTableRegions|applyStableStartedTable|precomputeCompleteStartedTableRegions)$", + "benchmark_ids": [ + "region.apply-deferred.time", + "region.apply-deferred.alloc", + "region.apply-stable.time", + "region.apply-stable.alloc", + "region.complete-fingerprints.time", + "region.complete-fingerprints.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "region.apply-deferred.time", + "region.apply-stable.time", + "region.complete-fingerprints.time" + ], + "required_classes": [ + "top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.class", + "top/ellan/mahjong/table/render/TableRegionFingerprintService.class" + ] + }, + "layout-cache": { + "include": "^top\\.ellan\\.mahjong\\.table\\.core\\.SessionRenderLayoutCacheBenchmark\\.(?:stableSessionCacheHit|representativeSeatComponentMiss|representativeWallComponentMiss)$", + "benchmark_ids": [ + "layout-cache.hit.time", + "layout-cache.hit.alloc", + "layout-cache.seat-miss.time", + "layout-cache.seat-miss.alloc", + "layout-cache.wall-miss.time", + "layout-cache.wall-miss.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "layout-cache.hit.time", + "layout-cache.seat-miss.time", + "layout-cache.wall-miss.time" + ], + "required_classes": [ + "top/ellan/mahjong/render/layout/TableRenderLayout.class", + "top/ellan/mahjong/table/core/MahjongTableSession.class" + ] + }, "ray-proxy": { "include": "^top\\.ellan\\.mahjong\\.perf\\.RayProxyCoordinatorBenchmark\\.proxyPlan(?:1Viewer|4Viewers|32Viewers)$", "benchmark_ids": [ @@ -414,6 +496,150 @@ "metric": "gc.alloc.rate.norm", "direction": "lower" }, + { + "id": "riichi.impossible.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleCall", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.impossible.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleCall", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "riichi.ron-only.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleRonOnly", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.ron-only.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleRonOnly", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "riichi.pon.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.firstPossiblePonAnalysis", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.pon.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.firstPossiblePonAnalysis", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.waiting.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.waitingTilesStandardHand", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.waiting.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.waitingTilesStandardHand", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.evaluate.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.evaluateStandardWin", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.evaluate.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.evaluateStandardWin", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.can-win.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.canWinWithFixedMeld", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.can-win.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.canWinWithFixedMeld", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.apply-deferred.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.planAndDeferRepresentativeTableRegions", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.apply-deferred.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.planAndDeferRepresentativeTableRegions", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.apply-stable.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.applyStableStartedTable", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.apply-stable.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.applyStableStartedTable", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.complete-fingerprints.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.precomputeCompleteStartedTableRegions", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.complete-fingerprints.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.precomputeCompleteStartedTableRegions", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.hit.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.stableSessionCacheHit", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.hit.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.stableSessionCacheHit", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.seat-miss.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeSeatComponentMiss", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.seat-miss.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeSeatComponentMiss", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.wall-miss.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeWallComponentMiss", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.wall-miss.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeWallComponentMiss", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, { "id": "ray.viewers1.time", "benchmark": "top.ellan.mahjong.perf.RayProxyCoordinatorBenchmark.proxyPlan1Viewer", diff --git a/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java b/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java new file mode 100644 index 0000000..83a2180 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java @@ -0,0 +1,94 @@ +package top.ellan.mahjong.riichi; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.riichi.model.MahjongTile; +import top.ellan.mahjong.riichi.model.TileInstance; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RiichiReactionBenchmark { + @Benchmark + public ReactionOptions structurallyImpossibleCall(NoCallState state) { + return state.player.reactionOptionsFor(state.discard, false, false); + } + + @Benchmark + public ReactionOptions structurallyImpossibleRonOnly(NoCallState state) { + return state.player.reactionOptionsFor(state.discard, false, true); + } + + @Benchmark + public ReactionOptions firstPossiblePonAnalysis(PossibleCallState state) { + return state.player.reactionOptionsFor(state.discard, false, false); + } + + @State(Scope.Thread) + public static class NoCallState { + private RiichiPlayerState player; + private TileInstance discard; + private Map furoReactions; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.player = player(List.of( + MahjongTile.M1, MahjongTile.M4, MahjongTile.M7, + MahjongTile.P1, MahjongTile.P4, MahjongTile.P7, + MahjongTile.S1, MahjongTile.S4, MahjongTile.S7, + MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON + )); + this.discard = tile(100, MahjongTile.EAST); + if (this.player.reactionOptionsFor(this.discard, false, false) != null) { + throw new IllegalStateException("No-call reaction fixture changed"); + } + Field cacheField = RiichiPlayerAnalysisState.class.getDeclaredField("cachedFuroReactions"); + cacheField.setAccessible(true); + this.furoReactions = (Map) cacheField.get(this.player); + } + + @Setup(Level.Invocation) + public void clearFuroReaction() { + this.furoReactions.clear(); + } + } + + @State(Scope.Thread) + public static class PossibleCallState { + private RiichiPlayerState player; + private TileInstance discard; + + @Setup(Level.Invocation) + public void setUp() { + this.player = player(List.of( + MahjongTile.M5_RED, MahjongTile.M5, MahjongTile.M1, MahjongTile.M9, + MahjongTile.P1, MahjongTile.P9, MahjongTile.S1, MahjongTile.S9, + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON + )); + this.discard = tile(200, MahjongTile.M5); + } + } + + private static RiichiPlayerState player(List hand) { + RiichiPlayerState player = new RiichiPlayerState("benchmark", "benchmark", false); + for (int index = 0; index < hand.size(); index++) { + player.getHands().add(tile(index, hand.get(index))); + } + return player; + } + + private static TileInstance tile(int id, MahjongTile tile) { + return new TileInstance(new UUID(0L, id + 1L), tile); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java new file mode 100644 index 0000000..8a5c26f --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java @@ -0,0 +1,208 @@ +package top.ellan.mahjong.table.core; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.EnumMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class SessionRenderLayoutCacheBenchmark { + @Benchmark + public TableRenderLayout.LayoutPlan stableSessionCacheHit(HitState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + @Benchmark + public TableRenderLayout.LayoutPlan representativeSeatComponentMiss(SeatMissState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + @Benchmark + public TableRenderLayout.LayoutPlan representativeWallComponentMiss(WallMissState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + private abstract static class CacheState { + private MethodHandle precompute; + + final void initializePrecompute() throws ReflectiveOperationException { + try { + Class cacheType = Class.forName("top.ellan.mahjong.table.core.SessionRenderLayoutCache"); + Constructor constructor = cacheType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object cache = constructor.newInstance(); + Method method = cacheType.getDeclaredMethod("precompute", TableRenderSnapshot.class); + method.setAccessible(true); + this.precompute = MethodHandles.lookup() + .unreflect(method) + .bindTo(cache) + .asType(MethodType.methodType(TableRenderLayout.LayoutPlan.class, TableRenderSnapshot.class)); + } catch (ClassNotFoundException ignored) { + this.precompute = MethodHandles.lookup().findStatic( + TableRenderLayout.class, + "precompute", + MethodType.methodType(TableRenderLayout.LayoutPlan.class, TableRenderSnapshot.class) + ); + } catch (NoSuchMethodException | IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } + + final TableRenderLayout.LayoutPlan precompute(TableRenderSnapshot snapshot) { + try { + return (TableRenderLayout.LayoutPlan) this.precompute.invokeExact(snapshot); + } catch (Throwable throwable) { + throw new IllegalStateException("Unable to invoke the layout precompute benchmark target", throwable); + } + } + } + + @State(Scope.Thread) + public static class HitState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L); + this.second = snapshot(2L); + this.precompute(this.first); + } + } + + @State(Scope.Thread) + public static class SeatMissState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L, 70, List.of(4)); + this.second = snapshot(2L, 70, List.of(3)); + this.precompute(this.first); + } + } + + @State(Scope.Thread) + public static class WallMissState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L, 70, List.of(4)); + this.second = snapshot(2L, 69, List.of(4)); + this.precompute(this.first); + } + } + + private static TableRenderSnapshot snapshot(long version) { + return snapshot(version, 70, List.of(4)); + } + + private static TableRenderSnapshot snapshot(long version, int remainingWallCount, List selectedIndices) { + List hand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, MahjongTile.M4, MahjongTile.M5_RED, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, MahjongTile.S6, MahjongTile.S7, + MahjongTile.S8, MahjongTile.RED_DRAGON, MahjongTile.RED_DRAGON + ); + List discards = List.of( + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.NORTH, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON, MahjongTile.RED_DRAGON, + MahjongTile.M1, MahjongTile.M9, MahjongTile.P1, MahjongTile.P9, + MahjongTile.S1, MahjongTile.S9, MahjongTile.M5_RED, MahjongTile.P5_RED, + MahjongTile.S5_RED, MahjongTile.M4, MahjongTile.P6 + ); + MeldView meld = new MeldView( + List.of(MahjongTile.P2, MahjongTile.P2, MahjongTile.P2), + List.of(false, false, false), + 1, + 90, + MahjongTile.P2 + ); + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SeatWind.values()) { + seats.put(wind, new TableSeatRenderSnapshot( + wind, + new UUID(0L, wind.index() + 1L), + wind.name(), + wind.name(), + 25_000, + wind == SeatWind.EAST, + true, + false, + true, + "viewer-" + wind.name(), + selectedIndices.get(0), + selectedIndices, + wind == SeatWind.EAST ? 5 : -1, + wind.index() + 1, + List.of(), + hand, + discards, + List.of(meld), + List.of(ScoringStick.P1000), + List.of(ScoringStick.P100) + )); + } + return new TableRenderSnapshot( + version, + 0L, + "benchmark_world", + 12.75D, + 64.0D, + -7.25D, + true, + false, + false, + remainingWallCount, + 1, + 7, + 5, + 1, + 0, + SeatWind.EAST, + SeatWind.SOUTH, + SeatWind.WEST, + "waiting", + "rules", + "center-" + version, + null, + null, + List.of(MahjongTile.M1), + MahjongVariant.RIICHI, + seats + ); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java new file mode 100644 index 0000000..29448b1 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java @@ -0,0 +1,56 @@ +package top.ellan.mahjong.table.core.round; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.model.MahjongTile; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class SichuanHuEvaluatorBenchmark { + private List waitingHand; + private List fixedMeldHand; + + @Setup(Level.Trial) + public void setUp() { + this.waitingHand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, + MahjongTile.M4, MahjongTile.M5_RED, MahjongTile.M6, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, + MahjongTile.S7, MahjongTile.S8, MahjongTile.S9, MahjongTile.P5 + ); + this.fixedMeldHand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, + MahjongTile.S9, MahjongTile.S9, MahjongTile.S5_RED, MahjongTile.S5 + ); + if (!SichuanHuEvaluator.waitingTiles(this.waitingHand, 0) + .equals(List.of(MahjongTile.P2, MahjongTile.P5)) + || !SichuanHuEvaluator.canWin(this.fixedMeldHand, MahjongTile.S5, 1)) { + throw new IllegalStateException("Sichuan benchmark fixture changed"); + } + } + + @Benchmark + public List waitingTilesStandardHand() { + return SichuanHuEvaluator.waitingTiles(this.waitingHand, 0); + } + + @Benchmark + public SichuanHuEvaluator.Result evaluateStandardWin() { + return SichuanHuEvaluator.evaluate(this.waitingHand, MahjongTile.P5_RED, 0); + } + + @Benchmark + public boolean canWinWithFixedMeld() { + return SichuanHuEvaluator.canWin(this.fixedMeldHand, MahjongTile.S5, 1); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java new file mode 100644 index 0000000..88a9ad2 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java @@ -0,0 +1,210 @@ +package top.ellan.mahjong.table.render; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.bukkit.entity.Player; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import top.ellan.mahjong.render.display.DisplayClickAction; +import top.ellan.mahjong.render.display.DisplayInteractionRayRegistry; +import top.ellan.mahjong.table.core.TableSessionContext; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RayProxyLifecycleBenchmark { + @Benchmark + public int initialCreate32Viewers(InitialCreateState state) { + state.coordinator.replace("viewer-actions", state.interactions); + return state.coordinator.entityCount(); + } + + @Benchmark + public int unchanged32Viewers(LifecycleState state) { + state.coordinator.replace("viewer-actions", state.geometryA); + return state.coordinator.entityCount(); + } + + @Benchmark + public int changedGeometry32Viewers(LifecycleState state) { + state.geometryBActive = !state.geometryBActive; + state.coordinator.replace("viewer-actions", state.geometryBActive ? state.geometryB : state.geometryA); + return state.coordinator.entityCount(); + } + + @Benchmark + public int oneViewerChurn(LifecycleState state) { + state.lastViewerActive = !state.lastViewerActive; + state.coordinator.replace("viewer-actions", state.lastViewerActive ? state.geometryA : state.geometryA31); + return state.coordinator.entityCount(); + } + + @State(Scope.Thread) + public static class InitialCreateState { + private SparrowRayInteractionProxyCoordinator coordinator; + private Map> interactions; + + @Setup(Level.Invocation) + public void setUp() { + Fixture fixture = fixture(); + this.coordinator = fixture.coordinator; + this.interactions = fixture.geometryA; + } + + @TearDown(Level.Invocation) + public void tearDown() { + this.coordinator.shutdown(); + } + } + + @State(Scope.Thread) + public static class LifecycleState { + private SparrowRayInteractionProxyCoordinator coordinator; + private Map> geometryA; + private Map> geometryB; + private Map> geometryA31; + private boolean geometryBActive; + private boolean lastViewerActive = true; + + @Setup(Level.Trial) + public void setUp() { + Fixture fixture = fixture(); + this.coordinator = fixture.coordinator; + this.geometryA = fixture.geometryA; + this.geometryB = fixture.geometryB; + this.geometryA31 = withoutLast(this.geometryA); + this.coordinator.replace("viewer-actions", this.geometryA); + if (this.coordinator.entityCount() != 32) { + throw new IllegalStateException("Ray lifecycle fixture changed"); + } + } + + @TearDown(Level.Trial) + public void tearDown() { + this.coordinator.shutdown(); + } + } + + private static Fixture fixture() { + Map players = players(); + TableSessionContext session = session(players); + return new Fixture( + new SparrowRayInteractionProxyCoordinator(session, new CountingBackend()), + interactions(players.keySet(), 0.0D), + interactions(players.keySet(), 0.5D) + ); + } + + private static Map> interactions( + Iterable viewers, + double centerX + ) { + Map> result = new LinkedHashMap<>(); + UUID worldId = new UUID(0x52415950524F5859L, 1L); + for (UUID viewerId : viewers) { + result.put(viewerId, List.of(new DisplayInteractionRayRegistry.RayInteraction( + worldId, centerX, 65.0D, 0.0D, 1.0D, 0.0D, + 0.24F, 0.90F, 0.075F, + DisplayClickAction.playerCommand("perf-ray", viewerId, "noop") + ))); + } + return Map.copyOf(result); + } + + private static Map> withoutLast( + Map> source + ) { + Map> result = new LinkedHashMap<>(source); + UUID last = null; + for (UUID viewerId : result.keySet()) last = viewerId; + result.remove(last); + return Map.copyOf(result); + } + + private static Map players() { + Map players = new LinkedHashMap<>(); + for (int index = 0; index < 32; index++) { + UUID id = new UUID(0x5241595649455700L, index + 1L); + InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { + case "getUniqueId" -> id; + case "isOnline" -> true; + case "hashCode" -> id.hashCode(); + case "equals" -> proxy == args[0]; + case "toString" -> "RayViewer[" + id + "]"; + default -> defaultValue(method); + }; + players.put(id, (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class[] {Player.class}, handler)); + } + return players; + } + + private static TableSessionContext session(Map players) { + InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { + case "id" -> "perf-ray-lifecycle"; + case "onlinePlayer" -> players.get(args[0]); + case "runForViewer" -> { ((Runnable) args[1]).run(); yield null; } + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "RayLifecycleSession"; + default -> defaultValue(method); + }; + return (TableSessionContext) Proxy.newProxyInstance( + TableSessionContext.class.getClassLoader(), new Class[] {TableSessionContext.class}, handler + ); + } + + private static Object defaultValue(Method method) { + Class type = method.getReturnType(); + if (!type.isPrimitive()) return null; + if (type == boolean.class) return false; + if (type == char.class) return '\0'; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0.0F; + if (type == double.class) return 0.0D; + return null; + } + + private record Fixture( + SparrowRayInteractionProxyCoordinator coordinator, + Map> geometryA, + Map> geometryB + ) {} + + private static final class CountingBackend implements SparrowRayInteractionProxyCoordinator.Backend { + private int nextEntityId = 1_000_000; + + @Override public boolean available() { return true; } + + @Override + public List create( + Player viewer, + List interactions + ) { + List proxies = new ArrayList<>(interactions.size()); + for (int index = 0; index < interactions.size(); index++) { + int entityId = ++this.nextEntityId; + proxies.add(() -> entityId); + } + return List.copyOf(proxies); + } + + @Override public void spawn(Player viewer, List proxies) {} + @Override public void destroy(Player viewer, List proxies) {} + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java new file mode 100644 index 0000000..1467324 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java @@ -0,0 +1,292 @@ +package top.ellan.mahjong.table.render; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.bukkit.Location; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.config.PluginSettings; +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.TableRenderSubject; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.scene.TableRenderer; +import top.ellan.mahjong.render.snapshot.TableRenderPrecomputeResult; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; +import top.ellan.mahjong.table.core.TableSessionContext; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RegionRenderApplyBenchmark { + private TableRegionDisplayCoordinator planningCoordinator; + private TableRegionDisplayCoordinator stableCoordinator; + private TableRenderPrecomputeResult planningResult; + private TableRenderPrecomputeResult stableResult; + private MethodHandle completeFingerprintPrecompute; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + TableSessionContext session = session(); + TableRenderSnapshot representativeSnapshot = snapshot(true); + TableRenderLayout.LayoutPlan representativeLayout = TableRenderLayout.precompute(representativeSnapshot); + this.completeFingerprintPrecompute = completeFingerprintHandle( + session, representativeSnapshot, representativeLayout + ); + this.planningResult = new TableRenderPrecomputeResult( + representativeSnapshot, + fingerprints(session, representativeSnapshot, representativeLayout), + representativeLayout + ); + this.planningCoordinator = new TableRegionDisplayCoordinator(session, new TableRegionFingerprintService(), 0, 0); + + TableRenderSnapshot stableSnapshot = snapshot(false); + TableRenderLayout.LayoutPlan stableLayout = TableRenderLayout.precompute(stableSnapshot); + Map fingerprints = fingerprints(session, stableSnapshot, stableLayout); + this.stableResult = new TableRenderPrecomputeResult(stableSnapshot, fingerprints, stableLayout); + this.stableCoordinator = new TableRegionDisplayCoordinator(session, new TableRegionFingerprintService()); + seedStableState(this.stableCoordinator, fingerprints); + int planned = plannedRegionCount(representativeSnapshot, representativeLayout); + if (planned != 327 + || !this.planningCoordinator.applyRenderPrecompute(this.planningResult) + || this.stableCoordinator.applyRenderPrecompute(this.stableResult)) { + throw new IllegalStateException("Region benchmark contract changed: planned=" + planned); + } + } + + @Benchmark + public boolean planAndDeferRepresentativeTableRegions() { + return this.planningCoordinator.applyRenderPrecompute(this.planningResult); + } + + @Benchmark + public boolean applyStableStartedTable() { + return this.stableCoordinator.applyRenderPrecompute(this.stableResult); + } + + @Benchmark + @SuppressWarnings("unchecked") + public Map precomputeCompleteStartedTableRegions() throws Throwable { + return (Map) this.completeFingerprintPrecompute.invokeExact(); + } + + private static MethodHandle completeFingerprintHandle( + TableSessionContext session, + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan layout + ) throws ReflectiveOperationException { + TableRegionFingerprintService service = new TableRegionFingerprintService(); + try { + Method method = TableRegionFingerprintService.class.getDeclaredMethod( + "precomputeRegionFingerprints", + TableRenderSubject.class, + TableRenderSnapshot.class, + TableRenderLayout.LayoutPlan.class + ); + return MethodHandles.lookup() + .unreflect(method) + .bindTo(service) + .bindTo(session) + .bindTo(snapshot) + .bindTo(layout) + .asType(MethodType.methodType(Map.class)); + } catch (NoSuchMethodException ignored) { + try { + return MethodHandles.lookup() + .findStatic( + RegionRenderApplyBenchmark.class, + "fingerprints", + MethodType.methodType( + Map.class, + TableSessionContext.class, + TableRenderSnapshot.class, + TableRenderLayout.LayoutPlan.class + ) + ) + .bindTo(session) + .bindTo(snapshot) + .bindTo(layout); + } catch (NoSuchMethodException | IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } catch (IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } + + private static Map fingerprints( + TableSessionContext session, + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan layout + ) { + TableRegionFingerprintService service = new TableRegionFingerprintService(); + Map values = new HashMap<>(service.precomputeRegionFingerprints(session, snapshot)); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + for (int index = 0; index < seat.hand().size(); index++) { + values.put( + handPublicRegionKey(wind, index), + service.handPublicTileFingerprint(snapshot, seat, seatPlan, index) + ); + values.put( + handPrivateRegionKey(wind, index), + service.handPrivateTileFingerprint(seat, seatPlan, index) + ); + } + for (int index = 0; index < seatPlan.discardPlacements().size(); index++) { + values.put(discardRegionKey(wind, index), service.discardTileFingerprint(seat, seatPlan, index)); + } + for (int index = 0; index < seatPlan.meldPlacements().size(); index++) { + values.put(meldRegionKey(wind, index), service.meldTileFingerprint(seat, seatPlan, index)); + } + } + for (int index = 0; index < layout.wallTiles().size(); index++) { + values.put(wallRegionKey(index), service.wallTileFingerprint(layout, index)); + } + return Map.copyOf(values); + } + + private static int plannedRegionCount(TableRenderSnapshot snapshot, TableRenderLayout.LayoutPlan layout) { + int count = 3 + layout.wallTiles().size(); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + if (seat.playerId() == null) { + count += 3; + continue; + } + count += 3 + seat.hand().size() * 2 + seatPlan.discardPlacements().size() + seatPlan.meldPlacements().size(); + } + return count; + } + + @SuppressWarnings("unchecked") + private static void seedStableState(TableRegionDisplayCoordinator coordinator, Map fingerprints) + throws ReflectiveOperationException { + Field fingerprintField = TableRegionDisplayCoordinator.class.getDeclaredField("regionFingerprints"); + fingerprintField.setAccessible(true); + ((Map) fingerprintField.get(coordinator)).putAll(fingerprints); + Field displayField = TableRegionDisplayCoordinator.class.getDeclaredField("regionDisplays"); + displayField.setAccessible(true); + Map> displays = + (Map>) displayField.get(coordinator); + displays.put("table", List.of()); + for (SeatWind wind : SeatWind.values()) { + displays.put(seatRegionKey("visual", wind), List.of()); + } + } + + private static String seatRegionKey(String region, SeatWind wind) { + return region + ":" + wind.name(); + } + + private static String discardRegionKey(SeatWind wind, int index) { + return seatRegionKey("discards-" + index, wind); + } + + private static String handPublicRegionKey(SeatWind wind, int index) { + return seatRegionKey("hand-public-" + index, wind); + } + + private static String handPrivateRegionKey(SeatWind wind, int index) { + return seatRegionKey("hand-private-" + index, wind); + } + + private static String meldRegionKey(SeatWind wind, int index) { + return seatRegionKey("melds-" + index, wind); + } + + private static String wallRegionKey(int index) { + return "wall-" + index; + } + + private static TableSessionContext session() { + TableRenderer renderer = new TableRenderer(); + InvocationHandler handler = (proxy, method, arguments) -> switch (method.getName()) { + case "id" -> "perf-region-apply"; + case "plugin" -> null; + case "renderer" -> renderer; + case "center" -> new Location(null, 12.75D, 64.0D, -7.25D); + case "currentVariant" -> MahjongVariant.RIICHI; + case "settings" -> PluginSettings.defaults(); + case "toString" -> "RegionApplySession"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + default -> defaultValue(method); + }; + return (TableSessionContext) Proxy.newProxyInstance( + TableSessionContext.class.getClassLoader(), new Class[] {TableSessionContext.class}, handler + ); + } + + private static TableRenderSnapshot snapshot(boolean withHands) { + List hand = withHands + ? List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, MahjongTile.M4, MahjongTile.M5_RED, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, MahjongTile.S6, MahjongTile.S7, MahjongTile.RED_DRAGON + ) + : List.of(); + List discards = List.of( + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.NORTH, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON, MahjongTile.RED_DRAGON, + MahjongTile.M1, MahjongTile.M9, MahjongTile.P1, MahjongTile.P9, + MahjongTile.S1, MahjongTile.S9, MahjongTile.M5_RED, MahjongTile.P5_RED, + MahjongTile.S5_RED, MahjongTile.M4, MahjongTile.P6 + ); + MeldView meld = new MeldView( + List.of(MahjongTile.P2, MahjongTile.P2, MahjongTile.P2), + List.of(false, false, false), 1, 90, MahjongTile.P2 + ); + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SeatWind.values()) { + seats.put(wind, new TableSeatRenderSnapshot( + wind, new UUID(0L, wind.index() + 1L), wind.name(), wind.name(), 25_000, + false, true, false, true, "viewer-" + wind.name(), -1, List.of(), -1, 0, + List.of(), hand, discards, List.of(meld), List.of(), List.of(ScoringStick.P100) + )); + } + return new TableRenderSnapshot( + 1L, 0L, "benchmark_world", 12.75D, 64.0D, -7.25D, + true, false, false, 70, 1, 7, 5, 1, 0, + SeatWind.EAST, SeatWind.SOUTH, SeatWind.WEST, + "waiting", "rules", "center", null, MahjongTile.RED_DRAGON, + List.of(MahjongTile.M1), MahjongVariant.RIICHI, seats + ); + } + + private static Object defaultValue(Method method) { + Class type = method.getReturnType(); + if (!type.isPrimitive()) return null; + if (type == boolean.class) return false; + if (type == char.class) return '\0'; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0.0F; + if (type == double.class) return 0.0D; + return null; + } +}