perf: avoid string reversal allocation in DomainSet.Has - #3049
Conversation
DomainSet.Has reversed and lowercased the query with utils.Reverse and strings.ToLower on every call, which converts to []rune and back. Reverse and lowercase ASCII keys in one pass into a stack buffer instead, keeping the geosite and rule-set matching path allocation free. Keys containing non-ASCII bytes keep using the rune-wise reversal the set is built with. BenchmarkDomainSetHas, -benchtime=2s -count=10, median: before 271 ns/op 6 B/op 0 allocs/op after 163 ns/op 0 B/op 0 allocs/op
|
That’s an interesting task. However, you should verify whether directly using |
Follow-up on review: drop the fixed-size stack buffer entirely. has() now reads the original key back to front through revLowerAt, so no buffer is reserved and nothing is copied. Non-ASCII keys still normalize through the rune-wise reversal the set is built with, byte-reversed so the accessor observes it unchanged. BenchmarkDomainSetHas, -benchtime=1s -count=10, vs current Alpha: short 286.1n -> 164.5n (-42%), 6 B/op -> 0 B/op long 479.1n -> 172.2n (-64%), 244 B/op -> 0 B/op, 2 allocs -> 0 The benchmark now covers keys over 32 bytes as well; the previous one only had 20-24 byte keys and hid the larger win.
|
Thanks — the stack space concern is fair, and chasing it led somewhere better than either option, because Escape analysis does agree with you: But "does not escape" is not the same as "stays on the stack". The generated code only keeps it off the heap while the length fits the compiler's 32-byte implicit temporary: So any hostname over 32 bytes — That also exposed a mistake in my benchmark: every key in it was 20–24 bytes, so it never left the stack path. It understated the problem as well — the current code allocates 244 B/op and 2 allocs/op on keys over 32 bytes, not the 6 B/op the short-key benchmark suggested. The benchmark now covers both lengths. Rather than trade a 255-byte frame against an allocation, the second commit removes the buffer altogether.
No buffer, no allocation at any key length, and slightly faster than the fixed buffer. On correctness: the three added tests still pass against an unmodified |
|
Based on the current code structure, a lowercase |
Follow-up on review: with the non-ASCII branch rewriting key and breaking, Has has a single path again, so the separate has() helper is no longer needed. Benchmarks are unchanged (p=0.869 short, p=1.000 long, n=10).
|
Done — the non-ASCII branch now rewrites Benchmarks are unchanged, so the split was not buying anything:
|
v1.19.26 -> v1.19.30-0.20260730072545-26c635f69bbe, the current tip of the upstream Alpha branch. Pulls in the DomainSet.Has rewrite (MetaCubeX/mihomo#3049), which removes the per-lookup allocation from geosite and rule-set matching, along with sing-tun 0.4.21 and quic-go 0.61.1. Note this moves the dependency from a release tag to a development branch commit.
Problem
DomainSet.Hasnormalizes every query with two string conversions before walking the trie:utils.Reverseconverts the key to[]runeand back tostring, so each lookup pays a rune decode, a reverse copy and a re-encode. This sits on the hot path of every domain match:GEOSITErules (component/geodata/router/condition.go),RULE-SETdomain matching (rules/provider/domain_strategy.go), the fake-ip skipper (component/fakeip/skipper.go) and the sniffer dispatcher (component/sniffer/dispatcher.go).The cost is length dependent. The Go compiler keeps a non-escaping conversion off the heap only while it fits its 32-byte implicit temporary, so hostnames longer than that — most CDN and cloud endpoints — allocate twice per lookup: 244 B/op and 2 allocs/op, against 6 B/op and 0 allocs/op for short keys.
Change
The trie stores reversed keys, but the reversal only has to be observable inside the matching loop.
Hasnow reads the key back to front through a small inlinable accessor, so no buffer is reserved and nothing is copied:Keys containing a byte >=
utf8.RuneSelfrewritekeythroughstrings.ToLower(utils.Reverse(key)), byte-reversed so the accessor observes it unchanged, then fall into the same loop. That path is required for correctness, not caution:NewDomainSetbuilds the set with rune-wise reversal, which only equals byte-wise reversal for ASCII.No exported signature, no
DomainSetfield, and no change to the MRS binary format written byWriteBin/ read byReadDomainSetBin.Commits, after review: the first used a fixed 255-byte stack buffer, the second removed it, and the third folded the matching loop back into
Hasnow that there is a single path again.make([]byte, len(key))was measured as an alternative to the fixed buffer and is not allocation free — escape analysis reportsdoes not escape, but the generated code still branches toruntime.makesliceonce the length exceeds 32 bytes, which costs 48 B/op and 1 allocs/op on long keys. Reading in place avoids both the reserved frame and the allocation.Benchmark
BenchmarkDomainSetHasis added in this PR: 10k suffix rules, keys mixing exact hits, subdomain hits, misses and uppercase input, split by length because of the 32-byte threshold above.benchstat,-benchtime=1s -count=10, linux/amd64:shortB/op, allocs/oplongB/op, allocs/opTo reproduce both sides, keeping the new benchmark and swapping only the implementation:
Tests
Added coverage the existing tests did not have:
TestDomainSetCase— uppercase queries against lowercase rules.TestDomainSetUnicode— non-ASCII rules and queries, exercising the rune-wise fallback.TestDomainSetOversizedKey— a key longer than any valid domain name.These three tests also pass against an unmodified
domain_set.go, which is deliberate: they pin existing behaviour rather than describe the new implementation.Match counts were also compared against the current implementation over a real
geosite.dat, using 20k mixed hit/miss queries per category:geosite:cn(112,332 rules) 27,185 hits,geosite:google5,345,geosite:apple8,935 — identical on both sides.Notes
Foreachstill callsutils.Reverse. That is a dump path rather than a lookup path, so it is left alone to keep this change focused.