feat(core): add HTTP/2 performance gates

This commit is contained in:
Zakaria El Orche
2026-08-13 21:29:21 +00:00
parent 6386264a1e
commit 3679eed74a
20 changed files with 862 additions and 21 deletions
@@ -0,0 +1,95 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Collection;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.profile.GCProfiler;
import org.openjdk.jmh.results.Result;
import org.openjdk.jmh.results.RunResult;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;
/** Short, forked JMH gates used by CI; full publication runs retain each benchmark's annotations. */
@EnabledIfSystemProperty(named = "flash.performance.gates", matches = "true")
class PerformanceGateTest {
private static final double ALLOCATION_NOISE_FLOOR = 0.05;
private static final String INCLUDE =
"(RequestPipelineBenchmark.parseAndRoute"
+ "|Http2StreamBenchmark.lifecycle"
+ "|Http2ResponseWriterBenchmark.encodeResponse"
+ "|HpackDecoderBenchmark.decodeTypicalBrowserRequest"
+ "|HpackEncoderBenchmark.encodeTypicalResponse"
+ "|FrameLayerBenchmark.readValidateAndDiscard)";
@Test
void allocationAndLatencyBaselinesHold() throws Exception {
Collection<RunResult> allocationResults = new Runner(allocationOptions()).run();
assertFalse(allocationResults.isEmpty(), "JMH did not discover the allocation gates");
for (RunResult run : allocationResults) {
String benchmark = shortName(run.getParams().getBenchmark());
Result<?> allocation = run.getSecondaryResults().get("gc.alloc.rate.norm");
assertTrue(allocation != null, "missing allocation measurement for " + benchmark);
assertTrue(
allocation.getScore() <= ALLOCATION_NOISE_FLOOR,
() -> benchmark + " allocated " + allocation.getScore() + " B/op");
}
Collection<RunResult> latencyResults = new Runner(latencyOptions()).run();
assertFalse(latencyResults.isEmpty(), "JMH did not discover the latency gates");
for (RunResult run : latencyResults) {
String benchmark = shortName(run.getParams().getBenchmark());
Double maximumNanos = MAXIMUM_P99_NANOS.get(benchmark);
assertTrue(maximumNanos != null, "missing latency baseline for " + benchmark);
Result<?> p99 = run.getSecondaryResults().get("p0.99");
assertTrue(p99 != null, "missing p99 measurement for " + benchmark);
double score = p99.getScore();
assertTrue(
score <= maximumNanos,
() -> benchmark + " p99 regressed to " + score + " ns/op; gate is " + maximumNanos);
}
}
private static Options allocationOptions() {
return commonOptions()
.mode(Mode.AverageTime)
.addProfiler(GCProfiler.class)
.build();
}
private static Options latencyOptions() {
return commonOptions().mode(Mode.SampleTime).build();
}
private static ChainedOptionsBuilder commonOptions() {
return new OptionsBuilder()
.include(INCLUDE)
.warmupIterations(2)
.warmupTime(TimeValue.milliseconds(250))
.measurementIterations(3)
.measurementTime(TimeValue.milliseconds(350))
.forks(1)
.shouldFailOnError(true);
}
private static String shortName(String benchmark) {
return benchmark.substring(benchmark.lastIndexOf('.') + 1);
}
// Filled from the controlled baseline run documented in BASELINES.md, with 35% CI headroom.
private static final Map<String, Double> MAXIMUM_P99_NANOS =
Map.of(
"parseAndRoute", 45_000.0,
"lifecycle", 2_900.0,
"encodeResponse", 1_350.0,
"decodeTypicalBrowserRequest", 7_100.0,
"encodeTypicalResponse", 850.0,
"readValidateAndDiscard", 2_700.0);
}
@@ -1,6 +1,10 @@
package dev.relism.flash.http2.hpack;
import java.util.concurrent.TimeUnit;
import dev.relism.flash.bytes.ByteWriter;
import java.nio.charset.StandardCharsets;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
@@ -22,6 +26,25 @@ public class HpackDecoderBenchmark {
private final HpackDecoder decoder = new HpackDecoder();
private final HpackHeaderBlock headers = new HpackHeaderBlock();
private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88};
private byte[] browserBlock;
@Setup(Level.Trial)
public void setupTypicalBlock() {
ByteWriter encoded = new ByteWriter(256);
HpackEncoder.writeIndexed(encoded, 2);
HpackEncoder.writeIndexed(encoded, 7);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 4, "/products?category=books".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 1, "shop.example.com".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 19, "text/html,application/xhtml+xml".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 16, "gzip, deflate".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 55, "Mozilla/5.0 benchmark".getBytes(StandardCharsets.US_ASCII), true);
browserBlock = java.util.Arrays.copyOf(encoded.array(), encoded.length());
}
@Benchmark
public int decodeStaticRequest() {
@@ -29,4 +52,11 @@ public class HpackDecoderBenchmark {
decoder.decode(block, 0, block.length, headers);
return headers.count();
}
@Benchmark
public int decodeTypicalBrowserRequest() {
headers.reset();
decoder.decode(browserBlock, 0, browserBlock.length, headers);
return headers.count();
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ByteWriter;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures a representative stateless response header block. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class HpackEncoderBenchmark {
private static final byte[] CONTENT_LENGTH = "1024".getBytes(StandardCharsets.US_ASCII);
private static final byte[] CONTENT_TYPE = "application/json".getBytes(StandardCharsets.US_ASCII);
private static final byte[] CACHE_CONTROL = "no-cache".getBytes(StandardCharsets.US_ASCII);
private static final byte[] ETAG_NAME = "etag".getBytes(StandardCharsets.US_ASCII);
private static final byte[] ETAG = "\"abc123\"".getBytes(StandardCharsets.US_ASCII);
private static final byte[] SERVER = "Flash".getBytes(StandardCharsets.US_ASCII);
private final ByteWriter output = new ByteWriter(128);
@Benchmark
public int encodeTypicalResponse() {
output.reset();
HpackEncoder.writeIndexed(output, 8);
HpackEncoder.writeLiteralWithNameIndex(output, 31, CONTENT_TYPE, true);
HpackEncoder.writeLiteralWithNameIndex(output, 28, CONTENT_LENGTH, false);
HpackEncoder.writeLiteralWithNameIndex(output, 24, CACHE_CONTROL, true);
HpackEncoder.writeLiteralWithNameIndex(output, 51, SERVER, true);
HpackEncoder.writeLiteral(output, ETAG_NAME, ETAG);
return output.length();
}
}
@@ -28,6 +28,7 @@ public class Http2BodyBenchmark {
private static final Http2RequestBody.ConsumptionListener NOOP = bytes -> {};
private final byte[] payload = new byte[1024];
private final byte[] streamingPayload = new byte[1024 * 1024];
private final byte[] target = new byte[1024];
private DataBufferPool pool;
private Http2RequestBody source;
@@ -35,6 +36,7 @@ public class Http2BodyBenchmark {
private Response response;
private Http2ResponseWriter responseWriter;
private ResettableInputStream responseSource;
private ResettableInputStream largeResponseSource;
@Setup(Level.Trial)
public void setup() throws IOException {
@@ -44,6 +46,7 @@ public class Http2BodyBenchmark {
response = new Response(200, ContentType.BINARY);
responseWriter = new Http2ResponseWriter();
responseSource = new ResettableInputStream(payload);
largeResponseSource = new ResettableInputStream(streamingPayload);
source.begin(-1, false, NOOP);
source.offer(1, payload, 0, payload.length, payload.length);
source.finish(1);
@@ -76,6 +79,20 @@ public class Http2BodyBenchmark {
return responseWriter.length();
}
@Benchmark
public int streamingResponseOneMiB() throws IOException {
largeResponseSource.rewind();
response.reset(200, ContentType.BINARY).stream(largeResponseSource, streamingPayload.length);
responseWriter.startFlowControlled(
response, 1, false, false, true, false, false, 16_384, 32_768, 16_384);
int wireBytes = responseWriter.length();
while (!responseWriter.finished()) {
responseWriter.resume(16_384, 16_384);
wireBytes += responseWriter.length();
}
return wireBytes;
}
private static final class ResettableInputStream extends InputStream {
private final byte[] source;
private int position;
@@ -0,0 +1,117 @@
package dev.relism.flash.http2.message;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.PreEncodedHeader;
import dev.relism.flash.models.Response;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Factorial measurements for the response knobs considered during tuning. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2TuningBenchmark {
@Param({"false", "true"})
public boolean huffmanDynamicValues;
@Param({"16384", "65536", "1048576"})
public int maxFrameSize;
private final byte[] largeBody = new byte[1024 * 1024];
private Http2ResponseWriter writer;
private Response response;
private Response streamingResponse;
private ResettableInputStream source;
@Setup(Level.Trial)
public void setup() {
writer = new Http2ResponseWriter();
response =
new Response(200, "hello", ContentType.JSON)
.header(new PreEncodedHeader("cache-control", "private, max-age=60"))
.header(new PreEncodedHeader("x-request-id", "d7bca219-6dd4-4ef0-a881-f21931e249c7"));
source = new ResettableInputStream(largeBody);
streamingResponse = new Response(200, ContentType.BINARY).stream(source, largeBody.length);
}
@Benchmark
public int encodeResponseHeaders() {
writer.prepare(
response,
1,
false,
true,
true,
huffmanDynamicValues,
false,
maxFrameSize,
32_768,
65_535);
return writer.length();
}
@Benchmark
public int streamOneMiB() throws IOException {
source.rewind();
writer.startFlowControlled(
streamingResponse,
1,
false,
false,
true,
huffmanDynamicValues,
false,
maxFrameSize,
32_768,
maxFrameSize);
int wireBytes = writer.length();
while (!writer.finished()) {
writer.resume(maxFrameSize, maxFrameSize);
wireBytes += writer.length();
}
return wireBytes;
}
private static final class ResettableInputStream extends InputStream {
private final byte[] bytes;
private int position;
private ResettableInputStream(byte[] bytes) {
this.bytes = bytes;
}
private void rewind() {
position = 0;
}
@Override
public int read() {
return position == bytes.length ? -1 : bytes[position++] & 0xff;
}
@Override
public int read(byte[] target, int offset, int length) {
if (position == bytes.length) return -1;
int count = Math.min(length, bytes.length - position);
System.arraycopy(bytes, position, target, offset, count);
position += count;
return count;
}
}
}
@@ -0,0 +1,77 @@
package dev.relism.flash.http2.stream;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures one response pass across a connection with N simultaneously live request streams. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2MultiplexingBenchmark {
private static final byte[] BODY = "ok".getBytes(StandardCharsets.US_ASCII);
@Param({"1", "8", "64", "256"})
public int liveStreams;
private Http2Stream[] streams;
@Setup(Level.Trial)
public void setup() {
Http2StreamTable table = new Http2StreamTable(liveStreams);
streams = new Http2Stream[liveStreams];
ByteWriter encoded = new ByteWriter(64);
HpackEncoder.writeIndexed(encoded, 2);
HpackEncoder.writeIndexed(encoded, 7);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 4, "/get".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
HpackDecoder decoder = new HpackDecoder();
for (int i = 0; i < streams.length; i++) {
Http2Stream stream = table.acquire(i * 2 + 1);
decoder.decode(encoded.array(), 0, encoded.length(), stream.headerBlock());
stream.assembleRequest(null, null);
streams[i] = stream;
}
}
@Benchmark
public int encodeAllLiveStreamResponses() {
int wireBytes = 0;
for (Http2Stream stream : streams) {
stream
.responseWriter()
.prepare(
stream.resetResponse().body(BODY),
stream.id(),
false,
false,
true,
false,
false,
16_384,
32_768,
65_535);
wireBytes += stream.responseWriter().length();
}
return wireBytes;
}
}
@@ -26,11 +26,14 @@ import org.openjdk.jmh.annotations.Warmup;
@Measurement(iterations = 5, time = 1)
public class Http2StreamBenchmark {
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
private static final byte[] POST_BODY = new byte[1024];
private Http2StreamTable streams;
private HpackDecoder decoder;
private byte[] requestBlock;
private int requestLength;
private byte[] postBlock;
private int postLength;
@Setup
public void setup() {
@@ -45,7 +48,19 @@ public class Http2StreamBenchmark {
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
requestBlock = block.array();
requestLength = block.length();
ByteWriter post = new ByteWriter(96);
HpackEncoder.writeIndexed(post, 3);
HpackEncoder.writeIndexed(post, 7);
HpackEncoder.writeLiteralWithNameIndex(
post, 4, "/ping".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
post, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
post, 28, "1024".getBytes(StandardCharsets.US_ASCII), false);
postBlock = post.array();
postLength = post.length();
lifecycle();
postOneKiB();
}
@Benchmark
@@ -62,4 +77,32 @@ public class Http2StreamBenchmark {
streams.release(stream);
return bytes;
}
/** Unary request shape: HPACK decode, one 1 KiB DATA payload, assembly and fixed response. */
@Benchmark
public int postOneKiB() {
Http2Stream stream = streams.acquire(1);
decoder.decode(postBlock, 0, postLength, stream.headerBlock());
stream.prepareRequestBody(null, false);
stream.receiveData(POST_BODY, 0, POST_BODY.length, POST_BODY.length);
stream.finishRequestBody();
stream.assembleRequest(null, null);
stream
.responseWriter()
.prepare(
stream.resetResponse().body(BODY),
1,
false,
false,
true,
false,
false,
16_384,
32_768,
65_535);
int bytes = stream.responseWriter().length();
streams.remove(1);
streams.release(stream);
return bytes;
}
}
@@ -190,6 +190,7 @@ public class RequestParser {
boolean transferEncodingSeen = false;
boolean transferEncodingChunked = false;
int headerCount = 0;
headerMap.beginParsed(buffer, sectionStart, headerEndIdx);
while (current < headerEndIdx) {
// deprecates line folding and treating a folded continuation as part of the
@@ -232,6 +233,7 @@ public class RequestParser {
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
}
headerMap.addParsed(current, colon - current, valueStart, lineEnd - valueStart);
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
@@ -270,8 +272,6 @@ public class RequestParser {
if (!contentLengthSeen) contentLength = 0;
}
headerMap.reset(buffer, sectionStart, headerEndIdx);
// ── Body / pipelining accounting ─────────────────────────────────────
int bodyStart = headerEndIdx + 4;
@@ -185,6 +185,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
if (!stream.beginResponseBatch()) {
throw new IllegalStateException("response batch already in flight");
}
detachFinalBatch(stream, responseWriter);
frameWriter.write(responseWriter);
} catch (Exception failure) {
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
@@ -220,6 +221,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
flowController.refundSend(stream, reserved - used);
}
applyBatchTransition(stream, responseWriter);
detachFinalBatch(stream, responseWriter);
frameWriter.write(responseWriter);
} catch (Exception failure) {
stream.endResponseBatch();
@@ -270,16 +272,22 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
}
if (stream.responseWriter().finished()) {
if (stream.state() == Http2StreamState.CLOSED) {
streams.retire(stream, streamId);
if (!streams.retire(stream, streamId)) streams.release(stream);
}
return;
}
scheduleResume(stream);
}
private void detachFinalBatch(Http2Stream stream, Http2ResponseWriter writer) {
if (writer.finished() && stream.state() == Http2StreamState.CLOSED) {
streams.detach(stream, stream.id());
}
}
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
int streamId = stream.id();
if (!streams.removeIfSame(stream, streamId)) return;
if (!streams.removeIfSame(stream, streamId) && stream.id() != streamId) return;
if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause);
try {
stream.cancel();
@@ -463,6 +463,7 @@ public final class Http2Client implements Closeable {
if (!origin.secure) {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(origin.host, origin.port), CONNECT_TIMEOUT_MS);
configureLowLatency(socket);
return socket;
}
SSLContext context;
@@ -473,6 +474,7 @@ public final class Http2Client implements Closeable {
}
SSLSocket socket =
(SSLSocket) context.getSocketFactory().createSocket(origin.host, origin.port);
configureLowLatency(socket);
SSLParameters parameters = socket.getSSLParameters();
parameters.setApplicationProtocols(new String[] {"h2"});
parameters.setEndpointIdentificationAlgorithm("HTTPS");
@@ -486,6 +488,10 @@ public final class Http2Client implements Closeable {
}
}
static void configureLowLatency(Socket socket) throws IOException {
socket.setTcpNoDelay(true);
}
private static final class Exchange {
private final int streamId;
private final MutableHeaderMap headers = new MutableHeaderMap();
@@ -16,6 +16,7 @@ public final class Http2StreamTable {
private final Http2Stream[] values;
private final int mask;
private final int maxEntries;
private final int maxObjects;
private final int[] closedIds;
private final byte[] closedKinds;
private int size;
@@ -42,6 +43,7 @@ public final class Http2StreamTable {
values = new Http2Stream[capacity];
mask = capacity - 1;
this.maxEntries = maxEntries;
maxObjects = maxEntries * 2;
this.dataBuffers = dataBuffers;
closedIds = new int[maxEntries * 2];
closedKinds = new byte[closedIds.length];
@@ -69,7 +71,7 @@ public final class Http2StreamTable {
free = stream.poolNext;
stream.poolNext = null;
} else {
if (created == maxEntries) return null;
if (created == maxObjects) return null;
stream = new Http2Stream(dataBuffers);
created++;
}
@@ -123,6 +125,13 @@ public final class Http2StreamTable {
return true;
}
/** Removes a wire-closed stream from live concurrency while retaining its in-flight buffer. */
public synchronized boolean detach(Http2Stream stream, int streamId) {
if (!removeIfSame(stream, streamId)) return false;
rememberClosed(streamId, CLOSED_NORMALLY);
return true;
}
public synchronized void rememberReset(int streamId) {
rememberClosed(streamId, CLOSED_BY_RESET);
}
@@ -77,12 +77,33 @@ public class Http1HeaderMap implements HeaderView {
private Slice valueSlice;
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer;
this.sectionStart = sectionStart;
this.sectionEnd = sectionEnd;
beginParsed(buffer, sectionStart, sectionEnd);
buildIndex();
}
/**
* Starts an index populated by the request parser while it validates the same header lines.
* This avoids rescanning a validated section solely to recover offsets already known there.
*/
public void beginParsed(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer;
this.sectionStart = sectionStart;
this.sectionEnd = sectionEnd;
headerCount = 0;
}
/** Adds one already-validated header to the current zero-copy index. */
public void addParsed(int nameOffset, int nameLength, int valueOffset, int valueLength) {
ensureIndexCapacity(headerCount + 1);
nameOffsets[headerCount] = nameOffset;
nameLengths[headerCount] = nameLength;
valueOffsets[headerCount] = valueOffset;
valueLengths[headerCount] = valueLength;
nameHashes[headerCount] =
ByteScan.hashNameIgnoreCaseAscii(buffer, nameOffset, nameLength);
headerCount++;
}
private void buildIndex() {
headerCount = 0;
if (buffer == null) return;
@@ -92,13 +113,7 @@ public class Http1HeaderMap implements HeaderView {
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
ensureIndexCapacity(headerCount + 1);
nameOffsets[headerCount] = i;
nameLengths[headerCount] = colon - i;
valueOffsets[headerCount] = vs;
valueLengths[headerCount] = lineEnd - vs;
nameHashes[headerCount] = ByteScan.hashNameIgnoreCaseAscii(buffer, i, colon - i);
headerCount++;
addParsed(i, colon - i, vs, lineEnd - vs);
}
i = lineEnd + 2;
}
@@ -0,0 +1,141 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
@Tag("benchmark")
@EnabledIfSystemProperty(named = "h2load.executable", matches = ".+")
@EnabledIfSystemProperty(named = "nghttpd.executable", matches = ".+")
class H2LoadMeasurementTest {
private static final int[] CONNECTIONS = {1, 10, 100, 1_000};
private static final int[] STREAMS = {1, 10, 100};
private static final Pattern RATE = Pattern.compile("([0-9.]+) req/s");
private static final Pattern REQUESTS =
Pattern.compile("requests: (\\d+) total, .*? (\\d+) succeeded, (\\d+) failed");
private FlashApp app;
private Process reference;
@AfterEach
void stop() {
if (app != null) app.stop().join();
if (reference != null) reference.destroyForcibly();
}
@Test
void measureFlashAndNghttpdMatrix(@TempDir Path directory) throws Exception {
int flashPort = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(flashPort)
.http2CleartextEnabled(true)
.h2MaxStreamsCreatedPerInterval(Integer.MAX_VALUE)
.h2MaxStreamsPerConnection(0)
.build());
app.get("/index.html", (request, response) -> "flash-load");
app.start();
int referencePort = freePort();
Files.writeString(directory.resolve("index.html"), "flash-load");
ProcessBuilder server =
new ProcessBuilder(
System.getProperty("nghttpd.executable"),
"--no-tls",
"--max-concurrent-streams=128",
"-d",
directory.toString(),
Integer.toString(referencePort));
applyLibraryPath(server);
reference = server.redirectErrorStream(true).start();
Thread.sleep(200);
System.out.println(
"implementation,connections,requested_streams,effective_streams,requests,requests_per_second");
for (int connections : CONNECTIONS) {
for (int streams : STREAMS) {
int requests = Math.max(1_000, connections * streams);
int effectiveStreams =
Math.max(
1,
Math.min(
Math.min(streams, Http2Limits.MAX_CONCURRENT_STREAMS),
4_096 / connections));
measure("flash", flashPort, connections, streams, effectiveStreams, requests);
measure("nghttpd", referencePort, connections, streams, effectiveStreams, requests);
}
}
}
private static void measure(
String implementation,
int port,
int connections,
int requestedStreams,
int effectiveStreams,
int requests)
throws Exception {
List<String> command = new ArrayList<>();
command.add(System.getProperty("h2load.executable"));
command.add("-n");
command.add(Integer.toString(requests));
command.add("-c");
command.add(Integer.toString(connections));
command.add("-m");
command.add(Integer.toString(effectiveStreams));
command.add("-t");
command.add(Integer.toString(Math.min(8, connections)));
command.add("http://127.0.0.1:" + port + "/index.html");
ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true);
applyLibraryPath(builder);
Process process = builder.start();
assertTrue(process.waitFor(Duration.ofMinutes(2).toMillis(), TimeUnit.MILLISECONDS));
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertEquals(0, process.exitValue(), output);
Matcher requestsResult = REQUESTS.matcher(output);
assertTrue(requestsResult.find(), output);
assertEquals(requests, Integer.parseInt(requestsResult.group(1)), output);
assertEquals(requests, Integer.parseInt(requestsResult.group(2)), output);
assertEquals(0, Integer.parseInt(requestsResult.group(3)), output);
Matcher rate = RATE.matcher(output);
assertTrue(rate.find(), output);
System.out.printf(
"%s,%d,%d,%d,%d,%s%n",
implementation,
connections,
requestedStreams,
effectiveStreams,
requests,
rate.group(1));
}
private static void applyLibraryPath(ProcessBuilder builder) {
String path = System.getProperty("nghttp.library.path");
if (path != null) builder.environment().put("LD_LIBRARY_PATH", path);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -2,6 +2,8 @@ package dev.relism.flash.http2.client;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
@@ -10,6 +12,7 @@ import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
@@ -101,6 +104,15 @@ class Http2ClientTest {
}
}
@Test
void configuresConnectionsForRequestResponseLatency() throws Exception {
try (Socket socket = new Socket()) {
assertFalse(socket.getTcpNoDelay());
Http2Client.configureLowLatency(socket);
assertTrue(socket.getTcpNoDelay());
}
}
private static MutableHeaderMap fields(String name, String value) {
MutableHeaderMap headers = new MutableHeaderMap();
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
@@ -3,6 +3,8 @@ package dev.relism.flash.http2.stream;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -52,4 +54,20 @@ class Http2StreamTableTest {
assertEquals(Http2StreamTable.CLOSED_BY_RESET, table.closedKind(3));
assertEquals(Http2StreamTable.CLOSED_UNKNOWN, table.closedKind(5));
}
@Test
void detachedFinalWriteDoesNotConsumeLiveStreamCapacity() {
Http2StreamTable table = new Http2StreamTable(1);
Http2Stream first = table.acquire(1);
assertTrue(table.detach(first, 1));
Http2Stream second = table.acquire(3);
assertNotNull(second);
assertNotSame(first, second);
table.release(first);
assertTrue(table.retire(second, 3));
assertEquals(2, table.createdCount());
assertEquals(2, table.freeCount());
}
}