📈

【.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で確認した内容になります。

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命令で呼ばれていることがわかり、そしてインライン化されていないと判断できます。

forループ内のTester.Addメソッド
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]
...
Console.WriteLine前のTester.Addメソッド
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

https://learn.microsoft.com/en-us/dotnet/core/runtime-config/compilation

Tiered Compilation無効化時の出力結果
; 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からデクリメントする形に変更

という最適化が行われた状態で実行されていることがわかります。

; インライン展開でcall命令がなくなり、(1,2,3,4)が定数畳み込みで10に事前計算
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に変更して再度実行して確認します。

ループ回数を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の場合に出力したアセンブリと比較するとループ回数の部分以外は同じであることがわかります。

Tier0の差分結果
G_M000_IG06:
-       cmp      dword ptr [rbp-0x40], 300
+       cmp      dword ptr [rbp-0x40], 0x7530 : 16進数表記で30000

次に出力されたTier1では、Tiered Compilation無効時の最適化と同じように、インライン展開によるcall命令がなくなり、定数畳み込みが行われています。

Tiered CompilationでのTier1のコード
; 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[6][7](On-stack replacement)
  • forループはすでにインクリメント方式で実行されている

OSRは、実行途中(スタックされている状態)でTier1のコードに切り替えていることができるという、.NET 7から追加されたパフォーマンス向上対応の一つになります。
これにより、例えばforループが含まれるメソッドでも、ループ回数が多い場合にはTier1のコードに切り替えることができるようになりました。

おわりに

ということで、Tiered Compilationで行われている最適化を確認することができました。
ただし、例えば「高頻度で呼び出されている」部分についてどうやってカウントしているのかなど、Dynamic PGO(Profile guided optimization)に関係する部分はここでは省略しています。

Dynamic PGOも踏まえて理解したほうがいいのは確かなので、Dynamic PGOについては、何縫ねの。さんの「.NET 8 で既定で有効になった Dynamic PGO について」のスライドがわかりやすく書かれていますので、これは見たほう良いです、いや見ましょう!

https://speakerdeck.com/nenonaninu/dot-net-8-deji-ding-deyou-xiao-ninatuta-dynamic-pgo-nituite?slide=49

ということで今回はこのへんで。

脚注
  1. dotnet/runtime tiered-compilation - https://github.com/dotnet/runtime/blob/main/docs/design/features/tiered-compilation.md ↩︎

  2. dotnet/runtime Managed Executables with Native Code - https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/readytorun-overview.md ↩︎

  3. ReadyToRun コンパイル - https://learn.microsoft.com/ja-jp/dotnet/core/deploying/ready-to-run ↩︎

  4. dotnet/runtime Viewing JIT disassembly and dumps - https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/jit/viewing-jit-dumps.md ↩︎

  5. dotnet/runtime compiler.h https://github.com/dotnet/runtime/blob/main/src/coreclr/jit/compiler.h#L10368-L10378 ↩︎

  6. What's new in .NET 7 - https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-7 ↩︎

  7. On Stack Replacement in the CLR - https://github.com/dotnet/runtime/blob/main/docs/design/features/OnStackReplacement.md ↩︎

Discussion