Ruby と SIMD の 2026 年 9 月 — どこに入っていて、日本語テキストが何を払っているか

調査メモ。Ruby には JSON 拡張とパーサに SIMD があるが、ファイルやソケットから来た文字列が必ず通る encoding 走査には無い。この手元のマシンでは、4 MiB の日本語テキストを読むと走査に 15 ms かかり、同じサイズの ASCII では実質ゼロだった。どう確かめたか、直すなら何が要るか。

rubysimdperformanceunicodebenchmarkscruby

自分のために書いている言語に 3 週間かけて SIMD を入れ、「lane が報われるのはどういう形か」 という規則を手に入れた。次に浮かぶ問いは、その規則が「実際に使っている言語」について何か 言うか、だった。そこで CRuby を調べた。何が既に SIMD を使っていて、何が使っておらず、 やる価値の残っているものはあるか。

これは提案ではなく調査メモだ。以下はすべて、出荷されているバイナリで確認したか、1 台の マシンで測ったものであり、そのマシンは明記する。不確かなところは不確かだと書く。

Ruby に既に入っているもの

ソースを grep するだけでは足りない。#if はコードをビルドから外せるからだ。そこで出荷 されている共有ライブラリを逆アセンブルし、「実際にベクトル命令を含む関数はどれか」を訊いた。 Apple silicon の逆アセンブラは NEON を「並びをニーモニック側に付けた」表記(add.2dtbl.16b)で書く。これは grep を書く前に知っておく価値がある。私の最初の一手は ARM の マニュアルの表記に合わせてしまい、2 万個のベクトル命令を持つライブラリに対して「0 個」と 報告した。

場所 Ruby 4.0.6(arm64)での状態
json 拡張 NEON と SSE2。独立した ext/json/simd/simd.h を持つ。出荷されている generator.bundleparser.bundle の両方にベクトル命令がある
prism(パーサ) master にはある。strpbrk と識別子走査を nibble 索引のテーブルで 16 バイト/step。2026-03-10 に入った。出荷されている 4.0.6 のライブラリには 1 つも無いので、今のところ 4.1 開発版のもの
digest 最近、各プラットフォーム向けの SIMD 対応が入った
String#count / #delete / #tr / #length ベクトル命令はあるが、clang が普通のループを自動ベクトル化した結果で、誰かが lane を書いたものではない
cgi/escapeerb/escape ベクトル命令はゼロ
string.c の encoding 走査 ベクトル命令はゼロ。ASCII の連続は語の技で 8 バイトずつ飛ばし、それ以外は 1 文字ずつ

トラッカーを見るともう 1 つ分かることがあり、これは「抜け」ではなく設計上の立場だ。CRuby の encoding まわりの最近の仕事は、走査を速くするのではなく避ける方に向かっている。結果を 文字列にキャッシュし、slice や連結を通して伝播させ、アクセサをインライン化する。SIMD による 文字列比較の提案(Feature #21706)はあるが、その pull request は 1 月に閉じられた。pack に SIMD の hex デコードを入れる pull request は今も開いている。

計測

走査を避ける戦略は、バイト列が新しくない限り有効だ。ソケットから来たバイト、ファイルから 読んだバイト、解凍器から出てきたバイトには、キャッシュされた答えが無い。誰かが見るしかない。 だからその場合を測った。

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? }

Apple silicon の Mac、ページキャッシュは温まった状態、4 MiB のファイル。Ruby 3.4.9 と 4.0.6 で 同じ数字になった。

日本語 ASCII
File.binread(バイト列、encoding 処理なし) 1.3 ms 1.7 ms
File.read を UTF-8 で 16.2 ms 1.7 ms
その差 14.8 ms なし
String#length(答えがキャッシュ済み) 0.21 ms
String#length(走査が必要) 15.2 ms
String#valid_encoding?(走査が必要) 15.5 ms 0.00 ms

4 メガバイトの日本語テキストを読むと、同じ量の英語を読むより 12 倍かかり、その差は丸ごと、 バイト列を 1 回走査する分だ。スループットにすると 日本語で約 280 MB/s、ASCII で約 24 GB/s

