Read OSS

μpb: Python・Ruby・PHPを支える軽量Cランタイム

中級

前提知識

  • 第4回:シリアライズ、アリーナ、TcTable(C++ランタイムとの比較)
  • Cプログラミングの基礎知識

μpb: Python・Ruby・PHPを支える軽量Cランタイム

Pythonでpip install protobuf、Rubyでgem install google-protobuf、PHPでpecl install protobufを実行したとき、インストールされるのはC++のprotobufランタイムではありません。インストールされるのはμpb――動的言語ランタイムのバックエンドとして専用に設計された、高速で小さなCのprotobuf実装です。

μpb(マイクロprotobuf、"upb"とも表記されます)は、protobufエコシステムの中で独自のポジションを占めています。C++ランタイムに匹敵するパース速度を、桁違いに少ないコード量で実現しているのです。本記事では、upbが生まれた理由、MiniTableによるスキーマ表現がいかにコンパクトに設計されているか、アリーナフュージングがupbの設計固有の問題をどう解決するか、そして各動的言語がC拡張を通じてupbをどのようにラップしているかを解説します。

μpbが存在する理由

upb READMEは、upbのポジショニングについて明快に語っています。組み込み用途に特に有効な、C++ランタイムとの3つの差別化ポイントが挙げられています:

  1. グローバル状態なしmain()実行前の登録もなく、グローバルなディスクリプタープールもない。protobufライブラリのロード・アンロードが発生しうる動的言語にとって、非常に重要な特性である。
  2. リフレクションのオプション化:リフレクションをリンクするかどうかに関係なく、生成されたメッセージは同じように動作する。C++ protobufではリフレクションとディスクリプターが深く絡み合っており、切り離すことができない。
  3. リフレクションベースの高速パース:実行時にロードされたメッセージ(リフレクション経由)でも、コンパイル済みメッセージと同じ速さでパースできる。C++ protobufではDynamicMessageを使うとパフォーマンスが低下する。
graph LR
    subgraph "C++ Protobuf"
        CPPSIZE["Large code size"]
        CPPGLOBAL["Global state required"]
        CPPREF["Reflection always present"]
    end
    
    subgraph "μpb"
        UPBSIZE["Small code size"]
        UPBNOGLOB["No global state"]
        UPBOPTREF["Reflection optional"]
    end
    
    subgraph "Consumers"
        PY["Python"] --> UPBSIZE
        RB["Ruby"] --> UPBNOGLOB
        PHP["PHP"] --> UPBOPTREF
        HPB["HPB (C++)"] --> UPBSIZE
    end

この差は実際に大きな影響をもたらします。upbを使ったPythonのprotobufライブラリのネイティブコードは数百KB程度に収まります。C++ランタイムを使った場合は数メガバイトになるうえ、Pythonのモジュールシステムとの相性も悪いグローバル状態まで持ち込まれてしまいます。

MiniTable: コンパクトなスキーマ表現

C++ランタイムが豊富なメソッドと文字列名を持つDescriptorオブジェクトを使うのに対し、upbはMiniTableを使います。これはフィールドアクセスとパースのみに最適化された、最小限でフラットな表現です。

upb_MiniTableは以下を含む構造体です:

  • upb_MiniTableFieldエントリーの配列(フィールドごとに1つ)
  • サブメッセージのMiniTableへのポインター(メッセージ型フィールド用)
  • upb_Message構造体内のフィールドデータのオフセット
  • hasbitとoneofケースの情報
classDiagram
    class upb_MiniTable {
        +upb_MiniTable_FindFieldByNumber()
        +upb_MiniTable_GetFieldByIndex()
        +upb_MiniTable_FieldCount()
        +upb_MiniTable_SubMessage()
        +upb_MiniTable_MapKey()
        +upb_MiniTable_MapValue()
    }
    
    class upb_MiniTableField {
        +field number
        +field type
        +offset in message
        +presence (hasbit/oneof)
    }
    
    class upb_MiniTableEnum {
        +value validation
    }
    
    upb_MiniTable --> upb_MiniTableField : fields array
    upb_MiniTable --> upb_MiniTable : sub-message links
    upb_MiniTableField --> upb_MiniTableEnum : enum validation

