Visitar URL original
Improve performance of String#scrub by skipping ASCII runs with `search_nonascii` by FletcherDares · Pull Request #16359 · ruby/ruby · GitHub
Skip to content

Improve performance of String#scrub by skipping ASCII runs with search_nonascii - #16359

Merged
ioquatix merged 2 commits into
ruby:masterfrom
FletcherDares:master
May 26, 2026
Merged

ioquatix merged 2 commits into
ruby:masterfrom
FletcherDares:master

Conversation

@FletcherDares

@FletcherDares FletcherDares commented Mar 10, 2026 •

Copy link
Copy Markdown
Contributor

Background

I identified that String#scrub and String#scrub! were slower than expected when dealing with very large strings with an unknown encoding. We have a use case where we run scrub on HTML pages and the following code change resulted in ~20x performance improvement.
Before:

string.force_encoding("UTF-8")
string.scrub!

After:

string.force_encoding("UTF-8")
string.scrub! unless string.valid_encoding?

I was curious about what caused the performance difference between String#scrub! and String#valid_encoding? when the string is valid utf-8. I decided to dig into the internal implementation of String#scrub and found the opportunity for a performance improvement, see the following suggestion.

Proposal:

// string.c:11838 (Ruby 4.1.0)
p = search_nonascii(p, e);
if (!p) {
    p = e;
}
// Enter scrub loop.
while (p < e) {
    int ret = rb_enc_precise_mbclen(p, e, enc);
    if (MBCLEN_NEEDMORE_P(ret)) {
        break;
    }
    else if (MBCLEN_CHARFOUND_P(ret)) {
        cr = ENC_CODERANGE_VALID;
        p += MBCLEN_CHARFOUND_LEN(ret);
+       p = search_nonascii(p, e);
+       if (!p) {
+           p = e;
+           break;
+       }
    }
    // MBCLEN_INVALID_P handling unchanged.

This patch does not change any observable behaviour, all tests pass.

Justification:

This adds the pattern used in String#valid_encoding? to String#scrub which allows us to use search_nonascii to skip ascii characters after encountering a multi-byte sequence. This is implemented for String#valid_encoding? in string.c:799 (Ruby 4.1.0).
search_nonascii returns a pointer to the address of the next non-ascii character.

Upsides:

  • Better performance (3-45x) when we have some ascii within the text that we are attempting to scrub. Testing with HTML examples showed 15x-40x speedups depending on the density of multibyte characters.
  • A version of this is implemented right now where it saved 1% average processing time and 1.5% at P90 over many requests.

Tradeoffs:

  • Worse performance (0.7x) if the text is entirely multibyte with no-ascii characters.

Results:

Benchmark Control Experiment Speedup
scrub_known_valid 29.075M 29.708M 1.0x
scrub_known_invalid 247.926k 740.033k 2.9x
scrub_unknown_but_valid_coderange 274.488k 889.488k 3.2x
scrub_unknown_and_invalid_coderange 269.232k 739.194k 2.8x
scrub_unknown_but_valid_coderange_worst_case 234.635k 164.706k 0.7x
scrub_unknown_and_invalid_coderange_worst_case 239.335k 162.561k 0.7x
scrub_japanese_html 187.47 2927.00 15.6x
scrub_english_html 510.22 22694.00 44.5x

Methodology:

  • Apple M4 Pro macOS 26.3
  • Used built in benchmark tooling
  • non-HTML benchmark Strings were all ~1KB
  • Benchmarked Japanese & English HTML pages pulled from real sites. (I didn't include them as they were large ~2MB)

@cloudbees-smart-tests

cloudbees-smart-tests Bot commented Mar 10, 2026 •

Copy link
Copy Markdown

❌ 1/68675 Tests Failed

test/test_timeout.rb#test_ractor
Error:
TestTimeout#test_ractor:
Test::Unit::ProxyError: execution of Test::Unit::CoreAssertions#assert_separately expired timeout (10 sec)
pid 89037 killed by SIGABRT (signal 6) (core dumped)
| 
| -: [BUG] Aborted at 0x000003e90000f062
| ruby 4.1.0dev (2026-03-10T14:52:22Z pull/16359/merge c031fdefb4) +PRISM [powerpc64le-linux]
| 
| -- Control frame information -----------------------------------------------
| c:0001 p:0000 s:0003 E:001438 l:y b:---- DUMMY  [FINISH]
| 
| 
| -- Threading information ---------------------------------------------------
| Total ractor count: 3
| Ruby thread count for this ractor: 1
| 
| -- C level backtrace information -------------------------------------------
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_print_backtrace+0x8) [0x10bc41f40] ../src/vm_dump.c:1105
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_vm_bugreport) ../src/vm_dump.c:1450
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_bug_for_fatal_signal+0xcc) [0x10bd901dc] ../src/error.c:1131
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(sigabrt+0x68) [0x10bb363b8] ../src/signal.c:962
| linux-vdso64.so.1(__kernel_sigtramp_rt64+0x0) [0x7fff97310484]
| [0x7fff96e2d574]
| [0x7fff96e37674]
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_native_mutex_lock+0x18) [0x10bb912c8] ../src/thread_pthread.c:125
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(thread_sched_set_locked+0x0) [0x10bb958e4] ../src/thread_pthread.c:403
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(thread_sched_lock_) ../src/thread_pthread.c:411
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(ubf_event_waiting) ../src/thread_pthread_mn.c:41
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(threadptr_set_interrupt_locked+0x38) [0x10bb9a7c8] ../src/thread.c:368
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(threadptr_set_interrupt) ../src/thread.c:380
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_threadptr_interrupt) ../src/thread.c:390
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_ractor_terminate_interrupt_main_thread+0xd0) [0x10bab0d10] ../src/ractor.c:847
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(ractor_terminal_interrupt_all) ../src/ractor.c:866
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(ractor_terminal_interrupt_all) ../src/ractor.c:858
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_ractor_terminate_all) ../src/ractor.c:887
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_ec_cleanup+0x21c) [0x10b99354c] ../src/eval.c:236
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(rb_main+0x24) [0x10b98cb48] ../src/main.c:42
| /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby(main) ../src/main.c:62
| [0x7fff96da6ca4]
| [0x7fff96da6eec]
| 
| -- Other runtime information -----------------------------------------------
| 
| * Loaded script: -
| 
| * Ruby Box: disabled
| * Loaded features:
| 
|     0 enumerator.so
|     1 monitor.so
|     2 thread.rb
|     3 fiber.so
|     4 rational.so
|     5 complex.so
|     6 pathname.so
|     7 ruby2_keywords.rb
|     8 set.rb
|     9 /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/encdb.so
|    10 /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/trans/transdb.so
|    11 /tmp/_actions-runner-working-dir/ruby/ruby/src/lib/timeout.rb
|    12 /tmp/_actions-runner-working-dir/ruby/ruby/src/lib/open3/version.rb
|    13 /tmp/_actions-runner-working-dir/ruby/ruby/src/lib/open3.rb
|    14 /tmp/_actions-runner-working-dir/ruby/ruby/build/rbconfig.rb
|    15 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/find_executable.rb
|    16 /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/rbconfig/sizeof.so
|    17 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/envutil.rb
|    18 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/colorize.rb
|    19 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/leakchecker.rb
|    20 /tmp/_actions-runner-working-dir/ruby/ruby/src/lib/prettyprint.rb
|    21 /tmp/_actions-runner-working-dir/ruby/ruby/src/lib/pp.rb
|    22 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/test/unit/assertions.rb
|    23 /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/-test-/sanitizers.so
|    24 /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/io/console.so
|    25 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/core_assertions.rb
|    26 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/test/unit/testcase.rb
|    27 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/test/jobserver.rb
|    28 /tmp/_actions-runner-working-dir/ruby/ruby/src/lib/optparse.rb
|    29 /tmp/_actions-runner-working-dir/ruby/ruby/src/tool/lib/test/unit.rb
| 
| * Process memory map:
| 
| 10b950000-10c010000 r-xp 00000000 fd:08 3969388                          /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby
| 10c010000-10c020000 r--p 006c0000 fd:08 3969388                          /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby
| 10c020000-10c030000 rw-p 006d0000 fd:08 3969388                          /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby
| 10c030000-10c040000 rw-p 00000000 00:00 0 
| 10004680000-10004aa0000 rw-p 00000000 00:00 0                            [heap]
| 7fff4c000000-7fff4c030000 rw-p 00000000 00:00 0 
| 7fff4c030000-7fff50000000 ---p 00000000 00:00 0 
| 7fff51000000-7fff532e0000 r--s 00000000 fd:08 3969388                    /tmp/_actions-runner-working-dir/ruby/ruby/build/ruby
| 7fff54000000-7fff54030000 rw-p 00000000 00:00 0 
| 7fff54030000-7fff58000000 ---p 00000000 00:00 0 
| 7fff5a000000-7fff5a110000 rw-p 00000000 00:00 0 
| 7fff5a110000-7fff5a120000 ---p 00000000 00:00 0 
| 7fff5a120000-7fff5a140000 rw-p 00000000 00:00 0 
| 7fff5a140000-7fff5a240000 rw-p 00000000 00:00 0 
| 7fff5a240000-7fff5a250000 ---p 00000000 00:00 0 
| 7fff5a250000-7fff5a270000 rw-p 00000000 00:00 0 
| 7fff5a270000-7fff5a370000 rw-p 00000000 00:00 0 
| 7fff5a370000-7fff5a380000 ---p 00000000 00:00 0 
| 7fff5a380000-7fff5a3a0000 rw-p 00000000 00:00 0 
| 7fff5a3a0000-7fff5a4a0000 rw-p 00000000 00:00 0 
| 7fff5a4a0000-7fff5a4b0000 ---p 00000000 00:00 0 
| 7fff5a4b0000-7fff5a4d0000 rw-p 00000000 00:00 0 
| 7fff5a4d0000-7fff7a000000 ---p 00000000 00:00 0 
| 7fff7a000000-7fff7a010000 ---p 00000000 00:00 0 
| 7fff7a010000-7fff7a080000 rw-p 00000000 00:00 0 
| 7fff7a080000-7fff7a090000 ---p 00000000 00:00 0 
| 7fff7a090000-7fff7a100000 rw-p 00000000 00:00 0 
| 7fff7a100000-7fff7a110000 ---p 00000000 00:00 0 
| 7fff7a110000-7fff7a180000 rw-p 00000000 00:00 0 
| 7fff7a180000-7fff7a190000 ---p 00000000 00:00 0 
| 7fff7a190000-7fff7a200000 rw-p 00000000 00:00 0 
| 7fff7a200000-7fff7a210000 ---p 00000000 00:00 0 
| 7fff7a210000-7fff7a280000 rw-p 00000000 00:00 0 
| 7fff7a280000-7fff7a290000 ---p 00000000 00:00 0 
| 7fff7a290000-7fff7a300000 rw-p 00000000 00:00 0 
| 7fff7a300000-7fff7a310000 ---p 00000000 00:00 0 
| 7fff7a310000-7fff7a380000 rw-p 00000000 00:00 0 
| 7fff7a380000-7fff7a390000 ---p 00000000 00:00 0 
| 7fff7a390000-7fff7a400000 rw-p 00000000 00:00 0 
| 7fff7a400000-7fff7a410000 ---p 00000000 00:00 0 
| 7fff7a410000-7fff7a480000 rw-p 00000000 00:00 0 
| 7fff7a480000-7fff7a490000 ---p 00000000 00:00 0 
| 7fff7a490000-7fff7a500000 rw-p 00000000 00:00 0 
| 7fff7a500000-7fff7a510000 ---p 00000000 00:00 0 
| 7fff7a510000-7fff7a580000 rw-p 00000000 00:00 0 
| 7fff7a580000-7fff7a590000 ---p 00000000 00:00 0 
| 7fff7a590000-7fff7a600000 rw-p 00000000 00:00 0 
| 7fff7a600000-7fff7a610000 ---p 00000000 00:00 0 
| 7fff7a610000-7fff7a680000 rw-p 00000000 00:00 0 
| 7fff7a680000-7fff7a690000 ---p 00000000 00:00 0 
| 7fff7a690000-7fff7a700000 rw-p 00000000 00:00 0 
| 7fff7a700000-7fff7a710000 ---p 00000000 00:00 0 
| 7fff7a710000-7fff7a780000 rw-p 00000000 00:00 0 
| 7fff7a780000-7fff7a790000 ---p 00000000 00:00 0 
| 7fff7a790000-7fff7a800000 rw-p 00000000 00:00 0 
| 7fff7a800000-7fff7a810000 ---p 00000000 00:00 0 
| 7fff7a810000-7fff7a880000 rw-p 00000000 00:00 0 
| 7fff7a880000-7fff7a890000 ---p 00000000 00:00 0 
| 7fff7a890000-7fff7a900000 rw-p 00000000 00:00 0 
| 7fff7a900000-7fff7a910000 ---p 00000000 00:00 0 
| 7fff7a910000-7fff7a980000 rw-p 00000000 00:00 0 
| 7fff7a980000-7fff7a990000 ---p 00000000 00:00 0 
| 7fff7a990000-7fff7aa00000 rw-p 00000000 00:00 0 
| 7fff7aa00000-7fff7aa10000 ---p 00000000 00:00 0 
| 7fff7aa10000-7fff7aa80000 rw-p 00000000 00:00 0 
| 7fff7aa80000-7fff7aa90000 ---p 00000000 00:00 0 
| 7fff7aa90000-7fff7ab00000 rw-p 00000000 00:00 0 
| 7fff7ab00000-7fff7ab10000 ---p 00000000 00:00 0 
| 7fff7ab10000-7fff7ab80000 rw-p 00000000 00:00 0 
| 7fff7ab80000-7fff7ab90000 ---p 00000000 00:00 0 
| 7fff7ab90000-7fff7ac00000 rw-p 00000000 00:00 0 
| 7fff7ac00000-7fff7ac10000 ---p 00000000 00:00 0 
| 7fff7ac10000-7fff7ac80000 rw-p 00000000 00:00 0 
| 7fff7ac80000-7fff7ac90000 ---p 00000000 00:00 0 
| 7fff7ac90000-7fff7ad00000 rw-p 00000000 00:00 0 
| 7fff7ad00000-7fff7ad10000 ---p 00000000 00:00 0 
| 7fff7ad10000-7fff7ad80000 rw-p 00000000 00:00 0 
| 7fff7ad80000-7fff7ad90000 ---p 00000000 00:00 0 
| 7fff7ad90000-7fff7ae00000 rw-p 00000000 00:00 0 
| 7fff7ae00000-7fff7ae10000 ---p 00000000 00:00 0 
| 7fff7ae10000-7fff7ae80000 rw-p 00000000 00:00 0 
| 7fff7ae80000-7fff7ae90000 ---p 00000000 00:00 0 
| 7fff7ae90000-7fff7af00000 rw-p 00000000 00:00 0 
| 7fff7af00000-7fff7af10000 ---p 00000000 00:00 0 
| 7fff7af10000-7fff7af80000 rw-p 00000000 00:00 0 
| 7fff7af80000-7fff7af90000 ---p 00000000 00:00 0 
| 7fff7af90000-7fff7b000000 rw-p 00000000 00:00 0 
| 7fff7bff0000-7fff7c000000 ---p 00000000 00:00 0 
| 7fff7c000000-7fff7d000000 rw-p 00000000 00:00 0 
| 7fff7d000000-7fff95000000 rw-p 00000000 00:00 0 
| 7fff95450000-7fff95470000 r-xp 00000000 fd:08 3431407                    /usr/lib/powerpc64le-linux-gnu/libgcc_s.so.1
| 7fff95470000-7fff95480000 r--p 00010000 fd:08 3431407                    /usr/lib/powerpc64le-linux-gnu/libgcc_s.so.1
| 7fff95480000-7fff95490000 rw-p 00020000 fd:08 3431407                    /usr/lib/powerpc64le-linux-gnu/libgcc_s.so.1
| 7fff95490000-7fff954c0000 rw-p 00000000 00:00 0 
| 7fff954c0000-7fff954d0000 ---p 00000000 00:00 0 
| 7fff954d0000-7fff954f0000 rw-p 00000000 00:00 0 
| 7fff954f0000-7fff95520000 rw-p 00000000 00:00 0 
| 7fff95520000-7fff95530000 ---p 00000000 00:00 0 
| 7fff95530000-7fff95550000 rw-p 00000000 00:00 0 
| 7fff95550000-7fff955d0000 rw-p 00000000 00:00 0 
| 7fff955e0000-7fff955f0000 rw-p 00000000 00:00 0 
| 7fff955f0000-7fff95600000 r-xp 00000000 fd:08 4072797                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/io/console.so
| 7fff95600000-7fff95610000 r--p 00000000 fd:08 4072797                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/io/console.so
| 7fff95610000-7fff95620000 rw-p 00010000 fd:08 4072797                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/io/console.so
| 7fff95620000-7fff95630000 r-xp 00000000 fd:08 4072666                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/-test-/sanitizers.so
| 7fff95630000-7fff95640000 r--p 00000000 fd:08 4072666                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/-test-/sanitizers.so
| 7fff95640000-7fff95650000 rw-p 00010000 fd:08 4072666                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/-test-/sanitizers.so
| 7fff95650000-7fff95680000 rw-p 00000000 00:00 0 
| 7fff95680000-7fff95690000 r-xp 00000000 fd:08 4072869                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/rbconfig/sizeof.so
| 7fff95690000-7fff956a0000 r--p 00000000 fd:08 4072869                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/rbconfig/sizeof.so
| 7fff956a0000-7fff956b0000 rw-p 00010000 fd:08 4072869                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/rbconfig/sizeof.so
| 7fff956b0000-7fff95750000 rw-p 00000000 00:00 0 
| 7fff95750000-7fff95760000 r-xp 00000000 fd:08 4072373                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/trans/transdb.so
| 7fff95760000-7fff95770000 r--p 00000000 fd:08 4072373                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/trans/transdb.so
| 7fff95770000-7fff95780000 rw-p 00010000 fd:08 4072373                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/trans/transdb.so
| 7fff95780000-7fff957b0000 rw-p 00000000 00:00 0 
| 7fff957b0000-7fff957c0000 r-xp 00000000 fd:08 4072309                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/encdb.so
| 7fff957c0000-7fff957d0000 r--p 00000000 fd:08 4072309                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/encdb.so
| 7fff957d0000-7fff957e0000 rw-p 00010000 fd:08 4072309                    /tmp/_actions-runner-working-dir/ruby/ruby/build/.ext/powerpc64le-linux/enc/encdb.so
| 7fff957e0000-7fff96d80000 rw-p 00000000 00:00 0 
| 7fff96d80000-7fff96fe0000 r-xp 00000000 fd:08 3431249                    /usr/lib/powerpc64le-linux-gnu/libc.so.6
| 7fff96fe0000-7fff96ff0000 r--p 00260000 fd:08 3431249                    /usr/lib/powerpc64le-linux-gnu/libc.so.6
| 7fff96ff0000-7fff97000000 rw-p 00270000 fd:08 3431249                    /usr/lib/powerpc64le-linux-gnu/libc.so.6
| 7fff97000000-7fff97130000 r-xp 00000000 fd:08 3431623                    /usr/lib/powerpc64le-linux-gnu/libm.so.6
| 7fff97130000-7fff97140000 r--p 00130000 fd:08 3431623                    /usr/lib/powerpc64le-linux-gnu/libm.so.6
| 7fff97140000-7fff97150000 rw-p 00140000 fd:08 3431623                    /usr/lib/powerpc64le-linux-gnu/libm.so.6
| 7fff97150000-7fff97190000 r-xp 00000000 fd:08 3431279                    /usr/lib/powerpc64le-linux-gnu/libcrypt.so.1.1.0
| 7fff97190000-7fff971a0000 r--p 00030000 fd:08 3431279                    /usr/lib/powerpc64le-linux-gnu/libcrypt.so.1.1.0
| 7fff971a0000-7fff971b0000 rw-p 00040000 fd:08 3431279                    /usr/lib/powerpc64le-linux-gnu/libcrypt.so.1.1.0
| 7fff971b0000-7fff97250000 r-xp 00000000 fd:08 3431433                    /usr/lib/powerpc64le-linux-gnu/libgmp.so.10.5.0
| 7fff97250000-7fff97260000 r--p 00090000 fd:08 3431433                    /usr/lib/powerpc64le-linux-gnu/libgmp.so.10.5.0
| 7fff97260000-7fff97270000 rw-p 000a0000 fd:08 3431433                    /usr/lib/powerpc64le-linux-gnu/libgmp.so.10.5.0
| 7fff97270000-7fff972a0000 r-xp 00000000 fd:08 3432053                    /usr/lib/powerpc64le-linux-gnu/libz.so.1.3
| 7fff972a0000-7fff972b0000 r--p 00020000 fd:08 3432053                    /usr/lib/powerpc64le-linux-gnu/libz.so.1.3
| 7fff972b0000-7fff972c0000 rw-p 00030000 fd:08 3432053                    /usr/lib/powerpc64le-linux-gnu/libz.so.1.3
| 7fff972d0000-7fff97310000 r--p 00000000 00:00 0                          [vvar]
| 7fff97310000-7fff97320000 r-xp 00000000 00:00 0                          [vdso]
| 7fff97320000-7fff97370000 r-xp 00000000 fd:08 3430920                    /usr/lib/powerpc64le-linux-gnu/ld64.so.2
| 7fff97370000-7fff97380000 r--p 00040000 fd:08 3430920                    /usr/lib/powerpc64le-linux-gnu/ld64.so.2
| 7fff97380000-7fff97390000 rw-p 00050000 fd:08 3430920                    /usr/lib/powerpc64le-linux-gnu/ld64.so.2
| 7fffdbf20000-7fffdbf50000 rw-p 00000000 00:00 0                          [stack]
| 
| 

    /tmp/_actions-runner-working-dir/ruby/ruby/src/test/test_timeout.rb:409:in 'TestTimeout#test_ractor'

