Go 1.18で安定版となるGoジェネリクスに続いて、PlanetScaleパフォーマンスエンジニアのVicent Martí氏は、それらがどのように機能するかを分析し、実際の実装のいくつかのパフォーマンス制限をハイライトした。彼はまた、役に立つ使用法について提案した。
Goジェネリクス実装の重要な特徴の1つは、ジェネリクスコードをコンパイルするためにC++、D、Rustなどの言語で使用される手法であるモノモルフィゼーションを部分的にしか使っていないことである。一言で言えば、モノモルフィゼーションとは、関数の実装を複製して、それを個別の型に特殊化することである。モノモルフィゼーションは通常、コンパイル時間とバイナリサイズを犠牲にすることで、継承ベースのポリモーフィックコードよりも高速となる。実際、モノモルフィゼーションでは呼び出しのオーバーヘッドがゼロとなるが、継承ベースのポリモーフィズムでは、仮想ディスパッチテーブルを介した間接ポインターを使用する必要がある。さらに、モノモルフィライズされたコードは、コンパイラによって特別に最適化および/あるいはインライン化される。
Martí氏によると、Goではモノモルフィゼーションは部分的にのみ適用され、「辞書を使用したGCShapeステンシル」と呼ばれる手法が使われる。この主な効果は、ポインタ型あるいはインターフェースのすべての引数が、同じベースの型に属するものとして扱われることである。つまり、その関数のモノモルフィックバージョンが1つだけ生成される。これを、int32
やfloat64
などの算術用の引数を取る関数と比較してみよう。これらの関数はそれぞれ独自の特殊な関数バージョンを取得する。
1.18でジェネリクスを使用するためにインターフェースを使用するピュア関数を変換するインセンティブはありません。変換すると、速度が低下するだけです。Goコンパイラは現在、メソッドがポインタを介して呼び出される関数の形状を生成できないためです。
Martí氏は、PlanetScaleのVitessオープンソースデータベースを使用して、ジェネリクスの導入が複雑な実在のアプリケーションにどのように影響するかをテストしている。彼はアセンブリレベルまで深堀りすることで、Goコンパイラによって生成されたコードがどのように見えるかを研究し、自身の論拠を形作っている。さらに、彼はいくつかのマイクロベンチマークを実行して、ベストケースのシナリオでジェネリクスがどれだけ遅くなる可能性があるかを正確に測定した。ただし、より現実的なシナリオでは、より悪い結果となる可能性がある。
実際の本番サービスではキャッシュの競合が発生する可能性があります。グローバルのitabTableには数百から数百万のエントリが含まれる可能性があります[...]。これは、Goプログラムのジェネリクスメソッド呼び出しのオーバーヘッドが、コードベースの複雑さとともに低下することを意味します。
Martí氏の推論の詳細に興味がある場合は、彼の非常に詳細で根拠のある分析をお見逃しなく。
分析の終わりに、Martí氏はGo 1.18でのジェネリクスの使用に関して、いくつか提案している。具体的には、メソッド呼び出しを非仮想化やインライン化しようとしたり、インターフェイスをジェネリクス関数に渡したり、インターフェイスベースのAPIをジェネリクスを使用するために書き直したりすることを非推奨としている。逆に、ジェネリクスでは、ジェネリクスデータ構造とコールバック引数を使用して、string
と[]byte
を引数として受け取るメソッドの重複を排除することが将来期待できるように見える。
ただし、Goジェネリクス仕様において、トレードオフの選択肢が様々な将来のバージョンにおいて、これらすべての改善を妨げるものはない。ただし、潜在的なリスクがどこにあるのかを知り、パフォーマンスの測定を忘れずにジェネリクスをコードベースに段階的に適用した方がよいでしょう。