Repository navigation
Conversation
5ea808f to
e854186
Compare
The code is lifted from similar work in the `json` gem.
SIMD use is limited to NEON and SSE2 as they can easily be
detected and assumed at compile time.
Using more advanced SIMD implementations would require runtime
detection, which probably isn't worth it.
```
== 1k no matches ==
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin25]
Warming up --------------------------------------
simd 1.430M i/100ms
Calculating -------------------------------------
simd 14.719M (± 0.5%) i/s (67.94 ns/i) - 74.350M in 5.051379s
Comparison:
master: 2389369.8 i/s
simd: 14718663.6 i/s - 6.16x faster
== 1k few matches ==
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin25]
Warming up --------------------------------------
simd 163.731k i/100ms
Calculating -------------------------------------
simd 1.702M (± 0.6%) i/s (587.67 ns/i) - 8.514M in 5.003388s
Comparison:
master: 1405436.9 i/s
simd: 1701649.4 i/s - 1.21x faster
== 1k many matches ==
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin25]
Warming up --------------------------------------
simd 153.148k i/100ms
Calculating -------------------------------------
simd 1.590M (± 1.2%) i/s (629.01 ns/i) - 7.964M in 5.009249s
Comparison:
master: 1225987.4 i/s
simd: 1589798.4 i/s - 1.30x faster
```
NB: I don't have an x86_64 machine to benchmark SSE2.
|
We've wanted this so long, for about 10 years https://github.com/k0kubun/hescape https://rubykaigi.org/2017/presentations/tanaka_akr.html. It's about time to actually land it. Would you mind being an ERB maintainer going forward? As much as I want that feature in the gem, I don't want to be the sole maintainer of that code. Having somebody from the Rails side is probably useful for its integration with HTML escape code as well. |
It would be a pleasure. |
|
👍 You're now an ERB maintainer. I made you an admin on this GitHub repository, and invited you as an owner on rubygems as well. As a maintainer, I expect you to merge and release your own changes without my approval, which include this PR and #137. Pushing a git tag triggers the gem push workflow. |
|
❤️ |
and update the list of active maintainers. We don't seem to enumerate all past maintainers for other gems in this file. Context: ruby/erb#136 (comment)
(ruby/erb#136) * Speedup ERB::Util.html_escape with SIMD The code is lifted from similar work in the `json` gem. SIMD use is limited to NEON and SSE2 as they can easily be detected and assumed at compile time. Using more advanced SIMD implementations would require runtime detection, which probably isn't worth it. ``` == 1k no matches == ruby 4.0.6 (2026-07-14 revision ruby/erb@03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- simd 1.430M i/100ms Calculating ------------------------------------- simd 14.719M (± 0.5%) i/s (67.94 ns/i) - 74.350M in 5.051379s Comparison: master: 2389369.8 i/s simd: 14718663.6 i/s - 6.16x faster == 1k few matches == ruby 4.0.6 (2026-07-14 revision ruby/erb@03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- simd 163.731k i/100ms Calculating ------------------------------------- simd 1.702M (± 0.6%) i/s (587.67 ns/i) - 8.514M in 5.003388s Comparison: master: 1405436.9 i/s simd: 1701649.4 i/s - 1.21x faster == 1k many matches == ruby 4.0.6 (2026-07-14 revision ruby/erb@03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- simd 153.148k i/100ms Calculating ------------------------------------- simd 1.590M (± 1.2%) i/s (629.01 ns/i) - 7.964M in 5.009249s Comparison: master: 1225987.4 i/s simd: 1589798.4 i/s - 1.30x faster ``` NB: I don't have an x86_64 machine to benchmark SSE2. ruby/erb@d5ddd13686
optimized_escape_html looked at one byte at a time and copied every clean byte into a buffer it allocated up front, even when nothing needed escaping. Port the SSE2/NEON match code that ruby/erb uses for ERB::Util.html_escape (ruby/erb#136 and its follow-ups, originally from the json gem), and build the escaping around it: - a string of 64 bytes or more is searched for the first of ' & < > " (its first 16 bytes, then 64 at a time); if there is none, it is returned as rb_str_dup(str) as before, without allocating a buffer; - otherwise the clean prefix is copied with 16-byte moves (one memcpy when it is 256 bytes or longer), and while 64 bytes or more are left the rest is taken 16 bytes at a time: a block with nothing to escape is copied whole, any other is escaped 4 bytes at a time, and after a block with 5 or more escapes the next one is escaped without being searched first; - the last 63 bytes or fewer, strings of 16 to 63 bytes, and without SIMD everything after the clean prefix are escaped 4 bytes at a time: 4 table lookups, then one move if none of them needs escaping; - a string under 16 bytes goes through the old loop. Strings under 64 bytes are not searched first and are escaped into a stack buffer: when they need escaping, a search costs more than it saves. Without SIMD (CGI_DISABLE_SIMD=1, MSVC, or other CPUs) the search checks 8 bytes per step with the existing table, costs about as much as escaping, and is skipped for strings under 128 bytes. The 4-byte step replaces byte-by-byte loops: gcc and clang each compiled some of their forms into code slower than the old loop. The escaping lives in a NOINLINE helper so that, with _FORTIFY_SOURCE=3, its small fixed-size memcpy calls stay plain moves instead of becoming __memcpy_chk calls. The short-string loop copies escapes byte by byte for the same reason. extconf.rb enables the SIMD path with the same compile check as erb (-DCGI_ENABLE_SIMD). Output, encoding and the "always a new, unfrozen String" behaviour are unchanged. Add tests for every escapable character at every position in strings of length 0 to 80 and around 128 bytes, for every other byte value, for strings where every k-th byte needs escaping, for clean prefixes of various lengths, and for strings that start at unaligned offsets.
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.
The code is lifted from similar work in the
jsongem. SIMD use is limited to NEON and SSE2 as they can easily be detected and assumed at compile time.Using more advanced SIMD implementations would require runtime detection, which probably isn't worth it.
Unsurprisingly, the gains are huge when large portions of the string don't contain anything to escape, but much more modest when it has to frequently stop to copy bytes and escape:
NB: I don't have an x86_64 machine to benchmark SSE2, which also mean I don't expect CI to pass on the first try...