Read OSS

C++ ランタイム:メッセージ、リフレクション、Arena アロケーション

上級

前提知識

  • 第1回:Protocol Buffers ソースコード全体マップ
  • 第2回:protoc の内側:.proto ファイルから型安全なコードへ
  • テンプレート、仮想ディスパッチ、メモリレイアウトを含む C++ の確かな知識
  • アリーナ/リージョンベースのメモリアロケーションの基本概念への理解

C++ ランタイム:メッセージ、リフレクション、Arena アロケーション

protobuf のパフォーマンス特性が最もよく表れているのが C++ ランタイムです。数ある言語ランタイムの中で最も成熟し、最も最適化され、最も複雑な実装を誇ります。バイナリサイズを最小化するために設計された二層のメッセージ階層、ホットパスの仮想化解除を可能にするカスタム vtable 機構、そしてメッセージごとのアロケーションコストをポインタのインクリメントにまで削減する三層の Arena アロケーターを備えています。

この記事では、生成された C++ コードの土台となるランタイムの基礎部分を解説します。パースエンジン(TcParser)については次回の記事で取り上げます。ここではメッセージのオブジェクトモデルとメモリ管理に焦点を当てます。

MessageLite と Message:バイナリサイズによる分割

生成された C++ の protobuf クラスは、MessageLiteMessage のいずれかを継承します。

classDiagram
    class MessageLite {
        +GetTypeName() string_view
        +New(Arena*) MessageLite*
        +Clear()
        +IsInitialized() bool
        +ParseFromString(string) bool
        +SerializeToString(string*) bool
        +ByteSizeLong() size_t
        #_class_data_ ClassData*
    }
    class Message {
        +GetDescriptor() Descriptor*
        +GetReflection() Reflection*
        +CopyFrom(Message)
        +MergeFrom(Message)
        +FindInitializationErrors(vector*)
        +SpaceUsedLong() size_t
    }
    MessageLite <|-- Message
    Message <|-- GeneratedMessage
    MessageLite <|-- LiteMessage
    class GeneratedMessage["MyMessage (generated)"]
    class LiteMessage["MyLiteMessage (generated)"]

この分割はひとえにバイナリサイズの削減が目的です。MessageLite はリフレクション機構を一切持たず、パース・シリアライズ・クリア・初期化チェックというコアのシリアライゼーション契約のみを提供します。Message はそこに GetDescriptor()GetReflection()、ディスクリプターベースの CopyFrom/MergeFrom、そして FindInitializationErrors を追加します。

message_lite.h のコメントにある通り、lite モードを有効にするには .proto ファイルに optimize_for = LITE_RUNTIME を指定します。フルのリフレクション機構がバイナリを肥大化させてしまうモバイルや組み込み向けのターゲットで特に有効です。サーバー環境では optimize_for = CODE_SIZE の方が適していることが多いでしょう。フルリフレクションを維持しながら、処理をリフレクションベースの実装に委譲することで生成コードをコンパクトに保てます。

PROTOBUF_CUSTOM_VTABLE と MessageCreator

実装上で最も興味深い仕組みの一つが PROTOBUF_CUSTOM_VTABLE メカニズムです。Clear() のディスパッチを見てみましょう。

#if defined(PROTOBUF_CUSTOM_VTABLE)
  void Clear() { (this->*_class_data_->clear)(); }
#else
  virtual void Clear() = 0;
#endif

PROTOBUF_CUSTOM_VTABLE が有効な場合、Clear()ByteSizeLong()、シリアライゼーションといったホットパスのメソッドは C++ の仮想ディスパッチではなく、_class_data_ 内の関数ポインターを経由してディスパッチされます。具象型がわかっている場合にコンパイラが仮想呼び出しを最適化(devirtualize)できるため、パフォーマンスがクリティカルな内部ループで大きな効果を発揮します。

MessageCreator クラスは高速な構築パターンを実装しており、構築戦略の選択に三種類のタグを使用します。

  • kZeroInit:アロケート後にゼロ埋め(デフォルト状態が全ゼロのメッセージ向け)
  • kMemcpy:アロケート後にプロトタイプからコピー(デフォルト値が非ゼロのメッセージ向け)
  • kFunc:カスタム関数を呼び出す(複雑な初期化が必要な場合)