[-> View Test suite health in main branch]

@ioquatix
ioquatix requested review from duerst and ima1zumi March 10, 2026 21:14
@duerst

duerst commented Mar 13, 2026

Copy link
Copy Markdown
Member

I have looked at the request. I'm not exactly sure what is meant by "unknown"; it seems it stands for "unknown encoding, assuming UTF-8", which should be stated explicitly.
'Speedup' makes this look very impressive, but in terms of actual time gained or lost, the inverse is more helpful: If an operation takes e.g. 1s, the maximum that can be saved is 1s, but much more can be lost (e.g. 9s if the operation suddenly takes 10s).
It should be possible to additionally speed up the case of valid UTF-8 (strings or ranges) rather than just ascii if that's desirable. But that would be a bit more code.

@FletcherDares

FletcherDares commented Mar 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @duerst, thank you so much for the review! Samuel spoke very highly of your Unicode knowledge.

I'm not exactly sure what is meant by "unknown"

"unknown" does mean "unknown encoding assuming UTF-8". In the example I provided this is the purpose of string.force_encoding("UTF-8") we have a string that we are now saying should be UTF-8 but have not validated whether that is true yet. This is the core difference between the first two benchmarks and the rest, in these we have cached whether it valid or not. This is why the first benchmark's performance is the same across both implementations (1.0x) as we use this cache to early exit on this line.

