Ruby と SIMD の 2026 年 9 月 — どこに入っていて、日本語テキストが何を払っているか
調査メモ。Ruby には JSON 拡張とパーサに SIMD があるが、ファイルやソケットから来た文字列が必ず通る encoding 走査には無い。この手元のマシンでは、4 MiB の日本語テキストを読むと走査に 15 ms かかり、同じサイズの ASCII では実質ゼロだった。どう確かめたか、直すなら何が要るか。
自分のために書いている言語に 3 週間かけて SIMD を入れ、「lane が報われるのはどういう形か」 という規則を手に入れた。次に浮かぶ問いは、その規則が「実際に使っている言語」について何か 言うか、だった。そこで CRuby を調べた。何が既に SIMD を使っていて、何が使っておらず、 やる価値の残っているものはあるか。
これは提案ではなく調査メモだ。以下はすべて、出荷されているバイナリで確認したか、1 台の マシンで測ったものであり、そのマシンは明記する。不確かなところは不確かだと書く。
Ruby に既に入っているもの
ソースを grep するだけでは足りない。#if はコードをビルドから外せるからだ。そこで出荷
されている共有ライブラリを逆アセンブルし、「実際にベクトル命令を含む関数はどれか」を訊いた。
Apple silicon の逆アセンブラは NEON を「並びをニーモニック側に付けた」表記(add.2d、
tbl.16b)で書く。これは grep を書く前に知っておく価値がある。私の最初の一手は ARM の
マニュアルの表記に合わせてしまい、2 万個のベクトル命令を持つライブラリに対して「0 個」と
報告した。
| 場所 | Ruby 4.0.6(arm64)での状態 |
|---|---|
json 拡張 |
NEON と SSE2。独立した ext/json/simd/simd.h を持つ。出荷されている generator.bundle と parser.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/escape、erb/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_mbclen を 1 文字につき 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 は要らない。
- UTF-8 の場合をインライン化する。 1 文字ごとの間接呼び出しを、圧倒的に多いエンコーディングで ある UTF-8 に特化した状態機械に置き換える。スカラ C の 1.5 GB/s という数字が示唆するのは、 普通のコードのままで 5 倍程度が取れるということだ。
- その上でベクトル化する。 nibble 索引のテーブル、16 バイト/step、NEON と SSSE3 の経路と
スカラのフォールバック。さらに 2〜3 倍。プラットフォーム検出は repo の中に 2 つも既にある
(
ext/json/simd/simd.h、prism/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 が間違った選択をしたという主張でもない。そもそも省ける走査をキャッシュで省く方が、 走査を速くするより強い。最近のコミット群はその戦略に沿っている。この計測が言っているのは、 キャッシュが助けられない場所はどこで、そこで何を払っているか、それだけだ。
日本語・中国語・韓国語を扱うなら、知っておく価値のある場所だと思う。新しいバイト列として 届く文字列はすべてこれを払っていて、その代金は英語で書かれたベンチマークには一切現れない。