feat(core): add HTTP/2 performance gates
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user