Ruby and SIMD in September 2026: Where It Is, and What Japanese Text Pays

A measurement note. Ruby has SIMD in its JSON extension and its parser, but not in the encoding scan that every string from a file or socket goes through. On this machine, reading 4 MiB of Japanese text costs 15 ms of scanning; the same file in ASCII costs nothing. Here is how that was verified and what it would take to fix.

rubysimdperformanceunicodebenchmarkscruby

I spent three weeks adding SIMD to a language I write for myself, and came out with a rule for where lanes pay. The obvious next question was whether that rule says anything about a language I actually use. So I went looking in CRuby: what already uses SIMD, what does not, and is there anything left worth doing.

This is a measurement note, not a proposal. Everything below was checked against the shipped binary or measured on one machine, and the machine is named. Where I am uncertain, I say so.

What Ruby already has

Grepping source is not enough — a #if can leave the code out of the build — so I disassembled the shipped library and asked which functions actually contain vector instructions. On Apple silicon the disassembler writes NEON with the arrangement on the mnemonic (add.2d, tbl.16b), which is worth knowing before you write the grep; my first pass matched the ARM manual’s syntax and reported zero vector instructions in a library that has twenty thousand.

where state in Ruby 4.0.6 (arm64)
json extension NEON and SSE2, in its own ext/json/simd/simd.h. The shipped generator.bundle and parser.bundle both contain vector code.
prism (the parser) In master: nibble-indexed lookup tables, sixteen bytes per step, for strpbrk and identifier scanning. Landed 2026-03-10. The shipped 4.0.6 library contains none of it, so it is 4.1-development material for now.
digest Recently gained SIMD support across platforms.
String#count, #delete, #tr, #length Vector instructions, but from clang auto-vectorizing an ordinary loop, not from anyone writing lanes.
cgi/escape, erb/escape No vector instructions at all.
the encoding scan in string.c No vector instructions at all. ASCII runs are skipped eight bytes at a time by a word trick; everything else is one character at a time.