ZeroInitCopyInit のパスは驚くほど高速です。Arena アロケーションの後に memsetmemcpy を実行するだけ — コンストラクタの呼び出しも、フィールドごとの初期化もありません。生成されたメッセージのレイアウトが、ゼロ状態またはコピーされたプロトタイプが有効なデフォルトインスタンスとなるよう慎重に設計されているため、このような実装が可能になっています。

ヒント: protobuf を多用するコードのプロファイリングでは、メッセージが optimize_for = LITE_RUNTIME を使っているか、デフォルトの SPEED モードかを確認しましょう。フルの Message 基底クラスはリフレクションテーブルやディスクリプター解決などの基盤を引き込み、バイナリサイズを大幅に増加させます。リフレクションを使わないのであれば、そのコストに見合う恩恵は得られません。

DynamicMessage:実行時のメッセージ構築

コンパイル時に型が分からないメッセージを扱わなければならない場面があります。DynamicMessage はそのようなケースに対応します。実行時に(例えば FileDescriptorSet のパースによって)取得した Descriptor* があれば、DynamicMessageFactory を使って完全に機能する Message オブジェクトを生成できます。

flowchart TD
    A["FileDescriptorSet<br/>(loaded at runtime)"] --> B["DescriptorPool::BuildFile()"]
    B --> C["Descriptor*"]
    C --> D["DynamicMessageFactory::GetPrototype()"]
    D --> E["DynamicMessage prototype"]
    E -->|"New(arena)"| F["DynamicMessage instance"]
    F --> G["Use via Reflection API"]

DynamicMessage は効率のために生成されたメッセージのメモリレイアウトを模倣しており、フィールドはマップではなく固定オフセットに格納されます。DynamicMessageFactory はタイプごとのメタデータをキャッシュするため、同じ型のインスタンスを追加で生成する際のコストも低く抑えられています。

主なユースケースとしては、任意の proto を転送するジェネリックプロキシ、protobuf プリティプリンターのようなスキーマ駆動ツール、スキーマ定義からメッセージを構築する必要があるテストフレームワークなどが挙げられます。

Arena アロケーションの詳細:三層構造

Arena システムは protobuf において最も重要なパフォーマンス機能です。メッセージやサブメッセージをヒープ上に個別アロケートするのではなく、Arena を一つ確保してその中で生成されたすべてのメッセージがライフタイムを共有します。Arena が破棄されると、すべてのメモリが一括解放されます。オブジェクトごとのデストラクタ呼び出しもなく、フラグメンテーションも発生しません。

実装は三層で構成されています。

flowchart TB
    subgraph Public["Public API"]
        A["Arena<br/>(arena.h)"]
    end
    subgraph ThreadSafe["Thread Safety Layer"]
        B["ThreadSafeArena<br/>(thread_safe_arena.h)"]
        B --> C1["SerialArena (Thread 1)"]
        B --> C2["SerialArena (Thread 2)"]
        B --> C3["SerialArena (Thread N)"]
    end
    subgraph Blocks["Block Management"]
        C1 --> D1["ArenaBlock → ArenaBlock → ..."]
        C2 --> D2["ArenaBlock → ..."]
    end
    A --> B

Arena は公開 API です。チューニング用の ArenaOptions を受け付けます。start_block_size(malloc からの初期アロケーションサイズ)、max_block_size(等比成長の上限)、オプションの initial_block(ユーザーが提供するメモリ)、カスタムの block_alloc/block_dealloc 関数などが設定できます。

ThreadSafeArena はスレッドごとの SerialArena インスタンスを管理します。スレッドが初めて Arena からアロケートする際、GetSerialArenaFast() を通じて自身の SerialArena を取得または生成します。この処理は mutex ではなくアトミック操作を使うため、ファストパスはロックフリーを維持できます。

template <AllocationClient alloc_client = AllocationClient::kDefault>
void* AllocateAligned(size_t n) {
  SerialArena* arena;
  if (ABSL_PREDICT_TRUE(GetSerialArenaFast(&arena))) {
    return arena->AllocateAligned<alloc_client>(n);
  } else {
    return AllocateAlignedFallback<alloc_client>(n);
  }
}