信じる前に確認が 2 つ要る。1 つ目、走査が本当に走っていること。入力を 1/4 にしたら時間も 1/4 に なるはずで、実際そうなった(4 MiB で 306 MB/s、1 MiB で 309 MB/s)。2 つ目、このベンチマークの 最初の版は何も測っていなかったString#b はバッファをコピーせず共有し、純 ASCII では encoding タグが変わってもキャッシュされた答えが無効にならない。つまり私が測っているつもりだった 「走査」は、20 GB/s で走る何もしない処理だった。あり得ない数字は、計器が「測っていません」と 言っている姿だ。

なぜ遅いのか

走査は string.c の 20 行だ。

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 が語の技で、これは本当に速い。純 ASCII の文字列ならメモリ帯域で走り、走査は 実質無料になる。問題は for ループの方で、ここは rb_enc_precise_mbclen1 文字につき 1 回、 エンコーディングオブジェクトの関数表を通して呼ぶ。間接呼び出しと数個の分岐が入り、コンパイラは その先を見通せない。それが 1 文字ごとに、全部が文字であるテキストに対して起こる。

規模感のために言うと、同じ検証をするスカラの C 状態機械はこのマシンで約 1.5 GB/s、私が自分の 言語のために書いた SIMD 版(16 バイト/step、nibble 索引のテーブル 3 枚、Keiser–Lemire の配置)は 2.9〜4.2 GB/s で走る。Ruby は 0.28 GB/s だ。lane を持ち出す前の、スカラ C との差だけで既に 5 倍ある。

やる価値のありそうなもの

別プロジェクトで得た規則はこうだった。lane が報われるのは、反復間の依存を局所化でき、添字が 連続で、テーブルが 16 エントリに収まり、演算が整数と bit で、本体がメモリ律速にならない程度に 密なときだ。UTF-8 検証はその全部を満たす。ほとんど教科書的な例であり、しかも Ruby 自身の パーサが、この形の問題のための道具を repo の中に既に持っている。

段階は 2 つに分けられ、1 段目に SIMD は要らない。

  1. UTF-8 の場合をインライン化する。 1 文字ごとの間接呼び出しを、圧倒的に多いエンコーディングで ある UTF-8 に特化した状態機械に置き換える。スカラ C の 1.5 GB/s という数字が示唆するのは、 普通のコードのままで 5 倍程度が取れるということだ。
  2. その上でベクトル化する。 nibble 索引のテーブル、16 バイト/step、NEON と SSSE3 の経路と スカラのフォールバック。さらに 2〜3 倍。プラットフォーム検出は repo の中に 2 つも既にある (ext/json/simd/simd.hprism/compiler/accel.h)。

その下に、同じ掃きで見つかった小さい候補が 2 つ。CGI.escapeHTML は 374 MB/s で、拡張バイナリに ベクトル命令が無い。HTML を描画する人にとっては熱い経路の、バイト分類問題だ。pack("H*") の hex デコードは 305 MB/s で、私が測った中で最も遅い行だが、こちらは既に pull request が開いている。

同じくらい役に立つのは、触らなくていいものの一覧だ。測定が「もう終わっている」と言っている。 純 ASCII の走査はメモリ帯域。coderange が既知の String#length は 4 メガバイトで 0.21 ms。 String#count#upcase#tr は 1〜2.2 GB/s で、clang が頼まれもせずベクトル化した結果だ。 ここに労力を注いでも何も返ってこない。

このメモが言っていないこと

1 台のマシンの話だ。Apple silicon、macOS、NEON。別のコンパイラの x86-64 サーバなら自動 ベクトル化もメモリ系も違い、とくに ASCII の速い経路は帯域に近いぶん動くだろう。そして、 CRuby が間違った選択をしたという主張でもない。そもそも省ける走査をキャッシュで省く方が、 走査を速くするより強い。最近のコミット群はその戦略に沿っている。この計測が言っているのは、 キャッシュが助けられない場所はどこで、そこで何を払っているか、それだけだ。

日本語・中国語・韓国語を扱うなら、知っておく価値のある場所だと思う。新しいバイト列として 届く文字列はすべてこれを払っていて、その代金は英語で書かれたベンチマークには一切現れない。

← Back to Notes