C#の定数畳み込み

# TL;DR

.NET Core環境におけるC#の定数畳み込みってば、相当簡単なことしか出来ないと思ってたら、それはILの話で、Native Codeまで話を落とすと結構積極的に定数畳み込みを実行していた。

ついでに言うなら、変数であっても条件が合えば畳み込まれるみたいで、**いつも通り沼にハマった**🕵️‍♂️



# なんでこんなこと調べたのか?

C#の定数畳み込みは`const`の右辺に記載可能な単純な四則演算位しか対応してないと認識していた。

C#というか、C#からILへコンパイルされる段階ではそれは正しい認識だったけど、最近 [C#の重箱の隅をつつく　～引数の位置で処理効率がビミョーに変わる～](https://qiita.com/Tokeiya/items/57784668a095f2c88e7d)とか、[ValueTupleとローカル関数](https://qiita.com/Tokeiya/items/f0c3e7912896d4995816)とか最近調べていく中でx64アセンブリと戯れる機会が増えた。で、ILの先のNative Codeに実行時コンパイルされていくなかで、結構積極的に定数の畳み込みしてる雰囲気があったので、適当に調べてみようかなって。



# C#からILの段階での畳み込み

C#をコンパイルするとILになるわけだけど、この段階で出来る定数畳み込みってばかなり限定的で、しっかり調べた訳ではないけど、`const int add=42+114514`みたいに、定数の右辺に記載が出来る程度のコトしか出来ない。

なので、数学的には一意に決まるけど、`const Angle=Math.Atan2(10,10)`みたいなコトは残念ながら書けない。



# ILからNative Codeの段階での畳み込み

最初、引数が定数ならその部分がたたみ込まれるかなと思って、以下のようなコードを書いてばらしてみた。

```cs
class Program
{
    static void Main()
    {
        var tmp = FoldingSample();
        Console.WriteLine(tmp);
    }

    public static double FoldingSample()
    {
        var accum = 0.0;
        for (int i = 0; i < 10_000_000; i++)
        {
            accum += Math.Atan2(42, 42);
        }
        return accum;
    }
}
```



でこいつをdisassembleした結果のうち、`FoldingSample`の部分が

```nasm

--			var accum = 0.0;
00007FFBE38F1550  push        rsi  
00007FFBE38F1551  sub         rsp,30h  
00007FFBE38F1555  vzeroupper  
00007FFBE38F1558  vmovaps     xmmword ptr [rsp+20h],xmm6  
00007FFBE38F155F  vxorps      xmm6,xmm6,xmm6  

--			for (int i = 0; i < 10_000_000; i++)
00007FFBE38F1564  xor         esi,esi  
00007FFBE38F1566  vmovsd      xmm0,qword ptr [7FFBE38F15A0h]  
00007FFBE38F156F  vmovsd      xmm1,qword ptr [7FFBE38F15A8h]  
00007FFBE38F1578  call        00007FFC4367DCC0  
--			{
				accum += Math.Atan2(42, 42);
00007FFBE38F157D  vaddsd      xmm6,xmm6,xmm0  
--			for (int i = 0; i < 10_000_000; i++)
00007FFBE38F1582  inc         esi  
--			for (int i = 0; i < 10_000_000; i++)
00007FFBE38F1584  cmp         esi,989680h  
00007FFBE38F158A  jl          00007FFBE38F157D  
--			}

			return accum;
00007FFBE38F158C  vmovaps     xmm0,xmm6  
00007FFBE38F1591  vmovaps     xmm6,xmmword ptr [rsp+20h]  
00007FFBE38F1598  add         rsp,30h  
00007FFBE38F159C  pop         rsi  
00007FFBE38F159D  ret  
```

こんな感じになった。

dasmの結果を眺めてみると`00007FFBE38F1566`～`00007FFBE38F1578`の部分で、`xmm0`と`xmm1`にリテラル`42.0`をコピーして、`Atan2`を実行しておりその結果はxmm0に入っている。[^1]で、続く加算処理はループを指定回数回しちゃいるけど、加算する値は計算済みの`xmm0`を使い回している。

このことから、`Math.Atan2(42, 42)`の部分がたたみ込まれていることがわかる。



# もう少し複雑にしてみる

