Laszlo

Hello, I am Laszlo

Software-Engineer, .NET developer

Contact Me

Integer Decoding with HTTP/3

HTTP/3 uses 2 different types of integer encoding in a transitive way. This blog post compares these encodings from size, decoding performance, and decoding complexity perspectives. These encodings are:

  • variable-length integer encoding defined by QUIC-TRANSPORT
  • prefixed integer encoding defined by HPACK

Seeing HPACK might be surprising as HTTP/3 uses QPACK for field compression. QPACK does not define the prefixed integer encoding; it inherits it from HPACK.

Both encodings encode positive integer numbers and zero.

Introduction

Variable-length Integer Encoding

Variable-length integer encoding uses the 2 MSB bits of the first byte to indicate how many bytes are used to encode the given integer. A number can be encoded in 1, 2, 4, or 8 bytes, hence its name.

The following table summarizes the different configurations of the encoding:

2MSB

Length

Usable Bits

Range

00

1

6

0-63

01

2

14

0-16383

10

4

30

0-1073741823

11

8

62

0-4611686018427387903

Decoding always starts at a byte boundary. After reading the first byte's two most significant bits, a decoder can determine how many additional bytes to read. In .NET, a single encoded byte (6 bits of it due to the reserved 2 MSBs) can be decoded as a byte, a 2-byte value as a ushort, a 4-byte value as a uint, and an 8-byte value as a ulong. For practical reasons, a decoder method signature might choose to return a ulong to cover the complete range of numbers. Bytes are ordered in network order (big-endian), which means a decoder might need to switch endianness for a given platform.

In .NET, the workhorse of decoding can be deferred to BinaryPrimitives.TryReadUIntXXBigEndian methods.

Prefixed Integer Encoding

Prefixed integer encoding can start anywhere within a byte but always finishes at a byte boundary. Its name prefixed refers to the first byte that does not start at a byte boundary. Typically, the prefix is a fixed number of bits (N), named as an N-bit prefixed number. The example below uses a 5-bit prefix:

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| ? | ? | ? |       Value       |

A 5-bit prefix alone can represent values less than 2^N-1; in this case, values [0-30] inclusive. When all bits of the prefix are one, the number is encoded in further bytes. The encoding allows for any number of valid bytes to follow. QPACK decoders must be able to parse up to 62 bits of numbers. Each further byte uses the MSB to indicate if it is the last byte. When MSB=0, it is the last byte. The remaining 7 bits encode a number. To decode a non-prefix byte, use the formula I = I + (B & 127) * 2^M, where B is the input byte, M is the number of bits consumed (increased by 7 for each new byte read), and I is the resulting number.

Given an input [0b00011111, 0b10011010, 0b00001010] as a 5-bit prefixed number: The first byte encodes 31. All bits of the prefix are 1, so further bytes are expected. The second byte 0b10011010 has MSB=1, indicating it is not the last byte. Its value is 0b10011010 & 127 = 26 (bitwise AND clears the MSB). The third byte is the last one as its MSB=0. For this byte, M is already incremented by 7. Its value is (0b00001010 & 127) * 2^7 = 1280. Hence, the final value is 31 + 26 + 1280 = 1337.

This encoding is typically used to indicate the length of an HTTP field's name or value part. In .NET, the HTTP header name and value are usually represented by the string type. Parsing a prefixed integer number usually makes sense up to the maximum length that a string can represent. An HTTP/3 implementation may impose further limits on the maximum size of the header frame.

Comparison

Size

The Prefixed Integer Encoding is more compact than variable-length encoding. It can encode numbers in 3 or 5 bytes. Compared to variable-length encoding, which would need 4 or 8 bytes even when some of those bytes carry no useful information and are just 0. Hence, the prefixed integer saves network bandwidth, which is crucial for a network protocol.

