プログラミング
コンパイラがUTF-8について意見が分かれるとき
When Compilers Disagree About UTF‑8 (nemanjatrifunovic.substack.com)
要約
UTF-8デコーディング関数にASCII高速パスを追加する最適化を行ったところ、Clangでは純粋なASCIIテキストでスループットが3倍、混合テキストで34%向上しましたが、GCCではASCIIテキストに全く変化がなく、混合テキストでは逆にパフォーマンスが悪化しました。最終的に、コードポイントの検証をデコード関数内に組み込むことで、Clangのパフォーマンスを維持しつつ、GCCでも15-20%の改善を達成しました。
全文翻訳
コンパイラがUTF-8について意見が分かれるとき
単純なASCII高速パスがClangのパフォーマンスを3倍にしたがGCCには影響がなかった理由
Nemanja Trifunovic
2026年7月26日
共有
2006年の夏、私はUTF-8文字列を扱うためのオープンソースC++ライブラリの作成を開始しました。ポータブルでSTLと良好に連携することを望んでいましたが、当時は最適化にそれほど力を入れていませんでした。最近、UTF-8デコーディングについて書き始めた際に、ライブラリの内部を再検討する時間を費やしました。私が最適化を決定した関数は、UTF-8エンコードされたコードポイントをデコードするものです。
template <typename octet_iterator>
utf_error validate_next(octet_iterator& it, octet_iterator end, utfchar32_t& code_point) {
if (it == end) return NOT_ENOUGH_ROOM;
// 失敗した場合に戻れるように、itの元の値を保存します
// もちろん、ストリームイテレータなどではあまり意味がありません
octet_iterator original_it = it;
utfchar32_t cp = 0;
// リードバイトに基づいてシーケンス長を決定します
const int length = utf8::internal::sequence_length(it);
// トレイルバイトを取得し、コードポイントを計算します
utf_error err = UTF8_OK;
switch (length) {
case 0: return INVALID_LEAD;
case 1: err = utf8::internal::get_sequence_1(it, end, cp); break;
case 2: err = utf8::internal::get_sequence_2(it, end, cp); break;
case 3: err = utf8::internal::get_sequence_3(it, end, cp); break;
case 4: err = utf8::internal::get_sequence_4(it, end, cp); break;
}
if (err == UTF8_OK) {
// デコードに成功しました。次に、セキュリティチェックを行います...
if (utf8::internal::is_code_point_valid(cp)) {
if (!utf8::internal::is_overlong_sequence(cp, length)){
// 合格!ここで返します。
code_point = cp;
++it;
return UTF8_OK;
} else err = OVERLONG_SEQUENCE;
} else err = INVALID_CODE_POINT;
}
// 失敗した場合 - イテレータの元の値を復元します
it = original_it;
return err;
}
これは非常に単純です。リードバイトの値に基づいてシーケンス長を決定し、その長さに応じて、バイトから適切なビットフィールドを抽出してコードポイントの値を構築します。コードポイントが正常にデコードされた後、さらに2つのチェックを実行します。1つはコードポイントの有効性、もう1つは過長UTF-8シーケンスです。両方のチェックに合格した場合、成功ステータスを返し、イテレータは次のシーケンスに進みます。
最適化の機会は、ASCIIがUTF-8のすべての有効性要件を自明に満たすという事実でした。リードバイトの高位ビットがゼロであることが判明した場合、「ASCII文字」が得られます。これは[U+0000, U+007F]の範囲の値であり、常に有効です。ゼロ拡張するだけでコードポイントが得られます。以下のようになります。
template <typename octet_iterator>
utf_error validate_next(octet_iterator& it, octet_iterator end, utfchar32_t& code_point) {
if (it == end) return NOT_ENOUGH_ROOM;
// 失敗した場合に戻れるように、itの元の値を保存します
// もちろん、ストリームイテレータなどではあまり意味がありません
octet_iterator original_it = it;
utfchar32_t cp = 0;
// リードバイトに基づいてシーケンス長を決定します
const int length = utf8::internal::sequence_length(it);
// トレイルバイトを取得し、コードポイントを計算します
utf_error err = UTF8_OK;
switch (length) {
case 0: return INVALID_LEAD;
case 1: err = utf8::internal::get_sequence_1(it, end, cp);
// 追加の検証は不要です
if (err == UTF8_OK) {
code_point = cp;
++it;
return UTF8_OK;
} else {
it = original_it;
return err;
}
break; // 警告を避けるため
case 2: err = utf8::internal::get_sequence_2(it, end, cp); break;
case 3: err = utf8::internal::get_sequence_3(it, end, cp); break;
case 4: err = utf8::internal::get_sequence_4(it, end, cp); break;
}
if (err == UTF8_OK) {
// デコードに成功しました。次に、セキュリティチェックを行います...
if (utf8::internal::is_code_point_valid(cp)) {
if (!utf8::internal::is_overlong_sequence(cp, length)){
// 合格!ここで返します。
code_point = cp;
++it;
return UTF8_OK;
} else err = OVERLONG_SEQUENCE;
} else err = INVALID_CODE_POINT;
}
// 失敗した場合 - イテレータの元の値を復元します
it = original_it;
return err;
}
ASCII専用文字列の処理における目に見える改善と、ASCII/非ASCII混合文字列への軽微な影響を期待していました。clang 18.1.3でのテストは私の期待を上回りました。純粋なASCIIテキストでは、UTF-8デコーディングのスループットは3倍になり、混合テキストでも改善は顕著で、約34%でした。
実際、この変更をGitHubに提出しましたが、その後gccでテストすることにしました。コンパイラは常に私を驚かせます。今回はASCIIテキストに全く違いがありませんでした!ゼロです!
高度に混合されたテキストでは、スループットは一貫して3〜4%悪化しましたが、それはそれほど驚くべきことではありませんでした。
実際に手を動かして、生成されたアセンブリコードを見てみましょう。g++でコンパイルされた元のバージョンでASCIIコードポイントに対して何が起こるかを見てみましょう。
15e0: ldrb w0, [x22] ; バイトをロード
15e4: tbz w0, #7, 1700 ; ビット7が0(ASCII)なら即時リターン...
...
1700: add x22, x22, #1 ; 1バイト消費
1704: cmp x2, x22 ; バッファ終了をチェック
1708: b.ne 15e0 ; 入力があればループ
これは最適化され、高度にインライン化されたビルドであり、最後の2つの命令はvalidate_nextをループで呼び出す関数からのものです。しかし、ASCIIブランチに検証チェックがないことは明らかです。コンパイラはチェックが常にパスすることを理解し、それらを単純に削除できました。私の最適化は何も変えませんでした。ASCIIブランチの生成コードは同じままです。次にclangを見てみましょう。
1870: ldrsb w13, [x20] ; バイトをロード、符号拡張
1874: and w12, w13, #0xff ; w12 = 符号なしバージョン
1878: tbnz w13, #31, 1890 ; 符号ビットがセットされていれば非ASCIIパスにジャンプ
187c: mov w14, wzr ; 継続バイトなし
1880: mov w15, #1 ; シーケンス長 = 1バイト
1884: mov x13, x20 ; x13 = リードバイトへのポインタ
1888: mov w16, w12 ; デコードされたコードポイント = バイト値
188c: b 19ac ; 一般的な有効性チェックへ...
...
19ac: lsr w17, w16, #16
19b0: and w18, w16, #0x1ff800
19b4: cmp w17, #0x10
19b8: ccmp w18, w11, #4, ls
19bc: b.eq 2300 ; 無効なコードポイント
19c0: cmp w16, #0x7f
19c4: b.ls 1860 ; 有効なASCII:イテレータを進める
明らかに、clangはコードポイントの有効性チェックを削除しませんでした。私の最適化により、コードは次のようになります。
15e0: ldrb w0, [x22]
15e4: tbz w0, #7, 16c8 ; ASCII...
; ASCII早期リターン完了
16c8: add x22, x22, #1
16cc: cmp x2, x22
16d0: b.ne 15e0
これはclangでの大幅なパフォーマンス向上の理由を確かに説明しています。
最終的に、両方のコンパイラを満足させる、やや大きな変更を加えました。デコード完了後にコードポイントの有効性や過長シーケンスをチェックする代わりに、get_sequence_*関数を拡張してインラインで検証を実行するようにしました。これにより、clangでのパフォーマンス上の利点はすべて維持され、gccでは混合テキストで15〜20%の改善が見られました(純粋なASCIIでは変化なし)。
教訓:コンパイラはトリッキーですが、最適化に時間を費やすことは報われることがあります。
共有
前
次