どこら辺まで複雑にしてもイケるのか気になったので、たたみ込まれているところに意地悪して、以下のように書いてみた。



```cs
	class Program
	{
		static void Main()
		{
			var tmp = FoldingSample();
			Console.WriteLine(tmp);
		}

		public static double FoldingSample()
		{
			var accum = 0.0;

			//ここはC#からILの段階で畳み込まれる。
			const double a = 60 * (Math.PI / 180);
			const double b = 45 * (Math.PI / 180);

			for (int i = 0; i < 10_000_000; i++)
			{
				//sin(a+b)
				accum += Math.Sin(a) * Math.Cos(b) + Math.Cos(b) * Math.Sin(a);

				//cos(a+b)
				accum += Math.Cos(a) * Math.Cos(b) - Math.Sin(a) * Math.Sin(b);
			}
			return accum;
		}
	}
```



ループ内に、複数のExprがあってしかも、Exprが多少複雑になってる。でこいつはイケるかな?とおもってdisassembleしてみたら

```nasm
			var accum = 0.0;
00007FFBF18F1550  push        rsi  
00007FFBF18F1551  sub         rsp,70h  
00007FFBF18F1555  vzeroupper  
00007FFBF18F1558  vmovaps     xmmword ptr [rsp+60h],xmm6  
00007FFBF18F155F  vmovaps     xmmword ptr [rsp+50h],xmm7  
00007FFBF18F1566  vmovaps     xmmword ptr [rsp+40h],xmm8  
00007FFBF18F156D  vmovaps     xmmword ptr [rsp+30h],xmm9  
00007FFBF18F1574  vxorps      xmm6,xmm6,xmm6  

			//ここはC#からILの段階で畳み込まれる。
			const double a = 60 * (Math.PI / 180);
			const double b = 45 * (Math.PI / 180);

			for (int i = 0; i < 10_000_000; i++)
00007FFBF18F1579  xor         esi,esi  
00007FFBF18F157B  vmovsd      xmm0,qword ptr [7FFBF18F1630h]  
00007FFBF18F1584  call        00007FFC5166DDC0  
00007FFBF18F1589  vmovaps     xmm7,xmm0  
00007FFBF18F158E  vmovsd      xmm0,qword ptr [7FFBF18F1638h]  
00007FFBF18F1597  call        00007FFC5166DD40  
00007FFBF18F159C  vmovaps     xmm8,xmm0  
00007FFBF18F15A1  vmovaps     xmm0,xmm7  
00007FFBF18F15A6  vmulsd      xmm0,xmm0,xmm8  
00007FFBF18F15AB  vmovaps     xmm9,xmm0  
00007FFBF18F15B0  vaddsd      xmm9,xmm9,xmm0  
00007FFBF18F15B5  vmovsd      xmm0,qword ptr [7FFBF18F1640h]  
00007FFBF18F15BE  call        00007FFC5166DD40  
00007FFBF18F15C3  vmulsd      xmm0,xmm0,xmm8  
00007FFBF18F15C8  vmovsd      qword ptr [rsp+28h],xmm0  
00007FFBF18F15CF  vmovsd      xmm0,qword ptr [7FFBF18F1648h]  
00007FFBF18F15D8  call        00007FFC5166DDC0  
00007FFBF18F15DD  vmulsd      xmm7,xmm7,xmm0  
00007FFBF18F15E2  vmovsd      xmm0,qword ptr [rsp+28h]  
00007FFBF18F15E9  vsubsd      xmm0,xmm0,xmm7  
			{
				//sin(a+b)
				accum += Math.Sin(a) * Math.Cos(b) + Math.Cos(b) * Math.Sin(a);
00007FFBF18F15EE  vaddsd      xmm6,xmm6,xmm9  

				//cos(a+b)
				accum += Math.Cos(a) * Math.Cos(b) - Math.Sin(a) * Math.Sin(b);
00007FFBF18F15F3  vaddsd      xmm6,xmm6,xmm0  
			for (int i = 0; i < 10_000_000; i++)
00007FFBF18F15F8  inc         esi  
00007FFBF18F15FA  cmp         esi,989680h  
00007FFBF18F1600  jl          00007FFBF18F15EE  
			}
			return accum;
00007FFBF18F1602  vmovaps     xmm0,xmm6  
00007FFBF18F1607  vmovaps     xmm6,xmmword ptr [rsp+60h]  
00007FFBF18F160E  vmovaps     xmm7,xmmword ptr [rsp+50h]  
00007FFBF18F1615  vmovaps     xmm8,xmmword ptr [rsp+40h]  
00007FFBF18F161C  vmovaps     xmm9,xmmword ptr [rsp+30h]  
00007FFBF18F1623  add         rsp,70h  
00007FFBF18F1627  pop         rsi  
			}
			return accum;
00007FFBF18F1628  ret  
```