Prefixed Integer Encoding is used for encoding the size of HTTP fields or values. The size of these fields is typically small (with a few exceptions like Cookie), so their sizes can usually be represented with fewer bytes or even only using the prefix. For example, in QPACK, an Indexed Field Line representation uses 2 bits as part of its wire protocol, but leaves 6+ bits to represent a static or dynamic table field entry. The most common field value (in the static table) can be encoded on these 6 bits without the need for additional bytes. With this, QPACK can transmit the most common HTTP fields using only a single byte. A prefix-encoded number can piggyback on the unused bits of the protocol.

Decoding Complexity

The previous section should hint at the decoding complexity. As mentioned above, decoding a variable-length number is relatively simple; it requires:

  • a bit shift operation
  • a branching operation (to decide how many additional bytes to read)
  • invoking BinaryPrimitives.TryReadUIntXXBigEndian, which reinterprets the bytes as a number while addressing endianness

Prefixed Integer Encoding can also be very cheap when the number is encoded only by the prefix. However, for larger numbers, each additional byte requires further processing:

  • an AND operation to mask the first bit
  • a power operation (which could use a bit shift operation instead)
  • a multiplication (which could use a bit shift operation instead)
  • a sum operation to calculate the result

The longer the encoded value, the longer it takes to decode. This encoding works best with small numbers. One way to address longer inputs is by employing Single Instruction Multiple Data (SIMD) operations. On x64 platforms, the MoveMask operation can help to calculate the length of the input, while the rest of the calculations can be done on each lane similarly to scalar operations. A SIMD implementation could also handle the prefix byte independently from the rest, as it requires special care.

Performance

To compare the performance of these decoders, I created three implementations:

  • Prefixed Integer Decoding
  • Prefixed Integer Decoding with SIMD
  • Variable-length Decoding

The input varies between 1-6 bytes, including the prefix. The decoded integer values are equal in all tests with respect to input size.

Prefixed Integer Encoding Performance

| Method            | InputSize | Mean      | Error     | StdDev    |
|------------------ |---------- |----------:|----------:|----------:|
| DecodeInteger     | 6         | 2.9444 ns | 0.0188 ns | 0.0176 ns |
| DecodeIntegerSimd | 6         | 1.4583 ns | 0.0425 ns | 0.0397 ns |
| DecodeInteger     | 4         | 1.6767 ns | 0.0215 ns | 0.0191 ns |
| DecodeIntegerSimd | 4         | 1.4461 ns | 0.0289 ns | 0.0270 ns |
| DecodeInteger     | 3         | 0.9078 ns | 0.0168 ns | 0.0157 ns |
| DecodeIntegerSimd | 3         | 1.3505 ns | 0.0134 ns | 0.0119 ns |
| DecodeInteger     | 1         | 0.2343 ns | 0.0351 ns | 0.0328 ns |
| DecodeIntegerSimd | 1         | 0.2247 ns | 0.0106 ns | 0.0099 ns |
| DecodeInteger     | 6         | 3.2998 ns | 0.0120 ns | 0.0113 ns |
| DecodeIntegerSimd | 6         | 1.4261 ns | 0.0081 ns | 0.0068 ns |

The performance of the SIMD decoder is independent of input size (except for the single-byte case, which is handled identically to the non-SIMD decoder). The non-SIMD decoder is faster on smaller inputs but becomes slower above 3 bytes compared to the SIMD operation.

Variable-length Performance

The Variable-length decoding yields significantly faster results in all cases:

| Method                | InputSize | Mean      | Error     | StdDev    | Median    |
|---------------------- |---------- |----------:|----------:|----------:|----------:|
| DecodeIntegerVariable | 1         | 0.0024 ns | 0.0041 ns | 0.0036 ns | 0.0005 ns |
| DecodeIntegerVariable | 2         | 0.2352 ns | 0.0118 ns | 0.0111 ns | 0.2339 ns |
| DecodeIntegerVariable | 4         | 0.6790 ns | 0.0194 ns | 0.0162 ns | 0.6711 ns |
| DecodeIntegerVariable | 8         | 0.2254 ns | 0.0148 ns | 0.0123 ns | 0.2216 ns |
| DecodeIntegerVariable | 8         | 0.2268 ns | 0.0164 ns | 0.0145 ns | 0.2260 ns |