Read OSS

高性能シリアライゼーション:アリーナアロケーションとテールコールテーブルパーシング

上級

前提知識

  • 第3回:ディスクリプタシステムとメッセージ階層
  • メモリアロケーションのパターンと CPU キャッシュ最適化の理解
  • C++ 低レベルプログラミング(ビット操作、テールコール)の経験

高性能シリアライゼーション:アリーナアロケーションとテールコールテーブルパーシング

protobuf は単なるデータフォーマットの仕様ではありません。速度を徹底的に追求した設計が施されています。Google のスケールでは、protobuf のパーシングスループットが 5% 改善するだけで、膨大なコンピューティングリソースの節約につながります。本記事では、C++ ランタイムにおける3つの主要なパフォーマンス最適化システムを解説します。バッファコピーを排除するゼロコピー I/O、個々のオブジェクト管理をリージョンベースの一括解放に置き換えるアリーナアロケーション、そして積極的なビットパッキングとコンパイラが強制するテールコールによって最高水準のスループットを実現する TcTable テールコールパーシングシステムです。

これらはリポジトリ全体の中でも、最も念入りに最適化されたコードと言えるでしょう。

ゼロコピー I/O:memcpy の排除

protobuf の I/O の基盤となるのが、ゼロコピーストリーム抽象化です。zero_copy_stream.h のコメントでは、従来の I/O との違いが明確に示されています。

従来のアプローチ:

char buffer[BUFFER_SIZE];
input->Read(buffer, BUFFER_SIZE);  // memcpy!
DoSomething(buffer, BUFFER_SIZE);

ゼロコピーのアプローチ:

const void* buffer;
int size;
input->Next(&buffer, &size);      // no copy!
DoSomething(buffer, size);

ここでの核心は、バッファを「ストリーム自身が所有する」という設計思想にあります。mmap されたファイルやネットワークバッファから読み込む場合、ストリームはバッキングメモリへのポインタをそのまま返せます。呼び出し側はコピーなしでその場で読み取るだけです。

flowchart LR
    subgraph "Traditional I/O"
        SRC1["Source Buffer"] -->|memcpy| BUF1["User Buffer"]
        BUF1 --> PARSE1["Parse"]
    end
    
    subgraph "Zero-Copy I/O"
        SRC2["Source Buffer"] -->|"pointer"| PARSE2["Parse directly"]
    end

CodedInputStreamCodedOutputStream は、このゼロコピーストリームの上に重ねて使うレイヤーで、varint のエンコード/デコード、タグのパース、固定長の読み取り、長さ区切りフィールドの処理といった protobuf 固有の wire フォーマット操作を担います。バッファ境界をまたぐ varint の読み取りなど一部のケースのために小さな内部バッファを持ちますが、ほとんどの操作はゼロコピーバッファを直接扱います。

アリーナアロケーション:リージョンベースのメモリ管理

パフォーマンスが重要なコードでは、protobuf メッセージを個別の new/delete で管理することはほとんどありません。代わりに使われるのが Arena アロケーションです。これはリージョンベースのメモリ管理方式で、アリーナ内に確保されたすべてのオブジェクトは、アリーナが破棄されるときに一括で解放されます。

Arena のヘッダには serial_arena.hthread_safe_arena.h がインクルードされており、二層構造の設計が見て取れます。

flowchart TD
    subgraph "Arena Public API"
        ARENA["Arena"]
    end
    
    subgraph "Internal Implementation"
        TSA["ThreadSafeArena<br/>(manages per-thread arenas)"]
        SA1["SerialArena<br/>(Thread 1)"]
        SA2["SerialArena<br/>(Thread 2)"]
        SA3["SerialArena<br/>(Thread N)"]
    end
    
    subgraph "Memory Blocks"
        B1["Block 1"]
        B2["Block 2"]
        B3["Block 3"]
    end
    
    ARENA --> TSA
    TSA --> SA1
    TSA --> SA2
    TSA --> SA3
    SA1 --> B1
    SA1 --> B2
    SA2 --> B3

SerialArena は高速パスを担います。空き領域の先頭ポインタと現在ブロックの末尾ポインタだけを管理する、シンプルなバンプアロケータです。アロケーションはポインタをインクリメントするだけ(アライメント調整後)で完了します。ロックも、フリーリストも、断片化の追跡も不要です。現在のブロックが満杯になったら、新しいブロックを確保します。

ThreadSafeArena はマルチスレッドのケースを管理します。各スレッドはスレッドローカルストレージに保存された専用の SerialArena を持つため、よくあるケース——アリーナを作成したスレッドからの割り当て——はロックフリーのまま実行できます。別スレッドからのアクセス時のみ、ミューテックスを取得して呼び出しスレッドの SerialArena を取得または作成します。