the maximum that can be saved is 1s, but much more can be lost

My understanding of this comes from the following example, given a string "abcdéfg" it is faster to perform String#valid_encoding? than it is to perform String#scrub. It turns out that this is because in the String#valid_encoding? implementation the flow is:

  1. Skip all of the ASCII characters at the start of the string with search_nonascii.
  2. Process the multi-byte in our case 'é'
  3. Skip all the ASCII characters until we hit another multi-byte. (line where this happens)
  4. Jump to 2 unless at the end of the string.

In String#scrub we do the following:

  1. Skip all of the ASCII characters at the start of the string with search_nonascii.
  2. Process (scrub) the multi-byte in our case 'é'
  3. Jump to 2 unless at the end of the string.

It turns out that processing the multi-bytes is slow as compared to the search_nonascii function. My suggested code change here is to add the same ASCII skip to String#scrub that is used in step 3 of the String#valid_encoding? implementation. If my understanding of this is correct, the pathological case would be strings of full multi-byte characters. This is what the two worst_case benchmarks show where we see worse performance than the original implementation of 0.7x across a 1KB string. This is from the extra search_nonascii function call per multi-byte which functions as a no-op.

It should be possible to additionally speed up the case of valid UTF-8 (strings or ranges) rather than just ascii if that's desirable

