GoCon2024の補足: Goと見るコンパイラ最適化事情
こんにちは、びしょ~じょです。
まずはこちらを御覧ください。
有村麻央さんはカッコよさを求めている一方どうしてもかわいくなってしまう、アンビバレントなキャラクターです。 なりたい自分、認識される、期待される自分、本来の自分、それらの乖離による悩み、向き合い方、そういった葛藤と成長がストーリーラインとして描かれます。 彼女のソロ楽曲『Fluorite』のMVでもその様子がみえます。
ってアイマスでMoe Shopマジ?! しかもカワイイ系ではなくクール、だけど甘さも残るメロディラインで、まさに有村麻央さんのための曲で間違いないです。
では次に、有村麻央さんの親愛度を10にしてください。
有村麻央さん、いいよね。いい…。
1. はじめに
先日GoConでお話をしてきました。
Go初心者枠ということで20分の枠だったため、GoのPGOを紹介するための最短ルートで説明した。 しかしてエッセンシャルな前提を結構端折っていったため、また語りたい部分をだいぶ削ったので、この記事でその補足をおこなう。
以下、Goの話は公式のコンパイラとそれによりemitされるランタイムの話とする。
2. Goの基本戦略
他言語との比較をおこなうために、まずはGoの基本戦略について個人的見解を述べる。
Goはシンプルな言語である。難しそうな言語機能でパッと思いつくのが、多相とインターフェイスだろうか。
しかし、多相はあるものの、型変数の導入位置は限られている。インターフェイスメソッドも一見難しそうだが冷静に考えると別に難しくない。インターフェイスメソッドが型変数を持てればワンチャン難しさが生まれそうだったが、無いんでムズくない。
別方面の難しさとしては、スライスのインデックスのrange
記法とか、ポインタやnil
があるところ、エラー送出が単に関数の戻り値なので無視できてしまうなどのがあるが、ランタイムに難しくないので一旦見なかったことにする。
そう、ランタイムですげー頑張っているところはさほどない(個人の感想です)。
Goroutines…? すみません、見なかったことにさせてください、すみません。
そんなわけで、Goはコンパイル時に爆最適化しなくてもそこそこ早くプログラムを実行できるポテンシャルが、意味論から分かるわけだ。 しかしてGoコンパイラも、コンパイルパスの一つである最適化を保守的におこなっている。
保守性の興味深い例としては、structのレイアウトだ。
各プリミティブ型の値のサイズは定まっている。任意型のポインタ並びに文字列は8bytes, uint8
は2bytes, struct{}
は実質void
で0bytes。
多相型は、全て特殊化された新たに作られるためコンパイル時に確定する。
フィールドを持つstructは、各フィールドのサイズ、そして順番で決まる。
しかし、Goコンパイラはアラインメントを考慮した順番の変更をおこなわない。
そのため、プログラマが注意しないとパディングが多く挟まるメモリ効率の悪いstructが生成される1。
このレイアウトルールを、令和のプログラマが解決せなあかんの?! そうだ。そのデータがCPUのキャッシュに乗るかどうかは全てあなたにかかっている。
代わりにコンパイルは高速におこなえるし、コンパイラ自身をシンプルな設計にしておける。 Rust、Scala、かかってこい。 とにかく、Goは言語仕様もコンパイラもランタイムもシンプルにしている、そういう思想がある。
- 言語仕様がシンプル
- コンパイル時間が短い
- 実行予測性が高いランタイム生成
ベターな書き方が基本的に無いので、書きながら考えることが少ない。 愚直に書かざるを得ないので、(実行をそのままなぞるという意味で)読みやすい。 コンパイル時間が短いので、フィードバックが早い。 最適化を保守的におこなうことで、ランタイムを予想した書き方ができる。
このように、ジュニア・シニア関係ない開発が容易におこなえる環境を提供するのが、Goのコンセプトになっていると、筆者はかんがえる。
3. インライン化戦略について
発表のとおり、Goはbudgetによってインライン化を制限している。 これはわりとありがちな制限。 一方、non-leafな関数をデフォルトでインライン化しないというのはなんか絶妙なラインで、non-leaf functionsに関して自分が誤解しているかもだが、インライン化全然しないか基本的に全部インライン化するかだと思う。肌感。 例外ハンドラがエラーの設計上存在せず関数呼び出しと戻り値の検査になっているので、インライン化ガンガンしてもいいと思うんだけどな〜。 そしてインライン化を強制するタグがない。
最適化なんてあんまり考えずにとっとと実装しろよというメッセージを感じる。
4. いろんなメソッドとその最適化
インターフェイスメソッドの呼び出しと最適化は、ランタイムの設計とべったりなため個々のケースを見ていくしかない。 Goの事情は発表にて分かったんで、よその様子を見てみる。
4-1. C++(gcc)
C++は通常仮想関数テーブルを使ってメソッドを動的に解決する。 いわゆるvtableで、発表あったinterface value+method listによる動的ディスパッチと同じようなもの。 C++もここでは最適化をおこなわないらしい。
一方、最適化とは別に、型変数まわりでは様子が異なる。
先述のとおり、Goはインターフェイスメソッドに多相な型を持てない。
C++はテンプレートパラメータを使って、メソッド自体に型変数を導入できる。
しかして、例えばList<T>
上のmap<V> : (T -> V) -> List<V>
のようなことが書ける。
ただし、これはコンパイル時にすべて特殊化されるため、コンパイル時間が結構たいへんになり、型検査のエラーもごっつ長いことがある2。
4-2. Scala(scala)
Optimizerのページを読むとなかなかおもしろい。 JVMがJITしてくれるのでScalaはもともとコンパイル時最適化をおこなわなかったらしい。 Scalaは関数型プログラミングをサポートしているが、JVMが想定しているソースがJavaだったため、Scalaで書かれたプログラムを実行するのにJITがあまり効かなかったため、コンパイル時の最適化器をあとからとりいれたとのこと。 この問題はJava8以降のラムダ式でも同様に起こるらしい。
一方上記のページ自体Scala2の話で、Scala3はまだ最適化器が無いらしい。 意味論がより整理されたScala3での最適化はどうなるか気になる。
メソッド呼び出しの解決はJVMによっておこなわれ、JITによってホットパスが最適化される。
型変数、いわゆるジェネリクスは実行時に基底型(Object
、または制約があればその基底型)に潰され、適宜型キャストがはさまる。
Go、C++と比べるとなんつうかリッチだな〜という印象。
JIT最高ですね。
4-3. Rust(rustc)
Rustはtraitsと、structに対するimpl
でメソッドを定義する。
Traitは静的に解決される。
dyn
を使えば動的ディスパッチができる。
4-4. Haskell(GHC)
Haskellはtype classes+instancesでメソッドを定義する。 ここでいうクラスは型上の分類と考えてほしく、ランタイムにクラスインスタンスが生成されるとかではない。 したがって、"メソッド"呼び出しは値ではなく呼び出しのコンテキストに依存する。 こちらは型上の話なので、コンパイル時にすべて解決できる。
この辺のランタイム設計や最適化は言語設計と強く関わっていることが、あらためて分かった。 Goがインターフェイスメソッドに型変数を導入することは、しばらく、あるいは未来永劫、ないだろう。 なので皆さんPGOを使ってください!
5. PGO vs JIT
ScalaがJVMをターゲットにしたコンパイラのためAOT Optimizationsをおこなうつもりがなかったというのは面白い話だった。 JITが実行時の情報を用いて実行時に最適化をおこなうのに対し、PGOは実行時情報をかき集めてコンパイル時に最適化をする。
- JIT
- 実行時に最適化してくれる
- コンパイルの(相対的)速さ
- PGO
- 世代をまたいだ実行時情報に基づく最適化
- ランタイムの暖気が不要
良し悪しといえば良し悪し。
なんか似てるのでバトルになりそう、という話がある。
1ヶ月とか1年みたいに超長期間プログラムをずっとランさせ続けるとしたら、JVMのようなJITコンパイルとGoのようなAOTコンパイルでいうと、実行中にどんどんプロファイルを最適化できるJITが最終的に勝利する可能性もあるのか?🤔
— 吉村 優 ☕ YOSHIMURA Yuu (@_yyu_) June 11, 2024
JITは1 generationの実行時情報のみ参照できるのに対し、GoのPGOはmulti generationsの実行時情報を加味できるため、PGOを用いて生成されたexecutableのほうが、最終的には高速に動作するんではないかと予想する。 そもそも、JITもPGOもある処理系がないと検証は困難な話ではある。
6. Dynamic PGO
そんな実行時のJIT、コンパイル時のPGOという虎と龍がいたんですが、.NETはDynamic PGOという、虎と龍の子のようなものを持っているそうです。
最新の.NET 8だとデフォルトで有効の模様。すげえ。
このスライドに詳しいことがまとまっており、.NET runtimeの変態さも相まって面白い。
JITはオッいけるやんとなったら即最適化して終わりのアグロプレイに対し、Dynamic PGOはstaticなPGOと同様、プロファイルを取得し、より最適化されたコードを生成するのを繰り返すため、レイトになるたびにどんどん性能が(理論上)上がっていく。 .NETではTier Compilationという複数段階(Tier-0、Tier-1の2段)のJIT compilationフェーズがあり、Tier-0ではコンパイル時間の短いサクッとした最適化をおこない、Tier-1のほうでDynamic PGOをおこなうようだ。 これにより、暖気の必要を最小限にしつつ、ライフサイクルの長い実行プロセスのパフォーマンスを爆上げできる。
これでええやん! と一瞬思うが、ネイティブバイナリでも同じようなことができるんかしらという疑問や、単純にランタイムが複雑になりますねというGoの思想と相反するところがある。
7. PGOの課題
さて、〆にPGOの話をする。 PGOには実行情報が必要とのことだが、これの品質について気になるところである。 発表のとおり、K社のGoアプリケーションでは、1ヶ月のうちに得られたプロファイルのCPU使用率が高い上位30個のプロファイルをPGOに利用していた。 このプロファイルは実行時間20分ごとに生成されるプロファイルであり、また1ヶ月に10〜20回は本番デプロイしているため、ランタイムのリビジョンも異なる。
いくつか数字やらが出てきたのでみんな大好き箇条書きにする。
- プロファイル数
- プロファイル1つあたりの計測時間
- リビジョン数
サンプルアプリケーションを動かし、プロファイルが多ければ多いほど最適化が進むというのが手元で観測できたので、プロファイル数は多く取るようにしてある。 このプロファイルは単一リビジョンだった。 プロファイル1つあたりの計測時間も同様に、長いほうが良さそうだった。
じゃあ、プロファイルを24時間取得するとどうなる? 1ヶ月取ると最適化できるものはすべて最適化できるのか? これはなんとも言い難い。 というのも、K社のサービスへのアクセスが24時間でまばらなため、例えば深夜はまったくアクセスがなく、夕方などにアクセスが増加し、APIエンドポイントが多く叩かれる。 これをさらに1ヶ月というスパンでみると、月の頭や給料日にピークがくることもわかる。 じゃあ、夕方のプロファイルだけ使いますか? 月初のプロファイルだけ使いますか? しかし月末に月初のプロファイルを使っても、リビジョンが違いすぎて関数が増えたり減ったりしてて本当に有意なプロファイルなのか…? などなど、最適化をちゃんとすすめてくれる良いプロファイルは何なのか、アプリケーション特性による可能性が高い。 とはいえ、アプリケーションユーザの流入出によってどれくらいアプリケーションが稼働するのか変わるため、プロファイル取得の最適化はなかなか困難に見える。
総括すると、
- プロファイル数 … 多いとよさそう
- プロファイル1つあたりの計測時間 … 長いとよさそう、ただし長すぎると微妙かもしれないしタイミングによるかもしれない
- リビジョン数 … あんまり世代が離れると意味ないんじゃないかなあ
GoのPGO導入は難しくないのでガンガンして良い、一方PGOの最適化はブルーオーシャンかつアプリケーション特性に依存する可能性が高いので難しい。
8. おわりに
今回はGoConの発表のフォローアップという名目で、書きたいことをつらつらと書いた。 コンパイル技術はまだまだ味がしておいしいですね。
-
この辺を参照: Sizeof struct in Go - stackoverflow ↩
-
これはどちらかというと型変数があるかどうかよりも、C++(gcc)における型変数がプリプロセッサによりもたらされる、すなわちコード生成によるものであるため。Template Haskellのエラーがまるで読めないのと同じ。 ↩