Skip to content

perf(sanitize): make clean text allocation-free on the hot path - #3120

Merged
SamMorrowDrums merged 1 commit into
mainfrom
sammorrowdrums-optimize-sanitizer-hot-paths
Aug 19, 2026
Merged

perf(sanitize): make clean text allocation-free on the hot path#3120
SamMorrowDrums merged 1 commit into
mainfrom
sammorrowdrums-optimize-sanitizer-hot-paths

Conversation

@SamMorrowDrums

Copy link
Copy Markdown
Collaborator

Fixes #3117

Sanitization ran several allocating passes over every user-authored field regardless of content. FilterInvisibleCharacters converted the whole input to []rune and back, FilterCodeFenceMetadata split and rejoined every line, and bluemonday ran unconditionally — so benign text paid full price on every conversion.

Changes

FilterInvisibleCharacters is scan-first / copy-on-first-match. It skips runs of ASCII without decoding them (no filtered rune is ASCII, and no variation selector is either), then copies into a builder only from the first rune that actually changes. Clean input is returned as the original string with zero allocations. Contextual variation-sequence handling is unchanged, and invalid UTF-8 is still re-encoded to U+FFFD to match the []rune round trip it replaces.

FilterCodeFenceMetadata walks lines in place instead of strings.Split/Join, and returns the input untouched when no line changes. sanitizeCodeFenceLine also stops rebuilding a line whose info string is already normalized.

