package dev.moonbridge.clientws.net; import java.lang.management.ManagementFactory; import java.util.Locale; import java.util.Random; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.Unpooled; import io.netty.buffer.UnpooledByteBufAllocator; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelOutboundHandlerAdapter; import io.netty.channel.ChannelPromise; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocket13FrameEncoder; import io.netty.handler.codec.http.websocketx.WebSocketFrame; /** * Client encoder microbenchmark: Netty 4.0.10's {@code WebSocket13FrameEncoder} ("base", with the frame wrapping the * old adapter did) against {@link ClientFrameEncoder} ("candidate"). * *
* A non-loop thread plays vanilla's client thread and calls {@code writeAndFlush} once per packet, in bursts of 1–24 * packets, waiting for each burst to be written. A sink at the head of the pipeline stands in for the socket. Packet * sizes: 88% 20–300 B, 11% 2–8 KiB, 1% 30–60 KiB, fixed seed. CPU and allocation are those of the event loop thread * (where the codec runs); buffers are unpooled heap buffers so the JVM's per-thread allocation counter sees ByteBuf * memory too. Usage: {@code --variant base|candidate --repeats N --label X} (the first phase is warm-up). * *
* The decoder candidate measured in testing/benchmarks/results/2026-10-23-client-websocket-codec.md (and rejected) is
* in commit 1decc44, together with this benchmark's decode modes.
*/
public final class CodecBenchmark {
static final long SEED = 20261023L;
static final int ENCODE_PACKETS = 100_000;
static final ByteBufAllocator ALLOC = new UnpooledByteBufAllocator(false);
static final byte[] CONTENT = new byte[64 * 1024];
static {
new Random(SEED).nextBytes(CONTENT);
}
private static final com.sun.management.ThreadMXBean THREADS = (com.sun.management.ThreadMXBean) ManagementFactory
.getThreadMXBean();
static int packetSize(Random r) {
int p = r.nextInt(1000);
if (p < 880) {
return 20 + r.nextInt(281);
} else if (p < 990) {
return 2048 + r.nextInt(6145);
}
return 30720 + r.nextInt(30721);
}
public static void main(String[] args) throws Exception {
String variant = "candidate";
int repeats = 3;
String label = "run";
for (int i = 0; i < args.length; i++) {
if (args[i].equals("--variant")) {
variant = args[++i];
} else if (args[i].equals("--repeats")) {
repeats = Integer.parseInt(args[++i]);
} else if (args[i].equals("--label")) {
label = args[++i];
}
}
boolean candidate = variant.equals("candidate");
if (!candidate && !variant.equals("base")) {
throw new IllegalArgumentException("--variant base|candidate");
}
System.out.println(
"# java " + System.getProperty("java.version")
+ " "
+ System.getProperty("java.vm.name")
+ ", "
+ System.getProperty("os.name")
+ " "
+ System.getProperty("os.arch")
+ ", cpus "
+ Runtime.getRuntime()
.availableProcessors());
NioEventLoopGroup group = new NioEventLoopGroup(1);
try {
Random r = new Random(SEED);
int[] sizes = new int[ENCODE_PACKETS];
for (int i = 0; i < sizes.length; i++) {
sizes[i] = packetSize(r);
}
int[] bursts = new int[ENCODE_PACKETS];
for (int i = 0; i < bursts.length; i++) {
bursts[i] = 1 + r.nextInt(24);
}
for (int rep = 0; rep <= repeats; rep++) {
String phase = rep == 0 ? "warmup" : "rep" + rep;
System.out.println(
"encode " + variant + " " + label + " " + phase + ": " + encode(group, candidate, sizes, bursts));
}
} finally {
group.shutdownGracefully(0, 1, TimeUnit.SECONDS)
.syncUninterruptibly();
}
}
// ---- encode ----
/** Socket stand-in: counts and releases what the encoder writes. */
static final class Sink extends ChannelOutboundHandlerAdapter {
long buffers;
long bytes;
long flushes;
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
ByteBuf b = (ByteBuf) msg;
buffers++;
bytes += b.readableBytes();
b.release();
promise.setSuccess();
}
@Override
public void flush(ChannelHandlerContext ctx) {
flushes++;
}
}
/** Base only: counts frames entering Netty's encoder (it writes a header and a body buffer per frame). */
static final class FrameCounter extends ChannelOutboundHandlerAdapter {
long frames;
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
if (msg instanceof WebSocketFrame) {
frames++;
}
ctx.write(msg, promise);
}
}
/** Base only: what the old WebSocketFrameAdapter did with outbound buffers. */
static final class LegacyFrames extends ChannelOutboundHandlerAdapter {
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
ByteBuf buf = (ByteBuf) msg;
if (buf.isReadable()) {
ctx.write(new BinaryWebSocketFrame(buf), promise);
} else {
buf.release();
promise.setSuccess();
}
}
}
private static Channel channel(NioEventLoopGroup group) {
Channel ch = new NioSocketChannel();
ch.config()
.setOption(ChannelOption.ALLOCATOR, ALLOC);
return ch;
}
private static long[] loopCounters(Channel ch) throws Exception {
return ch.eventLoop()
.submit(new Callable