APIは整数インデックスによるアクセスを中心に設計されています。ハッシュテーブルと文字列比較を必要とする名前でのフィールド検索ではなく、フィールド番号ベースのupb_MiniTable_FindFieldByNumber()と、位置によるアクセスのためのupb_MiniTable_GetFieldByIndex()が提供されています。フィールド番号によるルックアップには、フィールド番号の分布に応じて密な表現か疎な表現かが使い分けられます。

重要なのは、MiniTableはシリアライズされたデータから実行時に構築できるという点です。Pythonのdescriptor_pool.cはまさにこの仕組みで動いています。Pythonでpool.Add(file_descriptor_proto)を呼び出すと、シリアライズされたディスクリプターがMiniTableの構築に使われ、そのメッセージ型のフル速度パースが可能になります。これがREADMEで「リフレクションベースの高速パース」と紹介されている機能です。

ヒント: protobufを扱うCライブラリを書く場合、フルのC++ランタイムをリンクするよりもupbのMiniTableアプローチの方がはるかに適しています。生成されたC APIで型付きアクセサーが使え、動的スキーマが必要な場合は実行時にディスクリプターからMiniTableを構築することもできます。

upbのアリーナとフュージング

C++ランタイムと同様に、upbもアリーナアロケーションを採用しています。ただしupbのarenaには独自の機能があります。それがアリーナフュージングです。

フュージングが解決する問題はこうです。アリーナAのメッセージがアリーナBのデータを参照している場合、何が起きるでしょうか?C++ protobufでは生成されたコードのコピーセマンティクスで管理されます。しかしupb(およびそれをラップする動的言語)では、メッセージはより自由に操作されることが多く、あるアリーナのサブメッセージを別のアリーナのメッセージのフィールドにセットするケースも起こりえます。

upb_Arena_Fuse()は2つのアリーナのライフタイムを結びつけ、両方がリリースされるまでどちらも解放されないようにします:

// Fuses the lifetime of two arenas, such that no arenas that have been
// transitively fused together will be freed until all of them have reached a
// zero refcount.
UPB_API bool upb_Arena_Fuse(const upb_Arena* a, const upb_Arena* b);
flowchart TD
    subgraph "Before Fuse"
        A1["Arena A<br/>(msg1)"]
        A2["Arena B<br/>(msg2)"]
    end
    
    subgraph "After msg1.sub = msg2"
        A3["Arena A<br/>(msg1)"]
        A4["Arena B<br/>(msg2)"]
        A3 ---|"Fused"| A4
    end
    
    subgraph "Lifetime"
        A5["Neither freed until<br/>both released"]
    end
    
    A3 --> A5
    A4 --> A5

フュージングは推移的です。AがBとフューズされ、BがCとフューズされると、3つすべてがライフタイムを共有します。実装にはunion-findデータ構造が使われており、この操作はスレッドセーフです。ヘッダーには重要な制約も記載されています。アリーナ間に参照サイクルを作ってはならず、すでにフューズされているアリーナ同士を再度フューズすることも禁止されています。

アリーナは参照カウントのためのupb_Arena_IncRefFor()upb_Arena_DecRefFor()や、全ブロック解放後に実行されるクリーンアップ関数を登録するためのupb_Arena_SetAllocCleanup()もサポートしています。

upb_MessageとWireフォーマット

upb_Messageは、upbのコアとなるメッセージ表現です。C++ protobufの型付きアクセサーを持つ生成クラスとは異なり、upb_Messageは汎用型です。すべてのメッセージは同じCの型を持ち、対応するupb_MiniTableによってのみ区別されます。

メッセージの作成はシンプルです:

upb_Message* msg = upb_Message_New(mini_table, arena);

メッセージはMiniTableで指定されたオフセットにフィールドデータを格納し、拡張フィールドと不明フィールドは別途保存されます。不明フィールドのイテレーションにはセグメントベースのAPIを使います:

uintptr_t iter = kUpb_Message_UnknownBegin;
upb_StringView data;
while (upb_Message_NextUnknown(msg, &data, &iter)) {
    // Process unknown field data
}

Wireフォーマットのデコードはupb/wire/decode.hにあり、フラグでいくつかのデコードオプションを指定できます:

