1 概要
いや、罠も何も自分の知識不足なんですけどね… orz
つい最近、c# の最適化ではまって、しかもちょっと恥ずかしい思いをしてきたので、開き直ってここで暴露してしまおうかと思います。
テーマは、すごく短いメソッドなのにインライン展開されない理由とその対策 です。
2. 実行環境
- OS => Windows 10 (x64)
- .NET ランタイム => .NET 8.0/7.0/6.0
3. 問題の内容
ちょっと前に自分の書いたコードのパフォーマンスが気になって、特に「どんなふうにインライン化されているか」について興味が出てきたので、解析し始めました。
最適化されたコードを確認することが目的なのですが、デバッガ無しでコードを追える自信がないので、ちょっと工夫してアセンブリを2つに分けます。
- アセンブリ1 => 実験対象のコードがあるクラスライブラリ (Release モードでビルド済み)
- アセンブリ2 => デバッガー上でアセンブリ1 のコードを呼び出すためのコンソールアプリケーション。
具体的な調査手順は、過去の記事 (小技) .NET プログラムの手軽?な解析方法 を参照してください。
インライン化したいメソッドのテストコードはこんな感じです。
public static class SimpleCalc
{
public static int Add(int x, int y) => x + y;
}
そして、その呼び出し元のコードはこんな感じです。
public static class Calc
{
public static int Add(int x, int y)
{
var z = SimpleCalc.Add(x, y);
z = SimpleCalc.Add(z, y);
z = SimpleCalc.Add(z, y);
z = SimpleCalc.Add(z, y);
...
z = SimpleCalc.Add(z, y);
return z;
}
}
ちなみに、SimpleCalc.Add()
の呼び出しの回数は 100 回にしました。
そして、実行時に機械語の内容を確認してみたのですが、何故か全くインライン化されていない ことがわかりました。
実際の Calc.Add()
メソッドの機械語は以下のようなものでした。
push rbp
sub rsp,1B0h
lea rbp,[rsp+1B0h]
mov dword ptr [rbp+10h],ecx
mov dword ptr [rbp+18h],edx
mov ecx,dword ptr [rbp+10h]
mov edx,dword ptr [rbp+18h]
call qword ptr [CLRStub[MethodDescPrestub]@00007FFE77937768 (07FFE77937768h)]
mov dword ptr [rbp-0Ch],eax
mov ecx,dword ptr [rbp-0Ch]
mov edx,dword ptr [rbp+18h]
call qword ptr [CLRStub[MethodDescPrestub]@00007FFE77937768 (07FFE77937768h)]
mov dword ptr [rbp-10h],eax
mov ecx,dword ptr [rbp-10h]
mov edx,dword ptr [rbp+18h]
call qword ptr [CLRStub[MethodDescPrestub]@00007FFE77937768 (07FFE77937768h)]
...
call qword ptr [CLRStub[MethodDescPrestub]@00007FFE77937768 (07FFE77937768h)]
mov dword ptr [rbp-18Ch],eax
mov ecx,dword ptr [rbp-18Ch]
mov edx,dword ptr [rbp+18h]
call qword ptr [CLRStub[MethodDescPrestub]@00007FFE77937768 (07FFE77937768h)]
nop
add rsp,1B0h
pop rbp
ret
call
命令の飛び先は SimpleCalc.Add()
メソッドで、SimpleCalc.Add()
メソッドの呼び出しがそのまま残ってしまっています。
SimpleCalc.Add()
メソッドに[MethodImpl(MethodImplOptions.AggressiveInlining)]
属性がついてないのがいけないのかと思い、属性をつけてみても結果は同じでした。
ただ、奇妙なことに、インライン化されないのは .NET 8.0 と .NET 7.0 で、.NET 6.0 の場合はインライン化されました。
以下は、.NET 6.0 の場合の Calc.Add()
の機械語です。
add ecx,edx
add ecx,edx
add ecx,edx
add ecx,edx
add ecx,edx
...
add ecx,edx
add ecx,edx
add ecx,edx
lea eax,[rcx+rdx]
add eax,edx
ret
SimpleCalc.Add()
メソッドの呼び出しが消えた代わりに加算命令だけになっていて、インライン化が行われていることがわかります。
4. 問題の調査結果
.NET の不具合かもと一瞬思ったのですが、こんな単純でしかもパフォーマンスに直結するような不具合を 私ならともかく 誰も気づかずに指摘しないなんてことは考えられません。
多分何か手順が間違ってるんだろうなと思いつつ、.NET のリポジトリに 報告 を入れてみました。
そしたら投稿から数分もしないうちにコメントがついたのですが、コメントで使われている用語の意味が解りません orz
四苦八苦しながら意味を調べてみたのですが、どうやら JIT による機械語への翻訳にはオプションがあるようで、そのうちの一つに 「Quick JIT を有効にするかどうか」 というのがありました。
Quick JIT を有効にすると、起動速度向上のためにコンパイル速度が重視され、その代わりに最適化が甘くなるようです。
その Quick JIT が既定では有効である、というのが今回の原因でした。
5. 対処方法 (Quick JIT を無効にする方法)
Quick JIT を無効にするためには、以下の何れかの方法があるそうです。
- アプリケーション (.exe) のプロジェクトファイル (.csproj) を編集する。
- アプリケーションの出力ディレクトリにある
<application name>.runtimeconfig.json
を編集する。 - 環境変数を設定する。
5.1 プロジェクトファイルを編集する方法
アプリケーションプロジェクトのプロジェクトファイル (.csproj) を編集して、以下の例のように <TieredCompilationQuickJit>false</TieredCompilationQuickJit>
を追加します。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
...
<TieredCompilationQuickJit>false</TieredCompilationQuickJit>
</PropertyGroup>
...
</Project>
編集するプロジェクトファイルは、アプリケーションのプロジェクトファイル であることに注意してください。試しにクラスライブラリのプロジェクトファイルのみを同様に編集してみましたが、効果はありませんでした。
ちなみに、この設定をしてビルドすると、次項で説明する .runtimeconfig.json
も自動的に書き換わります。
5.2 .runtimeconfig.json
ファイルを編集する方法
アプリケーションの出力ディレクトリにある <application name>.runtimeconfig.json
というファイルを書き換えて、System.Runtime.TieredCompilation.QuickJit
プロパティの値を false
にします。
以下はその設定例です。
{
"runtimeOptions": {
"tfm": "net8.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false,
"System.Runtime.TieredCompilation.QuickJit": false
}
}
}
5.3 環境変数を設定する方法
Microsoft のドキュメント Runtime configuration options for compilation によると、COMPlus_TieredCompilation
または DOTNET_TieredCompilation
の環境変数の値を 0
にすることにより、Quick JIT を無効にできるそうです。(明示的に有効にしたい場合は 1
)
私自身は試していませんが…
6. 対処の結果
今回は、コンソールアプリケーションのプロジェクトファイルに <TieredCompilationQuickJit>false</TieredCompilationQuickJit>
を追加しました。
その後リビルドを行い、改めてクラスライブラリの解析を行った結果、.NET 8.0/7.0 でもインライン化を確認できました。
add ecx,edx
add ecx,edx
add ecx,edx
...
add ecx,edx
add ecx,edx
add ecx,edx
lea eax,[rcx+rdx]
add eax,edx
ret
やっぱり .NET はまだまだ分からないことだらけですね…
7. 結論
- .NET の JIT のオプションには「Quick JIT を有効にするかどうか」というものがある。
- Quick JIT は既定では有効であり、これが有効であるとコンパイル速度 (≒アプリケーションの起動速度) は向上するものの、最適化が甘くなり、インライン展開も行われなくなる。
- Quick JIT を無効にする方法はいくつかある。詳細は 5. 対処方法 (Quick JIT を無効にする方法) を参照。
8. 参考記事
c# のインライン化について、以下のページにとても分かりやすく書かれています。
Microsoft が公開している JIT のオプションに関するドキュメントです。
9. おまけ
.NET のリポジトリで報告するために作成したテストプログラムの URL を貼っておきます。こちらの方がわかりやすいかもです。
https://github.com/rougemeilland/Experiment.Issue.CsharpInline