Skip to content

Commit 6a5ac12

Browse files
committed
perf: benchmark scheduler reflection dispatch
1 parent 05a1a4c commit 6a5ac12

5 files changed

Lines changed: 318 additions & 0 deletions

File tree

.github/workflows/ensure-labels.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
{ name: "performance-region-fingerprint", color: "bfdadc", description: "Use the table region fingerprint performance profile" },
2929
{ name: "performance-display-spawn", color: "bfdadc", description: "Use the display entity spawn performance profile" },
3030
{ name: "performance-region-queue", color: "bfdadc", description: "Use the region update queue performance profile" },
31+
{ name: "performance-scheduler-reflection", color: "bfdadc", description: "Use the scheduler reflection performance profile" },
3132
{ name: "performance-ray-proxy", color: "bfdadc", description: "Use the client ray-proxy performance profile" }
3233
];
3334

.github/workflows/performance-ab.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ on:
3636
- region-fingerprint
3737
- display-spawn
3838
- region-queue
39+
- scheduler-reflection
3940
- ray-proxy
4041
- infra
4142
forks:
@@ -163,6 +164,7 @@ jobs:
163164
"performance-region-fingerprint": "region-fingerprint",
164165
"performance-display-spawn": "display-spawn",
165166
"performance-region-queue": "region-queue",
167+
"performance-scheduler-reflection": "scheduler-reflection",
166168
"performance-ray-proxy": "ray-proxy",
167169
}
168170
selected = [profile for label, profile in choices.items() if label in labels]

docs/performance-testing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ and exactly one profile label to a pure performance PR:
5555
- `performance-region-fingerprint` - a real started-table snapshot and layout, measuring the
5656
complete region map plus a batch of private/public hands, discards, melds and all 136 wall
5757
slots;
58+
- `performance-scheduler-reflection` - representative global, region and entity scheduling
59+
bursts through both Folia-style reflective capabilities and standard Paper fallbacks;
5860
- `performance-ray-proxy` - 1, 4 and 32-viewer coordinator lifecycle and unchanged-geometry
5961
reuse. This CPU/allocation profile does not measure protocol bytes or client hit coverage.
6062

