【.NET】Tiered Compilationの最適化を覗いてみる
はじめに
.NETランタイムでは、.NET Core 2.1からTiered Compilationが導入され、.NET Core 3からデフォルトで有効になりました。
とはいっても、普段のアプリ、ライブラリ開発で常に意識するものでもないため、公式ドキュメントやブログ等で紹介されている内容を確認するぐらいでした。
ただ、ふと実際に確認したくなったため、その手順等を簡単にまとめてみました。
そもそもTiered Compilationとは
Tiered Compilationは、アプリケーションの起動時や定常状態時のパフォーマンスを向上させつつ、パフォーマンスの低下を抑える目的で導入されました。
Tiered Compilation[1]では、まず全てのコードは最適化対象のコードと対象ではないコードの二つに分類されます。
最適化対象のコードについては、以下の二つのバリエーションが存在します。
- Tier0 - 最低限の最適化しかされていないコード(コンパイル速度優先)
- Tier1 - 最適化されたコード、JIT コンパイラによる最適化されたコード(実行速度優先)
動作としては、まず実行時には、Tier0のコードが生成し呼び出されます。そのコードが高頻度で呼び出されていると、バッググラウンドでTier1の最適化コードを生成し、こちらが呼び出されるようになります。
これにより、「アプリケーションの起動時や定常状態時のパフォーマンスを向上させつつ、パフォーマンスの低下を最低限に抑える」ということを実現しています。
今回は、実際にTiered CompilationによってTier0からTier1に切り替わっているのか簡単なサンプルコードを例に確認しました。
Tiered Compilation有効時の最適化内容
今回は以下のソースコードを例にどう変化しているか確認します。
const int loopCount = 300;
var sum = 0;
for (int i = 0; i < loopCount; i++)
{
sum += Tester.Add(1, 2, 3, 4);
}
sum += Tester.Add(1, 2, 3, 4);
Console.WriteLine(sum);
public class Tester
{
public static int Add(int a, int b, int c, int d)
{
return a + b + c + d;
}
}
.NETには、メソッドのJIT逆アセンブル結果を出力する機能として、DOTNET_JitDisasm[4]が用意されています。
こちらを利用することで、指定したメソッドのアセンブリ命令を見ることができます。
まず、DOTNET_JitDisasmに逆アセンブルするメソッド名を設定します。
今回は、Powershellで一時的な環境変数として設定します。
> $env:DOTNET_JitDisasm = "<Main>$"
ただし、少し注意事項として、最上位レベルのステートメントの場合、古来よりの名前であるMainではなく、<Main>$になるためこちらを設定する必要があります。
※以下ILSpyで確認した内容になります。

