Skip to content

perf: cache header order lookups instead of doing them per comparison - #22

Open
burruplambert wants to merge 2 commits into
bogdanfinn:masterfrom
burruplambert:perf/header-sort-precomputed-indices
Open

perf: cache header order lookups instead of doing them per comparison#22
burruplambert wants to merge 2 commits into
bogdanfinn:masterfrom
burruplambert:perf/header-sort-precomputed-indices

Conversation

@burruplambert

Copy link
Copy Markdown

Depends on #18 - please read first

This branch contains #18's commit as well as its own, because it requires that fix. Without the pooled-state reset from #18, a sorter reused by SortedKeyValues still has order set while its caches are sized for the previous header, and Less indexes past them:

panic: runtime error: index out of range [2] with length 2
    github.com/bogdanfinn/fhttp.(*headerSorter).Less

Merge #18 first and this rebases to a single commit, or merge this and #18 becomes redundant. Either is fine, but this must not land without that reset.

Summary

headerSorter.Less did two order map lookups, each preceded by a strings.ToLower of the header key, on every comparison - so a sort costs O(n log n) map lookups and lowercasing. In CPU profiles of a header-heavy client this shows up as runtime.mapaccess2_faststr and aeshashbody dominating the sort.

Decorate-sort-undecorate instead: SortedKeyValuesBy resolves each key's lookup once into orderIdx/orderOK slices on the pooled sorter, Swap keeps them aligned with kvs, and Less compares the cached results using the same four-branch logic as before.

Why cache (index, ok) rather than a sentinel

The obvious compression is to map absent keys to len(order) and compare ints. That is wrong for reachable inputs: the order map is built as order[v] = i over the Header-Order: values, so a repeated entry such as ["c", "a", "c"] gives {"c": 2, "a": 1} - a present key whose index equals len(order), which would then tie with every absent key and sort differently. Caching the (index, ok) pair keeps the comparison correct for any order map, including duplicate, negative and sparse values.

Since only the lookup is hoisted and the branch logic is untouched, the produced order is identical in all cases.

Tests

  • order equivalence across all-present, all-absent, mixed and case-varying keys
  • the repeated-entry case above
  • pool reuse cycling one sorter through ordered and orderless sorts of different sizes
  • a seeded differential test that checks Less against a reference implementation of the per-comparison semantics over 200 rounds of adversarial order maps

Measurements

Ordered header encoding -33% on this branch's base. BenchmarkHeaderWriteSubset about -8%. In production the sorter's comparison cost effectively disappeared from the profile.

This supersedes the header-sorting half of the closed #17, which only cached the lowercased keys and left the map lookups in place.

Note

TestHeaderWrite case 9 fails on master before and after this change - the order map is built from the Header-Order: values verbatim while the comparator looks them up lowercased, so mixed-case entries never match. Unrelated, and left alone since fixing it changes wire behavior.

headerSorter instances are pooled and shared between SortedKeyValuesBy
and SortedKeyValues, but SortedKeyValues never cleared hs.order. A
sorter previously used for an ordered sort would keep its order map and
apply it to a later orderless sort, producing the wrong header order
whenever the new header's lowercased keys collide with entries in the
stale order map.

Reset hs.order before sorting in SortedKeyValues and add a regression
test.
headerSorter.Less did two order map lookups, each preceded by a
strings.ToLower of the header key, on every comparison - O(n log n) map
lookups and lowercasing per sort. In CPU profiles of a header-heavy
client workload this shows up as runtime.mapaccess2_faststr and
aeshashbody dominating the sort.

Decorate-sort-undecorate instead: SortedKeyValuesBy resolves each key's
order lookup once into (orderIdx, orderOK) slices on the pooled sorter,
Swap keeps them aligned with kvs, and Less compares the cached results
with the exact same four-branch logic as before.

Caching the (index, ok) pair rather than mapping absent keys to a
sentinel index keeps the comparison correct for every possible order
map, including maps whose values reach len(order) - reachable through a
Header-Order: list with a repeated entry, e.g. ["c","a","c"] gives
{"c": 2, "a": 1} - as well as duplicate or negative values. Since the
branch logic is unchanged and only the lookup is hoisted, the produced
order is identical in all cases.

The orderless SortedKeyValues path is unchanged; the caches are
populated only when an order map is set, and the existing pooled-state
reset keeps a stale order from ever indexing them.

Adds order-equivalence tests (including the repeated-entry case), a
pool-reuse test cycling one sorter through ordered and orderless sorts
of different sizes, and a seeded differential test that checks Less
against a reference implementation of the per-comparison semantics
across adversarial order maps.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant