Skip to main content

グローバル変数使用を削減するためのClang使用

9月

03, 2020

by RandomTruffle


テック

価値のあるプログラムはすべて、少なくともある程度のグローバル状態がありますが、ありすぎることは良くない場合があります。 C++(Robloxのエンジンコードのほぼ100%を構成している)では、このグローバル状態はmain()の前に初期化され、 main()に戻った後に破壊され、これはほとんど非決定性の順序で起きます。 原因を推論する(または変更する)のが難しい紛らわしい始動とセマンティクスのシャットダウンという結果につながることに加えて、ひどい不安定さにもつながる可能性があります。

またRobloxコードは、長時間実行される多数の切断されたスレッド(決して結合されることのないスレッドで、止まると決めるまでただ実行し続けるが終わらないかもしれない)を作成します。 これらの2つのことは、重なるとシャットダウンで非常に深刻なネガティブな交流をします。それは、長時間実行されているスレッドは破壊されているグローバル状態にアクセスし続けるからです。 これは、クラッシュ率の増加、テストスイートの脆弱性、そして全般的な不安定さにつながります。

このような混乱から脱出する最初のステップは、問題の程度を理解することなので、この記事ではグローバル始動フローの可視性を得る一つのテクニックについてお話しましょう。 グローバル変数の使用を削減することでRobloxのゲームエンジンプラットフォーム全体で安定性を向上させるのに、これを当社がどう使っているかを述べます。

-finstrument-functionsの導入

今まで使ったことのない新しい見慣れないコンパイラの選択肢について知ることほど私にとってエキサイティングなことはありません。ですから、同僚がClang Command Line Referenceの中にあるこのオプションに導いてくれたときは、かなり嬉しかったです。 これは、それまで使ったことがありませんでしたが非常にクールだと感じました。 コンパイラに関数が入力されたり、終了したりするたびに通知させることができればという考え方があります。何らかのシンボライザーでこの情報をフィルタすることを通じて、関数のレポートを生成するのは、 a) main()の前に起き、 b) コールスタックで一番最初の関数 (グローバルであることを示す)です。

残念なことに、基本的に文書化は使い方や、してくれそうなことを実際にするかどうかを述べることなしに、その選択肢が存在することを教えてくれるだけです。 似たように見える2つの違った選択肢があり (-finstrument-functions-finstrument-functions-after-inlining)、そして私はまだその違いが完全にはよくわかっていません。 何が起きたかを見るためにgodbolt上で簡単なサンプルを吐き出すことに決めましたが、それはここで見れます。 同じソースリストの2つのアセンブリ出力があることに注意してください。 1つは最初のオプションを使い、もう一つは2つ目のオプションを使っているので、違いを理解するためにアセンブリ出力を比べることができます。 このサンプルからいくつか学べることがあります。

  1. コンパイラは、インラインであってもなくても、コールをすべての関数の中にある__cyg_profile_func_enter__cyg_profile_func_exitに対して注入しています。
  2. 2つの選択肢の唯一の違いは、インライン関数のコールサイトで起きます。
  3. -finstrument-functionsでは、コールサイトで計装のインライン済み関数が挿入され、 -finstrument-functions-after-inliningでは、外積関数のための計装しかありません。 これは、-finstrument-functions-after-inliningを使っているとき、どの関数がどこでインラインかを決定することができないという意味です。

もちろん、これは文書化がすると言ったことそのもののようですが、自分を納得させるには中身を見る必要があることもあります。

このすべてを違う方法でするには、このトレースでのインライン関数へのコールについて知りたい場合は、-finstrument-functionsを使う必要があります。そうでなければ、その計装がコンパイラによって黙って削除されるからです。 悲しいことに、実際の例では-finstrument-functions を機能させることはできませんでhした。 いつもStandard C++ ライブラリの深部にあるリンカーエラーにつながり、これは理由が分かりませんでした。 私の最も有力な予測は、インラインは試行錯誤的なことが多く、これはどういうわけかオプティマイザーが違う変換単位から違うインライン決定をするときに微妙なODR (1つの定義ルール) 違反につながることがあります。 幸運なことに、グローバルコンストラクタ(これは私たちが大事にしているものです)はいずれにせよインラインではありえないため、これは問題ではありませんでした。