I'm really curious about any ideas you have here! I'm also happy to answer any questions.

@duerst

duerst commented Mar 23, 2026

Copy link
Copy Markdown
Member

@FletcherDares:

I wrote:

the maximum that can be saved is 1s, but much more can be lost

to which you explained again why things get faster (which I already understand). What I tried to suggest is that you show the inverse of the speedup (call it slowdown or whatever) in your table. That would e.g. be 1.42 for scrub_unknown_but_valid_coderange_worst_case and 0.022 for scrub_english_html. This makes it much easier to compare the different numbers.

It should be possible to additionally speed up the case of valid UTF-8 (strings or ranges) rather than just ascii if that's desirable. But that would be a bit more code.
SIMD processing can go a long way. For an example (not validating, but transcoding) see https://dl.acm.org/doi/abs/10.1145/1345206.1345222. With 64-bit words, 8 bytes can be handled at the same time, even without SIMD instructions. An example of this is the function count_utf8_lead_bytes_with_word in string.c, which is used in the function enc_strlen, but assumes valid UTF-8.

@FletcherDares

FletcherDares commented May 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @duerst, sorry for the delay!

Here's an updated table that includes slowdown figures as well:

Benchmark Control Experiment Speedup Slowdown
scrub_known_valid 29.075M 29.708M 1.0x 1.0x
scrub_known_invalid 247.926k 740.033k 2.9x 0.34x
scrub_unknown_but_valid_coderange 274.488k 889.488k 3.2x 0.31x
scrub_unknown_and_invalid_coderange 269.232k 739.194k 2.8x 0.36x
scrub_unknown_but_valid_coderange_worst_case 234.635k 164.706k 0.7x 1.42x
scrub_unknown_and_invalid_coderange_worst_case 239.335k 162.561k 0.7x 1.47x
scrub_japanese_html 187.47 2927.00 15.6x 0.064x
scrub_english_html 510.22 22694.00 44.5x 0.022x

