7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ValueTupleとローカル関数

Last updated at Posted at 2019-09-08

TL;DR

Case1とCase2でどっちが早いのか試してみた

Case1

private int NoUseClosure((int x, int y) point)
{
    int getDist((int, int) p)=>(int)Math.Sqrt(p.Item1 * p.Item1 + p.Item2 * p.Item2);
    return getDist(point);
}

Case2

private int UseClosure((int x, int y) point)
{
    int getDist()=>(int)Math.Sqrt(point.x * point.x + point.y * point.y);
    return getDist();
}

そしたら、Case2の方が速かった。
これがちょっと予想外だった。
で、ついでに言うなら次回以降のネタだけど、これがコーナーケースだった。

実際試してみたこと

下記のような簡単なベンチマークプログラムを組んで試してみた。

なおベンチマークフレームワークは安心と信頼のBenchmarkDotnetを使った。

using System;
using BenchmarkDotNet.Attributes;

namespace ConsoleApp2
{
	[MarkdownExporter]
	public class ValueTupleBench
	{
		private const int Iteration = 100_000;


		private int NoUseClosure((int x, int y) point)
		{
			int getDist((int, int) p)
			{
				var tmp = p.Item1 * p.Item1 + p.Item2 * p.Item2;
				return (int) Math.Sqrt(tmp);
			}

			return getDist(point);
		}


		private int UseClosure((int x, int y) point)
		{
			int getDist()
			{
				var tmp = point.x * point.x + point.y * point.y;
				return (int) Math.Sqrt(tmp);
			}

			return getDist();
		}

		[Benchmark]
		public int UseClosure()
		{
			var accum = 0;

			for (var i = 0; i < Iteration; i++) accum += UseClosure((i, i));

			return accum;
		}

		[Benchmark]
		public int NoUseClosure()
		{
			var accum = 0;

			for (var i = 0; i < Iteration; i++) accum += NoUseClosure((i, i));

			return accum;
		}
	}
}

で、その結果が下記の通り


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.401
  [Host]     : .NET Core 2.2.6 (CoreCLR 4.6.27817.03, CoreFX 4.6.27818.02), 64bit RyuJIT
  DefaultJob : .NET Core 2.2.6 (CoreCLR 4.6.27817.03, CoreFX 4.6.27818.02), 64bit RyuJIT


Method Mean Error StdDev
UseClosure 320.3 us 0.9566 us 0.8948 us
NoUseClosure 1,814.3 us 3.0716 us 2.7229 us

5.5倍くらいUseClosureの方が速くなっている。

これが個人的に予想外だった。

なんで予想外だったのか?

なんでこの結果が自分にとって予想外だったのかというと、上記メソッドを各々展開するとわかる。

Case1の場合

Case1の場合、割と単純に以下のようにコンパイラはローカル関数を展開する1

private static int GetDist((int, int) p) => (int) Math.Sqrt(p.Item1 * p.Item1 + p.Item2 * p.Item2);

public static int NoUseClosure((int x, int y) point)=>GetDist(point);

ローカル関数がエンクロージャの変数/引数をキャプチャしてないので別々の関数呼び出しとして処理される。

Case2の場合

Case2の場合は、コンパイラはローカル関数を下記のように展開する。

public static class Case2
{
    [StructLayout(LayoutKind.Auto)]
    private struct PlaceFolder
    {
        public (int x, int y) Point;
    }

    private static int GetDist(ref PlaceFolder pf) =>
        (int) Math.Sqrt(pf.Point.x * pf.Point.x + pf.Point.y * pf.Point.y);

    public static int UseClosure((int x, int y) point)
    {
        PlaceFolder pf = default;
        pf.Point = point;

        return GetDist(ref pf);
    }
}

ローカル関数内で、エンクロージャの引数をキャプチャしているので、コンパイラが作成したPlaceFolderに格納された上で、その構造体の参照を変数として渡す形になっている。

実行前の考察

このように、Case1Case2を比較すると、Case1の方が、やっていることが単純だし、余計なこともしていないので、高いパフォーマンスを出せるのではないかと予想していた。

沼にハマる

実際には、余計なことをしているCase2の方が先の通り5.5倍くらい速くてなぜそんなことが起きてしまったのか気になったので、沼に会えてハマってみたら、なんとなく説明できそうになったのと、割とこれがコーナケースだった。

また、逆アセンブリの結果の考察において、語彙が揺らいでいたり不正確な可能性があるので、その点はご指摘ください。

Case1をばらす

では最初に、Case1をばらしてみる。で、先の検証の通り、C#のコンパイラは上記のように展開するので、単純化して、ばらす対象として下記のようなコードを書いた。

using System;

namespace ConsoleApp2
{
	public static class Case1
	{
		private static int GetDist((int, int) p) => (int) Math.Sqrt(p.Item1 * p.Item1 + p.Item2 * p.Item2);

		public static int GetDistance((int x, int y) point) => GetDist(point);
	}

