
2026/04/05 21:46
「`repeated` プロトコルバッファフィールドの逐次的エンコードとデコード」
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
この記事では、プロトコルバッファのワイヤーフォーマットを直接扱うことで、大規模な Perfetto トレースファイルをストリームエンコードおよびデコードする方法について説明しています。これにより、トレース全体をメモリに読み込むことなく、継続的なディスクI/Oが可能になります。
Trace メッセージは packet = 1 の繰り返しフィールドであり、各パケットには TracePacket が格納されています。バイナリエンコーディングでは、各パケットの先頭にキー バイト 0x0A(フィールド 1、ワイヤータイプ 2 – 長さ限定)が配置され、その後に埋め込みメッセージが何バイトで構成されるかを示す varint 長さが続きます。プロトコルバッファの基本概念―varint エンコードとキー計算 (field_number << 3) | wire_type、および四つのワイヤータイプ(0:varint、1:64ビット固定長、2:長さ限定、5:32ビット固定長)―を使用してこれらのキーと長さを解析します。記事では、Rust のコードスニペット (
encode_varint、append_trace_packet、decode_next_trace_packet) を示し、パケットデータの前にキー/長さペアを書き込む方法と、ストリームからそれらを読み取る方法を説明しています。また、プロトコルバッファメッセージは自己完結型ではないため、単純にバイト 0x0A を検索しても十分ではなく、埋め込みデータ内にその値が含まれる可能性があることにも触れています。このように各パケットを手動でフレーミングすることで、開発者はパケットを増分的に追加・読み取りでき、大きなメモリ構造を避けつつ、ギガバイト規模の Perfetto トレースを効率的に処理できます。
本文
「repeated」フィールドの逐次エンコード・デコード
目次
- 動機:Perfettoとの連携
- 課題:順序付きエンコードとデコード
- Protobuf のワイヤーフォーマット
- VARINTs
- 基本的なメッセージ構造
- ネストされたメッセージ
- repeated フィールド
- 全体像の整理
- 逐次シリアライズ/追加
- 逐次デシリアライズ
- 完全なサンプル
本記事は、Google のオープンソース Protobuf スイートに関するユーザー向けの基礎知識を前提としています。簡潔に言うと、Protobuf は
.proto ファイルでメッセージ群を定義し、コンパイラ(または代替実装)が多くの言語用にエンコーダ・デコーダを生成します。すべて共通のバイナリ「ワイヤ」フォーマットを共有しています。
動機:Perfettoとの連携
Perfetto のトレースビューアは、Protobuf で保存された時間ベースのトレースを可視化します。1 つのトレースは、
TracePacket メッセージが長い列として並ぶ構造です。各 TracePacket は、トラックとそのトラック上のイベントを記述します。例:
message TracePacket { optional uint64 timestamp = 8; oneof data { TrackEvent track_event = 11; TrackDescriptor track_descriptor = 60; // … } }
課題:順序付きエンコードとデシリアライズ
CircumSpect でトレースを生成すると、
TracePacket の数が数百万に達し、ギガバイト級のデータになります。Perfetto はすべてのパケットを単一の Trace メッセージにまとめ、repeated フィールドとして保持します:
message Trace { repeated TracePacket packet = 1; }
Protobuf のシリアライズ形式は「ゼロコピー」ではなく、まずメモリ上で構造体を作成し、それからワイヤフォーマットへ変換する必要があります。非常に大きなトレースの場合、これは膨大なメモリを消費します。
理想的には以下のようにしたい:
- パケットが生成され次第、ディスクへ書き込む(ストリーミング出力)。
- 全体のトレースを読み込まずに個々のパケットを処理する(ストリーミング入力)。
ほとんどの Protobuf ライブラリはこの機能を直接サポートしていません。
Protobuf のワイヤーフォーマット
VARINTs
Protobuf で最も重要なのが可変長整数(「varint」)です。小さい数値は 1 バイト、より大きいものは複数バイトで表現されます。例として
42 (0x2a) は 1 バイトの 0x2a に、255 (0xff) は 2 バイト 0xff 0x01 になります。
基本的なメッセージ構造
メッセージは key‑value ペアの連続です。key はフィールド番号とワイヤタイプをエンコードします:
(key = (field_number << 3) | wire_type)
ワイヤタイプ(wire type)は以下の通りです。
| ID | 型 |
|---|---|
| 0 | varint (int32, int64, uint32 等) |
| 1 | 64‑bit 固定長(fixed64, double) |
| 2 | 長さ付き(string, bytes, 埋め込みメッセージ、packed repeated) |
| 5 | 32‑bit 固定長(fixed32, float) |
key 自体も varint でエンコードされます。
例
message Msg { uint64 data = 1; }
{data: 42} のエンコード:
key : (1 << 3) | 0 = 0x08 → 0x08 value : 42 → 0x2a
結果は
0x08 0x2a。
ネストされたメッセージ
埋め込みメッセージは wire type 2 を使用します。key の後に varint 長さが付き、続いて子メッセージのエンコードが入ります。
例:
message Parent { Msg child = 1; }
{child: {data: 42}} のエンコード:
key : (1 << 3) | 2 = 0x0a → 0x0a len : 2 → 0x02 child : 0x08 0x2a → 0x08 0x2a
結果は
0x0a 0x02 0x08 0x2a。
repeated フィールド
スカラーの repeated は packed(wire type 2)でエンコードされます。埋め込みメッセージの repeated は単に key‑value ペアを繰り返すだけです。
例:
message MultiParent { repeated Msg children = 1; }
{data:42} を 2 個持つ場合:
0x0a 0x02 0x08 0x2a (最初の子) 0x0a 0x02 0x08 0x2a (次の子)
全体像の整理
Trace は「packet = 1」という repeated 埋め込みメッセージフィールドです。したがって各 TracePacket のエンコードは以下のようになります:
– フィールド 1、wire type 2 の key。0x0a- パケット長を varint で表す。
- パケット自身の Protobuf エンコード。
この構造により ストリーミング が可能です:パケットをエンコードし、key と長さを書き込み、ファイルへ追記します。同様に、最初に key と長さを解析すれば 1 パケットずつ読み込めます。
逐次シリアライズ/追加
以下は Rust(
prost)の擬似コードで、既存の Trace バッファにパケットを追記する例です:
/// u64 を Protobuf varint としてエンコード。 fn encode_varint(mut v: u64, buf: &mut Vec<u8>) { loop { let septet = (v & 0x7F) as u8; v >>= 7; let ctrl_bit = if v == 0 { 0 } else { 0x80 }; buf.push(septet | ctrl_bit); if v == 0 { return; } } } /// 既にエンコード済みの Trace バッファへ TracePacket を追加。 fn append_trace_packet(pckg: &TracePacket, buf: &mut Vec<u8>) -> anyhow::Result<()> { encode_varint(0x0A, buf); // key encode_varint(pckg.encoded_len() as u64, buf); // 長さ pckg.encode(buf)?; // パケットデータ Ok(()) }
逐次デシリアライズ
トレースバッファから1パケットを読み取るには、まず key と長さを解析します:
fn consume_byte(buf: &mut &[u8]) -> Option<u8> { if buf.is_empty() { return None; } let (byte, rest) = buf.split_at(1); *buf = rest; Some(byte[0]) } fn decode_varint(buf: &mut &[u8]) -> anyhow::Result<u64> { let mut v = 0u64; let mut offs = 0usize; loop { let byte = consume_byte(buf).ok_or_else(|| anyhow!("Unterminated varint"))?; let septet = (byte & 0x7F) as u64; let ctrl_bit = byte & 0x80; v |= septet << offs; offs += 7; if ctrl_bit == 0 { break; } if offs >= 70 { return Err(anyhow!("Varint too long")); } } Ok(v) } fn decode_next_trace_packet(buf: &mut &[u8]) -> anyhow::Result<(TracePacket, usize)> { let len_orig = buf.len(); // key let key = decode_varint(buf)?; if key != 0x0A { return Err(anyhow!("Incorrect Key Byte")); } // 長さ let len = decode_varint(buf)?; if len == 0 { return Err(anyhow!("Incorrect Len (zero)")); } if len > buf.len() as u64 { return Err(anyhow!("Incorrect Len (more than remaining bytes)")); } // パケットデータ let (packet_buf, rest) = buf.split_at(len as usize); *buf = rest; let pkt = TracePacket::decode(&mut packet_buf[..])?; if !packet_buf.is_empty() { return Err(anyhow!("Incorrect Len (bytes left after decode)")); } Ok((pkt, len_orig - buf.len())) }
完全なサンプル
完全に動作する例(テスト付き)は同梱リポジトリで確認できます。ここでは
TracePacket オブジェクトをファイルへ逐次書き込みし、メモリ上に全体のトレースを保持せずに 1 パケットずつ読み返す方法を示しています。