diff --git a/src/cow_hpack.erl b/src/cow_hpack.erl index 87c4514..1450049 100644 --- a/src/cow_hpack.erl +++ b/src/cow_hpack.erl @@ -30,11 +30,18 @@ -export([encode/2]). -export([encode/3]). +-export([bench/0]). + -record(state, { size = 0 :: non_neg_integer(), max_size = 4096 :: non_neg_integer(), configured_max_size = 4096 :: non_neg_integer(), - dyn_table = [] :: [{pos_integer(), {binary(), binary()}}] + dyn_table = [] :: [{pos_integer(), {binary(), binary()}}], + %% Map indices for O(1) lookup during encoding. + %% Maps store sequence numbers; actual index = 62 + (count - 1 - seq) + %% where count = maps:size(dyn_field_idx). + dyn_field_idx = #{} :: #{{binary(), binary()} => non_neg_integer()}, + dyn_name_idx = #{} :: #{binary() => non_neg_integer()} }). -opaque state() :: #state{}. @@ -862,12 +869,16 @@ table_find_field({<<"user-agent">>, <<>>}, _) -> 58; table_find_field({<<"vary">>, <<>>}, _) -> 59; table_find_field({<<"via">>, <<>>}, _) -> 60; table_find_field({<<"www-authenticate">>, <<>>}, _) -> 61; -table_find_field(Header, #state{dyn_table=DynamicTable}) -> - table_find_field_dyn(Header, DynamicTable, 62). - -table_find_field_dyn(_, [], _) -> not_found; -table_find_field_dyn(Header, [{_, Header}|_], Index) -> Index; -table_find_field_dyn(Header, [_|Tail], Index) -> table_find_field_dyn(Header, Tail, Index + 1). +table_find_field(Header, State) -> + table_find_field_dyn(Header, State). + +%% O(1) lookup using map index. +%% Maps store sequence numbers; convert to actual index. +table_find_field_dyn(Header, #state{dyn_field_idx=FieldIdx}) -> + case FieldIdx of + #{Header := Seq} -> 62 + maps:size(FieldIdx) - 1 - Seq; + _ -> not_found + end. table_find_name(<<":authority">>, _) -> 1; table_find_name(<<":method">>, _) -> 2; @@ -921,12 +932,16 @@ table_find_name(<<"user-agent">>, _) -> 58; table_find_name(<<"vary">>, _) -> 59; table_find_name(<<"via">>, _) -> 60; table_find_name(<<"www-authenticate">>, _) -> 61; -table_find_name(Name, #state{dyn_table=DynamicTable}) -> - table_find_name_dyn(Name, DynamicTable, 62). - -table_find_name_dyn(_, [], _) -> not_found; -table_find_name_dyn(Name, [{Name, _}|_], Index) -> Index; -table_find_name_dyn(Name, [_|Tail], Index) -> table_find_name_dyn(Name, Tail, Index + 1). +table_find_name(Name, State) -> + table_find_name_dyn(Name, State). + +%% O(1) lookup using map index. +%% Maps store sequence numbers; convert to actual index. +table_find_name_dyn(Name, #state{dyn_field_idx=FieldIdx, dyn_name_idx=NameIdx}) -> + case NameIdx of + #{Name := Seq} -> 62 + maps:size(FieldIdx) - 1 - Seq; + _ -> not_found + end. table_get(1, _) -> {<<":authority">>, <<>>}; table_get(2, _) -> {<<":method">>, <<"GET">>}; @@ -1058,22 +1073,38 @@ table_get_name(Index, #state{dyn_table=DynamicTable}) -> {_, {Name, _}} = lists:nth(Index - 61, DynamicTable), Name. -table_insert(Entry = {Name, Value}, State=#state{size=Size, max_size=MaxSize, dyn_table=DynamicTable}) -> +table_insert(Entry = {Name, Value}, State=#state{size=Size, max_size=MaxSize, + dyn_table=DynamicTable, dyn_field_idx=FieldIdx, dyn_name_idx=NameIdx}) -> EntrySize = byte_size(Name) + byte_size(Value) + 32, if EntrySize + Size =< MaxSize -> - %% Add entry without eviction - State#state{size=Size + EntrySize, dyn_table=[{EntrySize, Entry}|DynamicTable]}; + %% Add entry without eviction. + %% Store sequence number = current count (0-indexed). + %% New entry gets highest seq, maps to index 62 after computation. + Seq = maps:size(FieldIdx), + State#state{ + size=Size + EntrySize, + dyn_table=[{EntrySize, Entry}|DynamicTable], + dyn_field_idx=FieldIdx#{Entry => Seq}, + dyn_name_idx=NameIdx#{Name => Seq} + }; EntrySize =< MaxSize -> - %% Evict, then add entry + %% Evict, then add entry. Rebuild maps from new table. {DynamicTable2, Size2} = table_resize(DynamicTable, MaxSize - EntrySize, 0, []), - State#state{size=Size2 + EntrySize, dyn_table=[{EntrySize, Entry}|DynamicTable2]}; + NewTable = [{EntrySize, Entry}|DynamicTable2], + {FieldIdx2, NameIdx2} = table_rebuild_indices(NewTable), + State#state{ + size=Size2 + EntrySize, + dyn_table=NewTable, + dyn_field_idx=FieldIdx2, + dyn_name_idx=NameIdx2 + }; EntrySize > MaxSize -> %% "an attempt to add an entry larger than the %% maximum size causes the table to be emptied %% of all existing entries and results in an %% empty table" (RFC 7541, 4.4) - State#state{size=0, dyn_table=[]} + State#state{size=0, dyn_table=[], dyn_field_idx=#{}, dyn_name_idx=#{}} end. table_resize([], _, Size, Acc) -> @@ -1083,14 +1114,42 @@ table_resize([{EntrySize, _}|_], MaxSize, Size, Acc) when Size + EntrySize > Max table_resize([Entry = {EntrySize, _}|Tail], MaxSize, Size, Acc) -> table_resize(Tail, MaxSize, Size + EntrySize, [Entry|Acc]). +%% Rebuild map indices from dynamic table list. +%% We iterate newest to oldest, assigning decreasing seq numbers. +%% Final count = maps:size(FieldIdx), used in lookup formula. +table_rebuild_indices(DynTable) -> + %% First build with positions 0,1,2... then adjust to seq numbers. + %% Seq = (Count - 1) - Position, so newest (pos 0) gets highest seq. + {FieldIdx0, NameIdx0, Count} = table_rebuild_indices(DynTable, 0, #{}, #{}), + %% Convert positions to seq numbers: seq = count - 1 - pos + FieldIdx = maps:map(fun(_, Pos) -> Count - 1 - Pos end, FieldIdx0), + NameIdx = maps:map(fun(_, Pos) -> Count - 1 - Pos end, NameIdx0), + {FieldIdx, NameIdx}. + +table_rebuild_indices([], Pos, FieldIdx, NameIdx) -> + {FieldIdx, NameIdx, Pos}; +table_rebuild_indices([{_, Entry = {Name, _}}|Tail], Pos, FieldIdx, NameIdx) -> + %% Only store first occurrence (lowest position = lowest index) for each key. + FieldIdx2 = case maps:is_key(Entry, FieldIdx) of + true -> FieldIdx; + false -> FieldIdx#{Entry => Pos} + end, + NameIdx2 = case maps:is_key(Name, NameIdx) of + true -> NameIdx; + false -> NameIdx#{Name => Pos} + end, + table_rebuild_indices(Tail, Pos + 1, FieldIdx2, NameIdx2). + table_update_size(0, State) -> - State#state{size=0, max_size=0, dyn_table=[]}; + State#state{size=0, max_size=0, dyn_table=[], dyn_field_idx=#{}, dyn_name_idx=#{}}; table_update_size(MaxSize, State=#state{size=CurrentSize}) when CurrentSize =< MaxSize -> State#state{max_size=MaxSize}; table_update_size(MaxSize, State=#state{dyn_table=DynTable}) -> {DynTable2, Size} = table_resize(DynTable, MaxSize, 0, []), - State#state{size=Size, max_size=MaxSize, dyn_table=DynTable2}. + {FieldIdx, NameIdx} = table_rebuild_indices(DynTable2), + State#state{size=Size, max_size=MaxSize, dyn_table=DynTable2, + dyn_field_idx=FieldIdx, dyn_name_idx=NameIdx}. -ifdef(TEST). prop_str_raw() -> @@ -1103,3 +1162,42 @@ prop_str_huffman() -> {Str, <<>>} =:= dec_str(iolist_to_binary(enc_str(Str, huffman))) end). -endif. + +%% Benchmark for dynamic table lookup performance. +%% Run with: cow_hpack:bench(). +bench() -> + %% Build a state with many dynamic table entries + Headers = [ + {<<"x-custom-", (integer_to_binary(N))/binary>>, <<"value-", (integer_to_binary(N))/binary>>} + || N <- lists:seq(1, 50) + ], + State0 = init(16384), % Large table to avoid eviction + State = lists:foldl(fun(H, S) -> table_insert(H, S) end, State0, Headers), + + %% Headers to look up (mix of existing and non-existing) + LookupHeaders = [ + {<<"x-custom-1">>, <<"value-1">>}, + {<<"x-custom-25">>, <<"value-25">>}, + {<<"x-custom-50">>, <<"value-50">>}, + {<<"x-nonexistent">>, <<"value">>}, + {<<":status">>, <<"200">>}, % static table + {<<"content-type">>, <<>>} % static table + ], + + Iterations = 100000, + + {Time, _} = timer:tc(fun() -> + bench_loop(Iterations, LookupHeaders, State) + end), + + io:format("HPACK table_find benchmark~n"), + io:format("Dynamic table entries: ~p~n", [length(Headers)]), + io:format("Iterations: ~p~n", [Iterations * length(LookupHeaders)]), + io:format("Time: ~p us (~.3f us/lookup)~n", + [Time, Time / (Iterations * length(LookupHeaders))]), + ok. + +bench_loop(0, _, _) -> ok; +bench_loop(N, Headers, State) -> + _ = [table_find(H, State) || H <- Headers], + bench_loop(N - 1, Headers, State). diff --git a/src/cow_http1.erl b/src/cow_http1.erl index 92e3f14..378bbc4 100644 --- a/src/cow_http1.erl +++ b/src/cow_http1.erl @@ -168,60 +168,96 @@ horse_parse_status_line_other() -> -endif. %% @doc Parse the list of headers. +%% +%% Optimized to use skip/len pattern instead of byte-by-byte binary +%% accumulation. This avoids O(n^2) allocation overhead for header values. -spec parse_headers(binary()) -> {[{binary(), binary()}], binary()}. parse_headers(Data) -> - parse_header(Data, []). + parse_header(Data, Data, []). -parse_header(<< $\r, $\n, Rest/bits >>, Acc) -> +%% parse_header/3: Data is current position, Orig is original binary for extraction +parse_header(<< $\r, $\n, Rest/bits >>, _Orig, Acc) -> {lists:reverse(Acc), Rest}; -parse_header(Data, Acc) -> - parse_hd_name(Data, Acc, <<>>). +parse_header(Data, Orig, Acc) -> + parse_hd_name(Data, Orig, Acc, <<>>). -parse_hd_name(<< C, Rest/bits >>, Acc, SoFar) -> +%% Header name parsing - still uses binary accumulation for ?LOWER macro +%% (names are typically short, so this is less impactful) +parse_hd_name(<< C, Rest/bits >>, Orig, Acc, SoFar) -> case C of - $: -> parse_hd_before_value(Rest, Acc, SoFar); - $\s -> parse_hd_name_ws(Rest, Acc, SoFar); - $\t -> parse_hd_name_ws(Rest, Acc, SoFar); - _ -> ?LOWER(parse_hd_name, Rest, Acc, SoFar) + $: -> parse_hd_before_value(Rest, Orig, Acc, SoFar); + $\s -> parse_hd_name_ws(Rest, Orig, Acc, SoFar); + $\t -> parse_hd_name_ws(Rest, Orig, Acc, SoFar); + _ -> ?LOWER(parse_hd_name, Rest, Orig, Acc, SoFar) end. -parse_hd_name_ws(<< C, Rest/bits >>, Acc, Name) -> +parse_hd_name_ws(<< C, Rest/bits >>, Orig, Acc, Name) -> case C of - $: -> parse_hd_before_value(Rest, Acc, Name); - $\s -> parse_hd_name_ws(Rest, Acc, Name); - $\t -> parse_hd_name_ws(Rest, Acc, Name) + $: -> parse_hd_before_value(Rest, Orig, Acc, Name); + $\s -> parse_hd_name_ws(Rest, Orig, Acc, Name); + $\t -> parse_hd_name_ws(Rest, Orig, Acc, Name) end. -parse_hd_before_value(<< $\s, Rest/bits >>, Acc, Name) -> - parse_hd_before_value(Rest, Acc, Name); -parse_hd_before_value(<< $\t, Rest/bits >>, Acc, Name) -> - parse_hd_before_value(Rest, Acc, Name); -parse_hd_before_value(Data, Acc, Name) -> - parse_hd_value(Data, Acc, Name, <<>>). - -parse_hd_value(<< $\r, Rest/bits >>, Acc, Name, SoFar) -> - case Rest of - << $\n, C, Rest2/bits >> when C =:= $\s; C =:= $\t -> - parse_hd_value(Rest2, Acc, Name, << SoFar/binary, C >>); - << $\n, Rest2/bits >> -> - Value = clean_value_ws_end(SoFar, byte_size(SoFar) - 1), - parse_header(Rest2, [{Name, Value}|Acc]) - end; -parse_hd_value(<< C, Rest/bits >>, Acc, Name, SoFar) -> - parse_hd_value(Rest, Acc, Name, << SoFar/binary, C >>). - -%% This function has been copied from cowboy_http. -clean_value_ws_end(_, -1) -> +parse_hd_before_value(<< $\s, Rest/bits >>, Orig, Acc, Name) -> + parse_hd_before_value(Rest, Orig, Acc, Name); +parse_hd_before_value(<< $\t, Rest/bits >>, Orig, Acc, Name) -> + parse_hd_before_value(Rest, Orig, Acc, Name); +parse_hd_before_value(Data, Orig, Acc, Name) -> + %% Calculate skip position based on where we are in the original binary + Skip = byte_size(Orig) - byte_size(Data), + parse_hd_value(Data, Orig, Skip, 0, Acc, Name). + +%% Optimized header value parsing using skip/len pattern. +%% Fast path: single segment header values (no folding). + +%% 4-byte chunking for faster scanning +parse_hd_value(<< C1, C2, C3, C4, Rest/bits >>, Orig, Skip, Len, Acc, Name) + when C1 =/= $\r, C2 =/= $\r, C3 =/= $\r, C4 =/= $\r -> + parse_hd_value(Rest, Orig, Skip, Len + 4, Acc, Name); +%% Single byte when approaching \r +parse_hd_value(<< C, Rest/bits >>, Orig, Skip, Len, Acc, Name) when C =/= $\r -> + parse_hd_value(Rest, Orig, Skip, Len + 1, Acc, Name); +%% Found \r - check for end of header or folding +parse_hd_value(<< $\r, $\n, C, Rest/bits >>, Orig, Skip, Len, Acc, Name) + when C =:= $\s; C =:= $\t -> + %% Header folding - switch to multi-segment mode + Part = binary:part(Orig, Skip, Len), + NewSkip = Skip + Len + 3, + parse_hd_value_folded(Rest, Orig, NewSkip, 0, Acc, Name, [C, Part]); +parse_hd_value(<< $\r, $\n, Rest/bits >>, Orig, Skip, Len, Acc, Name) -> + %% End of header value - extract and clean trailing whitespace + Value = clean_value_ws_end(Orig, Skip, Len), + parse_header(Rest, Orig, [{Name, Value}|Acc]). + +%% Multi-segment header value parsing (header folding case). +%% Uses iolist accumulation for multiple segments. +parse_hd_value_folded(<< C1, C2, C3, C4, Rest/bits >>, Orig, Skip, Len, Acc, Name, Parts) + when C1 =/= $\r, C2 =/= $\r, C3 =/= $\r, C4 =/= $\r -> + parse_hd_value_folded(Rest, Orig, Skip, Len + 4, Acc, Name, Parts); +parse_hd_value_folded(<< C, Rest/bits >>, Orig, Skip, Len, Acc, Name, Parts) when C =/= $\r -> + parse_hd_value_folded(Rest, Orig, Skip, Len + 1, Acc, Name, Parts); +parse_hd_value_folded(<< $\r, $\n, C, Rest/bits >>, Orig, Skip, Len, Acc, Name, Parts) + when C =:= $\s; C =:= $\t -> + %% Another fold + Part = binary:part(Orig, Skip, Len), + NewSkip = Skip + Len + 3, + parse_hd_value_folded(Rest, Orig, NewSkip, 0, Acc, Name, [C, Part|Parts]); +parse_hd_value_folded(<< $\r, $\n, Rest/bits >>, Orig, Skip, Len, Acc, Name, Parts) -> + %% End of folded header value + Part = binary:part(Orig, Skip, Len), + FullValue = iolist_to_binary(lists:reverse([Part|Parts])), + Value = clean_value_ws_end(FullValue, 0, byte_size(FullValue)), + parse_header(Rest, Orig, [{Name, Value}|Acc]). + +%% Optimized trailing whitespace removal using Skip/Len. +clean_value_ws_end(_Orig, _Skip, 0) -> <<>>; -clean_value_ws_end(Value, N) -> - case binary:at(Value, N) of - $\s -> clean_value_ws_end(Value, N - 1); - $\t -> clean_value_ws_end(Value, N - 1); - _ -> - S = N + 1, - << Value2:S/binary, _/bits >> = Value, - Value2 +clean_value_ws_end(Orig, Skip, Len) -> + case binary:at(Orig, Skip + Len - 1) of + $\s -> clean_value_ws_end(Orig, Skip, Len - 1); + $\t -> clean_value_ws_end(Orig, Skip, Len - 1); + _ -> binary:part(Orig, Skip, Len) end. -ifdef(TEST). diff --git a/src/cow_qpack.erl b/src/cow_qpack.erl index 98089ae..f930431 100644 --- a/src/cow_qpack.erl +++ b/src/cow_qpack.erl @@ -27,6 +27,8 @@ -export([execute_decoder_instructions/2]). -export([encoder_set_settings/3]). +-export([bench/0]). + -record(state, { %% Configuration. %% @@ -70,6 +72,11 @@ %% us to know what index the newest entry is using. dyn_table = [] :: [{pos_integer(), {binary(), binary()}}], + %% Map indices for O(1) lookup during encoding. + %% Store absolute indices (which never change for an entry). + dyn_field_idx = #{} :: #{{binary(), binary()} => non_neg_integer()}, + dyn_name_idx = #{} :: #{binary() => non_neg_integer()}, + %% Decoder-specific state. %% We keep track of streams that are currently blocked @@ -1410,17 +1417,26 @@ table_get_name_static(98) -> <<"x-frame-options">>. table_insert(Entry={Name, Value}, State=#state{capacity=Capacity, size=Size0, insert_count=InsertCount, dyn_table=DynamicTable0, + dyn_field_idx=FieldIdx, dyn_name_idx=NameIdx, draining_size=DrainingSize}) -> EntrySize = byte_size(Name) + byte_size(Value) + 32, if EntrySize + Size0 =< Capacity -> + %% Add entry without eviction. Index is current InsertCount. {ok, State#state{size=Size0 + EntrySize, insert_count=InsertCount + 1, - dyn_table=[{EntrySize, Entry}|DynamicTable0]}}; + dyn_table=[{EntrySize, Entry}|DynamicTable0], + dyn_field_idx=FieldIdx#{Entry => InsertCount}, + dyn_name_idx=NameIdx#{Name => InsertCount}}}; EntrySize =< Capacity -> + %% Evict, then add entry. Rebuild maps. {DynamicTable, Size} = table_evict(DynamicTable0, Capacity - EntrySize, 0, []), + NewTable = [{EntrySize, Entry}|DynamicTable], + {FieldIdx2, NameIdx2} = table_rebuild_indices(NewTable, InsertCount), {ok, State#state{size=Size + EntrySize, insert_count=InsertCount + 1, - dyn_table=[{EntrySize, Entry}|DynamicTable], + dyn_table=NewTable, + dyn_field_idx=FieldIdx2, + dyn_name_idx=NameIdx2, %% We reduce the draining size by how much was gained from evicting. draining_size=DrainingSize - (Size0 - Size)}}; true -> % EntrySize > Capacity -> @@ -1436,25 +1452,33 @@ table_evict([{EntrySize, _}|_], MaxSize, Size, Acc) table_evict([Entry = {EntrySize, _}|Tail], MaxSize, Size, Acc) -> table_evict(Tail, MaxSize, Size + EntrySize, [Entry|Acc]). -table_find_dyn(Entry, #state{insert_count=InsertCount, dyn_table=DynamicTable}) -> - table_find_dyn(Entry, DynamicTable, InsertCount - 1). - -table_find_dyn(_, [], _) -> - not_found; -table_find_dyn(Entry, [{_, Entry}|_], Index) -> - Index; -table_find_dyn(Entry, [_|Tail], Index) -> - table_find_dyn(Entry, Tail, Index - 1). +%% Rebuild map indices from dynamic table. +%% InsertCount is the count BEFORE incrementing for the new entry. +%% Newest entry (first in list) gets index InsertCount, then decrement. +table_rebuild_indices(DynTable, InsertCount) -> + table_rebuild_indices(DynTable, InsertCount, #{}, #{}). + +table_rebuild_indices([], _, FieldIdx, NameIdx) -> + {FieldIdx, NameIdx}; +table_rebuild_indices([{_, Entry = {Name, _}}|Tail], Index, FieldIdx, NameIdx) -> + %% Only store first occurrence (highest index = newest) for each key. + FieldIdx2 = case maps:is_key(Entry, FieldIdx) of + true -> FieldIdx; + false -> FieldIdx#{Entry => Index} + end, + NameIdx2 = case maps:is_key(Name, NameIdx) of + true -> NameIdx; + false -> NameIdx#{Name => Index} + end, + table_rebuild_indices(Tail, Index - 1, FieldIdx2, NameIdx2). -table_find_name_dyn(Name, #state{insert_count=InsertCount, dyn_table=DynamicTable}) -> - table_find_name_dyn(Name, DynamicTable, InsertCount - 1). +%% O(1) lookup using map index. +table_find_dyn(Entry, #state{dyn_field_idx=FieldIdx}) -> + maps:get(Entry, FieldIdx, not_found). -table_find_name_dyn(_, [], _) -> - not_found; -table_find_name_dyn(Name, [{_, {Name, _}}|_], Index) -> - Index; -table_find_name_dyn(Name, [_|Tail], Index) -> - table_find_name_dyn(Name, Tail, Index - 1). +%% O(1) lookup using map index. +table_find_name_dyn(Name, #state{dyn_name_idx=NameIdx}) -> + maps:get(Name, NameIdx, not_found). %% @todo These functions may error out if the encoder is invalid (2.2.3. Invalid References). table_get_dyn_abs(Index, #state{insert_count=InsertCount, dyn_table=DynamicTable}) -> @@ -1579,3 +1603,51 @@ table_get_dyn_post_base_test() -> {<<"i">>, <<"j">>} = table_get_dyn_post_base(2, 2, State3), ok. -endif. + +%% Benchmark for dynamic table lookup performance. +%% Run with: cow_qpack:bench(). +bench() -> + %% Build a state with many dynamic table entries + Headers = [ + {<<"x-custom-", (integer_to_binary(N))/binary>>, <<"value-", (integer_to_binary(N))/binary>>} + || N <- lists:seq(1, 50) + ], + %% Create encoder state with capacity set + State0 = #state{ + settings_received=true, + max_table_capacity=16384, + max_blocked_streams=100, + capacity=16384 + }, + State = lists:foldl(fun(H, S) -> + {ok, S2} = table_insert(H, S), + S2 + end, State0, Headers), + + %% Headers to look up (mix of existing and non-existing) + LookupHeaders = [ + {<<"x-custom-1">>, <<"value-1">>}, + {<<"x-custom-25">>, <<"value-25">>}, + {<<"x-custom-50">>, <<"value-50">>}, + {<<"x-nonexistent">>, <<"value">>}, + {<<":status">>, <<"200">>}, % static table + {<<"content-type">>, <<"application/json">>} % static table + ], + + Iterations = 100000, + + {Time, _} = timer:tc(fun() -> + bench_loop(Iterations, LookupHeaders, State) + end), + + io:format("QPACK table_find benchmark~n"), + io:format("Dynamic table entries: ~p~n", [length(Headers)]), + io:format("Iterations: ~p~n", [Iterations * length(LookupHeaders)]), + io:format("Time: ~p us (~.3f us/lookup)~n", + [Time, Time / (Iterations * length(LookupHeaders))]), + ok. + +bench_loop(0, _, _) -> ok; +bench_loop(N, Headers, State) -> + _ = [table_find_dyn(H, State) || H <- Headers], + bench_loop(N - 1, Headers, State).