SerialArena はバンプポインターアロケーターです。各 SerialArenaArenaBlock オブジェクトの連結リストを所有します。アロケーションの処理はシンプルで、ポインターを進めてブロック上限を超えていないか確認し、メモリを返すだけです。現在のブロックが使い切られると、より大きな新しいブロックをアロケートして連結します。

この結果、メッセージを大量に扱うワークロードではアロケーションのオーバーヘッドがほぼゼロになります。通常のケースではポインターの比較とインクリメントが一回行われるだけで、処理全体が単一スレッドのキャッシュライン内に収まります。

FieldArenaRep の移行とハイブリッドアロケーション

Arena システムは現在、重要な進化の途上にあります。従来はすべてのメッセージが自身を所有する Arena へのポインターを保持していました。FieldArenaRep<T> テンプレートは、フィールドがフルの Arena ポインターではなく Arena オフセットを使う新しいパターンを導入しています。

template <typename T>
struct FieldArenaRep {
  using Type = T;
  static T* Get(Type* arena_rep) { return arena_rep; }
};

デフォルトは何もしない恒等変換ですが、特殊化によって Arena 情報をより効率的に保持する型でフィールドをラップできます。FieldHasArenaOffset<T>() ヘルパーは、フィールドが新しいオフセットベースの表現を使っているかどうかを検出します。

ハイブリッドアロケーションモデルにより、オブジェクトはスタック、ヒープ、Arena のいずれにも存在できます。異なる Arena をまたぐ参照が検出された場合(例えば別の Arena に属するサブメッセージをセットするとき)、ランタイムは所有権の不変条件を維持するために自動的にコピーを実行します。これは複雑さを増しますが、Arena 導入以前のコードとの後方互換性を守るための必要なトレードオフです。

CachedSize、Reflection、スレッドセーフティ

CachedSize クラスは小さいながらも示唆に富む最適化です。すべてのメッセージはシリアライズ後のバイトサイズをキャッシュして、再計算を避けます。このキャッシュにはリラックスアトミックオーダリングが使われていますが、メッセージ自体のスレッドセーフティのためではありません(メッセージへの書き込みはスレッドセーフではありません)。理由は別のところにあります。デフォルトインスタンスは複数スレッドから読み取られる可能性があるグローバルな共有オブジェクトであり、不要な書き込みを避けるゼロ書き込み最適化によって読み取り専用メモリへの書き込みを回避しているのです。

void Set(Scalar desired) const noexcept {
  if (ABSL_PREDICT_FALSE(desired == 0)) {
    if (Get() == 0) return;  // Skip write to global default instances
  }
  __atomic_store_n(&atom_, desired, __ATOMIC_RELAXED);
}

Reflection インターフェース(Message::GetReflection() 経由でアクセス)は FieldDescriptor を使った実行時のフィールドアクセスを提供します。コンパイル時に具象メッセージ型を知らなくても、任意のフィールドの取得・設定、セットされたフィールドのイテレーション、未知フィールドの操作が可能です。JSON エンコーディング、テキストフォーマット出力、適合性テストスイートなど、汎用ユーティリティの多くがこの機能に支えられています。

C++ ランタイムのスレッドセーフティモデルはシンプルです。ディスクリプターはイミュータブルで安全に共有できます。メッセージは並行読み取りは安全ですが、書き込みには外部の同期が必要です。Arena はアロケーションに関してスレッドセーフですが、破棄はシングルスレッドで行う必要があります。

ヒント: 短命な小さなメッセージを大量に生成する場合(RPC ハンドラーの内部など)は、必ず Arena アロケーションを使いましょう。N 回のヒープアロケーションと N 回のデストラクタ呼び出しが、N 回のバンプポインターの加算と一回のブロックチェーン解放に置き換わり、桁違いのパフォーマンス差が生まれることがあります。

次回の内容

C++ ランタイムのメッセージ階層とメモリ管理の仕組みを見てきました。しかしシステムの中で最も高速な部分——メッセージが実際にワイヤーフォーマットからどのようにパースされるか——については、まだ触れていません。次回の記事では TcParser を詳しく解説します。64ビットのパックされたディスパッチテーブル、16ビットのフィールドレイアウトエンコーディング、そしてゼロオーバーヘッドの関数チェーニングを実現する Clang の musttail 属性が鍵です。