こんな感じのdasmをえた。そいでこいつを眺めてみると`00007FFBF18F1579`～00007FFBF18F15B0が`Math.Sin(a) * Math.Cos(b) + Math.Cos(b) * Math.Sin(a)`の畳み込みを行い結果を`xmm9`に突っ込んでる。で続く`00007FFBF18F15B5`～`00007FFBF18F15E9`で`Math.Cos(a) * Math.Cos(b) - Math.Sin(a) * Math.Sin(b)`の畳み込みを行い、結果を`xmm0`につっこんでいる。でループの内方ではこの`xmm9`と`xmm0`を指定回数`xmm6`に加算して結果を返してる。

このことから、ボチボチ複雑な定数計算も畳み込んでくれるんじゃないかなと推測できる。



# 変数相手にどうなるか? ～沼への招待～

実はここまでの結論で割と満足しちゃって、じゃあ、畳み込まれた場合と畳み込まれてない場合でどれくらい差が出るかなんてことやってまとめようと思って以下のようなコードを書いた上でBenchmarkDotnetで実行した。

```cs
using System;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Loggers;
using BenchmarkDotNet.Running;

namespace NativeBinarySurvey
{

	public static class Calc
	{
		public const int Iteration = 10_000_000;

		public const double Constant = 42.0;
		public static double Field = 42.0;

		public static double UseField()
		{
			var accum = 0.0;

			for (int i = 0; i < Iteration; i++)
			{
				accum += Math.Atan2(Field, Field);
			}

			return accum;
		}

		public static double UseConst()
		{
			var accum = 0.0;

			for (int i = 0; i < Iteration; i++)
			{
				accum += Math.Atan2(Constant, Constant);
			}

			return accum;
		}
	}

	[ShortRunJob]
	public class ConstantFolding
	{
		[Benchmark]
		public double UseFieldNoChange() => Calc.UseField();

		[Benchmark]
		public double UseConstant() => Calc.UseConst();
	}

	class Program
	{
		static void Main()
		{
			BenchmarkRunner.Run<ConstantFolding>();
		}
	}
}

```

書いたコードの企図としては、定数使う方は定数畳み込みが行われて、変数使う方は変数だけに定数畳み込みが行われず、計算の実行スピードが大きく違いました。メデタシ メデタシで終わるつもりだった。。。

そしたら実行結果が

``` ini

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362
AMD Ryzen 7 1700X, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=2.2.402
  [Host]   : .NET Core 2.2.7 (CoreCLR 4.6.28008.02, CoreFX 4.6.28008.03), 64bit RyuJIT
  ShortRun : .NET Core 2.2.7 (CoreCLR 4.6.28008.02, CoreFX 4.6.28008.03), 64bit RyuJIT

Job=ShortRun  IterationCount=3  LaunchCount=1  
WarmupCount=3  

```
| Method           |     Mean |     Error |    StdDev |
| ---------------- | -------: | --------: | --------: |
| UseFieldNoChange | 8.607 ms | 0.0377 ms | 0.0021 ms |
| UseConstant      | 8.633 ms | 0.2888 ms | 0.0158 ms |

こんなことになって、どーも変数使っても畳み込まれてるかんじがして、いつも通り**沼にハマってしまった**😱



# 変数使ってみた方をばらす

ベンチマークの結果見ただけでほぼ確定だけど、ダメ押しでさっきの`Calc.UseField()`をdisassembleしてみることにした。

