はじめに
ちょいとばかり、びみょいなTopicなので、いくらか補足を入れておきます。
- 実行サンプルは後出しジャンケン的に差が付きやすいように恣意的に作ってあります。(ここ一番重要)
- 以下の検討には、cscのVersion4.0.30319.34209を利用しております。
- 下記は全てコンパイル結果の現状有姿を検討したものであり、将来的に変更される可能性があります。
- タイトル通り、重箱の隅をつついているだけです。
個人的に、余程のことがない限り、以下の検討事項を実際考慮してコーディングすることはほとんどないです。
それこそ、unsafeの利用を検討することとほとんど同じ位どーでもいい話です。
ただ、可読性その他を犠牲にしても早さが欲しいと言う要請があるのなら、あるいは使えるかも知れないので、一通りまとめてみました。
前準備
多分に作為的ですが、今回のゲームのプレイヤーは以下の二つです。
public class SampleRef
{
public SampleRef(int value)
{
Value = value;
}
public int Value { get; set; }
public static SampleRef operator +(SampleRef x, SampleRef y)
{
return new SampleRef(x.Value + y.Value);
}
}
public struct SampleVal
{
public SampleVal(int value) : this()
{
Value = value;
}
public int Value { get; set; }
public static SampleVal operator +(SampleVal x, SampleVal y)
{
return new SampleVal(x.Value + y.Value);
}
}
ありがちと言えば、ありがちな、中身が全く一緒の値型と参照型。
また、ヘルパとして、GCの挙動を調べたいので、以下のような値型を作成しました 1。
public struct GcCounter
{
public static GcCounter GetCurrentCount(bool isCollect)
{
if (isCollect)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
return new GcCounter(GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2));
}
private readonly int _gen0;
private readonly int _gen1;
private readonly int _gen2;
private GcCounter(int gen0, int gen1, int gen2)
{
_gen0 = gen0;
_gen1 = gen1;
_gen2 = gen2;
}
public int Gen0
{
get { return _gen0; }
}
public int Gen1
{
get { return _gen1; }
}
public int Gen2
{
get { return _gen2; }
}
public int TotalCount
{
get { return _gen0 + _gen1 + _gen2; }
}
public override string ToString()
{
return "Gen0:" + _gen0 + " Gen1:" + _gen1 + " Gen2:" + _gen2 + " Total:" + TotalCount;
}
public static GcCounter operator -(GcCounter x, GcCounter y)
{
return new GcCounter(x._gen0 - y._gen0, x._gen1 - y._gen1, x._gen2 - y._gen2);
}
}
なんてことのない、ガベコレのカウンタです。
検討する内容
比較検討する要件として
の3パターンを、各々、参照型と値型で実行して、検討してみます。
まず実行して結果を得てみる
参照型煮関する考察は、以下のサンプルコードを使いました。
internal class Program
{
private const int Iteration = 100000000;
private static void Main(string[] args)
{
SampleRef refA=new SampleRef(1);
SampleRef refB = new SampleRef(2);
SampleRef refC = new SampleRef(3);
SampleRef refRet;
SampleVal valA = new SampleVal(1);
SampleVal valB = new SampleVal(2);
SampleVal valC = new SampleVal(3);
SampleVal valRet;
Stopwatch chrono=new Stopwatch();
int accum;
accum = 0;
GcCounter cnt = GcCounter.GetCurrentCount(true);
chrono.Restart();
for (var i = 0; i < Iteration; i++)
{
refRet = RefUseClosure(refA, refB, refC);
accum += refRet.Value;
}
chrono.Stop();
cnt = GcCounter.GetCurrentCount(false) - cnt;
Console.WriteLine("参照型で閉包を使用");
Console.WriteLine(accum);
Console.WriteLine(chrono.Elapsed);
Console.WriteLine(cnt);
Console.WriteLine();
accum = 0;
cnt = GcCounter.GetCurrentCount(true);
chrono.Restart();
for (var i = 0; i < Iteration; i++)
{
refRet = RefNotUseClosure(refA, refB, refC);
accum += refRet.Value;
}
chrono.Stop();
cnt = GcCounter.GetCurrentCount(false) - cnt;
Console.WriteLine("参照型で閉包を不使用");
Console.WriteLine(accum);
Console.WriteLine(chrono.Elapsed);
Console.WriteLine(cnt);
Console.WriteLine();
accum = 0;
cnt = GcCounter.GetCurrentCount(true);
chrono.Restart();
for (var i = 0; i < Iteration; i++)
{
refRet = RefNotUseLambda(refA, refB, refC);
accum += refRet.Value;
}
chrono.Stop();
cnt = GcCounter.GetCurrentCount(false) - cnt;
Console.WriteLine("参照型でラムダ式を不使用");
Console.WriteLine(accum);
Console.WriteLine(chrono.Elapsed);
Console.WriteLine(cnt);
Console.WriteLine();
accum = 0;
cnt = GcCounter.GetCurrentCount(true);
chrono.Restart();
for (var i = 0; i < Iteration; i++)
{
valRet = ValUseClosure(valA, valB, valC);
accum += valRet.Value;
}
chrono.Stop();
cnt = GcCounter.GetCurrentCount(false) - cnt;
Console.WriteLine("値型で閉包を使用");
Console.WriteLine(accum);
Console.WriteLine(chrono.Elapsed);
Console.WriteLine(cnt);
Console.WriteLine();
accum = 0;
cnt = GcCounter.GetCurrentCount(true);
chrono.Restart();
for (var i = 0; i < Iteration; i++)
{
valRet = ValNotUseClosure(valA, valB, valC);
accum += valRet.Value;
}
chrono.Stop();
cnt = GcCounter.GetCurrentCount(false) - cnt;
Console.WriteLine("値型で閉包を不使用");
Console.WriteLine(accum);
Console.WriteLine(chrono.Elapsed);
Console.WriteLine(cnt);
Console.WriteLine();
accum = 0;
cnt = GcCounter.GetCurrentCount(true);
chrono.Restart();
for (var i = 0; i < Iteration; i++)
{
valRet = ValNotUseLambda(valA, valB, valC);
accum += valRet.Value;
}
chrono.Stop();
cnt = GcCounter.GetCurrentCount(false) - cnt;
Console.WriteLine("値型でラムダ式を不使用");
Console.WriteLine(accum);
Console.WriteLine(chrono.Elapsed);
Console.WriteLine(cnt);
Console.WriteLine();
}
//参照型で閉包を使用
private static SampleRef RefUseClosure(SampleRef a, SampleRef b, SampleRef c)
{
Func<SampleRef> func = () => a + b + c;
return func();
}
//参照型で閉包を不使用
private static SampleRef RefNotUseClosure(SampleRef a, SampleRef b, SampleRef c)
{
Func<SampleRef, SampleRef, SampleRef, SampleRef> func = (x, y, z) => x + y + z;
return func(a, b, c);
}
//参照型でラムダ式を不使用
private static SampleRef RefNotUseLambda(SampleRef a, SampleRef b, SampleRef c)
{
return a + b + c;
}
//値型で閉包を使用
private static SampleVal ValUseClosure(SampleVal a, SampleVal b, SampleVal c)
{
Func<SampleVal> func = () => a + b + c;
return func();
}
//値型で閉包を不使用
private static SampleVal ValNotUseClosure(SampleVal a, SampleVal b, SampleVal c)
{
Func<SampleVal, SampleVal, SampleVal, SampleVal> func = (x, y, z) => x + y + z;
return func(a, b, c);
}
//値型でラムダ式を不使用
private static SampleVal ValNotUseLambda(SampleVal a, SampleVal b, SampleVal c)
{
return a + b + c;
}
}
自分の実行環境3の場合、結果は以下の通り。
参照型で閉包を使用
600000000
00:00:02.8989906
Gen0:3623 Gen1:0 Gen2:0 Total:3623
参照型で閉包を不使用
600000000
00:00:01.1715695
Gen0:1144 Gen1:0 Gen2:0 Total:1144
参照型でラムダ式を不使用
600000000
00:00:00.8151667
Gen0:1144 Gen1:0 Gen2:0 Total:1144
値型で閉包を使用
600000000
00:00:01.8944874
Gen0:2479 Gen1:0 Gen2:0 Total:2479
値型で閉包を不使用
600000000
00:00:00.7306754
Gen0:0 Gen1:0 Gen2:0 Total:0
値型でラムダ式を不使用
600000000
00:00:00.5325055
Gen0:0 Gen1:0 Gen2:0 Total:0
結果の考察
直感的に、呼び出しの階層が深くなる分、ラムダ式を内部で利用しているパターンの方が分が悪いですし、
コレは結果を見ても、概ね的を得ているかと思います。
但し、ラムダ式を使ったパターンでも、閉包の利用の有無で、実際問題、ラムダ式をの利用の有無以上に差が付いていることがわかります。
また、同様に、ガベージコレクタの実行回数にも差異が認められます。
次に、この差異がなぜ発生するのか考察していきます。
閉包で利用されるのローカル変数の取り扱い
閉包を利用した際、値型であっても、参照型であっても実装上、匿名クラスのパブリックフィールドという取り扱いになります。
コレは以下のような問題を解決するためです.
class Program
{
static void Main(string[] args)
{
//ここでデリゲートを取得すると言うことは
Func<int> f = GetFunction(42);
//ここの時点で本来GetFunctionメソッドのローカル変数であるはずの”tmp”を利用していることになる。
Console.WriteLine(f());
}
public static Func<int> GetFunction(int value)
{
//なので、こいつをスタックフレームでは無く、どこか他に格納する必要がある。
int tmp = value*2;
return () => tmp;
}
}
上記のコンパイル結果を適当にフォーマットして書き下すと概ね以下のようになります。
using System;
namespace ConsoleApplication3
{
class Program
{
//コンパイラが作る隠しクラス(のつもり)
private class Anonymous
{
//行儀は良くないけど、まぁコンパイラが作るものだし、外から見えないしこれでいい。
public int tmp;
public int GetFunctionImpl()
{
return tmp;
}
}
static void Main(string[] args)
{
Func<int> f = GetFunction(42);
Console.WriteLine(f());
}
public static Func<int> GetFunction(int value)
{
Anonymous anony = new Anonymous();
anony.tmp = value*2;
return anony.GetFunctionImpl;
}
}
}
このように、本来、値型なのでマネージヒープを利用しないはずだった、”tmp”がじつは、マネージヒープに退避させられていることがわかります。
そしてここいらへんが、実行結果に差異をもたらす原因にもなっています。
結局どー言うことなのさ?
ということで、値型の簡単な呼び出しでも、条件によっては、マネージヒープを利用することになり、高頻度に当該メソッドが呼び出されてしまうなら、
それなりに実行時間とメモリ利用量にインパクトを与えることになります。
他方、ガーベージコレクタの実行状況を見ると、Generation0しか発生しておらず、そこまで過大なインパクトを与えているわけでも無いこと、
そこから考えるに、局所的かつ相当な高頻度でこんなコトしない限り、恐らく一般的なガベージコレクタの実行に紛れてさしたる差も付かない形になるのがオチだと思います。
回避策として、諸々を突っ込んだキャッシュクラスをこさえて、実行時にそいつを使い回すだけ使い回すという戦略が無くは無いでしょうが、
非同期・並行実行時の状態管理だのを考えるに、労多くして功少なしを地で行く結果になると思います。
まとめ
今回の事例は、「こーいう事例もあるのだ」的に頭の片隅に置いておいていただければ幸いです。
また、この辺のコンパイル結果は仕様になってはいないので4、あり得ないとは思いますが、この辺の実装結果に依拠したコーディングは危険だと思います。
また、冒頭にも述べましたが、今回の事例は、後出しジャンケン的な相当恣意的なサンプルとなっています。
個人的に、この辺が問題になったことはほとんど無い反面、C#にunsafeが存在するように、知識の一つとして持っておくことはそれなりに意味があるのでは無いかと思い、まとめてみました。