Skip to content

perf(operators): read the @pm haystack in place instead of copying it - #26

Merged
ggrigoras-ops merged 3 commits into
mainfrom
def-57621-pm-zero-copy
Oct 14, 2026
Merged

ggrigoras-ops merged 3 commits into
mainfrom
def-57621-pm-zero-copy

Conversation

@ggrigoras-ops

@ggrigoras-ops ggrigoras-ops commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Stops @pm copying every value it scans. Part of work on the CPU cost of large JSON bodies; benchmark numbers follow in a comment.

What

pmEvaluate called matcher.Iter(value), which is IterByte([]byte(value)) in the aho-corasick library: the whole value is copied into a new []byte on every call. This hands the matcher a read-only view of the string through IterByte instead. The view is a new helper, internal/strings.UnsafeBytes, next to the existing WrapUnsafe.

Why

Take JSON bodies of 640 KB, 1 MB and 4.7 MB (8 members each). A profile of a production ruleset (7152 SecRule lines) on one 4.7 MB body, with SecRxPreFilter On, shows AhoCorasick.Iter as 1209 MB of the 2025 MB allocated over 6 transactions: about 200 MB, some 40 times the body, per request. Each @pm rule 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 On cuts 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

UnsafeBytes is 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 @pm and @pmFromDataset build; @pmFromFile builds 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 with pm.go as in main and as in this PR:

value before after
1 KiB 2701 ns, 1144 B, 4 allocs 2558 ns, 120 B, 3 allocs
64 KiB 164.8 us, 65656 B, 4 allocs 155.7 us, 120 B, 3 allocs
1 MiB 2.63 ms, 1048697 B, 4 allocs 2.49 ms, 120 B, 3 allocs

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 @pm evaluation 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 in CORAZA_BENCH_RULES; CORAZA_BENCH_RULECOST=1 prints the rules that cost the most and CORAZA_BENCH_DIRECTIVES adds 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 without coraza.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.


  • My code includes positive and negative tests.
  • I have an appropriate description with correct grammar.
  • I have read the Contribution guidelines and Code of Conduct.
  • My code is properly linted and passes pre-commit tests.

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.
@ggrigoras-ops

ggrigoras-ops commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Numbers on the three bodies, measured locally with BenchmarkCRSLargeJSON on a production ruleset (7152 SecRule lines), 5 iterations per body, SecRxPreFilter On where stated. The same rules matched in every run.

body CPU per body, prefilter off CPU per body, prefilter on allocated per body, prefilter on allocated per body, prefilter on + this PR
640 KB 810 ms 141 ms 38 MB 10 MB
1 MB 1308 ms 220 ms 61 MB 15.5 MB
4.7 MB 6114 ms 1018 ms 280 MB 67.5 MB

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 SecRxPreFilter On, a config line, not from this PR.

The SecRxPreFilter runs above used the prefilter before the fix in #27 (it was skipping an XSS rule it should have run). With both PRs applied: 130 / 209 / 950 ms per body and 9.8 / 14.9 / 68.6 MB allocated, same matched rules.

What is left in the 4.7 MB profile with both on: @pmFromFile (Aho-Corasick NFA) about 38% of CPU, Go regexp about 20%, the prefilter's own matcher about 10%.

@ggrigoras-ops
ggrigoras-ops marked this pull request as ready for review October 13, 2026 11:45
@ggrigoras-ops
ggrigoras-ops merged commit 9b5a14e into main Oct 14, 2026
26 checks passed
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.

2 participants