```nasm
			var accum = 0.0;
00007FFBE3911550  push        rsi  
00007FFBE3911551  sub         rsp,30h  
00007FFBE3911555  vzeroupper  
00007FFBE3911558  vmovaps     xmmword ptr [rsp+20h],xmm6  
00007FFBE391155F  vxorps      xmm6,xmm6,xmm6  

			for (int i = 0; i < Iteration; i++)
00007FFBE3911564  xor         esi,esi  
00007FFBE3911566  mov         rcx,7FFBE37F4B80h  
00007FFBE3911570  mov         edx,1  
00007FFBE3911575  call        00007FFC4341B010  
00007FFBE391157A  vmovsd      xmm1,qword ptr [7FFBE37F4BB8h]  
00007FFBE3911583  vmovaps     xmm0,xmm1  
00007FFBE3911588  call        00007FFC4367DCC0  
			{
				accum += Math.Atan2(Field, Field);
00007FFBE391158D  vaddsd      xmm6,xmm6,xmm0  
			for (int i = 0; i < Iteration; i++)
00007FFBE3911592  inc         esi  
00007FFBE3911594  cmp         esi,989680h  
00007FFBE391159A  jl          00007FFBE391158D  
			}

			return accum;
00007FFBE391159C  vmovaps     xmm0,xmm6  
00007FFBE39115A1  vmovaps     xmm6,xmmword ptr [rsp+20h]  
00007FFBE39115A8  add         rsp,30h  
00007FFBE39115AC  pop         rsi  
00007FFBE39115AD  ret  
```

そしたら、さっきの定数使った方と同じように、ループの外方に`Math.Atan2(Field, Field)`を括りだして、それをループの内方で加算してる。

このことから、**使ってる変数が変更されるか否かと言うフロー解析をRyu JITはちゃんとやっている**という、割ととんでもない結論が出てきた。[^2]

実際これ見たとき、**まじか～～～**🤐🤐と言う独り言が口をついた



# 畳み込まれない様にしてみる

本末転倒だけど、じゃあ畳み込まれない為にゃどーすりゃ良いか考えた。で、結果わりかし簡単に変数が変更されるシナリオこさえりゃ良いじゃ無いかと言うことで、さっきのベンチマークに

```cs
[Benchmark]
public double UseFieldChange()
{
	Calc.Field = 42.0;
	return Calc.UseField();
}
```

こんなベンチマークを追加して実行してみた結果が以下。

``` ini

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362
AMD Ryzen 7 1700X, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=2.2.402
  [Host]   : .NET Core 2.2.7 (CoreCLR 4.6.28008.02, CoreFX 4.6.28008.03), 64bit RyuJIT
  ShortRun : .NET Core 2.2.7 (CoreCLR 4.6.28008.02, CoreFX 4.6.28008.03), 64bit RyuJIT

Job=ShortRun  IterationCount=3  LaunchCount=1  
WarmupCount=3  

```
| Method           |       Mean |     Error |    StdDev |
| ---------------- | ---------: | --------: | --------: |
| UseFieldNoChange |   8.615 ms | 0.2664 ms | 0.0146 ms |
| UseFieldChange   | 199.176 ms | 2.6635 ms | 0.1460 ms |
| UseConstant      |   8.599 ms | 0.0128 ms | 0.0007 ms |



ということで、意図通り、`UseFieldChange`で定数畳み込みが外れた結果を得ることが出来た。



# まとめ　～じつは未解決な部分～

いつも通り、軽い気持ちで調べてみたら沼にハマったというお約束な展開だった。

検証を追えてみての感想は、ILまでだとたいした畳み込みはしないけど、JITステージでかなり積極的に畳み込んでるな～～と思った。

また、変数使えば流石にそこを畳み込むようなことはしないだろうと思ったら畳み込む結果になっており、これには相当驚いた。

で、今回の積み残しは、さっきの`UseFieldNoChange`と`UseFieldChange`は同じ`Calc.UseField`を呼び出してるけど、Native codeはまるっきし変わることになる。なので、この辺のNativeコンパイル済みのコードを使い回すのか新たにコンパイルし直すのかのJITの戦略がどうなっているのかは気になった。[^3]





[^2]: とはいえJITは結局のトコロ、実行時のコストなのでそこまで詳細な解析はしてないと思う。
[^1]: [64bitWindowsのABI](https://docs.microsoft.com/ja-jp/cpp/build/x64-calling-convention?view=vs-2019#return-values)によれば、doubleの返却値はxmm0に突っ込まれてる。
[^3]: 先にも言ったとおり、あくまで実行時のコストに直結するのでやり過ぎるとろくなことにならない