There is one more thing the tracker shows, and it is a design position rather than an omission: CRuby’s recent work on encoding has gone into avoiding the scan — caching the result on the string, propagating it through slices and concatenations, inlining the accessor — rather than making the scan faster. A proposal to add SIMD string comparison exists (Feature #21706) and its pull request was closed in January. An open pull request adds SIMD hex decoding to pack.

The measurement

Avoiding a scan works until the bytes are new. Bytes off a socket, out of a file, or out of a decompressor have no cached answer, and somebody has to look at them. So I measured that case.

require 'benchmark'; require 'tempfile'
def ms(label, reps)
  yield
  t = Benchmark.realtime { reps.times { yield } }
  printf("%-50s %7.2f ms\n", label, t * 1000 / reps)
end

N = 4 * 1024 * 1024
def mk(n, pat)
  s = (pat * (n / pat.bytesize + 2)).b[0, n]
  s.force_encoding("UTF-8").scrub("").b[0, n].force_encoding("UTF-8")
end
cjk = mk(N, "日本語だけの文章です。改行もあります。\n")
asc = mk(N, "plain ascii sentences here, with newlines too.\n")

f1 = Tempfile.new(['ja', '.txt']); f1.binmode; f1.write(cjk); f1.flush
f2 = Tempfile.new(['en', '.txt']); f2.binmode; f2.write(asc); f2.flush

ms("File.binread (Japanese bytes, no encoding work)", 20) { File.binread(f1.path) }
ms("File.read UTF-8 (Japanese)", 20)                      { File.read(f1.path, encoding: "UTF-8") }
ms("File.binread (ASCII bytes)", 20)                      { File.binread(f2.path) }
ms("File.read UTF-8 (ASCII)", 20)                         { File.read(f2.path, encoding: "UTF-8") }

cjk.valid_encoding?
ms("String#length, coderange already known (Japanese)", 20) { cjk.length }
ms("String#length, forces a scan (Japanese)", 20)           { cjk.b.force_encoding("UTF-8").length }
ms("String#valid_encoding?, forces a scan (ASCII)", 20)     { asc.b.force_encoding("UTF-8").valid_encoding? }

On an Apple-silicon Mac, warm page cache, 4 MiB files, the same numbers on Ruby 3.4.9 and 4.0.6:

Japanese ASCII
File.binread (bytes, no encoding work) 1.3 ms 1.7 ms
File.read as UTF-8 16.2 ms 1.7 ms
the difference 14.8 ms none
String#length with the answer already cached 0.21 ms
String#length when it must scan 15.2 ms
String#valid_encoding? when it must scan 15.5 ms 0.00 ms

Reading four megabytes of Japanese text costs twelve times what reading the same amount of English costs, and the entire difference is one scan over the bytes. In throughput: about 280 MB/s for Japanese, and about 24 GB/s for ASCII.

Two checks before believing any of that. First, the scan has to actually run: a quarter of the input must cost a quarter of the time, and it does (306 MB/s at 4 MiB, 309 MB/s at 1 MiB). Second, my first version of this benchmark measured nothing at all — String#b shares the buffer instead of copying it, and for pure ASCII the encoding tag can change without invalidating the cached answer, so the “scan” I thought I was timing was a no-op running at 20 GB/s. An impossible number is the instrument telling you it is not measuring.

Why it is slow

The scan is twenty lines in string.c:

if (rb_enc_asciicompat(enc)) {
    p = search_nonascii(p, e);
    if (!p) return ENC_CODERANGE_7BIT;
    for (;;) {
        int ret = rb_enc_precise_mbclen(p, e, enc);
        if (!MBCLEN_CHARFOUND_P(ret)) return ENC_CODERANGE_BROKEN;
        p += MBCLEN_CHARFOUND_LEN(ret);
        if (p == e) break;
        p = search_nonascii(p, e);
        if (!p) break;
    }
}

search_nonascii is the word trick, and it is genuinely fast: for a pure-ASCII string it runs at memory bandwidth and the whole scan is free. The for loop is the other case, and it calls rb_enc_precise_mbclen once per character, through the encoding object’s function table. That is an indirect call, a few branches, and no chance for the compiler to see across it — per character, for text that is entirely characters.

For scale, a scalar UTF-8 state machine written in C, doing the same validation, runs at about 1.5 GB/s on this machine, and the SIMD version I wrote for my own language — sixteen bytes per step, three nibble-indexed tables, the Keiser-Lemire arrangement — runs at 2.9 to 4.2 GB/s. Ruby is at 0.28 GB/s. The gap to the scalar C state machine is already five times, before any lanes are involved.

What would be worth doing

My rule from the other project is that lanes pay when the dependence between iterations can be made local, the index is contiguous, the tables fit in sixteen entries, the operations are integer and bitwise, and the body is dense enough not to be memory-bound. UTF-8 validation satisfies every one of those — it is close to the canonical example — and Ruby’s own parser already contains the machinery, in-tree, for exactly this shape of problem.

There are two separable steps, and the first needs no SIMD at all:

  1. Inline the UTF-8 case. Replace the per-character indirect call with a state machine specialised for UTF-8, which is by far the most common encoding. This is what the 1.5 GB/s scalar C number suggests is available: maybe five times, from ordinary code.
  2. Then vectorise it. Nibble-indexed tables, sixteen bytes per step, with NEON and SSSE3 paths and a scalar fallback. Another two to three times on top, and platform detection already exists in the repository twice over (ext/json/simd/simd.h, prism/compiler/accel.h).

Below that, two smaller candidates showed up in the same sweep. CGI.escapeHTML runs at 374 MB/s with no vector code in its extension, and it is a byte-classification problem in a hot path for anyone rendering HTML. Hex decoding through pack("H*") runs at 305 MB/s — the slowest row I measured — and already has an open pull request.

Just as useful is the list of things not worth touching, because the measurements say the work is already done. Scanning pure ASCII is at memory bandwidth. String#length with a known coderange is 0.21 ms for four megabytes. String#count, #upcase and #tr are between 1 and 2.2 GB/s because clang vectorized them without being asked. Effort there returns nothing.

What this note is not

It is one machine — Apple silicon, macOS, NEON. An x86-64 server with a different compiler will have different auto-vectorization and a different memory system, and the ASCII fast path in particular is close enough to bandwidth that it will move. It is also not a claim that CRuby chose wrong: caching a scan you can skip entirely beats making it fast, and that is the strategy the recent commits follow. The measurement only says where the cache cannot help, and how much is being paid there.

If you work in Japanese, Chinese or Korean, that is a place worth knowing about. Every string that arrives as fresh bytes pays it, and the price is invisible in every benchmark written in English.

← Back to Notes