Go Protobuf: 新しいOpaque API

Go Protobuf: The new Opaque API by Michael Stapelberg

Protocol Buffers (Protobuf) はGoogleの言語非依存なデータ交換フォーマットです。詳しくは protobuf.dev を参照してください。

2020年3月、私たちは google.golang.org/protobuf モジュールをリリースしました。これはGo Protobuf APIの大規模な刷新でした。このパッケージでは、リフレクションのファーストクラスサポートdynamicpb 実装、そしてテストを容易にする protocmp パッケージが導入されました。

そのリリースでは、新しいAPIを持つ新しいprotobufモジュールが導入されました。そして今日、私たちは生成コード向けの追加APIをリリースします。これはつまり、プロトコルコンパイラ (protoc) によって作られる .pb.go ファイル内のGoコードのことです。この記事では、新しいAPIを作った動機を説明し、それをプロジェクトでどう使うかを紹介します。

はっきりさせておきたいのですが、私たちは何かを削除するわけではありません。既存の生成コード向けAPIは引き続きサポートします。ちょうど、(google.golang.org/protobuf の実装をラップする形で)古いほうのprotobufモジュールを今も変わらずサポートしているのと同じです。Goは後方互換性の維持にコミットしており、これはGo Protobufにも当てはまります!

既存のOpen Struct API

私たちは既存のAPIを Open Struct API と呼ぶことにしました。なぜなら、生成される構造体型が直接アクセスに対して開かれているからです。次のセクションでは、これが新しいOpaque APIとどう違うのかを見ていきます。

プロトコルバッファを使うには、まず次のような .proto 定義ファイルを作成します。

edition = "2023";  // proto2とproto3の後継

package log;

message LogEntry {
  string backend_server = 1;
  uint32 request_size = 2;
  string ip_address = 3;
}

次に、プロトコルコンパイラ (protoc) を実行して、(.pb.go ファイルの中に)次のようなコードを生成します。

package logpb

type LogEntry struct {
  BackendServer *string
  RequestSize   *uint32
  IPAddress     *string
  // …内部フィールドは省略…
}

func (l *LogEntry) GetBackendServer() string {  }
func (l *LogEntry) GetRequestSize() uint32   {  }
func (l *LogEntry) GetIPAddress() string     {  }

これで生成された logpb パッケージをGoコードからインポートし、logpb.LogEntry メッセージをprotobufのワイヤーフォーマットにエンコードするために proto.Marshal のような関数を呼び出せるようになります。

詳細は生成コードAPIのドキュメントを参照してください。

フィールドの有無 (Field Presence)

この生成コードの重要な側面のひとつが、フィールドの有無 (フィールドがセットされているかどうか) をどうモデル化するかです。たとえば、上の例では有無をポインタでモデル化しているため、BackendServer フィールドには次のいずれかを設定できます。

  1. proto.String("zrh01.prod"):フィールドはセットされていて、“zrh01.prod” が入っている
  2. proto.String(""):フィールドはセットされている(nilでないポインタ)が、空の値が入っている
  3. nil ポインタ:フィールドはセットされていない

生成コードにポインタがないことに慣れているとしたら、おそらく syntax = "proto3" から始まる .proto ファイルを使っているのでしょう。フィールドの有無の挙動は、年を経るごとに変わってきました。

  • syntax = "proto2" はデフォルトで 明示的な有無 (explicit presence) を使う
  • syntax = "proto3" はデフォルトで 暗黙的な有無 (implicit presence) を使っていた(このケースでは上記の2番と3番を区別できず、どちらも空文字列として表現される)が、後に optional キーワードで明示的な有無を選択できるように拡張された
  • proto2とproto3の両方の後継である edition = "2023" は、デフォルトで明示的な有無を使う

新しいOpaque API

私たちが新しい Opaque API を作ったのは、生成コードAPI をメモリ上の内部表現から切り離すためです。(既存の) Open Struct APIにはこのような分離がありません。プログラムがprotobufメッセージのメモリに直接アクセスできてしまいます。たとえば、flag パッケージを使ってコマンドラインフラグの値をprotobufメッセージのフィールドにパースすることもできてしまいます。

var req logpb.LogEntry
flag.StringVar(&req.BackendServer, "backend", os.Getenv("HOST"), "…")
flag.Parse() // -backend フラグから BackendServer フィールドを埋める

このような密結合の問題点は、protobufメッセージのメモリ上のレイアウトを二度と変更できなくなってしまうことです。この制約を取り除くことで、以下で見るような多くの実装上の改善が可能になります。

新しいOpaque APIで何が変わるのでしょうか。上の例で生成されるコードは次のように変わります。

