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 000000000..d555f78e7 --- /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 c94d6df90..2fbe81906 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 7b8771e8e..ffd6858b1 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 000000000..c8ec580c0 --- /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 92823da04..3eb25e24b 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 ec30db525..2347668bf 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 000000000..6952a3ba9 --- /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) + } + } + } +}