From 4433c96c1b6e606d04d9da149e46f46319a033a4 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sun, 13 Sep 2026 13:54:41 +0100 Subject: [PATCH] perf: Huffman encode HPACK string literals from a byte array into a byte array Motivation: HuffmanEncoder read its input one char at a time through String.charAt (twice per string literal, once to size it and once to encode it) and wrote its output one byte at a time through OutputStream.write(int), which is synchronized on the ByteArrayOutputStream that HeaderCompression passes in. The per-byte write dominated the cost of HPACK encoding. Modification: HuffmanEncoder now takes byte[] input, as the upstream twitter/hpack code did, and encode returns the coded bytes as an array sized by the length the caller already computed, which Encoder writes in one call. Encoder converts each string literal to octets once and uses them for the length check, the Huffman form and the raw form. That conversion, StringTools.asciiStringBytes, now encodes as ISO-8859-1, the exact inverse of asciiStringFromBytes: a raw literal carrying an opaque octet in 0x80-0xFF now sends that octet where US-ASCII encoding substituted '?'; the Huffman form already sent the octet. MiMa excludes cover the package-private HuffmanEncoder signatures. Result: Encoding a typical 10-header browser request into a ByteArrayOutputStream is ~2.6x faster on JDK 17 (9.6us to 3.6us, see the PR for the JMH table). The raw literal path is unchanged in speed and both literal forms now round-trip every octet through the decoder. Tests: - sbt "http-core/Test/testOnly org.apache.pekko.http.shaded.com.twitter.hpack.HpackEncoderSpec org.apache.pekko.http.impl.util.StringToolsSpec" (Scala 2.13.18 and 3.3.8) - sbt "http2-tests/Test/testOnly org.apache.pekko.http.impl.engine.http2.Http2ServerSpec org.apache.pekko.http.impl.engine.http2.Http2ClientSpec org.apache.pekko.http.impl.engine.http2.RequestParsingSpec" - sbt "http-bench-jmh/Jmh/run -f 2 -wi 5 -i 8 -w 1 -r 2 HpackEncoderBenchmark" before and after - sbt http-core/mimaReportBinaryIssues; sbt javafmtCheckAll (JDK 17) - scalafmt --mode diff-ref=upstream/main; sbt headerCreateAll References: Refs #1299 - same bulk-copy change for EnhancedString.getAsciiBytes --- .../twitter/hpack/HpackEncoderBenchmark.scala | 74 +++++++++++ .../shaded/com/twitter/hpack/Encoder.java | 12 +- .../com/twitter/hpack/HuffmanEncoder.java | 45 ++++--- .../hpack-huffman-encode-bytes.excludes | 20 +++ .../pekko/http/impl/util/StringTools.scala | 10 +- .../http/impl/util/StringToolsSpec.scala | 16 +++ .../com/twitter/hpack/HpackEncoderSpec.scala | 121 ++++++++++++++++++ 7 files changed, 269 insertions(+), 29 deletions(-) create mode 100644 http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala create mode 100644 http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes create mode 100644 http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala diff --git a/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala b/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala new file mode 100644 index 0000000000..d555f78e74 --- /dev/null +++ b/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pekko.http.shaded.com.twitter.hpack + +import java.io.ByteArrayOutputStream +import java.util.concurrent.TimeUnit + +import org.openjdk.jmh.annotations._ + +@State(Scope.Benchmark) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@BenchmarkMode(Array(Mode.AverageTime)) +@Fork(2) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 8, time = 2) +class HpackEncoderBenchmark { + + /** `default` lets the encoder pick Huffman when it is shorter, the other two force one string literal form. */ + @Param(Array("default", "huffman", "raw")) + var mode = "default" + + // indexing is disabled so that every call encodes the values as string literals instead of table references + var encoder: Encoder = null + val out = new ByteArrayOutputStream(1024) + + // a typical browser request; every value goes through HuffmanEncoder.getEncodedLength and most through encode + val headers: Array[(String, String)] = Array( + ":method" -> "GET", + ":scheme" -> "https", + ":authority" -> "www.example.com", + ":path" -> "/api/v1/users/12345/orders?page=2&sort=created_at&direction=desc", + "user-agent" -> + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "accept" -> "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language" -> "en-US,en;q=0.9", + "accept-encoding" -> "gzip, deflate, br", + "cookie" -> "session=ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12; theme=dark; consent=1", + "cache-control" -> "no-cache") + + @Setup + def setup(): Unit = + encoder = mode match { + case "default" => new Encoder(4096, false, false, false) + case "huffman" => new Encoder(4096, false, true, false) + case "raw" => new Encoder(4096, false, false, true) + } + + @Benchmark + def encodeHeaders(): Int = { + out.reset() + var i = 0 + while (i < headers.length) { + val (name, value) = headers(i) + encoder.encodeHeader(out, name, value, false) + i += 1 + } + out.size + } +} diff --git a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java index c94d6df90d..2fbe81906f 100644 --- a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java +++ b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java @@ -172,15 +172,17 @@ private static void encodeInteger(OutputStream out, int mask, int n, int i) thro /** Encode string literal according to Section 5.2. */ private void encodeStringLiteral(OutputStream out, String string) throws IOException { - int length = string.length(); - int huffmanLength = Huffman.ENCODER.getEncodedLength(string); + // convert once up front: the length computation, the Huffman coder and the raw literal all work + // on the octets + byte[] stringBytes = StringTools.asciiStringBytes(string); + int length = stringBytes.length; + int huffmanLength = Huffman.ENCODER.getEncodedLength(stringBytes); if ((huffmanLength < length && !forceHuffmanOff) || forceHuffmanOn) { encodeInteger(out, 0x80, 7, huffmanLength); - Huffman.ENCODER.encode(out, string); + out.write(Huffman.ENCODER.encode(stringBytes, huffmanLength), 0, huffmanLength); } else { - byte[] stringBytes = StringTools.asciiStringBytes(string); encodeInteger(out, 0x00, 7, length); - out.write(stringBytes, 0, stringBytes.length); + out.write(stringBytes, 0, length); } } diff --git a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java index 7b8771e8e3..ffd6858b16 100644 --- a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java +++ b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java @@ -31,9 +31,6 @@ package org.apache.pekko.http.shaded.com.twitter.hpack; -import java.io.IOException; -import java.io.OutputStream; - final class HuffmanEncoder { private final int[] codes; @@ -53,23 +50,27 @@ final class HuffmanEncoder { /** * Compresses the input string literal using the Huffman coding. * - * @param out the output stream for the compressed data - * @throws IOException if an I/O error occurs. In particular, an IOException may be - * thrown if the output stream has been closed. + *

