Repository navigation
perf(operators): read the @pm haystack in place instead of copying it - #26
Conversation
pmEvaluate called matcher.Iter(value), which copies the whole value into a []byte on every call. A large request body was copied once per @pm rule and per argument: in a profile of the Imunify360 coraza ruleset on a 4.7 MB JSON body this was 60% of all allocations, about 200 MB per request. Hand the matcher a read-only view of the string through IterByte instead. The conversion is a new helper in internal/strings, where the project keeps its zero-copy helpers. See ADR-0061.
BenchmarkCRSLargeJSON sends the three JSON bodies of the perf-cloudways-badday lab scenario (640 KB, 1 MB, 4.7 MB, 8 members of 'a') through the CRS, or through the ruleset in CORAZA_BENCH_RULES, and can report the rules that cost the most.
The copies are pointer-free, so they add allocation volume (how often the GC runs), not scan work. ADR-0061 said they lengthen GC cycles.
|
Numbers on the three bodies, measured locally with
On these bodies this PR changes allocation, not CPU: -76% allocated per body, and CPU within run-to-run noise (149 / 227 / 1011 ms with it). The CPU win comes from The What is left in the 4.7 MB profile with both on: |
Stops
@pmcopying every value it scans. Part of work on the CPU cost of large JSON bodies; benchmark numbers follow in a comment.What
pmEvaluatecalledmatcher.Iter(value), which isIterByte([]byte(value))in the aho-corasick library: the whole value is copied into a new[]byteon every call. This hands the matcher a read-only view of the string throughIterByteinstead. The view is a new helper,internal/strings.UnsafeBytes, next to the existingWrapUnsafe.Why
Take JSON bodies of 640 KB, 1 MB and 4.7 MB (8 members each). A profile of a production ruleset (7152
SecRulelines) on one 4.7 MB body, withSecRxPreFilter On, showsAhoCorasick.Iteras 1209 MB of the 2025 MB allocated over 6 transactions: about 200 MB, some 40 times the body, per request. Each@pmrule tests each argument, so the body is copied once per rule and argument.It is the second cost, not the first. The first is Go's regexp engine, which is about 86% of CPU on these bodies and which
SecRxPreFilter Oncuts roughly sixfold with identical detections (640 KB: 810 ms to 141 ms, 1 MB: 1308 to 220 ms, 4.7 MB: 6114 to 1018 ms per body, same matched rules). That is a configuration line, not part of this PR.Safety
UnsafeBytesis read-only by contract and the new use needs an ADR (AGENTS.md): ADR-0061 is included. The claim it rests on is that the library only reads the haystack. I read the pinned version (v0.0.0-20250424160509-463d218d4745): the iterator stores the slice, and the search code, the prefilters, the DFA and the NFA only index it. The DFA is what@pmand@pmFromDatasetbuild;@pmFromFilebuilds the NFA, and both go through the same search code. A library bump has to keep that in view.Numbers
BenchmarkPmLargeValue,-count=5, medians, same test file withpm.goas inmainand as in this PR:About 5% less time per call, and no allocation that grows with the value. The larger win is the garbage: the copies are about 200 MB per 4.7 MB request, and allocation volume sets how often the GC runs.
Tests
TestUnsafeBytes: content,len == cap, and that the slice aliases the string.TestPmDoesNotCopyLargeValue: one@pmevaluation of a 1 MiB value allocates at most 1/16 of it. Fails with the copy in place.TestPmFindsPatternInLargeValue: pattern at the start, in the middle, at the very end, one byte short, and absent, on values of 1 MiB and more.BenchmarkCRSLargeJSON(second commit): the three bodies above through the CRS, or through the ruleset inCORAZA_BENCH_RULES;CORAZA_BENCH_RULECOST=1prints the rules that cost the most andCORAZA_BENCH_DIRECTIVESadds directives before the rules.Verification
Locally:
gofmt,go build ./...,go vet ./...,go test ./...,go test -tags coraza.rule.rx_prefilter ./internal/operators/,go test -race ./internal/strings/ ./internal/operators/,tinygo test -short -v ./internal/...with and withoutcoraza.no_memoize,go run mage.go lint,go run mage.go adr.Not included
Enabling
SecRxPreFilter(a configuration line on the integrator's side), and replacing the Aho-Corasick matcher of@pm, which is the largest CPU cost left once the prefilter is on.