package logpb

type LogEntry struct {
  xxx_hidden_BackendServer *string // もうエクスポートされない
  xxx_hidden_RequestSize   uint32  // もうエクスポートされない
  xxx_hidden_IPAddress     *string // もうエクスポートされない
  // …内部フィールドは省略…
}

func (l *LogEntry) GetBackendServer() string {  }
func (l *LogEntry) HasBackendServer() bool   {  }
func (l *LogEntry) SetBackendServer(string)  {  }
func (l *LogEntry) ClearBackendServer()      {  }
// …

Opaque APIでは、構造体のフィールドは隠され、直接アクセスできなくなります。代わりに、新しいアクセサメソッドによってフィールドの取得、設定、クリアができるようになります。

Opaque構造体の省メモリ化

メモリレイアウトに加えた変更のひとつは、要素的な(elementary)フィールドの有無をより効率的にモデル化することです。

  • (既存の) Open Struct API はポインタを使うため、フィールドの容量コストに64ビットワード分が加算されます。
  • Opaque API はビットフィールドを使うため、(パディングのオーバーヘッドを無視すれば)フィールド1つにつき1ビットで済みます。

変数とポインタの数を減らすことは、アロケータとガベージコレクタへの負荷も下げます。

パフォーマンスの改善幅はプロトコルメッセージの形状に大きく依存します。この変更が影響するのは整数、真偽値、enum、浮動小数点数といった要素的なフィールドのみで、文字列、repeatedフィールド、サブメッセージには影響しません(これらの型に対しては利益が小さいためです)。

私たちのベンチマーク結果によると、要素的なフィールドが少ないメッセージはこれまでと同程度のパフォーマンスを示す一方、要素的なフィールドが多いメッセージは、デコード時のアロケーション数が大幅に少なくなります。

             │ Open Struct API │             Opaque API             │
             │    allocs/op    │  allocs/op   vs base               │
Prod#1          360.3k ± 0%       360.3k ± 0%  +0.00% (p=0.002 n=6)
Search#1       1413.7k ± 0%       762.3k ± 0%  -46.08% (p=0.002 n=6)
Search#2        314.8k ± 0%       132.4k ± 0%  -57.95% (p=0.002 n=6)

アロケーションを減らすことは、protobufメッセージのデコードもより効率的にします。

             │ Open Struct API │             Opaque API            │
             │   user-sec/op   │ user-sec/op  vs base              │
Prod#1         55.55m ± 6%        55.28m ± 4%  ~ (p=0.180 n=6)
Search#1       324.3m ± 22%       292.0m ± 6%  -9.97% (p=0.015 n=6)
Search#2       67.53m ± 10%       45.04m ± 8%  -33.29% (p=0.002 n=6)

(すべての測定はAMD Castle Peak Zen 2上で行われました。ARMおよびIntel CPUでの結果も同様です。)

注: 暗黙的な有無を使うproto3も同様にポインタを使わないため、proto3から移行してきた場合はパフォーマンスの改善が見られないでしょう。パフォーマンス上の理由から暗黙的な有無を使っていて、空のフィールドと未セットのフィールドを区別できる利便性を諦めていたのであれば、Opaque APIによって、パフォーマンスを犠牲にすることなく明示的な有無を使えるようになります。

遅延デコーディング (Lazy Decoding)

遅延デコーディングは、サブメッセージの内容を proto.Unmarshal の実行中ではなく、最初にアクセスされたときにデコードするパフォーマンス最適化です。一度もアクセスされないフィールドの不要なデコードを避けることで、パフォーマンスを改善できます。

遅延デコーディングは、(既存の) Open Struct APIでは安全にサポートできません。Open Struct APIはゲッターを提供していますが、(デコードされていない)構造体フィールドを露出したままにするのは非常にエラーの温床になります。フィールドが最初にアクセスされる直前にデコード処理が確実に走るようにするには、フィールドを非公開にし、そのすべてのアクセスをゲッターとセッター関数経由にする必要があります。

このアプローチによって、Opaque APIで遅延デコーディングを実装することが可能になりました。もちろん、すべてのワークロードがこの最適化から恩恵を受けるわけではありませんが、恩恵を受けるワークロードにとっては、その結果は劇的なものになりえます。私たちは、トップレベルのメッセージの条件(たとえば backend_server が新しいLinuxカーネルバージョンを実行しているマシンのいずれかかどうか)に基づいてメッセージを破棄し、メッセージの深くネストしたサブツリーのデコードをスキップできるログ解析パイプラインを見てきました。