-finstrument-functions-after-inliningでも、依然として数多くのリンカーエラーがあるということを言わねばならないと思いますが、それはすでに理解できました。 できる限りうまく言うとしたら、この選択肢は–whole-archiveリンカーセマンティクスを伴うようです。 –whole-archiveについての議論は、このブログ記事の範疇外ですが、コンパイラのコマンドライン上でリンカーグループ(例えば-Wl,–start-group-Wl,–end-group)を使うことで解決したと言えば十分でしょう。 この選択肢なしでこれらの同じリンカーエラーにならなかったことに少し驚いていて、まだその理由は完全に理解できていません。 もし、この選択肢がなぜリンカーセマンティクスを変更するかご存知でしたら、コメント欄で教えてください!

コールバックフックの導入

もし、あなたが鋭敏なら、一体__cyg_profile_func_enter__cyg_profile_func_exit は何なのか、そしてなぜプログラムが未定義記号リファレンスエラーを出すことなく前者でうまくリンクしているのかを訝しんでいるかもしれません。というのは、コンパイラは明らかに定義したことのない関数を呼び出そうとしているからです 幸運なことに、そもそもこの記号がどこから来たかを明らかにできるように、リンカー内のアルゴリズムを見ることができるという選択肢がありました。 特に、 -y <symbol> は、リンカーがどのように問題解決しているかを教えてくれるはずです<symbol>。 まず、ダミープログラムと私たちが自分で定義した1つの記号で試し、それからh __cyg_profile_func_enter で試します。

zturner@ubuntu:~/src/sandbox$ cat instr.cpp
int main() {}

zturner@ubuntu:~/src/sandbox$ clang++-9 -fuse-ld=lld -Wl,-y -Wl,main instr.cpp
/usr/bin/../lib/gcc/x86_64-linux-gnu/crt1.o: reference to main
/tmp/instr-5b6c60.o: definition of main

ここでは、びっくりするようなことはありません。 C Runtimeライブラリはmain()を参照し、オブジェクトファイルがそれを定義します。 ここで、__cyg_profile_func_enter-finstrument-functions-after-inliningに何が起きたかを見てみましょう。

zturner@ubuntu:~/src/sandbox$ clang++-9 -fuse-ld=lld
-finstrument-functions-after-inlining -Wl,-y -Wl,__cyg_profile_func_enter instr.cpp
/tmp/instr-8157b3.o: reference to __cyg_profile_func_enter
/lib/x86_64-linux-gnu/libc.so.6: shared definition of __cyg_profile_func_enter

ここで、libcが定義を提供するのが分かり、オブジェクトファイルがそれを参照します。 Unix-yプラットフォーム上では、リンク作成はWindowsとは少し違った機能の仕方をしますが、基本的にはこれが意味するのは、cppファイル内でこの関数を自分で定義すればリンカーはただ自動的に共有されたライブラリバージョンよりもそれを優先するということです。 実行時間出力なしでのgodboltリンク作業はこちら。 今、これでだいたいどこに向かっているかお分かりだと思いますが、まだ解決していない問題がいくつかあります。

  1. これをプログラム全体でやるつもりはありません。 メインに到達したらすぐに止めるつもりです。
  2. このトレースを記号化する方法が必要です。

最初の問題を解決するのは簡単です。 しなければならないことは、呼び出された関数のアドレスをメインのアドレスと比較して、そこからトレースを止めるべきだということを示すフラグを設定することだけです。 (メインのアドレスを取ることは、未定義の動作[1]ですが、この目的には役割を果たしてくれ、このコードを出荷するわけではないので、 ¯\_(ツ)_/¯)。 二つ目の問題は、おそらくもう少し議論の余地があるでしょう。

トレースの記号化

これらのトレースを記号化するには、2つのものが必要です。 まず、永続ストレージのどこかにトレースを保管する必要があります。 どのような妥当な性能でも、リアルタイムで記号化することは期待できません。 魔法のファイル名にトレースを保存するために何らかのCコードを書くか、私がしたことをすることもできます。それは、stderr(この方法で 実行するときにstderrを何らかのパイプにつなぐことができます)に書くことです。

次に、そしておそらくさらに重要なことは、それぞれのアドレスに対して、アドレスが属しているモジュールへのフルパスを書き出すことが必要です。 あなたのプログラムは、多くの共有されたライブラリを読み込みますが、アドレスを記号に変換するためには、どの共有されたライブラリまたは実行可能なアドレスが実際にどこに属しているのかを知らなくてはなりません。 加えて、ディスク上のファイルに記号のアドレスを書き出すには注意しなければなりません。 プログラムの実行、オペレーティングシステムはメモリのどこにでも読み込んだ可能性があります。 そして、もし事後に記号化するならば、メモリで読み込まれた場所の情報が失われた後に参照できることを確かめる必要があります。 linux関数dladdr()は、必要な情報のどちらも与えてくれます。 機能しているgodboltサンプルの当社のコードベースに現れるインストルメンテーションフックの厳密な導入は、こちらです。

