From 1a71abe6cf19963d0d181452fde13bba2af74755 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Thu, 16 Jul 2026 13:50:30 +0800 Subject: [PATCH] perf: benchmark singleton display region spawns --- .github/workflows/ensure-labels.yml | 1 + .github/workflows/performance-ab.yml | 2 + perf/ab/gate-config.json | 26 +++++ .../DisplayEntitiesSpawnAllBenchmark.java | 97 ++++++++++++++++ .../DisplayEntitiesSpawnAllContractTest.java | 108 ++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 src/perfTest/java/top/ellan/mahjong/perf/DisplayEntitiesSpawnAllBenchmark.java create mode 100644 src/test/java/top/ellan/mahjong/render/display/DisplayEntitiesSpawnAllContractTest.java diff --git a/.github/workflows/ensure-labels.yml b/.github/workflows/ensure-labels.yml index 2d45a93a..d9ad067b 100644 --- a/.github/workflows/ensure-labels.yml +++ b/.github/workflows/ensure-labels.yml @@ -26,6 +26,7 @@ jobs: { name: "performance-snapshot", color: "bfdadc", description: "Use the viewer snapshot performance profile" }, { name: "performance-gb-bot", color: "bfdadc", description: "Use the GB bot decision performance profile" }, { name: "performance-region-fingerprint", color: "bfdadc", description: "Use the table region fingerprint performance profile" }, + { name: "performance-display-spawn", color: "bfdadc", description: "Use the display entity spawn performance profile" }, { name: "performance-ray-proxy", color: "bfdadc", description: "Use the client ray-proxy performance profile" } ]; diff --git a/.github/workflows/performance-ab.yml b/.github/workflows/performance-ab.yml index a2120294..a0f3127d 100644 --- a/.github/workflows/performance-ab.yml +++ b/.github/workflows/performance-ab.yml @@ -34,6 +34,7 @@ on: - snapshot - gb-bot - region-fingerprint + - display-spawn - ray-proxy - infra forks: @@ -159,6 +160,7 @@ jobs: "performance-snapshot": "snapshot", "performance-gb-bot": "gb-bot", "performance-region-fingerprint": "region-fingerprint", + "performance-display-spawn": "display-spawn", "performance-ray-proxy": "ray-proxy", } selected = [profile for label, profile in choices.items() if label in labels] diff --git a/perf/ab/gate-config.json b/perf/ab/gate-config.json index a07a8602..47dec658 100644 --- a/perf/ab/gate-config.json +++ b/perf/ab/gate-config.json @@ -110,6 +110,20 @@ "top/ellan/mahjong/table/render/TableRegionFingerprintService.class" ] }, + "display-spawn": { + "include": "^top\\.ellan\\.mahjong\\.perf\\.DisplayEntitiesSpawnAllBenchmark\\.spawnRepresentativeTableRegions$", + "benchmark_ids": [ + "display.spawn.time", + "display.spawn.alloc" + ], + "minimum_passes": 2, + "must_pass": [ + "display.spawn.time" + ], + "required_classes": [ + "top/ellan/mahjong/render/display/DisplayEntities.class" + ] + }, "ray-proxy": { "include": "^top\\.ellan\\.mahjong\\.perf\\.RayProxyCoordinatorBenchmark\\.proxyPlan(?:1Viewer|4Viewers|32Viewers)$", "benchmark_ids": [ @@ -239,6 +253,18 @@ "metric": "gc.alloc.rate.norm", "direction": "lower" }, + { + "id": "display.spawn.time", + "benchmark": "top.ellan.mahjong.perf.DisplayEntitiesSpawnAllBenchmark.spawnRepresentativeTableRegions", + "metric": "primary", + "direction": "lower" + }, + { + "id": "display.spawn.alloc", + "benchmark": "top.ellan.mahjong.perf.DisplayEntitiesSpawnAllBenchmark.spawnRepresentativeTableRegions", + "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/perf/DisplayEntitiesSpawnAllBenchmark.java b/src/perfTest/java/top/ellan/mahjong/perf/DisplayEntitiesSpawnAllBenchmark.java new file mode 100644 index 00000000..b62f7b3a --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/perf/DisplayEntitiesSpawnAllBenchmark.java @@ -0,0 +1,97 @@ +package top.ellan.mahjong.perf; + +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +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.render.display.DisplayEntities; +import top.ellan.mahjong.render.display.DisplayEntityRuntime; + +/** Measures the one-entity region shape used by hand, discard, meld and wall tiles. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class DisplayEntitiesSpawnAllBenchmark { + private static final int REPRESENTATIVE_SINGLETON_REGION_COUNT = 268; + + private Entity entity; + private DisplayEntityRuntime runtime; + private List singletonSpec; + + @Setup(Level.Trial) + public void setUp() { + Plugin plugin = proxy(Plugin.class); + this.entity = proxy(Entity.class); + this.runtime = new EmptyViewerRuntime(plugin); + this.singletonSpec = List.of(new ConstantEntitySpec(this.entity)); + this.verifyContract(); + } + + @Benchmark + public int spawnRepresentativeTableRegions() { + int spawned = 0; + for (int region = 0; region < REPRESENTATIVE_SINGLETON_REGION_COUNT; region++) { + spawned += DisplayEntities.spawnAll(this.runtime, this.singletonSpec).size(); + } + return spawned; + } + + private void verifyContract() { + List spawned = DisplayEntities.spawnAll(this.runtime, this.singletonSpec); + if (spawned.size() != 1 || spawned.get(0) != this.entity) { + throw new IllegalStateException("Singleton spawn contract changed"); + } + try { + spawned.add(this.entity); + throw new IllegalStateException("spawnAll must return an immutable list"); + } catch (UnsupportedOperationException expected) { + // Expected contract. + } + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type) { + return (T) Proxy.newProxyInstance( + type.getClassLoader(), + new Class[] {type}, + (instance, method, arguments) -> { + throw new UnsupportedOperationException(method.toString()); + } + ); + } + + private record EmptyViewerRuntime(Plugin bukkitPlugin) implements DisplayEntityRuntime { + @Override + public Collection onlinePlayers() { + return List.of(); + } + } + + private record ConstantEntitySpec(Entity entity) implements DisplayEntities.EntitySpec { + @Override + public Entity spawn(DisplayEntityRuntime runtime) { + return this.entity; + } + + @Override + public boolean canReuse(DisplayEntityRuntime runtime, Entity candidate) { + return candidate == this.entity; + } + + @Override + public void apply(DisplayEntityRuntime runtime, Entity candidate) { + throw new UnsupportedOperationException("Benchmark does not reconcile entities"); + } + } +} diff --git a/src/test/java/top/ellan/mahjong/render/display/DisplayEntitiesSpawnAllContractTest.java b/src/test/java/top/ellan/mahjong/render/display/DisplayEntitiesSpawnAllContractTest.java new file mode 100644 index 00000000..4a19418b --- /dev/null +++ b/src/test/java/top/ellan/mahjong/render/display/DisplayEntitiesSpawnAllContractTest.java @@ -0,0 +1,108 @@ +package top.ellan.mahjong.render.display; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.List; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; + +class DisplayEntitiesSpawnAllContractTest { + @Test + void singletonSpawnReturnsTheSameEntityInAnImmutableList() { + Entity entity = proxy(Entity.class); + CountingRuntime runtime = new CountingRuntime(proxy(Plugin.class)); + + List spawned = DisplayEntities.spawnAll(runtime, List.of(new ConstantEntitySpec(entity))); + + assertEquals(1, spawned.size()); + assertSame(entity, spawned.get(0)); + assertEquals(1, runtime.onlinePlayerReads()); + assertThrows(UnsupportedOperationException.class, () -> spawned.add(entity)); + } + + @Test + void singletonNullSpawnReturnsAnImmutableEmptyList() { + CountingRuntime runtime = new CountingRuntime(proxy(Plugin.class)); + + List spawned = DisplayEntities.spawnAll(runtime, List.of(new ConstantEntitySpec(null))); + + assertEquals(0, spawned.size()); + assertEquals(1, runtime.onlinePlayerReads()); + assertThrows(UnsupportedOperationException.class, () -> spawned.add(proxy(Entity.class))); + } + + @Test + void multipleSpawnsPreserveOrderAndSkipNulls() { + Entity first = proxy(Entity.class); + Entity second = proxy(Entity.class); + CountingRuntime runtime = new CountingRuntime(proxy(Plugin.class)); + + List spawned = DisplayEntities.spawnAll( + runtime, + List.of(new ConstantEntitySpec(first), new ConstantEntitySpec(null), new ConstantEntitySpec(second)) + ); + + assertEquals(2, spawned.size()); + assertSame(first, spawned.get(0)); + assertSame(second, spawned.get(1)); + assertEquals(1, runtime.onlinePlayerReads()); + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type) { + return (T) Proxy.newProxyInstance( + type.getClassLoader(), + new Class[] {type}, + (instance, method, arguments) -> { + throw new UnsupportedOperationException(method.toString()); + } + ); + } + + private static final class CountingRuntime implements DisplayEntityRuntime { + private final Plugin plugin; + private int onlinePlayerReads; + + private CountingRuntime(Plugin plugin) { + this.plugin = plugin; + } + + @Override + public Plugin bukkitPlugin() { + return this.plugin; + } + + @Override + public Collection onlinePlayers() { + this.onlinePlayerReads++; + return List.of(); + } + + private int onlinePlayerReads() { + return this.onlinePlayerReads; + } + } + + private record ConstantEntitySpec(Entity entity) implements DisplayEntities.EntitySpec { + @Override + public Entity spawn(DisplayEntityRuntime runtime) { + return this.entity; + } + + @Override + public boolean canReuse(DisplayEntityRuntime runtime, Entity candidate) { + return candidate == this.entity; + } + + @Override + public void apply(DisplayEntityRuntime runtime, Entity candidate) { + throw new UnsupportedOperationException("Contract test does not reconcile entities"); + } + } +}