フラグ 用途
kUpb_DecodeOption_AliasString 文字列をコピーせず入力バッファーをエイリアスする
kUpb_DecodeOption_CheckRequired 必須フィールドが欠けている場合に失敗する
kUpb_DecodeOption_AlwaysValidateUtf8 proto2でもUTF-8を強制する
kUpb_DecodeOption_DisableFastTable ファストテーブルパーサーを無効化する(デバッグ用)

AliasStringオプションは動的言語において特に重要です。Pythonのbytesオブジェクトからメッセージをパースするとき、パースされたメッセージ内の文字列をコピーせず入力バッファーを直接参照させることができます。これは入力バッファーがメッセージより長く生存する場合にのみ有効ですが、両方が同じアリーナ上にある場合は自然に保証されます。

言語バインディング:Python・Ruby・PHP

各動的言語はC拡張モジュールを通じてupbをラップしています。中でもPythonの実装が最も成熟しており、良いリファレンスになります。

python/message.cはインクルードの構成からブリッジのパターンが読み取れます。Python C APIのヘッダー(python/message.hpython/convert.h)とupbのヘッダー(upb/message/message.hupb/wire/decode.hupb/reflection/message.h)の両方が引き込まれています。Pythonのメッセージオブジェクトはアリーナへの参照とともにupb_Message*をラップしています。

python/descriptor_pool.cでは、スキーマ側の仕組みが確認できます。PyUpb_DescriptorPool構造体はupb_DefPool*——upbのリフレクションレベルの型レジストリ("def"システム)——をラップしています:

typedef struct {
    PyObject_HEAD
    upb_DefPool* symtab;
    PyObject* db;  // The DescriptorDatabase underlying this pool
} PyUpb_DescriptorPool;
flowchart TD
    subgraph "Python Layer"
        PyMsg["PyUpb_Message<br/>(Python object)"]
        PyPool["PyUpb_DescriptorPool<br/>(Python object)"]
    end
    
    subgraph "upb Layer"
        UMsg["upb_Message*"]
        UArena["upb_Arena*"]
        UDef["upb_DefPool*"]
        UMini["upb_MiniTable*"]
    end
    
    PyMsg --> UMsg
    PyMsg --> UArena
    PyPool --> UDef
    UDef --> UMini
    UMsg -.->|"field access via"| UMini

Pythonのコードでmsg.SerializeToString()を呼び出すと、C拡張はメッセージのupb_Message*upb_MiniTable*を渡してupb_Encode()を呼び出します。msg.ParseFromString(data)ではupb_Decode()を呼び出します。Pythonラッパーは型変換(Python int ↔ upb int32/int64、Python str ↔ upb string)、エラーレポート、アリーナのライフサイクル管理を担当しています。

RubyとPHPも同じパターンに従っています。Rubyの拡張はruby/ext/google/protobuf_c/に、PHPの拡張はphp/ext/google/protobuf/にあり、それぞれの言語のC拡張APIの慣習に合わせながら、同じ方法でupbをラップしています。

ヒント: Python・Ruby・PHPでprotobufの問題をデバッグするとき、最も多い原因はアリーナ関連——すでに解放されたアリーナ上のデータを参照しているメッセージ——です。アリーナフュージングがこれを防ぐはずですが、C拡張APIなどからメッセージの内部を直接操作している場合は、アリーナのライフタイムの境界に注意してください。

エコシステムにおけるupbの位置づけ

upbは軽量なCライブラリにとどまらず、3つの主要な言語ランタイムが共有する基盤です。このアーキテクチャ上の決断は大きな意味を持ちます。upbのパーサーへの改善はPython・Ruby・PHPへ即座に反映されます。アリーナ管理のバグ修正も3言語を同時に直すことになります。

第6回では、2つの視点からコード生成のパターンを探ります。1つはRustの革新的なデュアルカーネルアーキテクチャ(C++ protobufとupbの両方をバックエンドとして使い、プロキシベースのAPIを提供する)、もう1つはC++コードジェネレーターのフィールド型特化のためのストラテジーパターンです。また、upbの上に構築されてフルのC++ランタイムと生のupbの中間の選択肢を提供する新しいC++ API、HPBも取り上げます。