アリーナはクリーンアップ登録もサポートしています。非トリビアルなデストラクタを持つオブジェクト(メッセージ内の std::string フィールドなど)は、アリーナの破棄時に呼び出されるクリーンアップコールバックを登録します。これは登録の逆順で実行されるクリーンアップリストで管理されます。

ヒント: アリーナアロケーションはオプトイン方式です。使用するには Arena::Create<MyMessage>(&arena) のように Arena* を渡すか、Message::New(arena) を使います。生成されたメッセージコードはアリーナアロケーションを自動的に検出し、動作を切り替えます。たとえば、アリーナ上の文字列フィールドはヒープではなくアリーナのアロケータを使用します。

TcTable:テールコールパーシングのアーキテクチャ

TcTable(Tail-Call Table)は、protobuf のパーシングにおける最も重要なパフォーマンス革新です。従来の switch ベースのフィールドパーシングループを、コンパイラが強制するテールコールでスタックの増大を防ぐ、テーブル駆動のディスパッチシステムに置き換えています。

中核となるデータ構造が TcFieldData です。フィールドのパースに必要なすべてのメタデータを 64 ビットの値一つに詰め込んでいます。

Bit:
+-----------+-------------------+
|63    ..     32|31     ..     0|
+---------------+---------------+
:   .   :   .   :   . 16|=======| [16] coded_tag()
:   .   :   .   : 24|===|   .   : [ 8] hasbit_idx()
:   .   :   . 32|===|   :   .   : [ 8] aux_idx()
:   . 48:---.---:   .   :   .   : [16] (unused)
|=======|   .   :   .   :   .   : [16] offset()
+-----------+-------------------+

コンストラクタでそのパッキング方法が明確に示されています。

constexpr TcFieldData(uint16_t coded_tag, uint8_t hasbit_idx,
                      uint8_t aux_idx, uint16_t offset)
    : data(uint64_t{offset} << 48 |
           uint64_t{aux_idx} << 24 |
           uint64_t{hasbit_idx} << 16 |
           uint64_t{coded_tag}) {}

この 64 ビット値には、一つのフィールドをパースするために必要なすべての情報が含まれています。高速照合に使う期待タグ、フィールドの存在追跡用の hasbit インデックス、サブメッセージテーブルや enum バリデータなどに使う補助データインデックス、そしてメッセージ struct 内でフィールド値が格納される位置のバイトオフセットです。

高速パスのパーシングループはこう動作します。wire からタグを読み取り、それを使って高速テーブル(2 の冪乗サイズの配列)へのインデックスを計算し、テーブルエントリの coded_tag が一致するか確認して、一致すればフィールド型に特化したハンドラにディスパッチします。ハンドラは値をパースしてフィールドオフセットに格納し、hasbit をセットして、次のフィールド処理のためにテーブルディスパッチへテールコールで戻ります。

MUSTTAIL:パーサをスタックレスに保つ

TcTable パーシングの仕組みはテールコールに依存しています。各フィールドハンドラは次のハンドラへのテールコールで終わらなければなりません。コンパイラがこれを通常の関数呼び出しに変換してしまうと、フィールドのたびにスタックが積み上がり、1000 フィールドを持つメッセージには 1000 スタックフレームが必要になってしまいます。

field_layout エンコーディングは、フィールド型のメタデータを 16 ビット値にパックします。

Bit:
+-----------------------+-----------------------+
|15        ..          8|7         ..          0|
+-----------------------+-----------------------+
:  .  :  .  :  .  :  .  :  .  :  .  : 3|========| [3] FieldKind
:     :     :     :     :     :  . 4|==|  :     : [1] FieldSplit
:     :     :     :     :    6|=====|  .  :     : [2] FieldCardinality
:  .  :  .  :  .  : 9|========|  .  :  .  :  .  : [3] FieldRep
:     :     :11|=====|  :     :     :     :     : [2] TransformValidation
:  .  :13|=====|  :  .  :  .  :  .  :  .  :  .  : [2] FormatDiscriminator
+-----------------------+-----------------------+

FieldKind 列挙型はすべての wire タイプの組み合わせをカバーしています。kFkVarintkFkPackedVarintkFkFixedkFkPackedFixedkFkStringkFkMessagekFkMap です。FieldCardinality は singular、optional(hasbit あり)、repeated、oneof の各フィールドを区別します。FieldRep はメモリ上の表現サイズ(8、32、64 ビット)を示します。

flowchart TD
    A["Read tag from wire"] --> B["Hash tag → fast table index"]
    B --> C{"coded_tag matches?"}
    C -->|Yes| D["Call field handler<br/>(varint, string, message, ...)"]
    C -->|No| E["Fall back to mini table<br/>(slow path)"]
    D --> F["Parse value + store at offset"]
    F --> G["Set hasbit"]
    G -->|"MUSTTAIL"| A
    E --> H["Linear scan of field entries"]
    H --> F