あと、補助機能としてDOTNET_JitDisasmDiffableも有効することで、実行時に毎回変わってしまうポインタの値を同一の値に置き換えてくれるため、テキストでの差分比較がしやすくなります。
$env:DOTNET_JitDisasmDiffable = 1
これらを環境変数へ設定後、Releaseビルドで実行します。
> dotnet run -c Release
そうすると、以下の逆アセンブルした中身が確認できるようになります。
; Assembly listing for method Program:<Main>$(System.String[]) (Instrumented Tier0)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; Instrumented Tier0 code
; rbp based frame
; partially interruptible
; compiling with minopt
G_M000_IG01:
push rbp
sub rsp, 112
lea rbp, [rsp+0x70]
xor eax, eax
mov dword ptr [rbp-0x3C], eax
mov dword ptr [rbp-0x40], eax
mov gword ptr [rbp+0x10], rcx
G_M000_IG02:
mov dword ptr [rbp-0x48], 0x3E8
xor eax, eax
mov dword ptr [rbp-0x3C], eax
xor eax, eax
mov dword ptr [rbp-0x40], eax
jmp SHORT G_M000_IG04
G_M000_IG03:
mov rcx, 0xD1FFAB1E
call CORINFO_HELP_COUNTPROFILE32
mov ecx, 1
mov edx, 2
mov r8d, 3
mov r9d, 4
call [Tester:Add(int,int,int,int):int]
add eax, dword ptr [rbp-0x3C]
mov dword ptr [rbp-0x3C], eax
mov eax, dword ptr [rbp-0x40]
inc eax
mov dword ptr [rbp-0x40], eax
G_M000_IG04:
mov eax, dword ptr [rbp-0x48]
dec eax
mov dword ptr [rbp-0x48], eax
cmp dword ptr [rbp-0x48], 0
jg SHORT G_M000_IG06
G_M000_IG05:
lea rcx, [rbp-0x48]
mov edx, 22
call CORINFO_HELP_PATCHPOINT
G_M000_IG06:
cmp dword ptr [rbp-0x40], 300
jl SHORT G_M000_IG03
mov rcx, 0xD1FFAB1E
call CORINFO_HELP_COUNTPROFILE32
mov ecx, 1
mov edx, 2
mov r8d, 3
mov r9d, 4
call [Tester:Add(int,int,int,int):int]
add eax, dword ptr [rbp-0x3C]
mov dword ptr [rbp-0x3C], eax
mov ecx, dword ptr [rbp-0x3C]
call [System.Console:WriteLine(int)]
nop
G_M000_IG07:
add rsp, 112
pop rbp
ret
; Total bytes of code 200
3010
出力結果
はじめに、先頭には以下の情報が出力されます。
; Assembly listing for method Program:<Main>$(System.String[]) (Instrumented Tier0)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; Instrumented Tier0 code
; rbp based frame
; partially interruptible
; compiling with minopt
ここでは、どのような最適化が行われているか、その状態などを確認することができます。
- コードの最適化レベル[5](
BLENDED_CODEは中間レベル?) - 命令セットやOS情報
- Tiered Compilationの状態
- JITのコード最適化戦略
<Main>$がどのようにアセンブリとして実行されているか確認することができます。
forループ内のTester.Addメソッドについては、各引数をレジスタに設定した後、call [Tester:Add(int,int,int,int):int]とあるため、Tester.Addメソッドがcall命令で呼ばれていることがわかり、そしてインライン化されていないと判断できます。
G_M000_IG03:
mov rcx, 0xD1FFAB1E
call CORINFO_HELP_COUNTPROFILE32
mov ecx, 1
mov edx, 2
mov r8d, 3
mov r9d, 4
call [Tester:Add(int,int,int,int):int]
add eax, dword ptr [rbp-0x3C]
...
G_M000_IG06:
...
call CORINFO_HELP_COUNTPROFILE32
mov ecx, 1
mov edx, 2
mov r8d, 3
mov r9d, 4
call [Tester:Add(int,int,int,int):int]
add eax, dword ptr [rbp-0x3C]
mov dword ptr [rbp-0x3C], eax
mov ecx, dword ptr [rbp-0x3C]
call [System.Console:WriteLine(int)]
nop
ということで、Tester.AddメソッドはTiered CompilationのTier0 コードとして実行されているということがわかります。
ただ、Tester.Addメソッドは加算だけ行うメソッドのため、実際にはインライン展開や定数畳み込み(Constant folding)などのJITコンパイラーによる最適化が行われてもおかしくありません。
public static int Add(int a, int b, int c, int d)
{
// 加算だけするメソッドなので、最適化対象になりそう
return a + b + c + d;
}
Tiered Compilation無効時の最適化内容
では逆にTiered Compilationを使用しない場合、最初から最適化を行うはずなので、どういう最適化が行われるか確認します。
Tiered Compilationを無効にする設定についても、DOTNET_TieredCompilationという設定がしっかりと用意されていますので、これもPowershellで一時的な環境変数として設定した後、Releaseビルドで実行します。
> $env:DOTNET_TieredCompilation = 0
> dotnet run -c Release
; Assembly listing for method Program:<Main>$(System.String[]) (FullOpts)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; fully interruptible
; No PGO data
; 0 inlinees with PGO data; 2 single block inlinees; 0 inlinees without PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
mov ecx, 10
mov eax, 300
align [0 bytes for IG03]
G_M000_IG03:
add ecx, 10
dec eax
jne SHORT G_M000_IG03
G_M000_IG04:
call [System.Console:WriteLine(int)]
nop
G_M000_IG05:
add rsp, 40
ret
; Total bytes of code 33
3010
大分すっきりした内容に変化しました。
分かる範囲でも
-
Tester.Add(1, 2, 3, 4)がインライン化と定数畳み込みにより10になり削除 - forループのカウンターが300からデクリメントする形に変更
という最適化が行われた状態で実行されていることがわかります。
G_M000_IG03:
- mov ecx, 1
- mov edx, 2
- mov r8d, 3
- mov r9d, 4
- call [Tester:Add(int,int,int,int):int]
+ add ecx, 10
Tier0からTier1に切り替わるケース
それではTiered Compilationが有効時には、どのような最適化が行われるか確認します。
Tier0からTier1に変更する条件として、高頻度で呼び出されると、バッググラウンドでTier1のコード生成し切り替わるはずなので、サンプルコードをforループの回数を300から30000に変更して再度実行して確認します。
- const int loopCount = 300;
+ const int loopCount = 30000;
; Assembly listing for method Program:<Main>$(System.String[]) (Instrumented Tier0)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; Instrumented Tier0 code
; rbp based frame
; partially interruptible
; compiling with minopt
G_M000_IG01:
push rbp
sub rsp, 112
lea rbp, [rsp+0x70]
xor eax, eax
mov dword ptr [rbp-0x3C], eax
mov dword ptr [rbp-0x40], eax
mov gword ptr [rbp+0x10], rcx
G_M000_IG02:
mov dword ptr [rbp-0x48], 0x3E8
xor eax, eax
mov dword ptr [rbp-0x3C], eax
xor eax, eax
mov dword ptr [rbp-0x40], eax
jmp SHORT G_M000_IG04
G_M000_IG03:
mov rcx, 0xD1FFAB1E
call CORINFO_HELP_COUNTPROFILE32
mov ecx, 1
mov edx, 2
mov r8d, 3
mov r9d, 4
call [Tester:Add(int,int,int,int):int]
add eax, dword ptr [rbp-0x3C]
mov dword ptr [rbp-0x3C], eax
mov eax, dword ptr [rbp-0x40]
inc eax
mov dword ptr [rbp-0x40], eax
G_M000_IG04:
mov eax, dword ptr [rbp-0x48]
dec eax
mov dword ptr [rbp-0x48], eax
cmp dword ptr [rbp-0x48], 0
jg SHORT G_M000_IG06
G_M000_IG05:
lea rcx, [rbp-0x48]
mov edx, 22
call CORINFO_HELP_PATCHPOINT
G_M000_IG06:
cmp dword ptr [rbp-0x40], 0x7530
jl SHORT G_M000_IG03
mov rcx, 0xD1FFAB1E
call CORINFO_HELP_COUNTPROFILE32
mov ecx, 1
mov edx, 2
mov r8d, 3
mov r9d, 4
call [Tester:Add(int,int,int,int):int]
add eax, dword ptr [rbp-0x3C]
mov dword ptr [rbp-0x3C], eax
mov ecx, dword ptr [rbp-0x3C]
call [System.Console:WriteLine(int)]
nop
G_M000_IG07:
add rsp, 112
pop rbp
ret
; Total bytes of code 200
; Assembly listing for method Program:<Main>$(System.String[]) (Tier1-OSR)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; Tier1-OSR code
; OSR variant for entry point 0x16
; optimized code
; optimized using Synthesized PGO
; rsp based frame
; fully interruptible
; with Synthesized PGO: fgCalledCount is 1
; 0 inlinees with PGO data; 2 single block inlinees; 0 inlinees without PGO data
G_M000_IG01:
sub rsp, 40
mov eax, dword ptr [rsp+0x64]
mov ecx, dword ptr [rsp+0x60]
G_M000_IG02:
add eax, 10
cmp ecx, 0x7530
jge SHORT G_M000_IG04
align [9 bytes for IG03]
G_M000_IG03:
inc ecx
add eax, 10
cmp ecx, 0x7530
jl SHORT G_M000_IG03
G_M000_IG04:
mov ecx, eax
call [System.Console:WriteLine(int)]
nop
G_M000_IG05:
add rsp, 160
pop rbp
ret
; Total bytes of code 63
300010
出力結果にTier0とTier1の<Main>$のアセンブリが二つ出力されることが確認できます。
まず最初に出力されたアセンブリは、Tier0のコードになり、ループ回数が300の場合に出力したアセンブリと比較するとループ回数の部分以外は同じであることがわかります。
G_M000_IG06:
- cmp dword ptr [rbp-0x40], 300
+ cmp dword ptr [rbp-0x40], 0x7530 : 16進数表記で30000
次に出力されたTier1では、Tiered Compilation無効時の最適化と同じように、インライン展開によるcall命令がなくなり、定数畳み込みが行われています。
; Assembly listing for method Program:<Main>$(System.String[]) (Tier1-OSR)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; Tier1-OSR code
; OSR variant for entry point 0x16
; optimized code
; optimized using Synthesized PGO
; rsp based frame
; fully interruptible
; with Synthesized PGO: fgCalledCount is 1
; 0 inlinees with PGO data; 2 single block inlinees; 0 inlinees without PGO data
G_M000_IG01:
sub rsp, 40
mov eax, dword ptr [rsp+0x64]
mov ecx, dword ptr [rsp+0x60]
G_M000_IG02:
add eax, 10
cmp ecx, 0x7530
jge SHORT G_M000_IG04
align [9 bytes for IG03]
G_M000_IG03:
inc ecx
add eax, 10
cmp ecx, 0x7530
jl SHORT G_M000_IG03
G_M000_IG04:
mov ecx, eax
call [System.Console:WriteLine(int)]
nop
G_M000_IG05:
add rsp, 160
pop rbp
ret
; Total bytes of code 63
ただし、完全同一というわけではありません。
同一ではない要因としては、以下の動作が関係していると思われます。
OSRは、実行途中(スタックされている状態)でTier1のコードに切り替えていることができるという、.NET 7から追加されたパフォーマンス向上対応の一つになります。
これにより、例えばforループが含まれるメソッドでも、ループ回数が多い場合にはTier1のコードに切り替えることができるようになりました。
おわりに
ということで、Tiered Compilationで行われている最適化を確認することができました。
ただし、例えば「高頻度で呼び出されている」部分についてどうやってカウントしているのかなど、Dynamic PGO(Profile guided optimization)に関係する部分はここでは省略しています。
Dynamic PGOも踏まえて理解したほうがいいのは確かなので、Dynamic PGOについては、何縫ねの。さんの「.NET 8 で既定で有効になった Dynamic PGO について」のスライドがわかりやすく書かれていますので、これは見たほう良いです、いや見ましょう!
ということで今回はこのへんで。
-
dotnet/runtime tiered-compilation - https://github.com/dotnet/runtime/blob/main/docs/design/features/tiered-compilation.md ↩︎
-
dotnet/runtime Managed Executables with Native Code - https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/readytorun-overview.md ↩︎
-
ReadyToRun コンパイル - https://learn.microsoft.com/ja-jp/dotnet/core/deploying/ready-to-run ↩︎
-
dotnet/runtime Viewing JIT disassembly and dumps - https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/jit/viewing-jit-dumps.md ↩︎
-
dotnet/runtime compiler.h https://github.com/dotnet/runtime/blob/main/src/coreclr/jit/compiler.h#L10368-L10378 ↩︎
-
What's new in .NET 7 - https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-7 ↩︎
-
On Stack Replacement in the CLR - https://github.com/dotnet/runtime/blob/main/docs/design/features/OnStackReplacement.md ↩︎
Discussion