	class Program
	{
		static void Main(string[] args)
		{
			var result = Case1.GetDistance((42, 114514));
			Console.WriteLine(result);
		}

	}
}

で、こいつのReleaseBuildを実行した際の、逆アセンブラが下記

--- G:\ConsoleApp2\ConsoleApp2\Program.cs --------------------------------------
			var result = Case1.GetDistance((42, 114514));
00007FFC373C1930  sub         rsp,28h  
00007FFC373C1934  mov         ecx,2Ah  
00007FFC373C1939  mov         eax,1BF52h  
00007FFC373C193E  lea         rdx,[rsp+20h]  
00007FFC373C1943  mov         dword ptr [rdx],ecx  
00007FFC373C1945  mov         dword ptr [rdx+4],eax  
00007FFC373C1948  mov         rcx,qword ptr [rsp+20h]  
00007FFC373C194D  call        00007FFC373C10B0  
00007FFC373C1952  mov         ecx,eax  
00007FFC373C1954  call        00007FFC373C1340  
00007FFC373C1959  nop  
00007FFC373C195A  add         rsp,28h  
00007FFC373C195E  ret  


--- G:\ConsoleApp2\ConsoleApp2\Program.cs --------------------------------------
		private static int GetDist((int, int) p) => (int) Math.Sqrt(p.Item1 * p.Item1 + p.Item2 * p.Item2);
00007FFC373C1980  vzeroupper  
00007FFC373C1983  mov         qword ptr [rsp+8],rcx  
00007FFC373C1988  mov         eax,dword ptr [rsp+8]  
00007FFC373C198C  imul        eax,eax  
00007FFC373C198F  mov         edx,dword ptr [rsp+0Ch]  
00007FFC373C1993  imul        edx,edx  
00007FFC373C1996  add         eax,edx  
00007FFC373C1998  vxorps      xmm0,xmm0,xmm0  
00007FFC373C199D  vcvtsi2sd   xmm0,xmm0,eax  
00007FFC373C19A2  vsqrtsd     xmm0,xmm0,xmm0  
00007FFC373C19A7  vcvttsd2si  eax,xmm0  
00007FFC373C19AC  ret  

ここから、取得した逆アセンブリの結果を検証してくけど、行数指定は下4桁とするのでご了承の程

さて、上記コードの1930195EまでがMainメソッド+GetDistanceメソッドになる。これは、GetDistanceメソッドは極めて単純なので、Inliningされて、Mainメソッドの中に展開されちまっているから。

で、1930195Eまでが、GetDistメソッドの中身になっている。

1930~194Dあたりまでの流れ

このセクションは、GetDistメソッドを呼ぶまでの仕込み担っている。抜粋すると

  1. でecxレジスタに42を突っ込む
  2. でeaxレジスタに114514を突っ込む
  3. rdxレジスタにrsp+0x20のアドレスを突っ込む
  4. rdxの指し示している先にecxの即値をmov
  5. rdx+4の指し示している先にeaxの即値をmov
  6. rcxにrsp+0x20の指し示している即値を64bit整数としてmov
    1. 結局の所これは4.と5.の結果をrcxにまとめてmovしていることになる。
  7. GetDistをcall

と言う流れになる。

1980~19ACあたりまでの流れ

で、こっちはGetDistの処理。こっちも抜粋すると

  1. rsp+0x08の指し示している先にrcxの即値をmov
  2. rsp+0x08の指し示している先の32bit整数をeaxにmov(即値は42)
  3. eax*eaxして、eaxに結果を格納
  4. rsp+0x0cの指し示している先の32bit整数をedxにmov(即値は114514)
  5. edx*edxして、edxに結果を格納
  6. eax+edxして、結果をeaxに格納
  7. xmm0にeaxの値を浮動小数点変換して格納
  8. xmm0をsqrtして結果をxmm0に格納
  9. eaxにxmm0の値を整数変換して格納
  10. りたーん!

となる。

その後、Console.WriteLineの処理になるけど個々じゃ関係ないので省略。

Case1のまとめ

rcxレジスタに32bit整数をパッキングして格納した上でcallしてるのがちょっとびっくりしたけど、元のコード通り、ValueTupleを値渡ししていることになる。

Case2をばらす

それでは次にCase2をばらしてみる。Case1同様、意味を壊さないようにしながら単純化して下記のようなコードをこさえた

using System;

namespace ConsoleApp2
{
	public static class Case2
	{
		struct PlaceFolder
		{
			public (int x, int y) Point;
		}

		private static int GetDist(ref PlaceFolder pf) =>
			(int) Math.Sqrt(pf.Point.x * pf.Point.x + pf.Point.y * pf.Point.y);

		public static int GetDistance((int x, int y) point)
		{
			PlaceFolder pf = default;
			pf.Point = point;

			return GetDist(ref pf);
		}
	}


	class Program
	{
		static void Main(string[] args)
		{
			var result = Case2.GetDistance((42, 114514));
			Console.WriteLine(result);
		}

	}
}

で、こいつの逆アセンブリの結果は下記の通り。