perf/ab/gate-config.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,23 @@
138138
"top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.class"
139139
]
140140
},
141+
"scheduler-reflection": {
142+
"include": "^top\\.ellan\\.mahjong\\.perf\\.ServerSchedulerReflectionBenchmark\\.schedule(?:Folia|Paper)Burst$",
143+
"benchmark_ids": [
144+
"scheduler.folia.time",
145+
"scheduler.folia.alloc",
146+
"scheduler.paper.time",
147+
"scheduler.paper.alloc"
148+
],
149+
"minimum_passes": 2,
150+
"must_pass": [
151+
"scheduler.folia.time",
152+
"scheduler.paper.time"
153+
],
154+
"required_classes": [
155+
"top/ellan/mahjong/runtime/ServerScheduler.class"
156+
]
157+
},
141158
"ray-proxy": {
142159
"include": "^top\\.ellan\\.mahjong\\.perf\\.RayProxyCoordinatorBenchmark\\.proxyPlan(?:1Viewer|4Viewers|32Viewers)$",
143160
"benchmark_ids": [
@@ -291,6 +308,30 @@
291308
"metric": "gc.alloc.rate.norm",
292309
"direction": "lower"
293310
},
311+
{
312+
"id": "scheduler.folia.time",
313+
"benchmark": "top.ellan.mahjong.perf.ServerSchedulerReflectionBenchmark.scheduleFoliaBurst",
314+
"metric": "primary",
315+
"direction": "lower"
316+
},
317+
{
318+
"id": "scheduler.folia.alloc",
319+
"benchmark": "top.ellan.mahjong.perf.ServerSchedulerReflectionBenchmark.scheduleFoliaBurst",
320+
"metric": "gc.alloc.rate.norm",
321+
"direction": "lower"
322+
},
323+
{
324+
"id": "scheduler.paper.time",
325+
"benchmark": "top.ellan.mahjong.perf.ServerSchedulerReflectionBenchmark.schedulePaperBurst",
326+
"metric": "primary",
327+
"direction": "lower"
328+
},
329+
{
330+
"id": "scheduler.paper.alloc",
331+
"benchmark": "top.ellan.mahjong.perf.ServerSchedulerReflectionBenchmark.schedulePaperBurst",
332+
"metric": "gc.alloc.rate.norm",
333+
"direction": "lower"
334+
},
294335
{
295336
"id": "ray.viewers1.time",
296337
"benchmark": "top.ellan.mahjong.perf.RayProxyCoordinatorBenchmark.proxyPlan1Viewer",
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
package top.ellan.mahjong.perf;
2+
3+
import java.lang.reflect.InvocationHandler;
4+
import java.lang.reflect.Method;
5+
import java.lang.reflect.Proxy;
6+
import java.util.concurrent.TimeUnit;
7+
import java.util.function.Consumer;
8+
import org.bukkit.Location;
9+
import org.bukkit.Server;
10+
import org.bukkit.World;
11+
import org.bukkit.entity.Entity;
12+
import org.bukkit.plugin.Plugin;
13+
import org.bukkit.scheduler.BukkitScheduler;
14+
import org.bukkit.scheduler.BukkitTask;
15+
import org.openjdk.jmh.annotations.Benchmark;
16+
import org.openjdk.jmh.annotations.BenchmarkMode;
17+
import org.openjdk.jmh.annotations.Level;
18+
import org.openjdk.jmh.annotations.Mode;
19+
import org.openjdk.jmh.annotations.OperationsPerInvocation;
20+
import org.openjdk.jmh.annotations.OutputTimeUnit;
21+
import org.openjdk.jmh.annotations.Scope;
22+
import org.openjdk.jmh.annotations.Setup;
23+
import org.openjdk.jmh.annotations.State;
24+
import org.openjdk.jmh.infra.Blackhole;
25+
import top.ellan.mahjong.runtime.PluginTask;
26+
import top.ellan.mahjong.runtime.ServerScheduler;
27+
28+
/** Measures the reflective scheduler-capability dispatch used across Paper and Folia. */
29+
@State(Scope.Thread)
30+
@BenchmarkMode(Mode.AverageTime)
31+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
32+
public class ServerSchedulerReflectionBenchmark {
33+
private static final int GLOBAL_TASKS = 4;
34+
private static final int REGION_TASKS = 16;
35+
private static final int ENTITY_TASKS = 64;
36+
private static final int REPRESENTATIVE_TASKS = GLOBAL_TASKS + REGION_TASKS + ENTITY_TASKS;
37+
private static final Runnable NO_OP = () -> {
38+
};
39+
40+
private Harness folia;
41+
private Harness paper;
42+
private Location location;
43+
44+
@Setup(Level.Trial)
45+
public void setUp() {
46+
World world = proxy(World.class, ServerSchedulerReflectionBenchmark::unsupported);
47+
this.location = new Location(world, 8.0, 64.0, -8.0);
48+
this.folia = createFoliaHarness();
49+
this.paper = createPaperHarness();
50+
this.verifyContract();
51+
}
52+
53+
@Benchmark
54+
@OperationsPerInvocation(REPRESENTATIVE_TASKS)
55+
public void scheduleFoliaBurst(Blackhole blackhole) {
56+
this.scheduleBurst(this.folia, blackhole);
57+
}
58+
59+
@Benchmark
60+
@OperationsPerInvocation(REPRESENTATIVE_TASKS)
61+
public void schedulePaperBurst(Blackhole blackhole) {
62+
this.scheduleBurst(this.paper, blackhole);
63+
}
64+
65+
private void scheduleBurst(Harness harness, Blackhole blackhole) {
66+
for (int index = 0; index < GLOBAL_TASKS; index++) {
67+
blackhole.consume(harness.scheduler().runGlobal(NO_OP));
68+
}
69+
for (int index = 0; index < REGION_TASKS; index++) {
70+
blackhole.consume(harness.scheduler().runRegion(this.location, NO_OP));
71+
}
72+
for (int index = 0; index < ENTITY_TASKS; index++) {
73+
blackhole.consume(harness.scheduler().runEntity(harness.entity(), NO_OP));
74+
}
75+
}
76+
77+
private void verifyContract() {
78+
this.verifyHarness(this.folia, true);
79+
this.verifyHarness(this.paper, false);
80+
}
81+
82+
private void verifyHarness(Harness harness, boolean expectFolia) {
83+
int foliaBefore = harness.foliaScheduler().invocations();
84+
int paperBefore = harness.paperInvocations().value;
85+
assertActive(harness.scheduler().runGlobal(NO_OP));
86+
assertActive(harness.scheduler().runRegion(this.location, NO_OP));
87+
assertActive(harness.scheduler().runEntity(harness.entity(), NO_OP));
88+
int foliaDelta = harness.foliaScheduler().invocations() - foliaBefore;
89+
int paperDelta = harness.paperInvocations().value - paperBefore;
90+
if (expectFolia ? foliaDelta != 3 || paperDelta != 0 : foliaDelta != 0 || paperDelta != 3) {
91+
throw new IllegalStateException(
92+
"Scheduler route changed: folia=" + foliaDelta + ", paper=" + paperDelta
93+
);
94+
}
95+
}
96+
97+
private static void assertActive(PluginTask task) {
98+
if (task == null || task.isCancelled()) {
99+
throw new IllegalStateException("Scheduler must return an active task handle");
100+
}
101+
}
102+
103+
private static Harness createFoliaHarness() {
104+
FakeFoliaScheduler foliaScheduler = new FakeFoliaScheduler();
105+
Counter paperInvocations = new Counter();
106+
BukkitScheduler bukkitScheduler = bukkitScheduler(paperInvocations);
107+
Server server = proxy(
108+
Server.class,
109+
(instance, method, arguments) -> switch (method.getName()) {
110+
case "getGlobalRegionScheduler", "getRegionScheduler" -> foliaScheduler;
111+
case "getScheduler" -> bukkitScheduler;
112+
default -> unsupported(instance, method, arguments);
113+
},
114+
FoliaServerAccess.class
115+
);
116+
Plugin plugin = plugin(server);
117+
Entity entity = proxy(
118+
Entity.class,
119+
(instance, method, arguments) -> {
120+
if (method.getName().equals("getScheduler") && method.getParameterCount() == 0) {
121+
return foliaScheduler;
122+
}
123+
return unsupported(instance, method, arguments);
124+
},
125+
FoliaEntityAccess.class
126+
);
127+
return new Harness(new ServerScheduler(plugin), entity, foliaScheduler, paperInvocations);
128+
}
129+
130+
private static Harness createPaperHarness() {
131+
FakeFoliaScheduler foliaScheduler = new FakeFoliaScheduler();
132+
Counter paperInvocations = new Counter();
133+
BukkitScheduler bukkitScheduler = bukkitScheduler(paperInvocations);
134+
Server server = proxy(
135+
Server.class,
136+
(instance, method, arguments) -> {
137+
if (method.getName().equals("getScheduler") && method.getParameterCount() == 0) {
138+
return bukkitScheduler;
139+
}
140+
return unsupported(instance, method, arguments);
141+
}
142+
);
143+
Plugin plugin = plugin(server);
144+
Entity entity = proxy(
145+
Entity.class,
146+
(instance, method, arguments) -> {
147+
if (method.getName().equals("getScheduler") && method.getParameterCount() == 0) {
148+
return null;
149+
}
150+
return unsupported(instance, method, arguments);
151+
}
152+
);
153+
return new Harness(new ServerScheduler(plugin), entity, foliaScheduler, paperInvocations);
154+
}
155+
156+
private static Plugin plugin(Server server) {
157+
return proxy(
158+
Plugin.class,
159+
(instance, method, arguments) -> switch (method.getName()) {
160+
case "isEnabled" -> true;
161+
case "getServer" -> server;
162+
default -> unsupported(instance, method, arguments);
163+
}
164+
);
165+
}
166+
167+
private static BukkitScheduler bukkitScheduler(Counter invocations) {
168+
BukkitTask task = proxy(
169+
BukkitTask.class,
170+
(instance, method, arguments) -> {
171+
if (method.getName().equals("isCancelled") && method.getParameterCount() == 0) {
172+
return false;
173+
}
174+
if (method.getName().equals("cancel") && method.getParameterCount() == 0) {
175+
return null;
176+
}
177+
return unsupported(instance, method, arguments);
178+
}
179+
);
180+
return proxy(
181+
BukkitScheduler.class,
182+
(instance, method, arguments) -> {
183+
if (method.getName().startsWith("runTask")) {
184+
invocations.value++;
185+
return task;
186+
}
187+
return unsupported(instance, method, arguments);
188+
}
189+
);
190+
}
191+
192+
private static Object unsupported(Object instance, Method method, Object[] arguments) {
193+
if (method.getDeclaringClass() == Object.class) {
194+
return switch (method.getName()) {
195+
case "equals" -> instance == arguments[0];
196+
case "hashCode" -> System.identityHashCode(instance);
197+
case "toString" -> instance.getClass().getInterfaces()[0].getSimpleName() + "Proxy";
198+
default -> throw new UnsupportedOperationException(method.toString());
199+
};
200+
}
201+
throw new UnsupportedOperationException(method.toString());
202+
}
203+
204+
@SuppressWarnings("unchecked")
205+
private static <T> T proxy(Class<T> primary, InvocationHandler handler, Class<?>... additionalInterfaces) {
206+
Class<?>[] interfaces = new Class<?>[additionalInterfaces.length + 1];
207+
interfaces[0] = primary;
208+
System.arraycopy(additionalInterfaces, 0, interfaces, 1, additionalInterfaces.length);
209+
return (T) Proxy.newProxyInstance(
210+
ServerSchedulerReflectionBenchmark.class.getClassLoader(),
211+
interfaces,
212+
handler
213+
);
214+
}
215+
216+
public interface FoliaServerAccess {
217+
Object getGlobalRegionScheduler();
218+
219+
Object getRegionScheduler();
220+
}
221+
222+
public interface FoliaEntityAccess {
223+
Object getScheduler();
224+
}
225+
226+
public static final class FakeFoliaScheduler {
227+
private final FakeScheduledTask task = new FakeScheduledTask();
228+
private int invocations;
229+
230+
public FakeScheduledTask run(Plugin plugin, Consumer<Object> consumer) {
231+
return this.invoked();
232+
}
233+
234+
public FakeScheduledTask run(Plugin plugin, Location location, Consumer<Object> consumer) {
235+
return this.invoked();
236+
}
237+
238+
public FakeScheduledTask run(Plugin plugin, Consumer<Object> consumer, Runnable retired) {
239+
return this.invoked();
240+
}
241+
242+
public int invocations() {
243+
return this.invocations;
244+
}
245+
246+
private FakeScheduledTask invoked() {
247+
this.invocations++;
248+
return this.task;
249+
}
250+
}
251+
252+
public static final class FakeScheduledTask {
253+
public void cancel() {
254+
}
255+
256+
public boolean isCancelled() {
257+
return false;
258+
}
259+
}
260+
261+
private static final class Counter {
262+
private int value;
263+
}
264+
265+
private record Harness(
266+
ServerScheduler scheduler,
267+
Entity entity,
268+
FakeFoliaScheduler foliaScheduler,
269+
Counter paperInvocations
270+
) {
271+
}
272+
}

0 commit comments

Comments
 (0)