The primary goal of this PR is to unify the approach between String#valid_encoding? and String#scrub by skipping ASCII characters (the performance improvement is a nice side effect of that change).

Given that scope, do you think it's worth pursuing SIMD or other further performance optimizations as part of this PR, or would you be okay with investigating / tackling those in a follow-up (i.e. is this blocking in your opinion)?

Improve performance of String#scrub by skipping ascii characters after finding a multi byte character using search_nonascii. Create benchmark for String#scrub to show performance difference made by including the new ascii skips.
@FletcherDares

FletcherDares commented May 22, 2026 •

Copy link
Copy Markdown
Contributor Author

I realized that if I gate the search_nonascii call behind a check to see if the first character is ASCII we can avoid the overhead from search_nonascii with a simple bitmask. This turns out to have done wonders for the performance as can be seen below:

The basis is that most ASCII characters will follow other ASCII character and most multi-byte will follow other multi-bytes.

Benchmark Base New w/ Guard Speedup Slowdown
scrub_known_valid 15.582M 15.794M 1.01x 0.99x
scrub_known_invalid 250.163k 730.166k 2.92x 0.34x
scrub_unknown_but_valid_coderange 272.724k 965.076k 3.54x 0.28x
scrub_unknown_and_invalid_coderange 248.361k 716.855k 2.89x 0.35x
scrub_unknown_but_valid_coderange_worst_case 250.724k 245.481k 0.98x 1.02x
scrub_unknown_and_invalid_coderange_worst_case 227.917k 226.813k 1.00x 1.00x
scrub_english_html 0.373k 16.991k 45.55x 0.02x
scrub_japanese_html 0.031k 0.704k 22.71x 0.04x

@ioquatix ioquatix left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. @nobu can you also review? Thanks :)

@ioquatix
ioquatix requested a review from nobu May 22, 2026 05:31

@ima1zumi ima1zumi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@ioquatix
ioquatix merged commit effac0a into ruby:master May 26, 2026
114 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.

4 participants