FilterHTMLTags skips bluemonday for provably inert input. The policy tokenizes as HTML and re-emits text through html.EscapeString, so anything it can rewrite must contain one of the five characters EscapeString touches (&, ', ", <, > — also the only way to open a tag, comment, doctype or entity), a byte the tokenizer rewrites (NUL → U+FFFD, CR folded into LF), or a byte outside ASCII that could be malformed UTF-8. The fast path accepts printable ASCII plus TAB and LF minus those five characters, which excludes all three. A loose <>& check would not be sufficient: " and ' are escaped, and CR/NUL are rewritten by the tokenizer.

Sanitize skips the second invisible/code-fence pass when HTML normalization returned its input byte for byte. HTML processing is the only stage that can introduce a character its input did not contain (entity decoding), so if it is the identity there is nothing new to find, and both filters are fixed points on the first pass's output.

Nothing here weakens filtering or broadens allowed HTML. Content that genuinely needs rewriting still goes through the full pipeline, and non-ASCII text still goes through bluemonday by design.

Equivalence evidence

pkg/sanitize/equivalence_test.go keeps a verbatim copy of the previous pipeline and diffs the new code against it:

  • ~22k deterministic corpus cases — every existing regression input, every rune class the filters branch on across 11 templates, all adjacent interesting-rune pairs, and 20k seeded pseudo-random strings including raw bytes and invalid UTF-8.
  • isHTMLInert checked against the live policy byte by byte (in isolation and in context) and over the whole corpus, plus 50k dense printable-ASCII strings drawn from an HTML-syntax-heavy alphabet.
  • Explicit fixed-point tests for the early return: both filters are idempotent, and the fence filter never reintroduces filterable runes.
  • Allocation contracts asserted with testing.AllocsPerRun, and a check that a list of known payloads still loses something.
  • Two fuzz targets: FuzzSanitizeMatchesReferenceImplementation and FuzzHTMLInertIsPolicyFixedPoint. Ran ~5.7M executions locally with no divergence.

Benchmarks

Committed in pkg/sanitize/bench_test.go and pkg/github/minimal_types_bench_test.go. Intel Ultra 9 185H, -count=6, benchstat:

benchmark before after Δ
Sanitize/TitleASCII 5.35 µs, 5.16 KiB, 14 allocs 114 ns, 0 B, 0 allocs −97.9%
Sanitize/Comment1KiB 45.3 µs, 20.2 KiB, 16 allocs 1.27 µs, 0 B, 0 allocs −97.2%
Sanitize/Body64KiB 2.47 ms, 1.25 MiB, 21 allocs 85.9 µs, 0 B, 0 allocs −96.5%
Sanitize/CodeFenceBody 256 µs, 132 KiB, 107 allocs 83.1 µs, 57.7 KiB, 19 allocs −67.5%
Sanitize/Body64KiBUnicode 2.72 ms, 1.22 MiB, 21 allocs 1.00 ms, 444 KiB, 13 allocs −63.2%
Sanitize/AdversarialUnicode 29.2 µs, 14.2 KiB 13.6 µs, 6.75 KiB −53.3%
Sanitize/AdversarialHTML 180 µs, 63.5 KiB 134 µs, 43.3 KiB −25.9%
SanitizeIssuePage (30 × 2 KiB) 2.73 ms, 1.55 MiB, 929 allocs 82.5 µs, 0 B, 0 allocs −97.0%
SanitizeCommentPage (100 × 1 KiB) 4.62 ms, 2.18 MiB, 1600 allocs 137 µs, 0 B, 0 allocs −97.0%
ConvertToMinimalIssuePage (30 issues) 3.00 ms, 1.55 MiB, 959 allocs 88.6 µs, 1.88 KiB, 30 allocs −97.1%
ConvertToMinimalCommentPage (100 comments) 5.04 ms, 2.19 MiB, 1700 allocs 169 µs, 6.25 KiB, 100 allocs −96.6%

All differences p=0.002 (n=6). The residual converter allocations are the struct and slice fields, not sanitization.

Projected overhead at 1,250 RPS

Per the issue's shapes, sanitizing one response:

shape before after
100 comments × 1 KiB 5.04 ms CPU, 2.19 MiB 0.169 ms CPU, 6.3 KiB
30 issues × 2 KiB 3.00 ms CPU, 1.55 MiB 0.089 ms CPU, 1.9 KiB

At 1,250 RPS:

  • Comment-heavy: 6.3 cores → 0.21 cores, allocation 2.7 GiB/s → 7.6 MiB/s.
  • Issue-heavy: 3.8 cores → 0.11 cores, allocation 1.9 GiB/s → 2.3 MiB/s.

Sanitization stops being a GC-bound cost centre for ordinary content. Non-ASCII bodies still pay for bluemonday (~1.0 ms per 64 KiB, down from 2.7 ms); extending the fast path past strict ASCII is deliberately left out of this change.

Validation

script/lint clean, script/test (race) green, script/generate-docs produces no diff.

Sanitizing user-authored response fields ran multiple allocating passes
over every string regardless of content: FilterInvisibleCharacters
converted the whole input to []rune and back, FilterCodeFenceMetadata
split and rejoined every line, and bluemonday ran unconditionally. On
comment- and issue-heavy responses this dominated conversion CPU and
allocation.

Three changes, none of which alter output or widen what the policy
allows:

- FilterInvisibleCharacters scans first and copies only from the first
  filtered rune, skipping ASCII runs without decoding them. Invalid
  UTF-8 is still re-encoded to U+FFFD, matching the []rune round trip it
  replaces.
- FilterCodeFenceMetadata walks lines in place and returns the input
  when no line changes.
- FilterHTMLTags skips bluemonday for input that is provably a fixed
  point of the policy: printable ASCII, TAB and LF, with none of the
  five characters html.EscapeString rewrites. Sanitize also skips the
  second invisible/code-fence pass when HTML normalization returned its
  input unchanged, since both filters are fixed points there.

Equivalence is pinned by a verbatim copy of the previous pipeline: the
new code is diffed against it over a corpus of ~22k deterministic cases
plus two fuzz targets, and the fast path is checked byte by byte against
the live bluemonday policy.

Benchmarks (Intel Ultra 9 185H, n=6):

  Sanitize/TitleASCII      5.35µs ->  114ns   1 -100% allocs
  Sanitize/Comment1KiB     45.3µs -> 1.27µs   1 -100% allocs
  Sanitize/Body64KiB       2.47ms -> 85.9µs   1 -100% allocs
  30 issues x 2KiB body    3.00ms -> 88.6µs   1.55MiB -> 1.9KiB
  100 comments x 1KiB      5.04ms ->  169µs   2.19MiB -> 6.3KiB

Content that genuinely needs rewriting still pays for it, and non-ASCII
text still goes through bluemonday by design.

Fixes #3117

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings August 19, 2026 14:26
@SamMorrowDrums
SamMorrowDrums requested a review from a team as a code owner August 19, 2026 14:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Optimizes sanitization hot paths while preserving existing filtering behavior.

Changes:

  • Adds allocation-free fast paths for clean text and inert HTML.
  • Avoids redundant sanitizer passes and code-fence reconstruction.
  • Adds equivalence, fuzz, allocation, and performance coverage.
Show a summary per file
File Description
pkg/sanitize/sanitize.go Implements sanitizer fast paths.
pkg/sanitize/equivalence_test.go Verifies behavioral equivalence and invariants.
pkg/sanitize/bench_test.go Benchmarks sanitizer workloads.
pkg/github/minimal_types_bench_test.go Benchmarks representative converters.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Balanced

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.

Optimize sanitizer hot paths for high-throughput response conversion

2 participants