LoginSignup
45
33
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

.NET の最適化の罠? (インライン展開がされなかった理由)

Last updated at Posted at 2024-01-18

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

45
33
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
33