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
に格納された上で、その構造体の参照を変数として渡す形になっている。
実行前の考察
このように、Case1
とCase2
を比較すると、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桁とするのでご了承の程
さて、上記コードの1930
~195E
までがMainメソッド+GetDistanceメソッドになる。これは、GetDistance
メソッドは極めて単純なので、Inliningされて、Main
メソッドの中に展開されちまっているから。
で、1930
~195E
までが、GetDist
メソッドの中身になっている。
1930~194Dあたりまでの流れ
このセクションは、GetDist
メソッドを呼ぶまでの仕込み担っている。抜粋すると
- でecxレジスタに42を突っ込む
- でeaxレジスタに114514を突っ込む
- rdxレジスタにrsp+0x20のアドレスを突っ込む
- rdxの指し示している先にecxの即値をmov
- rdx+4の指し示している先にeaxの即値をmov
- rcxにrsp+0x20の指し示している即値を64bit整数としてmov
- 結局の所これは4.と5.の結果をrcxにまとめてmovしていることになる。
- GetDistをcall
と言う流れになる。
1980~19ACあたりまでの流れ
で、こっちはGetDist
の処理。こっちも抜粋すると
- rsp+0x08の指し示している先にrcxの即値をmov
- rsp+0x08の指し示している先の32bit整数をeaxにmov(即値は42)
- eax*eaxして、eaxに結果を格納
- rsp+0x0cの指し示している先の32bit整数をedxにmov(即値は114514)
- edx*edxして、edxに結果を格納
- eax+edxして、結果をeaxに格納
- xmm0にeaxの値を浮動小数点変換して格納
- xmm0をsqrtして結果をxmm0に格納
- eaxにxmm0の値を整数変換して格納
- りたーん!
となる。
その後、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
で、こいつも、1930
~196A
までが、Main
メソッドと、Case2.GetDistance
メソッドの処理となり、1980
~19A4
がCase2.GetDist
の処理となっている。
1930~1059あたりの流れ
ここは、先のCase1
と同様、GetDist呼び出すための仕込みとなってる。抜粋して何をしてるのか見てみると、
- ecxに42を突っ込む
- eaxに114514を突っ込む
- edxを0クリア
- rdxの即値をスタックポインタ+0x20の指し示す先にコピー(rdxはedxが0クリアされてるので0になっている)
- なぜか同じ事をもう一度してる
- rsp+0x20のアドレスをrcxに格納(ecxとeaxの展開先アドレス=42と114514)
- GetDistをcall
と言う流れになる。
1980~19A4あたりの流れ
こちらも抜粋してみてみる
- eaxにrcxの指し示している先をコピー(42)
- eax*eaxの結果をeaxに格納
- edxにrcx+0x04の指し示している先をコピー(114514)
- edx*edxの結果をedxに格納
- eax+edxの結果をeaxに格納
- 後は
Case1
と同じ
Case2のまとめ
こちらは、Case1
とは異なり、参照情報を引数にしてGetDist
を呼び出していることになる。
まとめ
考察
ここまで、Case1
とCase2
が中で何をやっているのかざっくりと検証してみた。
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)
とかになると、完全に様相がことなることになる。
この辺は次回以降と言うことで。