Skip to content

Commit

Permalink
Micro-optimizations for Decompressor and Context (#6)
Browse files Browse the repository at this point in the history
Microbenchmarks show a ~15+% improvement in decoding performance.

- Unroll find, avoid hash lookups, avoid calls to `compute_current_size`, constant folding.
  • Loading branch information
maruth-stripe committed Mar 12, 2024
1 parent dc78e3f commit f016342
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 23 deletions.
32 changes: 22 additions & 10 deletions lib/protocol/hpack/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ module Protocol
# - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10
module HPACK
# Header representation as defined by the spec.
NO_INDEX_TYPE = {prefix: 4, pattern: 0x00}.freeze
NEVER_INDEXED_TYPE = {prefix: 4, pattern: 0x10}.freeze
CHANGE_TABLE_SIZE_TYPE = {prefix: 5, pattern: 0x20}.freeze
INCREMENTAL_TYPE = {prefix: 6, pattern: 0x40}.freeze
INDEXED_TYPE = {prefix: 7, pattern: 0x80}.freeze
HEADER_REPRESENTATION = {
indexed: {prefix: 7, pattern: 0x80},
incremental: {prefix: 6, pattern: 0x40},
no_index: {prefix: 4, pattern: 0x00},
never_indexed: {prefix: 4, pattern: 0x10},
change_table_size: {prefix: 5, pattern: 0x20},
indexed: INDEXED_TYPE,
incremental: INCREMENTAL_TYPE,
no_index: NO_INDEX_TYPE,
never_indexed: NEVER_INDEXED_TYPE,
change_table_size: CHANGE_TABLE_SIZE_TYPE
}

# To decompress header blocks, a decoder only needs to maintain a
Expand Down Expand Up @@ -280,8 +285,8 @@ def change_table_size(size)

# Returns current table size in octets
# @return [Integer]
def current_table_size
@table.sum{|k, v| k.bytesize + v.bytesize + 32}
def compute_current_table_size
@table.sum { |k, v| k.bytesize + v.bytesize + 32 }
end

private
Expand All @@ -296,6 +301,11 @@ def add_to_table(command)
command.freeze

@table.unshift(command)
@current_table_size += entry_size(command)
end

def entry_size(e)
e[0].bytesize + e[1].bytesize + 32
end

# To keep the dynamic table size lower than or equal to @table_size,
Expand All @@ -304,14 +314,16 @@ def add_to_table(command)
# @param command [Hash]
# @return [Boolean] whether +command+ fits in the dynamic table.
def size_check(command)
cursize = current_table_size

@current_table_size ||= compute_current_table_size

cmdsize = command.nil? ? 0 : command[0].bytesize + command[1].bytesize + 32

while cursize + cmdsize > @table_size
while @current_table_size + cmdsize > @table_size
break if @table.empty?

e = @table.pop
cursize -= e[0].bytesize + e[1].bytesize + 32
@current_table_size -= e[0].bytesize + e[1].bytesize + 32
end

cmdsize <= @table_size
Expand Down
75 changes: 64 additions & 11 deletions lib/protocol/hpack/decompressor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ module HPACK
# context of the opposing peer. Decompressor must be initialized with
# appropriate starting context based on local role: client or server.
class Decompressor

MASK_SHIFT_4 = (~0x0 >> 4) << 4

def initialize(buffer, context = Context.new, table_size_limit: nil)
@buffer = buffer
@context = context
Expand Down Expand Up @@ -98,30 +101,80 @@ def read_header
pattern = peek_byte

header = {}
header[:type], type = HEADER_REPRESENTATION.find do |_t, desc|
mask = (pattern >> desc[:prefix]) << desc[:prefix]
mask == desc[:pattern]
end

raise CompressionError unless header[:type]
type = nil

# (pattern & MASK_SHIFT_4) clears bottom 4 bits,
# equivalent to (pattern >> 4) << 4. For the
# no-index and never-indexed type we only need to clear
# the bottom 4 bits (as specified by NO_INDEX_TYPE[:prefix])
# so we directly check against NO_INDEX_TYPE[:pattern].
# But for change-table-size, incremental, and indexed
# we must clear 5,6, and 7 bits respectively.
# Consider indexed where we need to clear 7 bits.
# Since (pattern & MASK_SHIFT_4)'s bottom 4 bits are cleared
# you can visualize it as
#
# INDEXED_TYPE[:pattern] = <some bits> 0 0 0 0 0 0 0
# ^^^^^^^^^^^^^^^^ 7 bits
# (pattern & MASK_SHIFT_4) = <pattern bits> b1 b2 b3 0 0 0 0
#
# Computing equality after masking bottom 7 bits (i.e., set b1 = b2 = b3 = 0)
# is the same as checking equality against
# <some bits> x1 x2 x3 0 0 0 0
# For *every* possible value of x1, x2, x3 (that is, 2^3 = 8 values).
# INDEXED_TYPE[:pattern] = 0x80, so we check against 0x80, 0x90 = 0x80 + (0b001 << 4)
# 0xa0 = 0x80 + (0b001 << 5), ..., 0xf0 = 0x80 + (0b111 << 4).
# While not the most readable, we have written out everything as constant literals
# so Ruby can optimize this case-when to a hash lookup.
#
# There's no else case as this list is exhaustive.
# (0..255).map { |x| (x & -16).to_s(16) }.uniq will show this

case (pattern & MASK_SHIFT_4)
when 0x00
header[:type] = :no_index
type = NO_INDEX_TYPE
when 0x10
header[:type] = :never_indexed
type = NEVER_INDEXED_TYPE
# checking if (pattern >> 5) << 5 == 0x20
# Since we cleared bottom 4 bits, the 5th
# bit can be either 0 or 1, so check both
# cases.
when 0x20, 0x30
header[:type] = :change_table_size
type = CHANGE_TABLE_SIZE_TYPE
# checking if (pattern >> 6) << 6 == 0x40
# Same logic as above, but now over the 4
# possible combinations of 2 bits (5th, 6th)
when 0x40, 0x50, 0x60, 0x70
header[:type] = :incremental
type = INCREMENTAL_TYPE
# checking if (pattern >> 7) << 7 == 0x80
when 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0
header[:type] = :indexed
type = INDEXED_TYPE
end

header[:name] = read_integer(type[:prefix])
header_name = read_integer(type[:prefix])

case header[:type]
when :indexed
raise CompressionError if header[:name].zero?
header[:name] -= 1
raise CompressionError if header_name.zero?
header[:name] = header_name - 1
when :change_table_size
header[:value] = header[:name]
header[:name] = header_name
header[:value] = header_name

if @table_size_limit and header[:value] > @table_size_limit
raise CompressionError, "Table size #{header[:value]} exceeds limit #{@table_size_limit}!"
end
else
if (header[:name]).zero?
if header_name.zero?
header[:name] = read_string
else
header[:name] -= 1
header[:name] = header_name - 1
end

header[:value] = read_string
Expand Down
4 changes: 2 additions & 2 deletions spec/protocol/hpack/rfc7541_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def self.fixtures(mode)
end

it 'should compute header table size' do
expect(context.current_table_size).to eq example[:streams][nth][:table_size]
expect(context.compute_current_table_size).to eq example[:streams][nth][:table_size]
end
end
end
Expand Down Expand Up @@ -95,7 +95,7 @@ def self.fixtures(mode)
end

it 'should compute header table size' do
expect(context.current_table_size).to eq example[:streams][nth][:table_size]
expect(context.compute_current_table_size).to eq example[:streams][nth][:table_size]
end
end
end
Expand Down

0 comments on commit f016342

Please sign in to comment.