The result is assembled in an array and returned instead of being written byte by byte to an + * OutputStream, whose per-byte write(int) (synchronized on the JDK + * stream implementations) dominated the cost of encoding. + * + * @param data the string literal to be Huffman encoded + * @param encodedLength the value of {@link #getEncodedLength(byte[])} for data, + * which the caller has typically already computed to decide whether to use Huffman coding + * @return the Huffman coded string literal, exactly encodedLength bytes long */ - public void encode(OutputStream out, String string) throws IOException { - if (out == null) { - throw new NullPointerException("out"); - } else if (string == null) { - throw new NullPointerException("string"); + public byte[] encode(byte[] data, int encodedLength) { + if (data == null) { + throw new NullPointerException("data"); } + byte[] out = new byte[encodedLength]; + int pos = 0; long current = 0; int n = 0; - int len = string.length(); - for (int i = 0; i < len; i++) { - int b = string.charAt(i) & 0xFF; + for (byte value : data) { + int b = value & 0xFF; int code = codes[b]; int nbits = lengths[b]; @@ -79,15 +80,21 @@ public void encode(OutputStream out, String string) throws IOException { while (n >= 8) { n -= 8; - out.write(((int) (current >> n))); + out[pos++] = (byte) (current >> n); } } if (n > 0) { current <<= (8 - n); current |= (0xFF >>> n); // this should be EOS symbol - out.write((int) current); + out[pos++] = (byte) current; + } + + if (pos != encodedLength) { + throw new IllegalArgumentException( + "encodedLength " + encodedLength + " does not match the Huffman encoded length " + pos); } + return out; } /** @@ -96,13 +103,13 @@ public void encode(OutputStream out, String string) throws IOException { * @param data the string literal to be Huffman encoded * @return the number of bytes required to Huffman encode data */ - public int getEncodedLength(String data) { + public int getEncodedLength(byte[] data) { if (data == null) { throw new NullPointerException("data"); } long len = 0; - for (int i = 0; i < data.length(); i++) { - len += lengths[data.charAt(i) & 0xFF]; + for (byte b : data) { + len += lengths[b & 0xFF]; } return (int) ((len + 7) >> 3); } diff --git a/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes new file mode 100644 index 0000000000..c8ec580c0d --- /dev/null +++ b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# The shaded HPACK HuffmanEncoder (a package-private class) codes byte arrays instead of Strings +ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanEncoder.encode") +ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanEncoder.getEncodedLength") diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala index 92823da04e..3eb25e24b8 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala @@ -27,9 +27,9 @@ private[http] object StringTools { // keeps the array as is with a LATIN1 coder, so it is the same single copy. new String(bytes, ISO88591) - def asciiStringBytes(string: String): Array[Byte] = { - // this is as fast as Unsafe.copyUSAsciiStrToBytes for recent JDK versions - // and avoids the use of deprecated Unsafe methods - string.getBytes(java.nio.charset.StandardCharsets.US_ASCII) - } + def asciiStringBytes(string: String): Array[Byte] = + // ISO-8859-1 makes this the exact inverse of asciiStringFromBytes: HPACK string literals are opaque octets and + // a character in 0x80-0xFF has to come out as that octet, not as the '?' that encoding with US-ASCII would + // substitute. Since JDK 9 (compact strings) it is a single array copy for a string with a LATIN1 coder. + string.getBytes(ISO88591) } diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala index ec30db5255..2347668bf4 100644 --- a/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala +++ b/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala @@ -50,5 +50,21 @@ class StringToolsSpec extends AnyWordSpec with Matchers { "encode an ASCII string to its US-ASCII bytes" in { StringTools.asciiStringBytes("abc") shouldEqual "abc".getBytes(StandardCharsets.US_ASCII) } + + "be the inverse of asciiStringFromBytes for every octet" in { + // HPACK string literals are opaque octets, so a char in 0x80-0xFF must map back to that octet rather than + // to the '?' that encoding with US-ASCII would substitute + val allOctets = Array.tabulate(256)(_.toByte) + StringTools.asciiStringBytes(StringTools.asciiStringFromBytes(allOctets)) shouldEqual allOctets + } + + "encode a character above 0xFF, which no octet can represent, as a single '?'" in { + // U+0100 forces the UTF-16 coder on JDK 9+, so this covers the non-arraycopy path too + StringTools.asciiStringBytes("aĀb") shouldEqual Array[Byte]('a', '?', 'b') + } + + "encode the empty string to an empty array" in { + StringTools.asciiStringBytes("") shouldEqual Array.emptyByteArray + } } } diff --git a/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala new file mode 100644 index 0000000000..6952a3ba92 --- /dev/null +++ b/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pekko.http.shaded.com.twitter.hpack + +import java.io.{ ByteArrayInputStream, ByteArrayOutputStream } + +import scala.collection.immutable.VectorBuilder + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import org.apache.pekko.http.impl.util.StringTools + +class HpackEncoderSpec extends AnyWordSpec with Matchers { + + private def hex(bytes: Array[Byte]): String = bytes.map(b => f"$b%02x").mkString + + private def huffmanEncode(bytes: Array[Byte]): Array[Byte] = + Huffman.ENCODER.encode(bytes, Huffman.ENCODER.getEncodedLength(bytes)) + + private def huffmanEncode(literal: String): Array[Byte] = huffmanEncode(StringTools.asciiStringBytes(literal)) + + // indexing off so that the header value is always sent as a string literal, never as a table reference + private def encodeHeader(name: String, value: String, forceHuffmanOn: Boolean, forceHuffmanOff: Boolean) + : Array[Byte] = { + val encoder = new Encoder(4096, false, forceHuffmanOn, forceHuffmanOff) + val out = new ByteArrayOutputStream() + encoder.encodeHeader(out, name, value, false) + out.toByteArray + } + + private def decodeHeaders(bytes: Array[Byte]): Seq[(String, String)] = { + val decoder = new Decoder(8192, 4096) + val headers = new VectorBuilder[(String, String)]() + decoder.decode(new ByteArrayInputStream(bytes), + (name: String, value: String, parsedValue: AnyRef, _: Boolean) => { + headers += name -> value + parsedValue + }) + headers.result() + } + + "HuffmanEncoder" should { + // the string literals from RFC 7541 Appendix C.4 with their expected Huffman codes + val rfc7541Examples = Seq( + "www.example.com" -> "f1e3c2e5f23a6ba0ab90f4ff", + "no-cache" -> "a8eb10649cbf", + "custom-key" -> "25a849e95ba97d7f", + "custom-value" -> "25a849e95bb8e8b4bf") + + "produce the Huffman codes from RFC 7541 Appendix C.4" in { + rfc7541Examples.foreach { + case (literal, expected) => + hex(huffmanEncode(literal)) shouldEqual expected + } + } + + "report the encoded length that encode actually produces" in { + rfc7541Examples.foreach { + case (literal, expected) => + Huffman.ENCODER.getEncodedLength(StringTools.asciiStringBytes(literal)) shouldEqual expected.length / 2 + } + } + + "encode the empty literal to no bytes" in { + huffmanEncode("") shouldEqual Array.emptyByteArray + Huffman.ENCODER.getEncodedLength(Array.emptyByteArray) shouldEqual 0 + } + + "encode every octet with the code for that octet" in { + // a byte above 0x7F must be looked up as an unsigned symbol, not as a negative array index + val allOctets = Array.tabulate(256)(_.toByte) + Huffman.DECODER.decode(huffmanEncode(allOctets)) shouldEqual allOctets + } + + "reject an encoded length that does not match the input" in { + val bytes = StringTools.asciiStringBytes("no-cache") + an[IllegalArgumentException] should be thrownBy Huffman.ENCODER.encode(bytes, 7) + an[ArrayIndexOutOfBoundsException] should be thrownBy Huffman.ENCODER.encode(bytes, 5) + } + } + + "Encoder" should { + "encode the value from RFC 7541 Appendix C.4.1 with Huffman coding when it is shorter" in { + // 0x01: literal without indexing, name index 1 (:authority); 0x8c: Huffman coded, 12 octets + hex(encodeHeader(":authority", "www.example.com", forceHuffmanOn = false, forceHuffmanOff = false)) shouldEqual + "018cf1e3c2e5f23a6ba0ab90f4ff" + } + + "encode the header from RFC 7541 Appendix C.2.1 as a raw literal when Huffman coding is forced off" in { + // 0x00: literal without indexing, new name; 0x0a/0x0d: raw string literals of 10 and 13 octets + hex(encodeHeader("custom-key", "custom-header", forceHuffmanOn = false, forceHuffmanOff = true)) shouldEqual + "000a637573746f6d2d6b65790d637573746f6d2d686561646572" + } + + "round-trip a value carrying opaque octets above 0x7F in both string literal forms" in { + // HPACK string literals are opaque octets: a decoder maps each byte to the char of the same value, so an + // encoder has to map each such char back to that byte, in the raw form as well as the Huffman form + val value = new String(Array.tabulate(256)(_.toChar)) + Seq(true, false).foreach { huffman => + val bytes = encodeHeader("x-opaque", value, forceHuffmanOn = huffman, forceHuffmanOff = !huffman) + decodeHeaders(bytes) shouldEqual Seq("x-opaque" -> value) + } + } + } +}