Skip to content

Speed up CGI.escapeHTML with an SSE2/NEON search - #143

Open
ptweezy wants to merge 1 commit into
ruby:masterfrom
ptweezy:simd-escape-html
Open

ptweezy wants to merge 1 commit into
ruby:masterfrom
ptweezy:simd-escape-html

Conversation

@ptweezy

@ptweezy ptweezy commented Nov 3, 2026 •

Copy link
Copy Markdown

CGI.escapeHTML checks one byte at a time and always allocates a buffer six times the input's size, even when nothing needs escaping. This ports the SSE2/NEON search from ruby/erb's ERB::Util.html_escape (ruby/erb#136 by Jean Boussier and the NEON follow-ups by Scott Myron, originally from the json gem) and builds the escaping around it:

  • Strings of 64 bytes or more are searched for the first ', &, <, > or ". A string with none is returned as a copy, with no buffer allocated.
  • Otherwise the clean prefix is copied at once. While 64 or more bytes remain, clean 16-byte blocks are copied whole and other blocks are escaped 4 bytes at a time. After a block with 5 or more escapes, the next block is escaped without a search.
  • The rest is escaped 4 bytes at a time: 4 table lookups, then one move if all 4 are clean. This replaces byte-by-byte loops, some of which gcc and clang each compiled into code up to 30% slower than the current loop.
  • Strings under 64 bytes skip the search, because it costs more than it saves when they need escaping. Strings under 16 bytes use the current loop.

Without SIMD (CGI_DISABLE_SIMD=1, MSVC, other CPUs), the search checks 8 bytes per step and is skipped under 128 bytes. extconf.rb uses erb's compile check.

Benchmarks

I timed 52 cases: ruby/ruby's benchmark/cgi_escape_html.yml, clean strings from 16 bytes to 8 KB, strings with a < every 8 to 128 bytes, dense and random text, markup snippets such as <a href="...?x=1&y=2">Link</a>, and strings of 0 to 31 bytes. Each run also timed a second build of the current code to measure noise. Numbers are speed relative to the current code, best of 5 runs (best of 25 under 32 bytes, and best of 3 interleaved rounds of 3 on GitHub's runners).

On each machine, the slowest case is faster than the current code or within noise:

SIMD build CGI_DISABLE_SIMD=1 Second build of current code
AMD Ryzen 7 9800X3D (Zen 5), gcc 13, _FORTIFY_SOURCE=2 1.002x 1.005x 0.95x to 1.11x
same, gcc 13 with Ubuntu's default _FORTIFY_SOURCE=3 0.984x¹ 1.012x 0.96x to 1.07x
same, clang 18 0.970x² 0.979x² 0.95x to 1.05x
AMD EPYC (GitHub ubuntu-24.04), gcc 13 1.022x 1.021x 0.97x to 1.05x
same, clang 18 0.985x³ 1.031x 0.97x to 1.06x
Neoverse-N2 (GitHub ubuntu-24.04-arm), gcc 13 1.083x 1.110x 0.99x to 1.01x
same, clang 18 1.058x 1.082x 0.98x to 1.01x

¹ "", in a table where the second build of the current code also read 0.982x on that row. Other runs give 1.02x to 1.03x.
² "abcde", in a table where both builds of the current code ran about 5% faster than in any other run. At best of 25 it is 1.03x and 1.02x.
³ clean_31. The same machine code was 1.033x or faster on every case in the previous run on that runner.

GitHub had no macOS arm64 runner available for the final version. An earlier version with the same NEON code passed the tests on Apple M1 and was 1.08x or faster in every case.

Selected cases on the Ryzen:

gcc, SIMD clang, SIMD gcc, CGI_DISABLE_SIMD=1 clang, CGI_DISABLE_SIMD=1
long_no_escape (1500 bytes, clean) 5.57x 8.33x 2.06x 2.46x
long_all_escape (50 bytes, all special) 1.22x 2.10x 1.24x 2.08x
example_html 1.46x 1.65x 1.24x 1.42x
"abcde" 1.10x 1.03x 1.03x 1.02x
8 KB, clean 6.31x 10.4x 1.92x 2.38x
1 KB, one < per 128 bytes 1.87x 1.93x 1.19x 1.27x
1 KB, one < per 32 bytes 1.49x 1.71x 1.20x 1.46x
64 bytes, one < per 8 bytes 1.05x 1.22x 1.07x 1.25x
24 bytes, a quarter special 1.03x 1.14x 1.02x 1.16x
80 bytes of <b>bold</b> 1.02x 1.53x 1.07x 1.62x
<a href="/path/to/page?x=1&y=2">Link</a> 1.07x 1.20x 1.06x 1.22x
1 KB, half the bytes special 1.20x 2.41x 1.15x 2.13x
1 KB, all special 1.32x 2.35x 1.21x 2.39x
1 KB, random printable ASCII 1.29x 1.52x 1.15x 1.35x
1 KB, random with a quarter special 1.03x 1.67x 1.15x 1.69x
UTF-8 text with markup 1.23x 1.54x 1.17x 1.46x

"gcc" means -D_FORTIFY_SOURCE=2. With Ubuntu's default _FORTIFY_SOURCE=3, the current loop's memcpy becomes a __memcpy_chk call, and the first three rows rise to 27.3x, 1.95x and 4.22x.

Testing

  • New tests cover each escapable character at every position for lengths 0 to 80 and 127 to 129, every other byte value, strings where every k-th byte needs escaping, clean prefixes of 15 to 1000 bytes, and unaligned starts.
  • rake test passes for the SIMD and CGI_DISABLE_SIMD=1 builds on x86-64 and arm64 Linux, with gcc and clang.
  • A differential check against the current extension gives identical results, encodings and frozen state for both builds. It makes about 170k calls, covering every byte value at every position, random strings, 11 encodings, and frozen and invalid strings.
  • A standalone harness of the SSE2, NEON and scalar paths, with exact-size heap buffers, passes ASan and UBSan with gcc and clang on x86-64 and Neoverse-N2.

This covers what #85 does (no buffer for clean strings, clean runs copied at once), so the two conflict. I can rebase onto #85 if you'd rather merge it first, or close this if you prefer the smaller change.

A follow-up could add a CGI_DISABLE_SIMD=1 CI entry, like ruby/erb#140, to keep both paths tested.

CGI.escapeHTML checked one byte at a time and always allocated a
buffer six times the input's size, even when nothing needed escaping.
ERB::Util.html_escape does the same job and got a SIMD search in
ruby/erb#136.

Port erb's SSE2/NEON match code (originally from the json gem).
Strings of 64 bytes or more are searched for the first ' & < > ", and
a string with none is returned by rb_str_dup with no buffer.
Otherwise the clean prefix is copied at once, clean 16-byte blocks
are copied whole, and the other bytes are escaped 4 at a time.

Strings under 64 bytes skip the search, since it costs more than it
saves when they need escaping.  The 4-byte step replaces a byte loop
because gcc and clang each compiled some forms of that loop into code
slower than the old one.  escape_html_into is NOINLINE so that with
_FORTIFY_SOURCE=3 its small memcpy calls stay plain moves instead of
becoming __memcpy_chk calls.

Without SIMD (CGI_DISABLE_SIMD, MSVC, other CPUs) the search checks 8
bytes per step and is skipped under 128 bytes.  extconf.rb uses erb's
compile check to define CGI_ENABLE_SIMD.  Output, encoding and the
"always a new, unfrozen String" behaviour are unchanged.

This branch has not been deployed

No deployments
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.

1 participant