--- G:\ConsoleApp2\ConsoleApp2\Program.cs --------------------------------------
			var result = Case2.GetDistance((42, 114514));
00007FFC20971930  sub         rsp,28h  
00007FFC20971934  mov         ecx,2Ah  
00007FFC20971939  mov         eax,1BF52h  
00007FFC2097193E  xor         edx,edx  
00007FFC20971940  mov         qword ptr [rsp+20h],rdx  
00007FFC20971945  mov         qword ptr [rsp+20h],rdx  
00007FFC2097194A  lea         rdx,[rsp+20h]  
00007FFC2097194F  mov         dword ptr [rdx],ecx  
00007FFC20971951  mov         dword ptr [rdx+4],eax  
00007FFC20971954  lea         rcx,[rsp+20h]  
00007FFC20971959  call        00007FFC209710B0  
00007FFC2097195E  mov         ecx,eax  
00007FFC20971960  call        00007FFC20971340  
00007FFC20971965  nop  
00007FFC20971966  add         rsp,28h  
00007FFC2097196A  ret  

--- G:\ConsoleApp2\ConsoleApp2\Program.cs --------------------------------------
			(int) Math.Sqrt(pf.Point.x * pf.Point.x + pf.Point.y * pf.Point.y);
00007FFC20971980  vzeroupper  
00007FFC20971983  mov         eax,dword ptr [rcx]  
00007FFC20971985  imul        eax,eax  
00007FFC20971988  mov         edx,dword ptr [rcx+4]  
00007FFC2097198B  imul        edx,edx  
00007FFC2097198E  add         eax,edx  
00007FFC20971990  vxorps      xmm0,xmm0,xmm0  
00007FFC20971995  vcvtsi2sd   xmm0,xmm0,eax  
00007FFC2097199A  vsqrtsd     xmm0,xmm0,xmm0  
00007FFC2097199F  vcvttsd2si  eax,xmm0  
00007FFC209719A4  ret  

で、こいつも、1930196Aまでが、Mainメソッドと、Case2.GetDistanceメソッドの処理となり、198019A4Case2.GetDistの処理となっている。

1930~1059あたりの流れ

ここは、先のCase1と同様、GetDist呼び出すための仕込みとなってる。抜粋して何をしてるのか見てみると、

  1. ecxに42を突っ込む
  2. eaxに114514を突っ込む
  3. edxを0クリア
  4. rdxの即値をスタックポインタ+0x20の指し示す先にコピー(rdxはedxが0クリアされてるので0になっている)
  5. なぜか同じ事をもう一度してる
  6. rsp+0x20のアドレスをrcxに格納(ecxとeaxの展開先アドレス=42と114514)
  7. GetDistをcall

と言う流れになる。

1980~19A4あたりの流れ

こちらも抜粋してみてみる

  1. eaxにrcxの指し示している先をコピー(42)
  2. eax*eaxの結果をeaxに格納
  3. edxにrcx+0x04の指し示している先をコピー(114514)
  4. edx*edxの結果をedxに格納
  5. eax+edxの結果をeaxに格納
  6. 後はCase1と同じ

Case2のまとめ

こちらは、Case1とは異なり、参照情報を引数にしてGetDistを呼び出していることになる。

まとめ

考察

ここまで、Case1Case2が中で何をやっているのかざっくりと検証してみた。

Case1では、ValutTupleがecx及びeaxにロードされて、スタックフレームに展開されて、それをまとめてrcxに積み込んでGetDistを呼び出す。呼び出されたGetDistはrcxをスタックフレームに展開して、それをeaxとedxにロードして整数演算を行っている。

Case2ではecx及びeaxにロードされて、スタックに展開されるところまでは一緒だけど、rcxには元フレームのアドレスをロードしてGetDistを呼び出す。呼び出されたGetDistはrcxに入ってるアドレスを元にeaxとedxに値をロードして整数演算を行っている。

このことから、Case1では値渡しのためにメモリとレジスタのやりとりにひして、Case2では元フレームに展開されたデータをGetDistでも使えるのでこの辺が速度に差が出る原因になっているような気がする。

残った疑問

とは言え、疑問は結構残っている。

レジスタとメモリのやりとりをざくっと数えた結果は

Register to Memory Memory to Register
Case1 3 3
Case2 4 2

このようになり、さして差は無い。

けれど、実行結果に結構差が出ている。なので、Case2で、rdxへの操作が何か必要以上に複雑なことになっているので、この辺がもしかするとまとまってxor rdx,rdxになっているのかなと推測したりも出来るけど2、いつもどおり、実際の所はわからなかった。

実はコーナーケースだった

次回以降のネタになるけど、これはコーナーケースで、例えば、(long x,long y)とかになると、完全に様相がことなることになる。

この辺は次回以降と言うことで。

  1. 但し、コンパイラの命名則はあれなので、名前だけは変えてある

  2. そう考えないと、手数が多い方が速くなる理屈が成り立たない

7
5
0

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
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?