GitHub、文字列比較の高速化技術「ケースフォールディング」をメモリ速度で実装
本文の状態
日本語全文を表示中
詳細モードで約23分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
GitHub Blog
GitHub はコード検索エンジン「Blackbird」の高速化のため、非 ASCII バイトで早期停止する従来の手法を廃止し、バッファ全体を分岐なしで処理する最適化を実装した Rust クレート「casefold」を公開した。
AI深層分析を開く2026年8月1日 01:23
AI深層分析
キーポイント
ケースフォールディングと小文字変換の明確な区別
表示用である小文字変換(locale 依存)とは異なり、比較用のケースフォールディングは文脈に依存しない対称的な関係を保つため、Unicode CaseFolding.txt の単純な対応のみを実装する。
非 ASCII バイトでの早期停止を廃止した最適化
ソースコードの大部分が ASCII であるという前提に基づき、最初の非 ASCII バイトでループを中断する従来の手法よりも、分岐なしでバッファ全体を掃引する方が高速であることを実証した。
Rust クレート「casefold」のオープンソース化
GitHub が Blackbird のインデックス作成やクエリ処理で得た知見を基に、メモリ速度で動作するケースフォールディングライブラリとして Rust クレート「casefold」を公開した。
分岐の排除によるベクトル化の実現
非ASCII文字での早期終了や条件分岐を除去することで、コンパイラが16バイト単位のNEON命令を用いた完全なベクトル化を実現できる。
演算によるブランチレス処理の構築
ASCII範囲テストを数値演算に置き換え、条件付き書き込みをビット演算で置換することでデータ依存制御フローを持たないループが完成する。
重要な引用
The fold of an ASCII letter is trivial—A..=Z map to a..=z, everything else is unchanged—so the ASCII pass is really just 'sweep the buffer, lowercase in place.'
It turns out to be faster to sweep the whole buffer with no branches than to stop early at the first non-ASCII byte.
Case folding is for comparison, and it's deliberately context-free and locale-independent.
The early-exit is what gates vectorization: keep the break but make the body perfectly branch-free and you still get zero vector instructions (~2.6 GiB/s); a data-dependent loop exit is enough on its own to keep the loop scalar.
編集コメントを表示
編集コメント
GitHub が自社の大規模検索インフラで得た知見をオープンソースとして提供した点は、技術コミュニティ全体のパフォーマンス向上に寄与する。特に分岐予測のオーバーヘッドを避けるための「早期停止の廃止」という逆説的なアプローチは、システム設計における重要な教訓となるだろう。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
ユーザーが「café」を検索した際に、コーパスに「CAFÉ」が含まれている場合や、「straße」と入力されたのに「STRASSE」と保存されている場合を想定してください。これらを一致として扱うには、大文字と小文字の区別をなくす正規化形式が必要です。つまり、大文字の違いだけで異なる 2 つの文字列が等しく比較できるような形式です。これがケースフォールディング(case folding)であり、テキストが表示されるのではなくマッチングが行われるあらゆる場面で登場します。具体的には検索エンジンや、(?i) フラグを持つ正規表現、大文字小文字を区別しないユーザー名やホスト名などです。
これは基本的な操作ですが、GitHub では非常に頻繁に実行されています。GitHub のコード検索エンジン「Blackbird」は 1.8 億リポジトリ以上(ソースコードで約 480TB)をインデックス化しています。すべてのバイトデータを n-gram に分割しインデックスを構築する前にケースフォールディングを行い、さらに潜在的なクエリの結果を検出する際にも、一致箇所を見つけるために別の明示的または暗黙的なケースフォールディング操作が必要です。この規模になると、基本的な操作の速度さえも重要になってきます。
この記事では、その高速化の方法について解説します。一見すると意外かもしれませんが、ASCII 処理における最大のスピードアップは、最適化を追加したのではなく、ある最適化を削除した結果得られました。最初の非 ASCII バイトで停止するよりも、分岐処理なしでバッファ全体を一掃する方が速いことが判明しました。この成果物は Rust クレート「casefold」としてオープンソース化されています。
フォールディングは小文字変換ではない
ついつい str::to_lowercase を使いたくなりますが、小文字への変換(lowercasing)とケースフォールディングは目的も実装も異なる別々の操作です。
大文字小文字の変換は表示用であり、言語や文脈に依存します。例えばギリシャ語の終止形シグマ(σ)は、単語の末尾では σf に、それ以外では σ に変換されますし、トルコ語の「I」も英語の「I」とは異なるルールで小文字化されます。
一方、ケースフォールディングは比較用であり、あえて文脈や言語に依存しないように設計されています。重要なのは、A が B と一致するフォールド結果を持つなら、B も A と同じ結果を持ち、それがあらゆる言語環境で対称的で安定した関係になることです。この目的のために、Unicode Character Database には明示的に CaseFolding.txt が用意されています。
実際の文字(ß、İ、終止形シグマなど)では、両者の処理が分岐します。そのため、小文字化をケースフォールディングの代用として使うと、誤った一致を生む可能性があります。このライブラリは、単純な 1 対 1 の変換(CaseFolding.txt のステータス C と S に相当)のみを実装しており、複数文字への「完全」フォールド(ß → ss など)やトルコ語特有のフォールド(点付き İ の処理など)は対象外です。これは珍しい選択ではありません。ripgrep などの一般的なツールや正規表現エンジンも同様の制限を設けており、ツール間で一貫性を保つことは重要です。
直感に反する核心:早期終了しないこと
主にソースコードを扱うため、フォールド対象のテキストは圧倒的に ASCII で構成されています。そのため、メモリ速度で処理できることが最も重要な要件です。他の要素は、稀な非 ASCII パスが性能を損なわないように守る役割に過ぎません。
ASCII 文字の変換は極めて単純です(A~Z は a~z にマッピングされ、それ以外は不変)なので、ASCII パスは本質的に「バッファを一掃してその場で小文字化」する処理になります。この処理を LLM に尋ねると、以下のようなコードが返されるかもしれません:
let bytes = s.as_bytes_mut();
for (i, b) in bytes.iter_mut().enumerate() {
if *b >= 0x80 {
break; // non-ASCII at index i: hand the rest to the Unicode path
}
if b.is_ascii_uppercase() {
*b += 32; // 'A'..='Z' → 'a'..='z'
}
}
一見すると理想的な実装に見えます。安価なバイト処理を行い、非 ASCII バイトに遭遇した瞬間にループを抜けて「本物の」Unicode パスへ任せるという、「必要な時だけ重い処理を行う」という考え方です。Apple M4 では約 3 GiB/s の速度で動作します。単独で見れば問題なさそうですが、分岐(if)の存在により、最適化された実装と比較して 15 倍以上も遅いことがわかります。
では、分岐を一つずつ削除していきましょう。
if b >= 0x80 { break } を削除し、ループ内で一切停止しないようにします。すべてのバイトをアキュムレータに蓄積し、ループ終了後に一度だけテストします(high_bit_acc |= *b)。非 ASCII バイトが存在したかどうかという情報は同じですが、ループ本体には分岐がなくなります。
A..=Z の範囲チェックも算術演算に変換しましょう。b.wrapping_sub(b'A') を使用すると、速度は 45 GiB/s に達します。これはほぼメモリの帯域幅そのものです。また、このパスを終える頃には high_bit_acc から非 ASCII データの処理が残っているかどうかを既に知ることができます。
各ステップがどれほど重要だったのか、純粋な ASCII データ(Apple M4、5.7 KB バッファ)で測定した結果は以下の通りです。
| バージョン | スループット | ベクトル化済みか |
|---|---|---|
| naive (break + 分岐テスト) | 3.1 GiB/s | いいえ (ベクトル命令 0 個) |
| → 分岐なしテスト/書き込み、break は維持 | 2.6 GiB/s | いいえ (ベクトル命令 0 個) |
| → 早期終了の break を削除 | 7.6 GiB/s | 部分的 (ベクトル命令 25 個) |
| → 分岐なしテスト + 書き込み(ループ全体) | >45 GiB/s | はい (ベクトル命令 41 個) |
ベクトル化を可能にするのは早期終了(early-exit)の制御です。break 文を残したまま本体内を完全に分岐なしにすれば、それでもベクトル命令はゼロのままとなり、転送速度は約 2.6 GiB/s に留まります。データ依存性のあるループ終了条件があるだけで、ループ全体がスカラー処理になってしまいます。break をなくして初めてコンパイラはベクトル化できます。
その後、大文字を小文字に変換する処理も分岐なしにすることで、部分的にベクトル化されたループ(条件付きストアが比較・ブレンディング・マスク付きストアとして残る状態、転送速度約 7.6 GiB/s)から、メモリ帯域幅を最大限活用できる直線的な演算へと変換されます。
注意:分岐なし処理はスカラーコードでは性能低下(ペサライゼーション)を招きます。先ほどの表をもう一度見てみましょう。break を残したまま本体内を分岐なしにすると、転送速度は約 2.6 GiB/s に落ち込み、単純に分岐を含むループ(3.1 GiB/s)よりも遅くなります。アセンブリコードを見ればその理由がわかります。
分岐ありのバージョンでは、実際に文字が変わった時だけバイトを書き出します。小文字や数字、スペース(実世界のテキストの大半を占めます)に対しては条件付きストア命令がスキップされ、それをガードする予測精度の高い分岐はほぼコストゼロです。一方、分岐なしのバージョンでは、ほとんど実行されないそのストアを、すべてのイテレーションで無条件に書き出す命令に置き換えます。結果として、数バイトだけを書き込む代わりに約 5,700 バイトすべてを書き戻すことになります。何のメリットもないのに追加の書き込みトラフィックが発生しているのです。
分岐なし書き込みが有効になるのは、ループがベクトル化された後だけです。その場合、書き出しは内容に関係なく 16 バイト分のベクトル書き込みとして単一命令になり、バイトごとのコストが消滅します。教訓:分岐なし本体は、ベクトル化を可能にするための手段として初めて価値があります。それ単体でスカラーコード内で使うと、逆に性能が低下する可能性があります。
標準ライブラリが採用しているのは、この中間的なアプローチです。1 バイトずつ検査するのではなく、[u8]::is_ascii は機械語単位でスキャンします。64 ビット環境では、2 つの u64 ラーンを OR 演算し、その上位ビットを 0x8080_8080_8080_8080 マスクで単一の操作でチェックすることで、1 回のイテレーションあたり 16 バイトを処理します。この高速パスの上に ASCII の前部を検出するチャンクスキャンを組み込み、その後分岐のない(ベクトル化可能な)変換ルーチンを走らせることで、ASCII フォールディングを実現できます。これにより、最初の非 ASCII ブロックで即座に終了できる利点を保ちつつ、両方の処理を高速化できます。
ただし、この手法の代償としてデータを 2 回読み込む必要があります(スキャン用と変換用の各 1 回)。その結果、スループットは約 23 GiB/s に留まります。これは単一パスで分岐なしに掃引する手法の半分程度ですが、単純なループによる破損処理の約 7 倍です。汎用的なデフォルトとして十分な性能を発揮しますが、ループ全体を制御し、検出と変換を 1 つの分岐なしパスに統合できる場合においては、絶対的な最高速度には届きません。
2 つの処理を融合させれば、もっと速くなるのではないか?これは自然な次の発想だ。チャンクごとの早期終了は維持しつつ、各 16 バイトブロックが ASCII であることを確認した直後にそのブロックを変換し、データを一度だけ読み込むという手法である。しかし測定結果では、この方法は 2 つのパスを使う場合に比べて約 2.6 倍も遅い。つまり、スループットは 8.7 GiB/s に過ぎず、2 パス構成の 23 GiB/s を大きく下回る。
内部ブロックの変換自体はベクトル化され、1 つの 16 バイト演算として実行されるが、問題はそのたびにデータ依存型の早期終了分岐が発生することだ。この分岐は 16 バイトごとに挿入されるため、ループを 1 ブロックずつしか処理できなくなる。コンパイラはブロック間でのアンローリングやソフトウェアパイプライン化を行わず、各イテレーションでロード→テスト→分岐→変換→ストアという一連のレイテンシをすべて支払う必要があるが、それを隠蔽する余地はない。
一方、2 つのパスに分けることで、それぞれが明確な役割を持つ。スキャン側は分岐が少なく書き込みも不要なワード単位のスキャンとなり、メモリを高速で駆け抜ける。変換側は完全にベクトル化され、分岐のない sweep として 45 GiB/s を超える速度で動作する。データへのアクセス回数が半分になる融合版よりも、2 つの高速で分岐のないパスの方が圧倒的に優れている。これは繰り返される教訓だ。ホットループにおいて、分岐こそが最大の敵なのである。
ヒープの使用を避ける
45 GiB/s の転送速度を実現するには、不要なメモリ割り当てをゼロに抑えることが不可欠です。simple_fold 関数は入力文字列を値として受け取り、ヒープバッファの所有権を取得して直接書き換え、結果を返します。
OR アキュムレータの上位ビットが立っていなければ、入力はすでに純粋な ASCII で、インプレースで変換済みです。この場合、新しいバッファを用意せず、コピーもせずに元の割り当て領域をそのまま返します。そうでない場合は、memchrto を使って最初の非 ASCII バイトまで検索し、そこから末尾を走査します。出力バッファは、文字が異なるバイト列に変換されるポイントに到達するまで未割り当て(書き込みカーソルなし)のまま維持されます。
多バイト内容が変換によって変化しないテキスト(CJK、ハングル、カナ、アラビア語、ヘブライ語、記号など)も、元の割り当て領域を一切変更せず、1 バイトもコピーすることなく返されます。
なぜ ASCII パスと同じようにインプレースで書き換えず、2 番目のバッファを用意するのでしょうか?それは、変換によって文字列が長くなる可能性があるからです。ほとんど全ての変換は UTF-8 の長さを維持するか短縮しますが、例外が 2 つあります。U+023A (Ⱥ) と U+023E (Ɀ) はそれぞれ 2 バイトですが、変換後は 3 バイトの文字(ⱥ, ɀ)になります。これらが発生すると、出力が入力バイト領域に収まらなくなるため、書き込み先の新しい領域が必要になります。
バッファは一度だけ割り当て、最悪ケースに合わせてサイズを確保します。必要に応じて拡張するのではなく、最初から十分な容量を持たせるのです。増分リザーブを行うと、容量の再確認や、必要な時に再割り当て、それまでに書き込んだデータのすべてをコピーし、長さや容量に関する追加の管理作業が必要になります。一方で、事前に一度だけバッファを確保しておけば、生ポインタによるカーソルが最初から最後までそのまま走査でき、そのような手間は一切不要です。また、最初の拡張や変更が発生するまでカーソルは null のままなので、これが「追加バッファを既に割り当てたか?」というフラグの役割も果たします。
サイズを決めるには成長の上限を知る必要がありますが、前述の 2 つの極端なケースからそれが導き出せます。入力バイト 2 個に対して出力は最大 3 バイトとなるため、出力容量は入力の 1.5 倍に抑えられます。これがまさに確保する容量です:
out = Vec::with_capacity(bytes.len() + bytes.len() / 2 + 4);
その後、ループ内では生ポインタを通じて書き込みを行い、キャパシティチェックは一切行いません。最後に一度だけ set_len を呼び出すだけです。さらに、分岐を減らすための 2 つの工夫があります。まず、2 つの fold の間に続く変更のないバイト列は、1 バイトずつではなく、copy_nonoverlapping で一度にコピーします。次に、各 fold では、リトルエンディアンのワードの全 4 バイトを必ず書き込み、その後カーソルを進めますが、その進み幅は fold された長さ(1〜4 バイト)だけとします。これにより、出力長に応じた分岐をホットパスから排除し、予約時に +4 を加えることで、最後の文字のオーバー書きも安全に行えます。
Unicode の処理も低コストに
文字が小文字に変換(fold)される場合でも、崖から転落するような処理(UTF-8 のデコード、ハッシュルックアップ、再エンコードなど)は避けたい。Unicode 16.0 では単純な変換マッピングが 1484 個存在するが、これらは非常に疎で構造化された関係にある。この 1484 個の情報を圧縮し、1776 バイトに収めることで、文字を完全にデコードすることなく変換処理を実行可能にする。
非 ASCII パスにおいても、圧倒的多数の文字は変換されない。「この文字を変換する」のではなく、「この文字は変換されるか?」という問いがホットな操作だ。答えはほぼ常に「いいえ」である。テーブルは、この否定テストをいかに安く行うかを最優先に設計されている必要がある。実際の変換処理は、すでに稀なパスの中でもさらに稀なケースに過ぎない。この優先順位こそが、以下のレイアウトの形状を決めている。ページビットマップが存在する理由はまさにこれであり、変換されない文字が、先頭の UTF-8 バイトから直接、デコードやスキャンを一切行わずに 1 回のビットテストで拒否されるようにするためだ。
これが、ハッシュマップがこのタスクには不適切な形状である理由そのものだ。単にサイズが大きいという問題ではない。ハッシュマップは「ヒット」を最適化している。存在するキーを約 1 回のプローブで見つけ出し、負荷係数や衝突が発生した場合のみ、追加の作業(より多くのプローブ、完全なキー比較)を行うように設計されている。しかし、私たちのワークロードは「ミス」、つまりテーブルにない文字によって支配されている。ハッシュマップにとってミスは最も嫌いなクエリであり、それでもキーをハッシュし、バケットへジャンプし、存在しないことを証明するまでプローブシーケンスを長く歩かなければならない。
変換可能なコードポイントは、64 コードポイント単位の「ページ」にクラスタリングされている。
コードポイントの折叠(fold)は、連続する範囲に集中しています。コード空間を 64 コードポイントごとの「ページ」に分割すると、約 1,484 の折叠が、存在可能な約 1,960 ページのうちわずか 59 ページのみで発生します。各ページに 1 ビットずつ割り当てた存在ビットマップ(presence bitmap)を使うことで、否定テストを即座に行えます。ビットが立っていない(クリアされている)場合は「折叠なし」を意味し、そのままコピーして処理完了となります。これが折叠のないスクリプトが高速で処理できる理由です。ビットが立っている場合のみ、2 つ目の構造体である累積カウントのサイドテーブルを参照します。このテーブルはページ内の順位(その前に存在するページ数)を示し、該当ページのデータエントリ範囲を特定します。約 1,900 の空ページについては何も保存しません。
let (word_idx, bit_idx, c_len) = if lead = PAGE_BITMAP.len() || (PAGE_BITMAP[word_idx] >> bit_idx) & 1 == 0 {
read += c_len;
continue;
}
word_idx はリードバイト(4 バイトシーケンスの場合は最初の継続バイト)のみによって決まるため、ビットマップの読み込みを早期に発行できます。
ページ内では、折叠は連続して現れます
あるページビットが、そのページ内で文字列変換(fold)が行われることを示しますが、どのコードポイントが対象か、あるいはどのように変換されるかは不明です。直感的には、各変換可能なコードポイントごとにエントリを設けるエンコーディングが考えられますが、これはデータ量が膨大になり、検索速度も低下します。1 つのページに数十もの変換ルールが存在する可能性があり、現在のコードポイントと一致するものを見つけるために、すべてを走査する必要が出てくるからです。
しかし、データの構造が再び救済してくれます。隣接するコードポイントは、ほとんどが同じ変換オフセット(delta)を持つことが圧倒的に多いです。例えば、A から Z までの英大文字はすべて +32 のオフセットで小文字に変換されますし、ラテン語拡張ブロックには 0x0100, 0x0102, 0x0104…のように、偶数番目のコードポイントのみが変換される交互パターンも多数存在します。
そのため、個々のコードポイントごとのエントリではなく、「開始点」「終了点」「間隔(stride)」「オフセット(delta)」という形式で連続する範囲を記録します。1 ビットのストライドフラグがあれば、連続するケースと「2 文字おき」のケースの両方をカバーできます。
この区間圧縮により、約 1484 個の個別の変換ルールが、59 ページ全体でわずか 238 の範囲(ページあたり平均約 4 つ)に集約されます。これにより、ページ内での検索対象は数十個から数個に減り、高速化が実現します。
この「オフセット付き区間エンコーディング」(ストライドの工夫を含む)は、Go の unicode パッケージから借用されたものです。同パッケージの CaseRange 構造体では、Lo/Hi の範囲と各ケースごとのオフセットを保持し、交互ブロックを示すために UpperLower という特殊値を使用しています。また、1 つの範囲が複数のページに跨らないよう、ページ境界で範囲を分割する処理も行われています。
このように、1 つの範囲レコードはたった 2 バイトで表現できます。
両端の値を 6 ビットに収め、2 つの配列に分割して保存します。具体的には、RUN_END_LOW[i] = end & 0x3F(スキャンキー)と RUN_START_STRIDE[i] = (start & 0x3F) | ((stride - 1) << 6) です。n 個のランがあるページでは n 個、あるいはランがなければ 0 となります。これにより、SWAR(SIMD Within A Register)技術を用いて一度に 8 バイト分の end_low をスキャンできます。
#[inline]
fn scan_end_low(lo: usize, n: usize, low_v: u8) -> usize {
const HIGH: u64 = 0x8080_8080_8080_8080;
const ONES: u64 = 0x0101_0101_0101_0101;
let bcast = (low_v as u64).wrapping_mul(ONES);
let mut base = 0;
while base < n {
// chunk は 8 バイトの `end_low` の値を格納
let chunk: u64 = unsafe { std::ptr::read_unaligned((RUN_END_LOW.as_ptr() as *const u8).add(base) as *const u64) };
// bcast は low_v を 8 バイトにブロードキャストした値
// HIGH ビットがセットされた chunk から bcast を減算し、結果の HIGH ビットをチェック
let ge = (chunk | HIGH).wrapping_sub(bcast) & HIGH;
if ge != 0 {
let j = base + (ge.trailing_zeros() / 8) as usize;
return j;
}
base += 8;
}
n
}この関数は、low_v 以上の最初のランのインデックスを返します。各ループで 8 バイトずつ処理し、条件を満たすバイトが見つかるまで進みます。もし low_v より小さい値しか含まれていない場合は、最終的に n を返します。
次に、UTF-8 の先頭バイトから文字列の長さを即座に算出する関数です。これはテーブル参照や分岐処理を一切行わず、単一のビット演算で完了します。
/// 先頭バイトが `lead` である UTF-8 シーケンスのバイト数を返す
#[inline]
pub fn utf8_len(lead: u8) -> usize {
const UTF8_LEN_BY_LEAD: u64 = 0x4322_1111_1111_1111;
((UTF8_LEN_BY_LEAD >> (4 * (lead >> 4))) & 0xF) as usize
}この定数 UTF8_LEN_BY_LEAD は、先頭バイトの上位 4 ビット(16 パターン)に対応する UTF-8 の長さを 4 ビットずつ格納しています。先頭バイトから上位 4 ビットを抽出し、それをシフトして該当する値を取り出し、下位 4 ビットでマスクすることで即座に長さが得られます。
また、先頭バイトの連続した 1 のビット数を数えることで、シーケンスの長さを推測することも可能です。これは (!lead).leading_zeros() を用いて計算できますが、上記のテーブルベースの方法の方が高速です。
進捗は折りたたみ後の長さによって決まるため、この手法は長さを変化させる折りたたみも処理できます。具体的には、U+212A ケルビン記号(3 バイト)を「k」(1 バイト)に、あるいは U+023A Ⱥ(2 バイト)を U+2C65 ⱥ(3 バイト)に変換する際にも、読み込んだバイト数よりも少ない、あるいは多いバイト数を出力することで対応します。これが我々が真に新しいと考える部分です。ICU、Go の unicode、Rust の regex、CPython、glibc など、他に見たすべてのフォールダーは、UTF-8 をコードポイントにデコードしてから折りたたみを適用し、最後に再エンコーディングを行います(SIMD 対応のものさえも最初にデコードします)。演算をバイト空間で行うことで、デコードとエンコードの両方を省略できます。これが、すでに答えがテーブル化されているハッシュマップよりも高速な理由です。ハッシュマップ側でも、キーのデコードと結果のエンコーディングは避けられません。
このバイト空間での演算は、入力データが正規形式(最短形式)の UTF-8 であると仮定しています。つまり、すべてのコードポイントは最小限のバイト数で符号化されている必要があります。ソースバイトを u32 として読み込み、ランごとのオフセットを加えることで正しい折りたたみエンコーディングに到達するのは、ソースが正規形式の場合に限られます。過長エンコーディング(必要なバイト数よりも多くパディングされたコードポイント、例えば「/」を 0xC0 0xAF で符号化する場合など)ではバイトパターンが異なり、length_mask やオフセット演算が破綻してしまいます。
これは Rust においては実際の制限にはなりません。&str や String は有効な UTF-8 を保持することが保証されており、定義上過長シーケンスは拒否されるからです。しかし、外部から生バイトを受け取る呼び出し元側では、事前に検証(または正規化)を行う必要があります。
The ASCII
原文を表示
Suppose a user searches for café and your corpus contains CAFÉ, or they type straße and you’ve stored STRASSE. To make these count as matches, you need a canonical form that erases case distinctions, so that two strings which differ only in case compare equal. That form is case folding, and it shows up wherever text is matched rather than displayed: search engines, regex (?i) flags, case-insensitive usernames and hostnames.
It’s a basic operation, but at GitHub we run it a lot. Blackbird, GitHub’s code search engine, indexes over 180 million repositories—more than 480TB of source code. Every byte is case-folded before we extract ngrams and build the index, and for every potential query result, another (implicit or explicit) case folding operation is needed to locate matches. At that scale, the speed of even a basic operation starts to matter.
This post is about how we made it fast, and it starts somewhere counterintuitive: the biggest win in the ASCII fast path came from removing an optimization, not adding one. It turns out to be faster to sweep the whole buffer with no branches than to stop early at the first non-ASCII byte. We open-sourced the result as a Rust crate called casefold.
Folding is not lowercasing
It is tempting to reach for str::to_lowercase, but lowercasing and folding are different operations with different goals:
Lowercasing is for display, and it’s locale- and context-sensitive: Greek final sigma lowercases to ς at the end of a word and σ elsewhere, and Turkish I lowercases differently than English I. Case folding is for comparison, and it’s deliberately context-free and locale-independent. The point is a relation that stays stable and symmetric, so that if A folds to match B, B folds to match A in any locale. The Unicode Character Database ships an explicit CaseFolding.txt for exactly that.
The two operations diverge on real characters—ß, İ, final sigma—which is why lowercasing as a stand-in silently produces wrong matches. This crate implements only the simple (1-to-1) folds—statuses C and S in CaseFolding.txt—and not the multi-character “full” folds (ß → ss) or Turkic locale folds (the dotted İ). This isn’t an unusual choice: common tools and regex engines like ripgrep make the same restriction, and being consistent across tools is important.
The counterintuitive core: Don’t stop early
We deal mostly with source code, so the text we fold is overwhelmingly ASCII and making it run at memory speed is the single most important thing we can do. Everything else just has to keep the rare non-ASCII path from spoiling it.
The fold of an ASCII letter is trivial—A..=Z map to a..=z, everything else is unchanged—so the ASCII pass is really just “sweep the buffer, lowercase in place.” Ask any LLM for it and you might get something like this:
let bytes = s.as_bytes_mut();
for (i, b) in bytes.iter_mut().enumerate() {
if *b >= 0x80 {
break; // non-ASCII at index i: hand the rest to the Unicode path
}
if b.is_ascii_uppercase() {
*b += 32; // 'A'..='Z' → 'a'..='z'
}
}
It looks ideal: do the cheap byte work, and the instant you hit a non-ASCII byte, break and let the “real” Unicode path take over: “only do the cheap work until you have to.” On an Apple M4 this runs at about 3 GiB/s. That sounds fine in isolation, but it is more than 15× short of “optimal” because of the if branches.
Let’s delete every branch, line by line:
if b >= 0x80 { break } → don’t stop at all. ORevery byte into an accumulator and test it once, after the loop: high_bit_acc |= *b. Same information (was there any non-ASCII byte?), zero branches in the body.
The A..=Z range test → make it arithmetic. b.wrapping_sub(b'A') < 26 is true exactly for A..=Z (any other byte wraps to ≥ 26), yielding a 0/1 mask with no branch.
The conditional write → fold the mask into the store.| (is_upper << 5)sets bit 5—turning an upper-case letter lower-case and being a no-op on everything else—the byte is always written, never branched on.
What’s left has no branch in its body and no early exit:
let mut high_bit_acc: u8 = 0;
for b in &mut bytes {
high_bit_acc |= *b; // detect any non-ASCII byte
let is_upper = b.wrapping_sub(b'A') < 26; // branchless A..=Z test
*b |= u8::from(is_upper) << 5; // set bit 5 → lowercase, else no-op
}
if high_bit_acc & 0x80 == 0 {
return bytes; // pure ASCII: already folded in place, no second buffer
}
A loop with no data-dependent control flow is trivially vectorizable: LLVM emits 16-byte-at-a-time NEON and the whole thing runs at > 45 GiB/s—essentially memory bandwidth. And we come out of the pass already knowing, from high_bit_acc, whether there’s any non-ASCII work left to do.
How much did each step matter? Measuring the cumulative ladder on pure ASCII (Apple M4, 5.7 KB buffer):
Version Throughput Vectorized?
naive (break + branch test) 3.1 GiB/s no (0 vector instrs)
→ branchless test/write, keep break 2.6 GiB/s no (0 vector instrs)
→ drop the early-exit break 7.6 GiB/s partially (25 vector instrs)
→ branchless test + write (the loop) >45 GiB/s fully (41 vector instrs)
The early-exit is what gates vectorization: keep the break but make the body perfectly branch-free and you still get zero vector instructions (~2.6 GiB/s); a data-dependent loop exit is enough on its own to keep the loop scalar. Only once the break is gone can the compiler vectorize. The final step—making the upper-case fold branchless—then turns a partially vectorized loop (which still compiles the conditional store to a compare-blend-masked-store, ~7.6 GiB/s) into the straight-line arithmetic that hits memory bandwidth.
Note: Branchless is a pessimization in scalar code. Look again at the table: making the body branchless while keeping the break (2.6 GiB/s) is actually slower than the naive branchy loop (3.1 GiB/s). The asm explains why. The branchy version only stores a byte when it actually changes one; its conditional strbis skipped for every lowercase letter, digit and space (the vast majority of real text), and the well-predicted branch that guards it is nearly free. The branchless version replaces that rarely taken store with an unconditional strbevery iteration, writing back all ~5,700 bytes instead of just the handful of upper-case ones. Extra write traffic for no benefit. Branchless-write only wins once the loop vectorizes, because then the store becomes a single 16-byte vector write regardless of content, and the per-byte cost disappears. The lesson: a branchless body is worth it only as the enabler for vectorization. On its own, in scalar code, it can cost you.
There’s also a middle ground, and it’s what standard libraries use. Instead of testing one byte at a time, [u8]::is_ascii scans a machine word at a time—on a 64-bit target it tests 16 bytes per iteration by OR-ing two u64 lanes and checking all their high bits with a single & 0x8080_8080_8080_8080 mask. You can build the ASCII fast path on top of that: chunk-scan to find the ASCII prefix, then run the branchless (vectorizable) convert over it. That keeps the early-exit ability—it still bails on the first non-ASCII block—while letting both halves go fast. The catch is that it reads the data twice (once to scan, once to convert), landing at about 23 GiB/s—roughly half of the single-pass branchless sweep, and ~7× the naive break loop. A solid, general-purpose default; just not the absolute ceiling when you control the whole loop and can fold detection and conversion into one branch-free pass.
Wouldn’t fusing the two passes be faster? It’s the obvious next thought: keep the chunked early-exit but convert each 16-byte block right after you’ve confirmed it’s ASCII, reading the data only once. Measured, it’s ~2.6× slower—8.7 GiB/s versus the two-pass 23. The inner block convert still vectorizes to a single 16-byte op, but now there’s a data-dependent early-exit branch every 16 bytes, and that branch pins the loop to one block at a time: the compiler doesn’t unroll or software-pipeline across blocks, and each iteration pays the full load→test→branch→convert→store latency with nothing to hide it behind. Split into two passes, each one is clean: the scan is a branch-light, store-free word scan that races through memory, and the convert is the fully-vectorized branch-free sweep at >45 GiB/s. Two fast, branch-free passes beat one branchy fused pass—even though the fused version touches the data half as many times. It’s the same lesson one more time: in the hot loop, the branch is the enemy.
Avoiding the heap
Forty-Five GiB/s also means doing zero unnecessary allocation. simple_fold takes the input String by value, owning the heap buffer it can mutate and return it. If the OR-accumulator’s high bit was clear, the input was pure ASCII already folded in place. We hand the same allocation straight back, no second buffer and no copy. Otherwise, we memchrto the first non-ASCII byte and scan the tail from there, leaving the output buffer unallocated (a null write cursor) until we hit a character that folds to different bytes. Text whose multibyte content never folds—CJK, Hangul, Kana, Arabic, Hebrew, symbols—also returns the original allocation untouched, never copying a byte.
Why a second buffer rather than rewriting in place like the ASCII pass? Because folding can make the string longer: almost every fold preserves the UTF-8 length or shrinks it, but two outliers grow—U+023A (Ⱥ) and U+023E (Ɀ) are 2 bytes each yet fold to 3-byte characters (ⱥ, ɀ). Once one appears, the output no longer fits in the input’s bytes, and we need somewhere new to write.
We allocate that buffer once, sized for the worst case, rather than growing it as more folds appear. Incremental reserve calls would mean re-checking capacity, occasionally reallocating, copying everything written so far, and juggling extra length/capacity bookkeeping; a single up-front allocation lets a raw write cursor run straight to the end with none of that. And since the cursor is nulluntil that first growing/changing fold, it doubles as the “have we allocated the extra buffer yet?” flag.
Sizing it needs a bound on growth, and those same two outliers give it: every 2 input bytes yield at most 3 output bytes, capping the output at 1.5× the input—exactly the capacity we reserve:
out = Vec::with_capacity(bytes.len() + bytes.len() / 2 + 4);
After that the loop writes through a raw pointer with no capacity checks and calls set_len once at the end. Two more details keep it branch-light. The run of unchanged bytes between two folds is moved with a single copy_nonoverlapping rather than byte by byte. And each fold unconditionally writes all 4 bytes of a little-endian word before bumping the cursor by only the folded length (1–4)—dropping a branch on the output length from the hot path, with the + 4 in the reservation as the headroom that makes the final character’s over-store safe.
Making Unicode cheap too
When a character does fold, we still don’t want to fall off a cliff—decode UTF-8, hash lookup, re-encode. Unicode 16.0 has 1484 simple-fold mappings, but they’re a very sparse and very structured relation. Four observations shrink them to 1776 bytes and let the fold run without ever decoding a full character.
Even on the non-ASCII path, the overwhelming majority of characters do not fold. The hot operation isn’t really “fold this character,” it’s “does this character fold?” Almost always no. The table has to make that negative test as cheap as possible; the actual folding is the rare case on an already-rare path. That priority is what shapes the layout below—the page bitmap exists precisely so a non-folding character is rejected in a single bit test, straight from its leading UTF-8 bytes, without decoding or scanning anything.
This is exactly why a HashMap<u32, u32> is the wrong shape for the job, not just a bigger one. A hash map is optimized for the hit: it finds a present key in roughly one probe, and only spends extra work (more probes, full key comparison) when load factor or collisions bite. But our workload is dominated by misses—characters that aren’t in the table at all—and a miss is a hash map’s least favorite query: it still has to hash the key, jump to a bucket, and walk the probe sequence far enough to prove absence.
Foldable code points cluster into 64-code-point “pages”
Foldable code points bunch together. Slice the code space into 64-code-point “pages” and the ~1484 folds touch just 59 of ~1960 possible pages. A one-bit-per-page presence bitmap answers the negative test on its own: a clear bit is a definitive “no fold”—copy through, done—which is what makes fold-free scripts cheap. Only on a set bit do we consult a second structure, a cumulative-popcount side table that ranks the page (how many populated pages precede it) to find its slice of entries, storing nothing for the ~1900 empty pages.
let (word_idx, bit_idx, c_len) = if lead < 0xE0 {
(0usize, lead & 0x1F, 2usize) // 2-byte: word 0
} else if lead < 0xF0 {
((lead & 0x0F) as usize, bytes[read + 1] & 0x3F, 3) // 3-byte: word = nibble
} else {
(
(((lead & 0x07) as usize) << 6) | (bytes[read + 1] & 0x3F) as usize,
bytes[read + 2] & 0x3F,
4usize,
) // 4-byte: merge 2 bytes
};
// reject without decoding: clear bit ⇒ no fold
if word_idx >= PAGE_BITMAP.len() || (PAGE_BITMAP[word_idx] >> bit_idx) & 1 == 0 {
read += c_len;
continue;
}
Because word_idxdepends only on the lead byte (and, for four-byte sequences, the first continuation byte), the bitmap load can be issued early.
Within a page, folds come in runs
A set page bit tells us something on this page folds, but not which code points or to what. The obvious encoding is one entry per foldable code point—but that is both bulky and slow to search: a page can hold dozens of folds, and we’d have to scan them all to find the one matching the current code point. The structure of the data rescues us again. Adjacent code points overwhelmingly share the same delta to their fold: A–Z all map +32, and Latin Extended is full of alternating runs like 0x0100, 0x0102, 0x0104, … where every second code point folds. Instead of per-code-point entries we store runs—start, end, stride, delta—and a 1-bit stride flag covers both the contiguous and the every-other case. This interval compression collapses the ~1484 individual folds into just 238 runs across the 59 pages (≈four per page), leaving the within-page search only a handful of entries to look at instead of dozens. This range-with-delta encoding (including the stride trick) is borrowed from Go’s unicode package, whose CaseRange records store a Lo/Hi range plus per-case deltas, with an UpperLower sentinel marking the alternating blocks. Runs are split at the page boundaries so a run never straddles two pages.
A run record is two clean bytes
With both endpoints inside one page they fit in 6 bits, split across two arrays: RUN_END_LOW[`i] = end & 0x3F (the scan key) and RUN_START_STRIDE[i`] = (start & 0x3F) | ((stride − 1) << 6) (read only on a hit). Because each key is one clean byte, the within-page search can go wide: rather than comparing cp & 0x3F against the runs one at a time, we load 8 end_low bytes into a single u64 and test all of them at once with one branchless SWAR step—(chunk | 0x80…80) − broadcast(low) & 0x80…80 sets the top bit of every lane whose key is ≥ cp & 0x3F. A single bit-scan of that mask (the keys are sorted, so the first set lane is the run we want) finds the slot. A page holds ~4 runs on average; that one 8-wide compare almost always resolves the entire search in a single step. One unlucky page does hold 30 runs, which puts the compare inside a short loop that strides eight keys at a time—but that loop trips at most a handful of times on exactly one page in all of Unicode, and never on the common ones. Either way: no per-run branch, and no code-point reconstruction anywhere.
/// Offset of the first run with end_low >= low_v in a page of n runs,
/// or n if none. Scans 8 end_low bytes at a time via SWAR.
#[inline]
fn scan_end_low(lo: usize, n: usize, low_v: u8) -> usize {
const HIGH: u64 = 0x8080_8080_8080_8080;
const ONES: u64 = 0x0101_0101_0101_0101;
let bcast = (low_v as u64).wrapping_mul(ONES);
let mut base = 0;
while base < n {
// RUN_END_LOW is padded by 8 bytes so this read is always in bounds.
let chunk = u64::from_le_bytes(
RUN_END_LOW[lo + base..lo + base + 8]
.try_into()
.expect("8-byte slice"),
);
// (b | 0x80) - low_v keeps its high bit iff b >= low_v (no
// cross-lane borrow). The first set lane is the first run >= low_v.
let ge = (chunk | HIGH).wrapping_sub(bcast) & HIGH;
if ge != 0 {
let j = base + (ge.trailing_zeros() / 8) as usize;
return if j < n { j } else { n };
}
base += 8;
}
n
}
Folding is a little-endian byte addition
On a little-endian machine the folded character’s UTF-8 bytes, read as a u32, equal the source bytes (as a u32) plus a per-run constant. A parallel BYTE_DELTA[i] table then turns the whole fold into a masked load, one wrapping_add, and a 4-byte store:
let word = u32::from_le_bytes(next_four_bytes) & length_mask; // keep this char's bytes
let folded = word.wrapping_add(BYTE_DELTA[i]); // the fold, as one byte add
write_u32_le(dst, folded); // store all 4 bytes...
dst += utf8_len(folded); // ...advance by the folded length
Both lengths in that snippet—the length_mask for the source character and the advance by the folded length for the destination—come from one more tiny trick. A UTF-8 sequence’s length is fixed by the top four bits of its lead byte, letting the 16 possible lengths pack one nibble each into a single 64-bit constant (0x4322_1111_1111_1111); the length is then a shift and a mask, (LEN_BITS >> (4 * (lead >> 4))) & 0xF—no if chain, no table memory, nothing for the predictor to get wrong. (A count leading ones—(!lead).leading_zeros()—would also work, since a lead byte carries one leading 1-bit per byte of the sequence.)
/// Number of bytes in the UTF-8 sequence whose lead byte is lead.
#[inline]
pub fn utf8_len(lead: u8) -> usize {
const UTF8_LEN_BY_LEAD: u64 = 0x4322_1111_1111_1111;
((UTF8_LEN_BY_LEAD >> (4 * (lead >> 4))) & 0xF) as usize
}
Because we advance by the folded length, this even handles length-changing folds—U+212A KELVIN SIGN (3 bytes) → k (1 byte), or U+023A Ⱥ (2 bytes) → U+2C65 ⱥ (3 bytes)—by writing fewer or more bytes than were read. That’s the part we believe is genuinely new: every other folder we looked at—ICU, Go’s unicode, Rust’s regex, CPython, glibc—decodes UTF-8 to a code point, applies the fold there, and re-encodes (even SIMD folders decode first). Doing the arithmetic in byte space skips both the decode and the encode, which is exactly why this path can outrun a hash map that already has the answer tabulated—the hash map still has to decode its key and encode its result. The byte-space arithmetic assumes the input is well-formed, shortest-form UTF-8—every code point encoded with the minimal number of bytes. Reading the source bytes as a u32and adding a per-run delta only lands on the correct folded encoding when the source is in canonical form; an overlong encoding (a code point padded into more bytes than necessary, e.g. / as 0xC0 0xAF) has a different byte pattern and would break thelength_mask and the delta arithmetic. This is not a real restriction in Rust—&str/String are guaranteed to hold valid UTF-8, which by definition rejects overlong sequences—but a caller feeding raw bytes from elsewhere must validate (or otherwise normalize) them first.
The ASCII
関連記事
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み