一例として、私たちが含めたマイクロベンチマークの結果を示します。遅延デコーディングによって処理量が50%以上、アロケーション数が87%以上削減されていることがわかります!

                  │   nolazy    │                lazy                │
                  │   sec/op    │   sec/op     vs base               │
Unmarshal/lazy-24   6.742µ ± 0%   2.816µ ± 0%  -58.23% (p=0.002 n=6)

                  │    nolazy    │                lazy                 │
                  │     B/op     │     B/op      vs base               │
Unmarshal/lazy-24   3.666Ki ± 0%   1.814Ki ± 0%  -50.51% (p=0.002 n=6)

                  │   nolazy    │               lazy                │
                  │  allocs/op  │ allocs/op   vs base               │
Unmarshal/lazy-24   64.000 ± 0%   8.000 ± 0%  -87.50% (p=0.002 n=6)

ポインタ比較のミスを減らす

フィールドの有無をポインタでモデル化すると、ポインタ関連のバグを招きます。

LogEntry メッセージ内で宣言されたenumを考えてみましょう。

message LogEntry {
  enum DeviceType {
    DESKTOP = 0;
    MOBILE = 1;
    VR = 2;
  };
  DeviceType device_type = 1;
}

device_type のenumフィールドを次のように比較するのはよくある間違いです。