すべてを組み合わせる

これで、ファイルがこのフォーマットでディスクに保存してあるので、あとはアドレスを記号化するだけです。addr2lineは、一つの選択肢ですが、こちらのほうがさらに強靭だと思ったのでllvm-symbolizer にしました。 Pythonスクリプトを書いてファイルを構文解析して、それぞれのアドレスを記号化し、元の出力ファイルがある同じ視覚的階層のフォーマットに印字します。 結果となる記号リストをフィルタするには、様々な選択肢があるので、あなたのケースにとって面白いものだけを入れて出力をクリーンアップできます。 例えば、その名前の中でboost::があるグローバルはすべて除外しました。これは、グローバル変数を使わないようにブーストを厳密に書き直すことができないからです。

スクリプトは思っているほど単純ではないのは、それぞれの行を這っていき記号化していくということは受け入れられないほどゆっくりしている(これを試したときは、最後にプロセスを断念するまで2時間もかかりました)からです。 これは、同じアドレスは何千回も現れるため、同じアドレスに対してllvm-symbolizerを複数回実行する理由がないからです。 アドレスリストの事前プロセスと重複の削除をするには、かなりの賢さがいります。 凄く面白いものではないので導入についてはこれ以上、詳しくは述べません。 しかし、それよりもっといいことをします。みなさんにソースをご提供します。

ですから、すべてが終わった後、コールツリーを取得するために内部ターゲットのうちどれでも1つを実行することができ、スクリプトを通じて実行し、それからこのように出力を得ます(ソースファイル情報が削除済みのRobloxプロセスからの実際の出力)。

excluded_symbols = [‘.*boost.*’]
excluded_modules = [‘/usr.*’]
/usr/lib/x86_64-linux-gnu/libLLVM-9.so.1: 140 unique addresses
InterestingRobloxProcess: 38928 unique addresses
/usr/lib/x86_64-linux-gnu/libstdc++.so.6: 1 unique addresses
/usr/lib/x86_64-linux-gnu/libc++.so.1: 3 unique addresses
29276グローバル変数の深度2でコールツリーをプリント。
__cxx_global_var_init.5 (InterestingFile1.cpp:418:22)
RBX::InterestingRobloxClass2::InterestingRobloxClass2() (InterestingFile2.cpp.:415:0)
__cxx_global_var_init.19 (InterestingFile2.cpp:183:34)
(anonymous namespace)::InterestingRobloxClass2::InterestingRobloxClass2()
(InterestingFile2.cpp:171:0)
__cxx_global_var_init.274 (InterestingFile3.cpp:2364:33)
RBX::InterestingRobloxClass3::InterestingRobloxClass3()

これでお渡ししました。これで戦は半分は終わりました。 このスクリプトをどのプラットフォームでも実行して、グローバルが実際にどのような順序で初期化されるのかを理解するために結果を比べ、それからゆっくりこのコードをグローバル初期化子から決定的で明確にできるメインにマイグレーションさせます。

未来の仕事

これを導入してしばらくした後に思ったのは、公開記号 (Windows言語が分かれば、dllexportしたもの)のいくつかを露出した一般的な目的のプロファイルフックを作成できるということです。そして、プラグインモジュールがこれに動的にフックで掛けられるようにしました。 このプラグインモジュールは、興味を持ったどのような任意の論理を用いてでもアドレスをフィルタできます。 私が行き当たった一つの興味深い使用例は、これはデバッグ情報を調べることができ、現在のアドレスが静的ローカル関数のコンストラクタをマッピングするかをチェックし、もしそうならアドレスを書き出すことです。 これによって、当社のレイジーな静的なものが初期化される順序について効果的により深い理解を得ることができました。 ここでは可能性は無限です。

参考文献

このような話に興味をお持ちでしたら、このような話題で私のお気に入りの資料をいくつか集めています。

  1. 各著者: The C++ 言語標準バージョン
  2. マット・ゴッドボルト(Matt Godbolt): The Bits Between the Bits: How We Get to main()
  3. ライアン・オニール(Ryan O’Neill): Learning Linux Binary Analysis
  4. ジョン・レヴィーン(John R. Levine ): Linkers and Loaders

  1. https://eel.is/c++draft/basic.exec#basic.start.main-3

Robloxコーポレーションとこのブログは、いかなる企業もサービスも推奨も支持もしません。 また、このブログに含まれる情報の正確さや完全性について、いかなる保証または約束もしません。

このブログ記事は、元はRobloxテックブログ に掲載されたものです。