perf: 去掉冷路径上的重复开销,并记录未采用的中继批量完成实验

- Message.validateChannel:通道名正则只编译一次(原来每条消息 String.matches 都重新编译),
  长度检查不再分配字节数组
- 后端 OwnedBukkitSessionService:反射查找按接收者类用 ClassValue 缓存(含未找到),
  玩家每次加入不再重复 getMethod
- testing/benchmarks/results/2026-10-22-relay-batch-completion:中继只给每批最后一次写入
  挂完成回调。I/O 线程分配降 22%、中继 lambda 降 97%,但每帧 CPU 只低 2.4% 且范围重叠,
  未达到事先设定的 10% 规则,未采用;补丁随记录保留
- PluginHostTest.placementFailuresOnlyFailTheirOwnRequest:40 ms 超时在负载下可能早于插件
  处理器运行而偶发失败,改为 500 ms 并断言首个请求确实到达插件

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
layue13andClaude Opus 5.5 committed 2026-10-22 18:34:36 +08:00
1 parent 4ed6162faf
commit fc1345d010
6 files changed
+263 -23

No files matched your search

@@ -92,12 +92,12 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener,
}
static ProofProperty stripForwardedProof(Player player) {
Object profile = invokeNoArg(player, "getProfile");
if (profile == null) profile = invokeNoArg(player, "getGameProfile");
Object profile = invoke(player, GET_PROFILE);
if (profile == null) profile = invoke(player, GET_GAME_PROFILE);
if (profile == null) return ProofProperty.unavailable();
Object properties = invokeNoArg(profile, "getProperties");
Object properties = invoke(profile, GET_PROPERTIES);
if (properties == null) return ProofProperty.unavailable();
Object matches = invoke(properties, "get", new Class<?>[]{Object.class}, ForwardedSessionProof.PROPERTY_NAME);
Object matches = invoke(properties, MULTIMAP_GET, ForwardedSessionProof.PROPERTY_NAME);
if (!(matches instanceof Iterable<?>)) return ProofProperty.unavailable();
Collection<Object> propertiesToRemove = new java.util.ArrayList<Object>();
Collection<String> values = new java.util.ArrayList<>(2);
@@ -105,40 +105,74 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener,
for (Object property : (Iterable<?>) matches) {
if (property == null) return ProofProperty.failed(true);
propertiesToRemove.add(property);
Object value = invokeNoArg(property, "getValue");
Object value = invoke(property, GET_VALUE);
if (value instanceof String && ((String) value).length() <= 2048) values.add((String) value);
else allValuesValid = false;
}
for (Object property : propertiesToRemove) {
Object removed = invoke(properties, "remove", new Class<?>[]{Object.class, Object.class},
ForwardedSessionProof.PROPERTY_NAME, property);
Object removed = invoke(properties, MULTIMAP_REMOVE, ForwardedSessionProof.PROPERTY_NAME, property);
if (!Boolean.TRUE.equals(removed)) return ProofProperty.failed(true);
}
Object remaining = invoke(properties, "get", new Class<?>[]{Object.class},
ForwardedSessionProof.PROPERTY_NAME);
Object remaining = invoke(properties, MULTIMAP_GET, ForwardedSessionProof.PROPERTY_NAME);
if (!(remaining instanceof Iterable<?>) || ((Iterable<?>) remaining).iterator().hasNext())
return ProofProperty.failed(!propertiesToRemove.isEmpty());
return ProofProperty.removed(propertiesToRemove.size() == 1 && allValuesValid && values.size() == 1
? values.iterator().next() : null);
}
private static Object invokeNoArg(Object receiver, String methodName) {
/**
* A public method this class calls reflectively: the GameProfile types differ between Bukkit builds, so the
* backend does not compile against them. Instances are lookup keys compared by identity.
*/
private static final class MethodRef {
final String name;
final Class<?>[] parameterTypes;
MethodRef(String name, Class<?>... parameterTypes) {
this.name = name;
this.parameterTypes = parameterTypes;
}
}
private static final MethodRef GET_PROFILE = new MethodRef("getProfile");
private static final MethodRef GET_GAME_PROFILE = new MethodRef("getGameProfile");
private static final MethodRef GET_PROPERTIES = new MethodRef("getProperties");
private static final MethodRef GET_VALUE = new MethodRef("getValue");
private static final MethodRef MULTIMAP_GET = new MethodRef("get", Object.class);
private static final MethodRef MULTIMAP_REMOVE = new MethodRef("remove", Object.class, Object.class);
/**
* Lookups resolved once per receiver class, including misses, so a join does not search methods again. ClassValue
* keeps the cache from holding a reloaded plugin's classes.
*/
private static final ClassValue<Map<MethodRef, Optional<Method>>> METHODS =
new ClassValue<Map<MethodRef, Optional<Method>>>() {
@Override protected Map<MethodRef, Optional<Method>> computeValue(Class<?> type) {
return new ConcurrentHashMap<MethodRef, Optional<Method>>();
}
};
private static Object invoke(Object receiver, MethodRef reference, Object... args) {
if (receiver == null) return null;
Map<MethodRef, Optional<Method>> methods = METHODS.get(receiver.getClass());
Optional<Method> method = methods.get(reference);
if (method == null) {
method = lookup(receiver.getClass(), reference);
methods.put(reference, method);
}
if (!method.isPresent()) return null;
try {
Method method = receiver.getClass().getMethod(methodName);
return method.invoke(receiver);
return method.get().invoke(receiver, args);
} catch (ReflectiveOperationException | RuntimeException unavailable) {
return null;
}
}
private static Object invoke(Object receiver, String methodName, Class<?>[] parameterTypes, Object... args) {
if (receiver == null) return null;
private static Optional<Method> lookup(Class<?> type, MethodRef reference) {
try {
Method method = receiver.getClass().getMethod(methodName, parameterTypes);
return method.invoke(receiver, args);
} catch (ReflectiveOperationException | RuntimeException unavailable) {
return null;
return Optional.of(type.getMethod(reference.name, reference.parameterTypes));
} catch (NoSuchMethodException | RuntimeException unavailable) {
return Optional.empty();
}
}
@@ -1,8 +1,8 @@
package dev.moonbridge.messaging;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Immutable message envelope shared by the proxy and Java 8 backend clients. The UUID identifies a message
@@ -70,11 +70,13 @@ public final class Message {
/** Returns a defensive copy of the payload. */
public byte[] payload() { return payload.clone(); }
/** Compiled once: validateChannel runs for every message, and String.matches would recompile it each time. */
private static final Pattern CHANNEL = Pattern.compile("[a-z0-9][a-z0-9_.-]*:[a-z0-9][a-z0-9_.-]*");
public static String validateChannel(String channel) {
Objects.requireNonNull(channel, "channel");
byte[] bytes = channel.getBytes(StandardCharsets.US_ASCII);
if (bytes.length == 0 || bytes.length > MAX_CHANNEL_BYTES || !channel.matches(
"[a-z0-9][a-z0-9_.-]*:[a-z0-9][a-z0-9_.-]*")) {
// The pattern admits ASCII only, so the character count is the byte count.
if (channel.isEmpty() || channel.length() > MAX_CHANNEL_BYTES || !CHANNEL.matcher(channel).matches()) {
throw new IllegalArgumentException("channel must be namespace:name using lowercase ASCII letters, digits, '.', '_' or '-'");
}
return channel;
@@ -1047,13 +1047,16 @@ class PluginHostTest {
void placementFailuresOnlyFailTheirOwnRequest() {
RecoveringPlacementPlugin plugin = new RecoveringPlacementPlugin();
BackendCatalog catalog = new InMemoryBackendCatalog();
PluginHost host = new PluginHost(catalog, players(), Duration.ofMillis(40));
// The timeout has to outlast dispatch to the plugin worker even on a loaded CI machine; with 40 ms it could
// fire before the handler ran, leaving nothing to cancel and shifting every later call by one.
PluginHost host = new PluginHost(catalog, players(), Duration.ofMillis(500));
host.load(List.of(plugin));
host.enable();
CompletionException failure = assertThrows(CompletionException.class,
() -> host.placeInitial(PLAYER).toCompletableFuture().join());
assertInstanceOf(PlacementTimeoutException.class, failure.getCause());
assertEquals(1, plugin.calls.get(), "the timed-out request must have reached the plugin");
assertTrue(plugin.firstDecision.isCancelled(), "timed-out placement should cancel unfinished plugin work");
assertTrue(catalog.find(new dev.moonbridge.core.backend.BackendId("placement")).isPresent());
CompletionException transientFailure = assertThrows(CompletionException.class,
@@ -0,0 +1,72 @@
burst32 base pair1 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11722.0 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.45 latency_ms_p50=1.368 p95=1.480 p99=1.558 elapsed_ms=2047.439 frames/s=375102.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 base pair1 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11387.1 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.12 latency_ms_p50=1.390 p95=1.691 p99=1.932 elapsed_ms=2107.656 frames/s=364385.9 proxy_io_cpu_ns/frame=2511 tcp_out_segments/frame=n/a
burst32 base pair1 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11324.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.06 latency_ms_p50=1.397 p95=1.663 p99=1.862 elapsed_ms=2119.349 frames/s=362375.5 proxy_io_cpu_ns/frame=2678 tcp_out_segments/frame=n/a
burst32 base pair1 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12346.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.06 latency_ms_p50=1.298 p95=1.395 p99=1.460 elapsed_ms=1943.884 frames/s=395085.4 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 candidate pair1 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12126.8 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.84 latency_ms_p50=1.325 p95=1.434 p99=1.497 elapsed_ms=1979.088 frames/s=388057.5 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 candidate pair1 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11649.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.38 latency_ms_p50=1.348 p95=1.713 p99=2.122 elapsed_ms=2060.189 frames/s=372781.2 proxy_io_cpu_ns/frame=2336 tcp_out_segments/frame=n/a
burst32 candidate pair1 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11246.6 warmup_roundtrips=2400 window=4 one_way_MiB/s=10.98 latency_ms_p50=1.402 p95=1.684 p99=1.899 elapsed_ms=2133.985 frames/s=359890.1 proxy_io_cpu_ns/frame=2538 tcp_out_segments/frame=n/a
burst32 candidate pair1 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11605.0 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.33 latency_ms_p50=1.381 p95=1.491 p99=1.550 elapsed_ms=2068.068 frames/s=371361.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 candidate pair2 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12552.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.26 latency_ms_p50=1.286 p95=1.390 p99=1.490 elapsed_ms=1912.021 frames/s=401669.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 candidate pair2 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11600.5 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.33 latency_ms_p50=1.361 p95=1.628 p99=1.894 elapsed_ms=2068.868 frames/s=371217.6 proxy_io_cpu_ns/frame=2484 tcp_out_segments/frame=n/a
burst32 candidate pair2 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11609.3 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.34 latency_ms_p50=1.361 p95=1.612 p99=1.787 elapsed_ms=2067.303 frames/s=371498.5 proxy_io_cpu_ns/frame=2498 tcp_out_segments/frame=n/a
burst32 candidate pair2 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12403.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.11 latency_ms_p50=1.294 p95=1.386 p99=1.436 elapsed_ms=1934.986 frames/s=396902.0 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 base pair2 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12067.8 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.78 latency_ms_p50=1.333 p95=1.451 p99=1.517 elapsed_ms=1988.762 frames/s=386169.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 base pair2 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12062.8 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.78 latency_ms_p50=1.324 p95=1.614 p99=1.981 elapsed_ms=1989.592 frames/s=386008.8 proxy_io_cpu_ns/frame=2425 tcp_out_segments/frame=n/a
burst32 base pair2 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12035.9 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.75 latency_ms_p50=1.318 p95=1.595 p99=1.864 elapsed_ms=1994.034 frames/s=385148.9 proxy_io_cpu_ns/frame=2440 tcp_out_segments/frame=n/a
burst32 base pair2 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=11991.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.71 latency_ms_p50=1.337 p95=1.449 p99=1.508 elapsed_ms=2001.427 frames/s=383726.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 base pair3 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12858.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.56 latency_ms_p50=1.248 p95=1.348 p99=1.414 elapsed_ms=1866.509 frames/s=411463.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 base pair3 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12040.1 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.76 latency_ms_p50=1.307 p95=1.611 p99=1.890 elapsed_ms=1993.347 frames/s=385281.7 proxy_io_cpu_ns/frame=2404 tcp_out_segments/frame=n/a
burst32 base pair3 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12033.7 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.75 latency_ms_p50=1.314 p95=1.573 p99=1.765 elapsed_ms=1994.404 frames/s=385077.4 proxy_io_cpu_ns/frame=2502 tcp_out_segments/frame=n/a
burst32 base pair3 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12406.2 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.12 latency_ms_p50=1.294 p95=1.411 p99=1.565 elapsed_ms=1934.514 frames/s=396998.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 candidate pair3 direct-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12303.4 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.02 latency_ms_p50=1.305 p95=1.403 p99=1.460 elapsed_ms=1950.681 frames/s=393708.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
burst32 candidate pair3 proxy-1: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12131.9 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.85 latency_ms_p50=1.301 p95=1.612 p99=2.163 elapsed_ms=1978.260 frames/s=388219.9 proxy_io_cpu_ns/frame=2334 tcp_out_segments/frame=n/a
burst32 candidate pair3 proxy-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=12193.9 warmup_roundtrips=2400 window=4 one_way_MiB/s=11.91 latency_ms_p50=1.298 p95=1.561 p99=1.754 elapsed_ms=1968.195 frames/s=390205.2 proxy_io_cpu_ns/frame=2340 tcp_out_segments/frame=n/a
burst32 candidate pair3 direct-2: roundtrips=24000 echoes=24000 correct=24000 errors=0 roundtrips/s=13064.1 warmup_roundtrips=2400 window=4 one_way_MiB/s=12.76 latency_ms_p50=1.232 p95=1.328 p99=1.382 elapsed_ms=1837.102 frames/s=418049.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 base pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=102851.6 warmup_roundtrips=2000 window=1 one_way_MiB/s=100.44 latency_ms_p50=0.032 p95=0.069 p99=0.085 elapsed_ms=194.455 frames/s=102851.6 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 base pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=44921.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=43.87 latency_ms_p50=0.068 p95=0.130 p99=0.186 elapsed_ms=445.218 frames/s=44921.8 proxy_io_cpu_ns/frame=31564 tcp_out_segments/frame=n/a
w1 base pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=57791.0 warmup_roundtrips=2000 window=1 one_way_MiB/s=56.44 latency_ms_p50=0.062 p95=0.114 p99=0.136 elapsed_ms=346.075 frames/s=57791.0 proxy_io_cpu_ns/frame=27075 tcp_out_segments/frame=n/a
w1 base pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=109442.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=106.88 latency_ms_p50=0.030 p95=0.065 p99=0.081 elapsed_ms=182.744 frames/s=109442.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 candidate pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=99185.9 warmup_roundtrips=2000 window=1 one_way_MiB/s=96.86 latency_ms_p50=0.034 p95=0.071 p99=0.087 elapsed_ms=201.642 frames/s=99185.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 candidate pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=50681.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=49.49 latency_ms_p50=0.072 p95=0.129 p99=0.160 elapsed_ms=394.619 frames/s=50681.8 proxy_io_cpu_ns/frame=32760 tcp_out_segments/frame=n/a
w1 candidate pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=54899.2 warmup_roundtrips=2000 window=1 one_way_MiB/s=53.61 latency_ms_p50=0.066 p95=0.119 p99=0.144 elapsed_ms=364.304 frames/s=54899.2 proxy_io_cpu_ns/frame=28599 tcp_out_segments/frame=n/a
w1 candidate pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=107379.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=104.86 latency_ms_p50=0.031 p95=0.066 p99=0.083 elapsed_ms=186.255 frames/s=107379.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 candidate pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=101572.0 warmup_roundtrips=2000 window=1 one_way_MiB/s=99.19 latency_ms_p50=0.033 p95=0.070 p99=0.085 elapsed_ms=196.905 frames/s=101572.0 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 candidate pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=44966.0 warmup_roundtrips=2000 window=1 one_way_MiB/s=43.91 latency_ms_p50=0.078 p95=0.161 p99=0.245 elapsed_ms=444.780 frames/s=44966.0 proxy_io_cpu_ns/frame=33267 tcp_out_segments/frame=n/a
w1 candidate pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=53790.7 warmup_roundtrips=2000 window=1 one_way_MiB/s=52.53 latency_ms_p50=0.068 p95=0.120 p99=0.142 elapsed_ms=371.811 frames/s=53790.7 proxy_io_cpu_ns/frame=29831 tcp_out_segments/frame=n/a
w1 candidate pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=106647.4 warmup_roundtrips=2000 window=1 one_way_MiB/s=104.15 latency_ms_p50=0.031 p95=0.067 p99=0.083 elapsed_ms=187.534 frames/s=106647.4 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 base pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=99284.1 warmup_roundtrips=2000 window=1 one_way_MiB/s=96.96 latency_ms_p50=0.034 p95=0.069 p99=0.084 elapsed_ms=201.442 frames/s=99284.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 base pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=48153.5 warmup_roundtrips=2000 window=1 one_way_MiB/s=47.02 latency_ms_p50=0.078 p95=0.133 p99=0.165 elapsed_ms=415.338 frames/s=48153.5 proxy_io_cpu_ns/frame=34794 tcp_out_segments/frame=n/a
w1 base pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=55567.3 warmup_roundtrips=2000 window=1 one_way_MiB/s=54.26 latency_ms_p50=0.066 p95=0.117 p99=0.139 elapsed_ms=359.924 frames/s=55567.3 proxy_io_cpu_ns/frame=27717 tcp_out_segments/frame=n/a
w1 base pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=84781.6 warmup_roundtrips=2000 window=1 one_way_MiB/s=82.79 latency_ms_p50=0.035 p95=0.106 p99=0.163 elapsed_ms=235.900 frames/s=84781.6 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 base pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=103762.5 warmup_roundtrips=2000 window=1 one_way_MiB/s=101.33 latency_ms_p50=0.032 p95=0.068 p99=0.085 elapsed_ms=192.748 frames/s=103762.5 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 base pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=47659.1 warmup_roundtrips=2000 window=1 one_way_MiB/s=46.54 latency_ms_p50=0.080 p95=0.134 p99=0.167 elapsed_ms=419.647 frames/s=47659.1 proxy_io_cpu_ns/frame=35591 tcp_out_segments/frame=n/a
w1 base pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=58511.2 warmup_roundtrips=2000 window=1 one_way_MiB/s=57.14 latency_ms_p50=0.062 p95=0.111 p99=0.135 elapsed_ms=341.815 frames/s=58511.2 proxy_io_cpu_ns/frame=26286 tcp_out_segments/frame=n/a
w1 base pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=109563.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=107.00 latency_ms_p50=0.031 p95=0.065 p99=0.081 elapsed_ms=182.542 frames/s=109563.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 candidate pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=102711.3 warmup_roundtrips=2000 window=1 one_way_MiB/s=100.30 latency_ms_p50=0.032 p95=0.069 p99=0.085 elapsed_ms=194.720 frames/s=102711.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w1 candidate pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=51613.9 warmup_roundtrips=2000 window=1 one_way_MiB/s=50.40 latency_ms_p50=0.071 p95=0.126 p99=0.153 elapsed_ms=387.493 frames/s=51613.9 proxy_io_cpu_ns/frame=31881 tcp_out_segments/frame=n/a
w1 candidate pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=56566.8 warmup_roundtrips=2000 window=1 one_way_MiB/s=55.24 latency_ms_p50=0.064 p95=0.116 p99=0.136 elapsed_ms=353.564 frames/s=56566.8 proxy_io_cpu_ns/frame=27733 tcp_out_segments/frame=n/a
w1 candidate pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=108651.2 warmup_roundtrips=2000 window=1 one_way_MiB/s=106.10 latency_ms_p50=0.031 p95=0.066 p99=0.083 elapsed_ms=184.075 frames/s=108651.2 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 base pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=140521.8 warmup_roundtrips=2000 window=16 one_way_MiB/s=137.23 latency_ms_p50=0.441 p95=0.608 p99=0.790 elapsed_ms=142.327 frames/s=140521.8 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 base pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=119463.8 warmup_roundtrips=2000 window=16 one_way_MiB/s=116.66 latency_ms_p50=0.416 p95=1.034 p99=2.207 elapsed_ms=167.415 frames/s=119463.8 proxy_io_cpu_ns/frame=24052 tcp_out_segments/frame=n/a
w16 base pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=116408.8 warmup_roundtrips=2000 window=16 one_way_MiB/s=113.68 latency_ms_p50=0.411 p95=1.253 p99=3.477 elapsed_ms=171.808 frames/s=116408.8 proxy_io_cpu_ns/frame=23561 tcp_out_segments/frame=n/a
w16 base pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=142838.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=139.49 latency_ms_p50=0.437 p95=0.590 p99=0.660 elapsed_ms=140.019 frames/s=142838.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 candidate pair1 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=142293.6 warmup_roundtrips=2000 window=16 one_way_MiB/s=138.96 latency_ms_p50=0.441 p95=0.612 p99=0.750 elapsed_ms=140.555 frames/s=142293.6 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 candidate pair1 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=110984.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=108.38 latency_ms_p50=0.495 p95=0.996 p99=1.765 elapsed_ms=180.205 frames/s=110984.5 proxy_io_cpu_ns/frame=21673 tcp_out_segments/frame=n/a
w16 candidate pair1 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=114924.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=112.23 latency_ms_p50=0.397 p95=0.950 p99=4.230 elapsed_ms=174.027 frames/s=114924.7 proxy_io_cpu_ns/frame=22167 tcp_out_segments/frame=n/a
w16 candidate pair1 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=147423.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=143.97 latency_ms_p50=0.387 p95=0.729 p99=1.202 elapsed_ms=135.664 frames/s=147423.1 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 candidate pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=142233.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=138.90 latency_ms_p50=0.441 p95=0.599 p99=0.763 elapsed_ms=140.614 frames/s=142233.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 candidate pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=110771.2 warmup_roundtrips=2000 window=16 one_way_MiB/s=108.18 latency_ms_p50=0.524 p95=0.949 p99=1.444 elapsed_ms=180.552 frames/s=110771.2 proxy_io_cpu_ns/frame=21900 tcp_out_segments/frame=n/a
w16 candidate pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=111617.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=109.00 latency_ms_p50=0.412 p95=1.100 p99=3.880 elapsed_ms=179.184 frames/s=111617.1 proxy_io_cpu_ns/frame=22810 tcp_out_segments/frame=n/a
w16 candidate pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=148224.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=144.75 latency_ms_p50=0.403 p95=0.661 p99=1.249 elapsed_ms=134.930 frames/s=148224.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 base pair2 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=145075.9 warmup_roundtrips=2000 window=16 one_way_MiB/s=141.68 latency_ms_p50=0.433 p95=0.596 p99=0.717 elapsed_ms=137.859 frames/s=145075.9 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 base pair2 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=110407.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=107.82 latency_ms_p50=0.519 p95=0.921 p99=1.564 elapsed_ms=181.147 frames/s=110407.5 proxy_io_cpu_ns/frame=22888 tcp_out_segments/frame=n/a
w16 base pair2 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=114153.1 warmup_roundtrips=2000 window=16 one_way_MiB/s=111.48 latency_ms_p50=0.404 p95=1.265 p99=3.695 elapsed_ms=175.203 frames/s=114153.1 proxy_io_cpu_ns/frame=22665 tcp_out_segments/frame=n/a
w16 base pair2 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=152408.3 warmup_roundtrips=2000 window=16 one_way_MiB/s=148.84 latency_ms_p50=0.368 p95=0.688 p99=1.206 elapsed_ms=131.226 frames/s=152408.3 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 base pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=139121.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=135.86 latency_ms_p50=0.447 p95=0.615 p99=0.808 elapsed_ms=143.759 frames/s=139121.5 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 base pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=118869.5 warmup_roundtrips=2000 window=16 one_way_MiB/s=116.08 latency_ms_p50=0.432 p95=1.135 p99=2.117 elapsed_ms=168.252 frames/s=118869.5 proxy_io_cpu_ns/frame=23829 tcp_out_segments/frame=n/a
w16 base pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=113474.4 warmup_roundtrips=2000 window=16 one_way_MiB/s=110.81 latency_ms_p50=0.422 p95=1.165 p99=3.149 elapsed_ms=176.251 frames/s=113474.4 proxy_io_cpu_ns/frame=24491 tcp_out_segments/frame=n/a
w16 base pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=140474.2 warmup_roundtrips=2000 window=16 one_way_MiB/s=137.18 latency_ms_p50=0.442 p95=0.614 p99=0.756 elapsed_ms=142.375 frames/s=140474.2 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 candidate pair3 direct-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=144711.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=141.32 latency_ms_p50=0.427 p95=0.615 p99=0.729 elapsed_ms=138.206 frames/s=144711.7 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
w16 candidate pair3 proxy-1: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=107387.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=104.87 latency_ms_p50=0.482 p95=1.050 p99=2.447 elapsed_ms=186.241 frames/s=107387.7 proxy_io_cpu_ns/frame=22794 tcp_out_segments/frame=n/a
w16 candidate pair3 proxy-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=114588.7 warmup_roundtrips=2000 window=16 one_way_MiB/s=111.90 latency_ms_p50=0.490 p95=0.803 p99=2.276 elapsed_ms=174.537 frames/s=114588.7 proxy_io_cpu_ns/frame=18155 tcp_out_segments/frame=n/a
w16 candidate pair3 direct-2: roundtrips=20000 echoes=20000 correct=20000 errors=0 roundtrips/s=143326.2 warmup_roundtrips=2000 window=16 one_way_MiB/s=139.97 latency_ms_p50=0.421 p95=0.649 p99=0.913 elapsed_ms=139.542 frames/s=143326.2 proxy_io_cpu_ns/frame=0 tcp_out_segments/frame=n/a
@@ -0,0 +1,57 @@
# Relay write completion per batch instead of per frame — 2026-10-22
Base revision: `4ed6162` (main). Candidate: [`2026-10-22-relay-batch-completion.patch`](2026-10-22-relay-batch-completion.patch). The ordinary `RawRelay` path still writes each frame with `peer.write`, but only the last write of each read batch gets a completion listener. The listener is attached when the batch is flushed and subtracts the whole batch from `writesInFlight`. Before, every frame got a listener, and every completion queued one more event-loop task. One channel completes its writes in order and fails every later write once one fails, so the last write's outcome covers the batch. The paused-replay path was unchanged.
**Outcome: not adopted.** Allocation on the proxy I/O threads fell by about a fifth, but per-frame CPU did not move beyond run-to-run range.
## Decision rule (set before the comparison)
Adopt only if, on burst32, the candidate's median proxy I/O CPU per frame is at least 10% below the base and the paired proxy/direct throughput is not lower. Window 1 and window 16 must show no consistent regression (medians within 10% or overlapping ranges).
## Profile that motivated it
A JFR `profile` recording of the base on burst32 attributed the sampled allocation on `moonbridge-session-io` threads as follows: `ChannelOutboundBuffer$Entry` 26.8%, `DefaultChannelPromise` 26.6%, `UnpooledSlicedByteBuf` (frame decoder slices) 20.8%, and two `RawRelay$Side` lambdas 24.2%. The lambdas were the per-frame completion listener and the task it queued. They were the only share the relay itself could remove without changing write semantics.
## Environment and method
Apple M4 (10 cores), macOS, Zulu OpenJDK 25.0.4.1, Netty 4.2.18.Final, kqueue transport, loopback only. Both variants were built with `:proxy-core:installDist`, and the same `ProxySessionBenchmark` source was compiled against each `moonbridge.jar`. For each workload, three base/candidate JVM pairs ran, alternating which variant started (base first in pairs 1 and 3). Each JVM ran `--repeats 2`, so there are six paired direct/proxy phases per cell. Every echo was checked; all 72 phases report `errors=0`.
```sh
java -Xms512m -Xmx512m --enable-native-access=ALL-UNNAMED -cp "<variant>/classes:<variant>/moonbridge.jar" \
dev.moonbridge.core.session.ProxySessionBenchmark --transport kqueue \
--connections 4 --messages 6000 --warmup 600 --payload 32 --window 4 --burst 32 --repeats 2 # burst32
--connections 4 --messages 5000 --warmup 500 --payload 1024 --window 1 --burst 1 --repeats 2 # w1
--connections 4 --messages 5000 --warmup 500 --payload 1024 --window 16 --burst 1 --repeats 2 # w16
```
## Results
Values are the median and [min–max] of the six proxy phases. "Proxy/direct" divides each proxy phase's frames/s by its paired direct phase.
| Workload | Variant | Proxy/direct | p50 ms | p95 ms | Proxy I/O CPU ns/frame |
| --- | --- | ---: | ---: | ---: | ---: |
| burst32 | base | 0.971 [0.917–1.004] | 1.321 [1.307–1.397] | 1.613 [1.573–1.691] | 2471 [2404–2678] |
| burst32 | candidate | 0.948 [0.924–0.986] | 1.355 [1.298–1.402] | 1.620 [1.561–1.713] | 2412 [2334–2538] |
| w1 | base | 0.507 [0.437–0.655] | 0.067 [0.062–0.080] | 0.123 [0.111–0.134] | 29640 [26286–35591] |
| w1 | candidate | 0.508 [0.443–0.521] | 0.070 [0.064–0.078] | 0.123 [0.116–0.161] | 30856 [27733–33267] |
| w16 | base | 0.811 [0.749–0.854] | 0.419 [0.404–0.519] | 1.150 [0.921–1.265] | 23695 [22665–24491] |
| w16 | candidate | 0.779 [0.742–0.799] | 0.486 [0.397–0.524] | 0.973 [0.803–1.100] | 22034 [18155–22810] |
Allocation, from one JFR `profile` recording per variant on burst32 (sampled weight on `moonbridge-session-io` threads):
| Variant | Total | Relay lambdas | Promise | Outbound entry | Slices |
| --- | ---: | ---: | ---: | ---: | ---: |
| base | 677 MB | 167 MB | 180 MB | 181 MB | 141 MB |
| candidate | 530 MB | 6 MB | 174 MB | 212 MB | 126 MB |
Raw output: [2026-10-22-relay-batch-completion-raw.txt](2026-10-22-relay-batch-completion-raw.txt) (lines are `workload variant pair phase: metrics`).
## Interpretation
- burst32: the median CPU per frame is 2.4% lower and the ranges overlap; paired throughput is slightly lower, also overlapping. The 10% CPU criterion is not met.
- The candidate removed 97% of the relay's own allocation and 22% of the I/O threads' total. JFR allocation samples are weighted estimates; the promise, entry and slice rows did not change in the code and differ only by sampling. On this heap, young-generation collection of short-lived objects is cheap enough that this does not show up as CPU per frame.
- w1 and w16: CPU and throughput ranges overlap. w16 p95 is lower, but p50 is higher, so there is no consistent direction either way.
## Limits
On this host the direct path runs at nearly the same rate as the proxy path (proxy/direct close to 1 on burst32). The single-JVM client and fake backend are the throughput limit, so throughput cannot show a proxy-side gain here; CPU per frame is the meaningful metric. This was one loopback run on one desktop, with no encryption (`ONLINE_BUNGEE`), WebSocket, real Forge traffic, cross-host RTT or production player counts. GC cost under a long-running, larger heap was not measured. A future run on Linux with epoll, or a GC-sensitive measurement (allocation rate under sustained load with many sessions), could revisit it with the patch above. Single-host loopback numbers are never capacity claims.
@@ -0,0 +1,72 @@
diff --git a/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java b/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java
index e1832e1..a6288f2 100644
--- a/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java
+++ b/proxy/core/src/main/java/dev/moonbridge/core/session/play/RawRelay.java
@@ -3,6 +3,7 @@ package dev.moonbridge.core.session.play;
import dev.moonbridge.core.protocol.ProtocolProfile;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
@@ -173,6 +174,9 @@ public final class RawRelay {
private volatile boolean closingAfterWrites;
private boolean readsEnabled;
private boolean flushPending;
+ /** The last frame written since the previous flush, and how many frames that batch holds. */
+ private ChannelFuture batchTail;
+ private int batchWrites;
private ScheduledFuture<?> closeDeadline;
private CompletableFuture<Void> pauseComplete;
private CompletableFuture<Void> resumeComplete;
@@ -340,17 +344,10 @@ public final class RawRelay {
try {
Consumer<ByteBuf> activeObserver = observer;
if (activeObserver != null) activeObserver.accept(((ByteBuf) message).duplicate());
- peer.write(message).addListener((ChannelFutureListener) future ->
- ctx.executor().execute(() -> {
- writesInFlight--;
- if (paused && writesInFlight == 0 && pauseComplete != null) {
- if (future.isSuccess()) pauseComplete.complete(null);
- else pauseComplete.completeExceptionally(future.cause());
- pauseComplete = null;
- }
- if (!future.isSuccess() || (closingAfterWrites && writesInFlight == 0)) closePair(ctx);
- else if (!closingAfterWrites) requestRead();
- }));
+ // Only the batch's last write gets a listener, attached when the batch is flushed: one channel
+ // completes writes in order and fails every later write once one fails, so its outcome covers all.
+ batchTail = peer.write(message);
+ batchWrites++;
scheduleFlush(ctx);
} catch (RuntimeException failure) {
writesInFlight--;
@@ -379,9 +376,27 @@ public final class RawRelay {
private void flushPeer() {
if (!flushPending) return;
flushPending = false;
+ ChannelFuture tail = batchTail;
+ int writes = batchWrites;
+ batchTail = null;
+ batchWrites = 0;
+ if (tail != null) tail.addListener((ChannelFutureListener) future -> batchCompleted(future, writes));
peer.flush();
}
+ private void batchCompleted(ChannelFuture future, int writes) {
+ context.executor().execute(() -> {
+ writesInFlight -= writes;
+ if (paused && writesInFlight == 0 && pauseComplete != null) {
+ if (future.isSuccess()) pauseComplete.complete(null);
+ else pauseComplete.completeExceptionally(future.cause());
+ pauseComplete = null;
+ }
+ if (!future.isSuccess() || (closingAfterWrites && writesInFlight == 0)) closePair(context);
+ else if (!closingAfterWrites) requestRead();
+ });
+ }
+
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) {
if (ctx.channel().isWritable()) opposite.requestRead();