Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <code>IOException</code> may be
* thrown if the output stream has been closed.
* <p>The result is assembled in an array and returned instead of being written byte by byte to an
* <code>OutputStream</code>, whose per-byte <code>write(int)</code> (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 <code>data</code>,
* which the caller has typically already computed to decide whether to use Huffman coding
* @return the Huffman coded string literal, exactly <code>encodedLength</code> 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];

Expand All @@ -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;
}

/**
Expand All @@ -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 <code>data</code>
*/
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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}