diff --git a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/HeaderParser.java b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/HeaderParser.java index 939034a4191d..07d86dc2d751 100644 --- a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/HeaderParser.java +++ b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/HeaderParser.java @@ -72,6 +72,24 @@ protected void reset() */ public boolean parse(ByteBuffer buffer) { + // Fast path: the whole 9 octets header is available, so read it + // with a single 8 octets read plus a single 1 octet read, rather + // than stepping the state machine once per octet. + if (state == State.LENGTH && cursor == 0 && buffer.remaining() >= Frame.HEADER_LENGTH) + { + int position = buffer.position(); + // Octets 0..7: 3 octets of length, 1 of type, 1 of flags, 3 of the stream id. + long header = buffer.getLong(position); + length = (int)(header >>> 40) & Frame.MAX_MAX_SIZE; + type = (int)(header >>> 32) & 0xFF; + flags = (int)(header >>> 24) & 0xFF; + // Octet 8: the least significant octet of the stream id. + // The most significant bit MUST be ignored as per specification. + streamId = (((int)header << 8) | (buffer.get(position + 8) & 0xFF)) & 0x7F_FF_FF_FF; + buffer.position(position + Frame.HEADER_LENGTH); + return true; + } + while (buffer.hasRemaining()) { switch (state) diff --git a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/PrefaceParser.java b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/PrefaceParser.java index f9fa696be5a7..5b0f38359b5a 100644 --- a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/PrefaceParser.java +++ b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/PrefaceParser.java @@ -24,6 +24,22 @@ public class PrefaceParser { private static final Logger LOG = LoggerFactory.getLogger(PrefaceParser.class); + /** + * {@code PREFACE_AS_LONGS[i]} holds the 8 preface octets starting at index {@code i}, + * so that the preface can be compared 8 octets at a time from any offset. + */ + private static final long[] PREFACE_AS_LONGS = prefaceAsLongs(); + + private static long[] prefaceAsLongs() + { + ByteBuffer preface = ByteBuffer.wrap(PrefaceFrame.PREFACE_BYTES); + long[] longs = new long[PrefaceFrame.PREFACE_BYTES.length - Long.BYTES + 1]; + for (int i = 0; i < longs.length; i++) + { + longs[i] = preface.getLong(i); + } + return longs; + } private final Parser.Listener listener; private int cursor; @@ -48,15 +64,49 @@ protected void directUpgrade() public boolean parse(ByteBuffer buffer) { + int end = PrefaceFrame.PREFACE_BYTES.length; + int needed = end - cursor; + // Fast path: all the remaining preface octets are available in the + // buffer, so compare them 8 at a time rather than one at a time. + if (buffer.remaining() >= needed) + { + int position = buffer.position(); + int index = 0; + for (; needed - index >= Long.BYTES; index += Long.BYTES) + { + if (buffer.getLong(position + index) != PREFACE_AS_LONGS[cursor + index]) + return invalidPreface(buffer); + } + if (index < needed) + { + if (needed >= Long.BYTES) + { + // Compare the last 8 octets, overlapping the ones already compared. + if (buffer.getLong(position + needed - Long.BYTES) != PREFACE_AS_LONGS[end - Long.BYTES]) + return invalidPreface(buffer); + } + else + { + // Fewer than 8 octets in total, for example after a direct upgrade. + for (; index < needed; index++) + { + if (buffer.get(position + index) != PrefaceFrame.PREFACE_BYTES[cursor + index]) + return invalidPreface(buffer); + } + } + } + buffer.position(position + needed); + cursor = 0; + if (LOG.isDebugEnabled()) + LOG.debug("Parsed preface bytes from {}", buffer); + return true; + } + while (buffer.hasRemaining()) { int currByte = buffer.get(); if (currByte != PrefaceFrame.PREFACE_BYTES[cursor]) - { - BufferUtil.clear(buffer); - notifyConnectionFailure(ErrorCode.PROTOCOL_ERROR.code, "invalid_preface"); - return false; - } + return invalidPreface(buffer); ++cursor; if (cursor == PrefaceFrame.PREFACE_BYTES.length) { @@ -69,6 +119,13 @@ public boolean parse(ByteBuffer buffer) return false; } + private boolean invalidPreface(ByteBuffer buffer) + { + BufferUtil.clear(buffer); + notifyConnectionFailure(ErrorCode.PROTOCOL_ERROR.code, "invalid_preface"); + return false; + } + protected void notifyConnectionFailure(int error, String reason) { try diff --git a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/SettingsBodyParser.java b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/SettingsBodyParser.java index 161dbeb83fb9..73edf072253f 100644 --- a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/SettingsBodyParser.java +++ b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/parser/SettingsBodyParser.java @@ -112,6 +112,22 @@ private boolean parse(ByteBuffer buffer, int streamId, int bodyLength) { if (length < 6) return connectionFailure(buffer, ErrorCode.FRAME_SIZE_ERROR.code, "invalid_settings_frame"); + if (buffer.remaining() >= 6) + { + // Fast path: the whole 6 octets setting is available, + // so read the identifier and the value in one go and + // stay in this state for the next setting. + settingId = buffer.getShort() & 0xFF_FF; + settingValue = buffer.getInt(); + length -= 6; + if (LOG.isDebugEnabled()) + LOG.debug(String.format("setting %d=%d", settingId, settingValue)); + if (!onSetting(buffer, settings, settingId, settingValue)) + return false; + if (length == 0) + return onSettings(buffer, settings); + break; + } if (buffer.remaining() >= 2) { settingId = buffer.getShort() & 0xFF_FF; diff --git a/tests/jetty-jmh/pom.xml b/tests/jetty-jmh/pom.xml index 04688cc335f5..9bd66977fb34 100644 --- a/tests/jetty-jmh/pom.xml +++ b/tests/jetty-jmh/pom.xml @@ -37,6 +37,11 @@ org.eclipse.jetty jetty-util + + org.eclipse.jetty.http2 + jetty-http2-common + ${project.version} + org.eclipse.jetty.toolchain jetty-test-helper diff --git a/tests/jetty-jmh/src/main/java/org/eclipse/jetty/http2/jmh/Http2FrameBenchmark.java b/tests/jetty-jmh/src/main/java/org/eclipse/jetty/http2/jmh/Http2FrameBenchmark.java new file mode 100644 index 000000000000..17863a877836 --- /dev/null +++ b/tests/jetty-jmh/src/main/java/org/eclipse/jetty/http2/jmh/Http2FrameBenchmark.java @@ -0,0 +1,189 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.http2.jmh; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.http.HostPortHttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpScheme; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.http.MetaData; +import org.eclipse.jetty.http2.frames.PrefaceFrame; +import org.eclipse.jetty.http2.frames.SettingsFrame; +import org.eclipse.jetty.http2.generator.HeaderGenerator; +import org.eclipse.jetty.http2.generator.HeadersGenerator; +import org.eclipse.jetty.http2.generator.SettingsGenerator; +import org.eclipse.jetty.http2.hpack.HpackEncoder; +import org.eclipse.jetty.http2.parser.HeaderParser; +import org.eclipse.jetty.http2.parser.Parser; +import org.eclipse.jetty.http2.parser.PrefaceParser; +import org.eclipse.jetty.http2.parser.SettingsBodyParser; +import org.eclipse.jetty.io.ArrayByteBufferPool; +import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.RateControl; +import org.eclipse.jetty.io.RetainableByteBuffer; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +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.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.RunnerException; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +/** + *

Benchmarks of the HTTP/2 frame path, one layer above + * HpackBenchmark: generating and parsing whole frames, so that the + * share of a frame that HPACK actually accounts for can be seen.

+ *

Both a warmed encoder and decoder pair and a cold one are covered, as the + * dynamic table changes the balance completely.

+ */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +public class Http2FrameBenchmark +{ + private static final int STREAM_ID = 13; + + private final ByteBufferPool bufferPool = new ArrayByteBufferPool(); + private MetaData.Request request; + private HeaderParser warmHeaderParser; + private ByteBuffer warmHeadersFrame; + private ByteBuffer coldHeadersFrame; + private ByteBuffer prefaceFrame; + private PrefaceParser prefaceParser; + private ByteBuffer warmSettingsFrame; + private SettingsBodyParser warmSettingsBodyParser; + private ByteBuffer coldSettingsFrame; + + @Setup + public void setup() throws Exception + { + HttpFields fields = HttpFields.build() + .put(HttpHeader.USER_AGENT, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .put(HttpHeader.ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + .put(HttpHeader.ACCEPT_ENCODING, "gzip, deflate, br") + .put(HttpHeader.ACCEPT_LANGUAGE, "en-GB,en;q=0.9,pl;q=0.8") + .put(HttpHeader.COOKIE, "session=8f14e45fceea167a5a36dedd4bea2543; theme=dark; consent=1") + .put("X-Request-Id", "b3a1c9e2-4f6d-4a1b-9c3e-7d2f8a5b6c4d"); + request = new MetaData.Request("GET", HttpScheme.HTTP.asString(), new HostPortHttpField("localhost:8080"), + "/some/resource/path?name=value", HttpVersion.HTTP_2, fields, -1); + + HeadersGenerator warmHeadersGenerator = new HeadersGenerator(new HeaderGenerator(bufferPool), new HpackEncoder()); + + warmHeaderParser = new HeaderParser(RateControl.NO_RATE_CONTROL); + + // Warm one generator and parser pair the way a connection warms them. + for (int i = 0; i < 4; i++) + { + warmHeadersFrame = generateHeaders(warmHeadersGenerator); + warmHeaderParser.parse(warmHeadersFrame.slice()); + } + + coldHeadersFrame = generateHeaders(new HeadersGenerator(new HeaderGenerator(bufferPool), new HpackEncoder())); + + prefaceFrame = ByteBuffer.wrap(PrefaceFrame.PREFACE_BYTES); + prefaceParser = new PrefaceParser(new Parser.Listener() {}); + + SettingsGenerator warmSettingsGenerator = new SettingsGenerator(new HeaderGenerator(bufferPool)); + + warmSettingsFrame = generateSettings(warmSettingsGenerator); + warmSettingsBodyParser = new SettingsBodyParser(warmHeaderParser, new Parser.Listener() {}); + + coldSettingsFrame = generateSettings(new SettingsGenerator(new HeaderGenerator(bufferPool))); + } + + private ByteBuffer generateSettings(SettingsGenerator generator) + { + RetainableByteBuffer.Mutable buffer = new RetainableByteBuffer.DynamicCapacity(); + Map settings = new HashMap<>(); + settings.put(SettingsFrame.INITIAL_WINDOW_SIZE, 32768); + settings.put(SettingsFrame.MAX_CONCURRENT_STREAMS, 128); + settings.put(SettingsFrame.MAX_FRAME_SIZE, 8192); + settings.put(SettingsFrame.HEADER_TABLE_SIZE, 4094); + generator.generateSettings(buffer, settings, true); + return toByteBuffer(buffer); + } + + private ByteBuffer generateHeaders(HeadersGenerator generator) throws Exception + { + RetainableByteBuffer.Mutable buffer = new RetainableByteBuffer.DynamicCapacity(); + generator.generateHeaders(buffer, STREAM_ID, request, null, true); + return toByteBuffer(buffer); + } + + private static ByteBuffer toByteBuffer(RetainableByteBuffer.Mutable buffer) + { + ByteBuffer slice = buffer.getByteBuffer(); + ByteBuffer copy = ByteBuffer.allocateDirect(slice.remaining()); + copy.put(slice).flip(); + buffer.release(); + return copy; + } + + @Benchmark + public boolean parseHeadersFrameWarm() + { + return warmHeaderParser.parse(warmHeadersFrame.slice()); + } + + @Benchmark + public boolean parseHeadersFrameCold() + { + // A fresh parser each time, so the dynamic table is empty and every + // field arrives as a literal, as on the first message of a connection. + return new HeaderParser(RateControl.NO_RATE_CONTROL).parse(coldHeadersFrame.slice()); + } + + @Benchmark + public boolean parseSettingsFrameWarm() + { + return warmSettingsBodyParser.parse(warmSettingsFrame.slice()); + } + + @Benchmark + public boolean parseSettingsFrameCold() + { + return new SettingsBodyParser(new HeaderParser(RateControl.NO_RATE_CONTROL), new Parser.Listener() {}) + .parse(coldSettingsFrame.slice()); + } + + @Benchmark + public boolean parsePreface() + { + return prefaceParser.parse(prefaceFrame.slice()); + } + + + public static void main(String[] args) throws RunnerException + { + Options options = new OptionsBuilder() + .include(Http2FrameBenchmark.class.getSimpleName()) + .forks(1) + .build(); + new Runner(options).run(); + } +}