Files
MoonBridge/testing/benchmarks/TransferPathBenchmark.java
layue13andClaude Opus 5.5 789648c82f refactor: 仓库根目录按用途分组
- proxy/:core、plugin-api、luckperms 及其上游子模块 vendor/luckperms
- messaging/、backend/ 保持在根目录(messaging 是代理与后端共用的 API)
- build-logic/(原 proxy-build-logic)
- testing/:smoke/ 与 benchmarks/,不进入发行物

Gradle 项目名不变(settings.gradle.kts 映射目录),任务路径与 Maven 坐标照旧。
脚本的仓库根定位、文档与记录中的链接同步更新。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:34:01 +08:00

399 lines
20 KiB
Java

package dev.moonbridge.core.session;
import dev.moonbridge.api.PlacementDecision;
import dev.moonbridge.api.PlayerView;
import dev.moonbridge.api.Plugin;
import dev.moonbridge.api.TransferStatus;
import dev.moonbridge.core.backend.BackendId;
import dev.moonbridge.core.backend.BackendOwner;
import dev.moonbridge.core.backend.BackendRegistration;
import dev.moonbridge.core.backend.InMemoryBackendCatalog;
import dev.moonbridge.core.plugin.PluginHost;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** Controlled end-to-end Minecraft 1.7.10 transfer benchmark for coordination overhead. */
public final class TransferPathBenchmark {
private static final String LOOPBACK = "127.0.0.2";
private static final int TIMEOUT_MILLIS = 30_000;
private static final int SETUP_TIMEOUT_SECONDS = 20;
private static final int[] CONCURRENCY = {1, 16, 64};
private static final int ASYNC_DELAY_MILLIS = 4;
private TransferPathBenchmark() { }
public static void main(String[] args) throws Exception {
int repeats = intArg(args, "repeats", 5);
boolean onlyNoSubscriber = hasFlag(args, "--only-no-subscriber");
if (repeats < 1 || repeats > 10) throw new IllegalArgumentException("repeats must be 1..10");
System.out.println("Offline Minecraft 1.7.10 transfer path; fake loopback backends; protocol setup excluded.");
if (onlyNoSubscriber) {
System.out.println("Scenario filter: host with no subscribers only.");
} else {
System.out.println("Scenarios: host with no subscribers; 1/4 allow-only immediate; 1/4 release-source immediate; "
+ "1/4 release-source with 4ms asynchronous prepare and release callbacks.");
}
if (onlyNoSubscriber) {
System.out.println("No TransferPreparingEvent listener is installed. Concurrency=1/16/64; "
+ "reports whole transfer NETWORK_READY latency and completed transfers/s.");
} else {
System.out.println("Each case uses the real PluginHost/AsyncEventDispatcher event path. Concurrency=1/16/64; "
+ "reports whole transfer NETWORK_READY latency and completed transfers/s.");
}
System.out.println("No Forge, real backend, real client, persistence plugin, or production workload is involved.");
for (int concurrency : CONCURRENCY) {
runScenario(concurrency, 0, false, 0, repeats);
if (onlyNoSubscriber) continue;
for (int participants : new int[]{1, 4}) {
runScenario(concurrency, participants, false, 0, repeats);
}
for (int participants : new int[]{1, 4}) {
runScenario(concurrency, participants, true, 0, repeats);
runScenario(concurrency, participants, true, ASYNC_DELAY_MILLIS, repeats);
}
}
}
private static void runScenario(int concurrency, int participants, boolean releaseSource,
int delayMillis, int repeats) throws Exception {
String mode = participants == 0 ? "no-subscriber"
: (releaseSource ? "release" : "allow") + "-p" + participants + "-delay" + delayMillis + "ms";
int measuredBatches = Math.max(repeats, (100 + concurrency - 1) / concurrency);
int warmupBatches = 3;
List<Long> latencyNanos = new ArrayList<>(concurrency * measuredBatches);
long totalElapsedNanos = 0;
int successes = 0;
try (FakeBackend source = new FakeBackend(concurrency);
FakeBackend targetA = new FakeBackend(concurrency);
FakeBackend targetB = new FakeBackend(concurrency)) {
source.start(); targetA.start(); targetB.start();
InMemoryBackendCatalog catalog = new InMemoryBackendCatalog();
register(catalog, "source", source.port());
register(catalog, "target-a", targetA.port());
register(catalog, "target-b", targetB.port());
ProxySessionListener proxy = new ProxySessionListener(
new InetSocketAddress(InetAddress.getByName(LOOPBACK), 0), catalog);
proxy.setPlacement(player -> CompletableFuture.completedFuture(
java.util.Optional.of(PlacementDecision.select("source"))));
PluginHost host = new PluginHost(catalog, proxy, Duration.ofSeconds(10), Duration.ofSeconds(10));
try {
List<Plugin> plugins = participants == 0 ? List.of() : List.of(createParticipantPlugin(
participants, releaseSource, delayMillis));
host.load(plugins);
host.enable();
proxy.setEvents(host, Duration.ofSeconds(10));
int proxyPort = ((InetSocketAddress) proxy.start().toCompletableFuture()
.get(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS).localAddress()).getPort();
ExecutorService clients = Executors.newFixedThreadPool(concurrency);
List<TransferWorker> workers = new ArrayList<>(concurrency);
try {
for (int i = 0; i < concurrency; i++) {
TransferWorker worker = new TransferWorker(proxyPort, proxy, username(0, i));
worker.open();
workers.add(worker);
}
for (int repeat = 0; repeat < warmupBatches + measuredBatches; repeat++) {
CountDownLatch begin = new CountDownLatch(1);
CountDownLatch finished = new CountDownLatch(concurrency);
AtomicReference<Throwable> failure = new AtomicReference<>();
for (TransferWorker worker : workers) {
String target = worker.nextTarget();
clients.execute(() -> {
try {
if (!begin.await(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS))
throw new IOException("start gate timed out");
worker.transfer(target);
} catch (Throwable problem) {
failure.compareAndSet(null, problem);
} finally {
finished.countDown();
}
});
}
long batchStart = System.nanoTime();
begin.countDown();
if (!finished.await(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS))
throw new IOException("transfers timed out at concurrency " + concurrency);
long batchElapsed = System.nanoTime() - batchStart;
Throwable problem = failure.get();
if (problem != null) throw new IOException("transfer worker failed", problem);
if (repeat < warmupBatches) continue;
totalElapsedNanos += batchElapsed;
for (TransferWorker worker : workers) {
latencyNanos.add(worker.latencyNanos);
successes++;
}
}
} finally {
clients.shutdownNow();
clients.awaitTermination(5, TimeUnit.SECONDS);
for (TransferWorker worker : workers) worker.close();
}
} finally {
try { proxy.close().toCompletableFuture().get(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); }
finally { host.close(); }
}
}
long[] sorted = latencyNanos.stream().mapToLong(Long::longValue).sorted().toArray();
double throughput = successes * 1_000_000_000.0 / totalElapsedNanos;
System.out.printf("mode=%s concurrency=%d warmup_batches=%d measured_batches=%d samples=%d "
+ "success=%d transfers/s=%.1f "
+ "latency_ms_p50=%.3f p95=%.3f p99=%.3f batch_elapsed_ms_total=%.1f%n",
mode, concurrency, warmupBatches, measuredBatches, sorted.length, successes, throughput,
millis(percentile(sorted, .50)),
millis(percentile(sorted, .95)), millis(percentile(sorted, .99)),
totalElapsedNanos / 1_000_000.0);
}
private static Plugin createParticipantPlugin(int participants, boolean releaseSource, int delayMillis)
throws ReflectiveOperationException {
Class<?> type = Class.forName("dev.moonbridge.core.session.TransferParticipantPlugin");
return (Plugin) type.getDeclaredConstructor(int.class, boolean.class, int.class)
.newInstance(participants, releaseSource, delayMillis);
}
private static void register(InMemoryBackendCatalog catalog, String name, int port) {
catalog.register(new BackendRegistration(new BackendId(name), new BackendOwner("benchmark", 0),
URI.create("tcp://" + LOOPBACK + ":" + port), Map.of(), Map.of()));
}
private static String username(int repeat, int index) {
return String.format("T%02dP%02dX", repeat, index);
}
private static double millis(long nanos) { return nanos / 1_000_000.0; }
private static long percentile(long[] sorted, double quantile) {
return sorted[Math.min(sorted.length - 1, (int) Math.ceil(quantile * sorted.length) - 1)];
}
private static final class TransferWorker implements AutoCloseable {
private final int proxyPort;
private final ProxySessionListener proxy;
private final String username;
private Socket socket;
private DataInputStream input;
private DataOutputStream output;
private PlayerView player;
private int transferOrdinal;
private long latencyNanos;
private TransferWorker(int proxyPort, ProxySessionListener proxy, String username) {
this.proxyPort = proxyPort; this.proxy = proxy; this.username = username;
}
private void open() throws Exception {
socket = new Socket();
socket.setTcpNoDelay(true);
socket.setSoTimeout(TIMEOUT_MILLIS);
socket.connect(new InetSocketAddress(InetAddress.getByName(LOOPBACK), proxyPort), 5000);
input = new DataInputStream(socket.getInputStream());
output = new DataOutputStream(socket.getOutputStream());
sendHandshakeAndLogin(output, username);
if (packetId(readFrame(input)) != 2 || packetId(readFrame(input)) != 1
|| packetId(readFrame(input)) != 8) throw new IOException("initial backend setup mismatch");
player = awaitPlayer(proxy, username);
}
private String nextTarget() {
return (transferOrdinal++ & 1) == 0 ? "target-a" : "target-b";
}
private void transfer(String target) throws Exception {
long start = System.nanoTime();
var result = proxy.transfer(player.identity(), target).toCompletableFuture()
.get(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
latencyNanos = System.nanoTime() - start;
if (result.status() != TransferStatus.NETWORK_READY)
throw new IOException("transfer returned " + result.status() + " "
+ result.detail().orElse(""));
int first = packetId(readFrame(input));
int second = packetId(readFrame(input));
int third = packetId(readFrame(input));
if (first != 7 || second != 7 || third != 8)
throw new IOException("unexpected target transition packet IDs " + first + "," + second + "," + third);
}
@Override public void close() throws IOException { if (socket != null) socket.close(); }
}
private static PlayerView awaitPlayer(ProxySessionListener proxy, String username) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(SETUP_TIMEOUT_SECONDS);
while (System.nanoTime() < deadline) {
for (PlayerView player : proxy.online()) if (player.username().equals(username)) return player;
Thread.sleep(2);
}
throw new IOException("proxy did not publish " + username);
}
private static final class FakeBackend implements AutoCloseable {
private final ServerSocket server = new ServerSocket();
private final ExecutorService clients;
private final List<Socket> active = Collections.synchronizedList(new ArrayList<>());
private volatile boolean closed;
private Thread acceptThread;
private FakeBackend(int concurrency) throws IOException {
clients = Executors.newFixedThreadPool(concurrency);
server.bind(new InetSocketAddress(InetAddress.getByName(LOOPBACK), 0), concurrency * 2);
}
private int port() { return server.getLocalPort(); }
private void start() {
acceptThread = new Thread(() -> {
while (!closed) {
try {
Socket socket = server.accept();
socket.setTcpNoDelay(true); socket.setSoTimeout(TIMEOUT_MILLIS); active.add(socket);
clients.execute(() -> serve(socket));
} catch (IOException problem) { if (!closed) close(); }
}
}, "transfer-benchmark-backend-accept");
acceptThread.setDaemon(true); acceptThread.start();
}
private void serve(Socket socket) {
try (Socket ignored = socket) {
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
readFrame(input);
byte[] login = readFrame(input);
ByteArrayInputStream loginData = new ByteArrayInputStream(login);
if (readVarInt(loginData) != 0) throw new IOException("expected Login Start");
String username = readString(loginData, 16);
UUID playerId = UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(StandardCharsets.UTF_8));
ByteArrayOutputStream success = new ByteArrayOutputStream();
writeVarInt(success, 2); writeString(success, playerId.toString()); writeString(success, username);
writeFrame(output, success.toByteArray());
writeFrame(output, joinGame(99));
writeFrame(output, positionAndLook());
while (!closed && !socket.isClosed()) readFrame(input);
} catch (EOFException ignored) { }
catch (IOException ignored) { }
finally { active.remove(socket); }
}
@Override public void close() {
if (closed) return;
closed = true;
try { server.close(); } catch (IOException ignored) { }
synchronized (active) { for (Socket socket : active) try { socket.close(); } catch (IOException ignored) { } }
clients.shutdownNow();
try { clients.awaitTermination(5, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
if (acceptThread != null) try { acceptThread.join(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
private static byte[] joinGame(int entityId) throws IOException {
ByteArrayOutputStream packet = new ByteArrayOutputStream();
writeVarInt(packet, 1);
DataOutputStream out = new DataOutputStream(packet);
out.writeInt(entityId); out.writeByte(0); out.writeByte(0); out.writeByte(0); out.writeByte(20);
writeString(packet, "default");
return packet.toByteArray();
}
private static byte[] positionAndLook() throws IOException {
ByteArrayOutputStream packet = new ByteArrayOutputStream();
writeVarInt(packet, 8);
DataOutputStream out = new DataOutputStream(packet);
out.writeDouble(.5); out.writeDouble(64); out.writeDouble(.5); out.writeFloat(0); out.writeFloat(0); out.writeByte(0);
return packet.toByteArray();
}
private static void sendHandshakeAndLogin(DataOutputStream output, String username) throws IOException {
ByteArrayOutputStream handshake = new ByteArrayOutputStream();
writeVarInt(handshake, 0); writeVarInt(handshake, 5); writeString(handshake, "localhost");
handshake.write(0); handshake.write(255); writeVarInt(handshake, 2); writeFrame(output, handshake.toByteArray());
ByteArrayOutputStream login = new ByteArrayOutputStream();
writeVarInt(login, 0); writeString(login, username); writeFrame(output, login.toByteArray());
}
private static int packetId(byte[] frame) throws IOException { return readVarInt(new ByteArrayInputStream(frame)); }
private static byte[] readFrame(DataInputStream input) throws IOException {
int length = readVarInt(input);
if (length < 1 || length > 1_048_576) throw new IOException("invalid frame length " + length);
byte[] body = new byte[length]; input.readFully(body); return body;
}
private static void writeFrame(DataOutputStream output, byte[] body) throws IOException {
writeVarInt(output, body.length); output.write(body); output.flush();
}
private static int readVarInt(java.io.InputStream input) throws IOException {
int result = 0;
for (int shift = 0; shift < 35; shift += 7) {
int next = input.read();
if (next < 0) throw new EOFException();
result |= (next & 0x7f) << shift;
if ((next & 0x80) == 0) return result;
}
throw new IOException("overlong VarInt");
}
private static void writeVarInt(java.io.OutputStream output, int value) throws IOException {
do {
int part = value & 0x7f; value >>>= 7;
output.write(value == 0 ? part : part | 0x80);
} while (value != 0);
}
private static String readString(ByteArrayInputStream input, int maxChars) throws IOException {
int length = readVarInt(input);
if (length < 0 || length > maxChars * 4) throw new IOException("invalid string length");
byte[] encoded = input.readNBytes(length);
if (encoded.length != length) throw new EOFException();
return new String(encoded, StandardCharsets.UTF_8);
}
private static void writeString(ByteArrayOutputStream output, String value) throws IOException {
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
writeVarInt(output, encoded.length); output.write(encoded);
}
private static int intArg(String[] args, String name, int fallback) {
int value = fallback;
for (int i = 0; i < args.length; i++) {
if (args[i].equals("--" + name)) {
if (++i >= args.length) throw new IllegalArgumentException("missing value for --" + name);
value = Integer.parseInt(args[i]);
} else if (args[i].equals("--only-no-subscriber")) {
continue;
} else if (args[i].startsWith("--")) {
throw new IllegalArgumentException("unknown option " + args[i]);
}
}
return value;
}
private static boolean hasFlag(String[] args, String flag) {
for (String arg : args) if (arg.equals(flag)) return true;
return false;
}
}