PROTOBUF_MUSTTAIL マクロは(対応コンパイラでは)[[clang::musttail]] をラップしており、テールコールをジャンプ命令としてコンパイルすることをコンパイラに強制します。ABI の問題などで保証できない場合は、サイレントなスタック増大ではなくコンパイルエラーになります。musttail をサポートしないコンパイラでは、トランポリンベースのアプローチにフォールバックします。

この設計により、パーサのスタック深度はメッセージのフィールド数に関わらず一定——通常わずか 2〜3 フレーム——に保たれます。フィールドが多く深くネストされたメッセージでは、これが安定したパーシングとスタックオーバーフローの境界線になります。

ParseFunctionGenerator:コンパイラとランタイムをつなぐ橋

TcTable のデータ構造はどこからともなく現れるわけではありません。C++ コードジェネレータによって生成されます。ParseFunctionGenerator クラスが、コンパイル時の世界(ディスクリプタ)とランタイムの世界(TcTable エントリ)を橋渡しします。

class ParseFunctionGenerator {
 public:
    static constexpr float kUnknownPresenceProbability = 0.5f;
    
    ParseFunctionGenerator(
        const Descriptor* descriptor, int max_has_bit_index,
        absl::Span<const int> has_bit_indices, const Options& options,
        const absl::flat_hash_map<absl::string_view, std::string>& vars,
        int index_in_file_messages);

変換の核心にあるのが静的メソッド BuildTcTableInfoFromDescriptor() です。スキーマを表す Descriptor とコード生成設定の Options を受け取り、高速パスとスローパスのエントリをすべて記述した TailCallTableInfo を生成します。

ジェネレータはフィールドの出現確率(未知フィールドはデフォルトで 50%)を考慮して高速テーブルへの配置を決定します。よく使われるフィールドは高速テーブルのスロットに入り、あまり使われないフィールドはミニテーブルのスローパスにフォールバックします。index_in_file_messages パラメータは、ファイル内のすべてのメッセージにまたがるグローバルレイアウト最適化に使われます。

sequenceDiagram
    participant Proto as .proto file
    participant Desc as Descriptor
    participant PFG as ParseFunctionGenerator
    participant TcInfo as TailCallTableInfo
    participant Gen as Generated .pb.cc

    Proto->>Desc: protoc parsing
    Desc->>PFG: BuildTcTableInfoFromDescriptor()
    PFG->>TcInfo: Field entries, fast table, aux data
    TcInfo->>Gen: GenerateTailCallTable()
    Note over Gen: Static TcParseTable<br/>embedded in .pb.cc

比較として、upb は異なるアプローチを採っています。コンパイル時に静的なパーステーブルを生成するのではなく、シリアライズされたディスクリプタデータから実行時に MiniTable を構築します。upb/wire/decode.h のデコーダはディスパッチで MiniTable を使い、kUpb_DecodeOption_AliasString(入力バッファの直接参照)や kUpb_DecodeOption_DisableFastTable(デバッグ用)等のオプションもサポートしています。

ヒント: protobuf のパフォーマンスをプロファイリングするなら、高速テーブルのヒット率が重要な指標になります。フィールド数が多いメッセージや、あまり一般的でないタグを持つメッセージはスローパスへのフォールバック率が高くなりがちです。よく使うフィールドに小さいフィールド番号(1〜16 は 1 バイトタグを使うため高速テーブルに収まりやすい)を割り当てることを検討してみましょう。

パフォーマンススタックの全体像

ゼロコピー I/O、アリーナアロケーション、TcTable パーシングの3つのシステムはパフォーマンススタックとして連携して機能します。ゼロコピーストリームは I/O 境界でのコピーを排除し、アリーナはオブジェクトごとのアロケーションオーバーヘッドを排除し、TcTable パーシングはフィールドディスパッチ時の分岐予測ミスとスタック増大を排除します。

その結果、パーシングスループットはメモリ帯域幅の限界に近づきます。パーサのボトルネックはもはや計算処理ではなく、メモリからデータを読み取る速度になるのです。

第 5 回では視点を移し、同じパフォーマンス目標に対して根本的に異なるアプローチを取る軽量 C ランタイム μpb を取り上げます。MiniTable がどのようにフルのディスクリプタ階層を置き換えるか、upb のアリーナが持つユニークな融合機構(fusing)がクロスアリーナの参照問題をどう解決するか、そして Python・Ruby・PHP が C 拡張モジュールを通じてどのように upb をラップするかを見ていきましょう。