Skip to content

Avoid quadratic suffix allocations and per-rune overhead in unescape - #1603

Open
dmitriplotnikov wants to merge 3 commits into
cel-expr:masterfrom
dmitriplotnikov:unescape-suffix-alloc
Open

dmitriplotnikov wants to merge 3 commits into
cel-expr:masterfrom
dmitriplotnikov:unescape-suffix-alloc

Conversation

@dmitriplotnikov

@dmitriplotnikov dmitriplotnikov commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator
  • Replace strings.NewReplacer newline normalization with normalizeNewlines, returning immediately via strings.IndexByte(value, '\r') when \r is absent and normalizing \r\n / \r in a single strings.Builder pass without genericReplacer.lookup suffix comparisons.
  • Fast-path non-backslash spans between escape sequences in unescape using strings.IndexByte(value, '\\') and for _, r := range span, and bound utf8.DecodeRuneInString input in unescapeChar to utf8.UTFMax bytes to prevent O(N^2) heap allocations under -d=libfuzzer.
  • Add unit tests and BenchmarkUnescape.

Benchmarks (go test -bench=BenchmarkUnescape -benchmem)

goos: linux · goarch: amd64 · pkg: cel.dev/cel-go/parser · cpu: Intel(R) Xeon(R) CPU @ 2.20GHz

Benchmark before.txt (sec/op) after.txt (sec/op) vs base
Unescape/NoEscapes_4KB-8 12871.0n ± 11% 271.7n ± 14% -97.89% (p=0.002 n=6)
Unescape/CRLF_Multiline_6KB-8 57.45µ ± 3% 18.00µ ± 31% -68.67% (p=0.002 n=6)
Unescape/EscapedASCII_100KB-8 936.4µ ± 6% 316.4µ ± 10% -66.22% (p=0.002 n=6)
Unescape/EscapedUTF8_100KRunes-8 2.284m ± 6% 1.052m ± 2% -53.95% (p=0.002 n=6)
geomean 199.4µ 35.72µ -82.09%
Benchmark before.txt (B/op) after.txt (B/op) vs base
Unescape/NoEscapes_4KB-8 9.523Ki ± 0% 0.000Ki ± 0% -100.00% (p=0.002 n=6)
Unescape/CRLF_Multiline_6KB-8 16.52Ki ± 0% 11.25Ki ± 0% -31.91% (p=0.002 n=6)
Unescape/EscapedASCII_100KB-8 464.0Ki ± 0% 256.0Ki ± 0% -44.83% (p=0.002 n=6)
Unescape/EscapedUTF8_100KRunes-8 896.0Ki ± 0% 496.0Ki ± 0% -44.64% (p=0.002 n=6)
geomean 89.94Ki ? 1 ? 2

1 summaries must be >0 to compute geomean · 2 ratios must be >0 to compute geomean

Benchmark before.txt (allocs/op) after.txt (allocs/op) vs base
Unescape/NoEscapes_4KB-8 3.000 ± 0% 0.000 ± 0% -100.00% (p=0.002 n=6)
Unescape/CRLF_Multiline_6KB-8 4.000 ± 0% 2.000 ± 0% -50.00% (p=0.002 n=6)
Unescape/EscapedASCII_100KB-8 5.000 ± 0% 2.000 ± 0% -60.00% (p=0.002 n=6)
Unescape/EscapedUTF8_100KRunes-8 5.000 ± 0% 2.000 ± 0% -60.00% (p=0.002 n=6)
geomean 4.162 ? 1 ? 2

1 summaries must be >0 to compute geomean · 2 ratios must be >0 to compute geomean

- Replace `strings.NewReplacer` newline normalization with `normalizeNewlines`, returning immediately via `strings.IndexByte(value, '\r')` when `\r` is absent and normalizing `\r\n` / `\r` in a single `strings.Builder` pass without `genericReplacer.lookup` suffix comparisons.
- Fast-path non-backslash spans between escape sequences in `unescape` using `strings.IndexByte(value, '\\')` and `for _, r := range span`, and bound `utf8.DecodeRuneInString` input in `unescapeChar` to `utf8.UTFMax` bytes to prevent `O(N^2)` heap allocations under `-d=libfuzzer`.
- Add unit tests and `BenchmarkUnescape`.

Benchmarks (`go test -bench=BenchmarkUnescape -benchmem`):

goos: linux
goarch: amd64
pkg: cel.dev/cel-go/parser
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
                                 │   before.txt   │              after.txt              │
                                 │     sec/op     │    sec/op     vs base               │
Unescape/NoEscapes_4KB-8           12871.0n ± 11%   271.7n ± 14%  -97.89% (p=0.002 n=6)
Unescape/CRLF_Multiline_6KB-8        57.45µ ±  3%   18.00µ ± 31%  -68.67% (p=0.002 n=6)
Unescape/EscapedASCII_100KB-8        936.4µ ±  6%   316.4µ ± 10%  -66.22% (p=0.002 n=6)
Unescape/EscapedUTF8_100KRunes-8     2.284m ±  6%   1.052m ±  2%  -53.95% (p=0.002 n=6)
geomean                              199.4µ         35.72µ        -82.09%

                                 │  before.txt  │                after.txt                 │
                                 │     B/op     │     B/op      vs base                    │
Unescape/NoEscapes_4KB-8           9.523Ki ± 0%   0.000Ki ± 0%  -100.00% (p=0.002 n=6)
Unescape/CRLF_Multiline_6KB-8      16.52Ki ± 0%   11.25Ki ± 0%   -31.91% (p=0.002 n=6)
Unescape/EscapedASCII_100KB-8      464.0Ki ± 0%   256.0Ki ± 0%   -44.83% (p=0.002 n=6)
Unescape/EscapedUTF8_100KRunes-8   896.0Ki ± 0%   496.0Ki ± 0%   -44.64% (p=0.002 n=6)
geomean                            89.94Ki                      ?                      ¹ ²
¹ summaries must be >0 to compute geomean
² ratios must be >0 to compute geomean

                                 │ before.txt │               after.txt                │
                                 │ allocs/op  │ allocs/op   vs base                    │
Unescape/NoEscapes_4KB-8           3.000 ± 0%   0.000 ± 0%  -100.00% (p=0.002 n=6)
Unescape/CRLF_Multiline_6KB-8      4.000 ± 0%   2.000 ± 0%   -50.00% (p=0.002 n=6)
Unescape/EscapedASCII_100KB-8      5.000 ± 0%   2.000 ± 0%   -60.00% (p=0.002 n=6)
Unescape/EscapedUTF8_100KRunes-8   5.000 ± 0%   2.000 ± 0%   -60.00% (p=0.002 n=6)
geomean                            4.162                    ?                      ¹ ²
¹ summaries must be >0 to compute geomean
² ratios must be >0 to compute geomean
Comment thread parser/unescape.go Resolved
…slash

Replace the unreachable non-backslash switch in unescapeChar with an assertion that s begins with '\', since unescape consumes all non-backslash spans via strings.IndexByte before calling unescapeChar. Update the unescapeChar doc comment to document the leading-backslash precondition.
Comment thread parser/unescape.go Outdated
// consumed directly by the fast path in unescape via strings.IndexByte,
// so unescapeChar is never called unless s[0] == '\\'.
if len(s) == 0 || s[0] != '\\' {
panic("unescapeChar: expected '\\' at start of escape sequence")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Prefer to error rather than panic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sure, I shoulda noticed this.

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