if cv.DeviceType == logpb.LogEntry_DESKTOP.Enum() { // 間違い!

バグに気づいたでしょうか。この条件は値ではなくメモリアドレスを比較しています。Enum() アクセサは呼び出しのたびに新しい変数をアロケートするため、この条件が真になることは決してありません。正しくは次のように書くべきでした。

if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {

新しいOpaque APIはこのミスを防ぎます。フィールドが隠されているため、すべてのアクセスはゲッターを経由しなければならないからです。

意図しない共有によるミスを減らす

もう少し込み入ったポインタ関連のバグを考えてみましょう。高負荷時に失敗するRPCサービスを安定化させようとしていると仮定します。リクエストミドルウェアの次の部分は正しく見えますが、それでも1人の顧客が大量のリクエストを送るたびにサービス全体がダウンしてしまいます。

logEntry.IPAddress = req.IPAddress
logEntry.BackendServer = proto.String(hostname)
// redactIP() 関数はIPAddressを127.0.0.1にマスクしますが、
// 予期せず logEntry だけでなく req でも同様にマスクしてしまいます!
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
    // バグ: 送信元にかかわらず、すべてのリクエストがここに来てしまう。
    return fmt.Errorf("server overloaded")
}

バグに気づいたでしょうか。1行目はうっかりポインタをコピーしてしまい(それによって指し示す先の変数が logEntryreq の間で共有されてしまい)、値をコピーしたわけではありませんでした。正しくは次のように書くべきでした。

logEntry.IPAddress = proto.String(req.GetIPAddress())

新しいOpaque APIは、セッターがポインタではなく値 (string) を受け取るため、この問題を防ぎます。

logEntry.SetIPAddress(req.GetIPAddress())

リフレクションの危険な角を丸める

特定のメッセージ型(たとえば logpb.LogEntry)だけでなく、どんなメッセージ型に対しても動作するコードを書くには、何らかのリフレクションが必要です。先の例ではIPアドレスをマスクする関数を使いましたが、どんな型のメッセージにも対応させるには func redactIP(proto.Message) proto.Message { … } のように定義できたでしょう。

何年も前は、redactIP のような関数を実装する唯一の選択肢はGoの reflect パッケージに頼ることでした。しかしこれは非常にきつい結合を生み出します。手元にあるのはジェネレータの出力だけで、入力となるprotobufメッセージの定義がどのようなものだったかを逆算しなければなりませんでした。2020年3月にリリースされたgoogle.golang.org/protobuf モジュールは、常に優先して使うべきProtobufリフレクションを導入しました。Goの reflect パッケージはデータ構造の(本来は実装の詳細であるはずの)表現をたどりますが、Protobufリフレクションは表現方法に関係なくプロトコルメッセージの論理的な木構造をたどります。

残念ながら、単にprotobufリフレクションを 提供する だけでは十分ではなく、いくつかの危険な角がまだ露出したままになっています。場合によっては、ユーザーが誤ってprotobufリフレクションの代わりにGoのリフレクションを使ってしまうことがあります。

たとえば、(Goのリフレクションを使う) encoding/json パッケージでprotobufメッセージをエンコードすることは技術的には可能ですが、その結果は正規のProtobuf JSONエンコーディングにはなりません。代わりにprotojsonパッケージを使ってください。

新しいOpaque APIは、メッセージ構造体のフィールドが隠されているため、この問題を防ぎます。Goのリフレクションをうっかり使ってしまっても、空のメッセージが見えるだけです。これは開発者をprotobufリフレクションへと導くのに十分明確です。

理想的なメモリレイアウトを可能にする

前述の「Opaque構造体の省メモリ化」セクションで見たベンチマーク結果は、protobufのパフォーマンスが具体的な使われ方に大きく依存することをすでに示しています。メッセージはどう定義されているか。どのフィールドがセットされているか。

Go Protobufを 誰にとっても できる限り高速に保つために、私たちは、ある1つのプログラムだけを助けて他のプログラムのパフォーマンスを損なうような最適化を実装するわけにはいきません。

Goコンパイラも、Go 1.20がプロファイルガイド最適化 (PGO) を導入するまでは同様の状況にありました。本番環境での挙動を(プロファイリングによって)記録し、そのプロファイルをコンパイラにフィードバックすることで、コンパイラは 特定のプログラムやワークロードに対して よりよいトレードオフを選べるようになります。

私たちは、プロファイルを使って特定のワークロード向けに最適化するというアプローチが、さらなるGo Protobufの最適化にとって有望だと考えています。Opaque APIはそれを可能にします。プログラムのコードはアクセサを使うため、メモリ上の表現が変わってもコード自体を更新する必要がありません。そのため、たとえば、めったにセットされないフィールドをオーバーフロー用の構造体に移す、といったことも可能になります。

移行

移行は自分のペースで進めてよいですし、まったく移行しなくても構いません。(既存の) Open Struct APIが削除されることはないためです。ただし、Opaque APIに移行していないと、その改善されたパフォーマンスや、今後Opaque APIを対象とした最適化の恩恵を受けられません。

新規開発にはOpaque APIを選ぶことをお勧めします。Protobuf Edition 2024(まだよく知らない方はProtobuf Editions Overviewを参照してください)では、Opaque APIがデフォルトになります。

Hybrid API

Open Struct APIとOpaque APIのほかに、Hybrid APIというものもあります。これは構造体のフィールドをエクスポートされたままにすることで既存のコードを動き続けさせつつ、新しいアクセサメソッドを追加することでOpaque APIへの移行も可能にします。

Hybrid APIでは、protobufコンパイラは2つのAPIレベルのコードを生成します。.pb.go はHybrid APIのコードであり、_protoopaque.pb.go の方はOpaque APIのコードで、protoopaque ビルドタグを付けてビルドすることで選択できます。

コードをOpaque APIに書き換える

詳しい手順は移行ガイドを参照してください。大まかな手順は次のとおりです。

  1. Hybrid APIを有効にする。
  2. open2opaque 移行ツールを使って既存のコードを更新する。
  3. Opaque APIに切り替える。

公開する生成コードにはHybrid APIを使おう

protobufの小規模な使用であれば同じリポジトリの中だけで完結することもありますが、通常、.proto ファイルは異なるチームが所有する異なるプロジェクトの間で共有されます。わかりやすい例は、複数の会社がかかわる場合です。(protobufを使った) Google APIを呼び出すには、自分のプロジェクトからGoogle Cloud Client Libraries for Goを使います。Cloud Client LibrariesをOpaque APIに切り替えることは破壊的なAPI変更になるため選択肢にはなりませんが、Hybrid APIへの切り替えなら安全です。

このような、生成コード(.pb.go ファイル)を公開しているパッケージへの私たちからのアドバイスは、ぜひHybrid APIに切り替えることです。.pb.go_protoopaque.pb.go の両方のファイルを公開してください。protoopaque 版があれば、パッケージの利用者は自分のペースで移行できます。

遅延デコーディングを有効にする

Opaque APIに移行すると、遅延デコーディングが(有効にはなっていませんが)使えるようになります! 🎉

有効にするには、.proto ファイル内のメッセージ型のフィールドに [lazy = true] アノテーションを付けます。

(.proto のアノテーションにかかわらず)遅延デコーディングを無効にしたい場合は、protolazy パッケージのドキュメントに、個々のUnmarshal操作またはプログラム全体に影響を与えるオプトアウト方法が説明されています。

次のステップ

open2opaqueツールを自動的な形で運用することで、私たちはここ数年でGoogleの .proto ファイルとGoコードの大部分をOpaque APIに変換してきました。より多くの本番ワークロードをOpaque APIに移すにつれて、私たちはその実装を継続的に改善してきました。

したがって、Opaque APIを試す際に問題に遭遇することはまずないと考えています。それでも万が一問題に遭遇した場合は、Go Protobufのissueトラッカーで私たちに知らせてください。

Go Protobufのリファレンスドキュメントは protobuf.dev → Go Reference にあります。

By Michael Stapelberg