C#: generics<T> + object 黒魔法 vs. 非ジェネリクスメソッド 実行速度編
前回の記事「C#: 組み込みの数値型を扱いたい generics の generics
で数値計算を実装すると結局 object
の暗躍が必要なので実行時コストが気になるよ、でもまあ実行コストを気にしない何かならプログラマー=サンが楽にすめばそれはそれでいいんじゃない、とか適当な事を書いた。
適当な事を書いたので気になって2018年に意識が束縛されたまま2019年になれないまま事故って死んだら嫌だなーとか思ったのでベンチマーキングしてみた😃
// 1. 非ジェネリクス版 public static decimal PlusOneD( decimal a ) => a + 1M; public static int PlusOneI( int a ) => a + 1; // 2. ジェネリクス版だけど特定の型だけに実装を絞って object の介入コストを評価したい版 public static T PlusOneGD<T>( T a ) => (T)(object)( (decimal)(object)a + 1M ); public static T PlusOneGI<T>( T a ) => (T)(object)( (int)(object)a + 1 ); // 3. 見よ、これが C# のジェネリクスの限界というものだ!版 //(C#イケメン的には本質的に設計と実装方針が間違ってるというのはオイトケ、ベンチマーキング用なのだ) public static T PlusOneGR<T>( T a ) { switch ( (object)a ) { case byte v: return (T)(object)(byte)( v + 1 ); case sbyte v: return (T)(object)(sbyte)( v + 1 ); case Int16 v: return (T)(object)(Int16)( v + 1 ); case UInt16 v: return (T)(object)(UInt16)( v + 1 ); case Int32 v: return (T)(object)(Int32)( v + 1 ); case UInt32 v: return (T)(object)(UInt32)( v + 1 ); case Int64 v: return (T)(object)(Int64)( v + 1 ); case UInt64 v: return (T)(object)(UInt64)( v + 1 ); case float v: return (T)(object)(float)( v + 1 ); case double v: return (T)(object)(double)( v + 1 ); case decimal v: return (T)(object)(decimal)( v + 1 ); } return a; // <- 例外吐くべきなんだけど、ベンチマーキング用にry } // 4. ↑のやつの未対応型へ例外飛ばす版 // (実際のところ例外は飛ばさないし、 try ってるわけではないのだけどまあ、一応気になったので確認用) public static T PlusOneGR<T>( T a ) { switch ( (object)a ) { case byte v: return (T)(object)(byte)( v + 1 ); case sbyte v: return (T)(object)(sbyte)( v + 1 ); case Int16 v: return (T)(object)(Int16)( v + 1 ); case UInt16 v: return (T)(object)(UInt16)( v + 1 ); case Int32 v: return (T)(object)(Int32)( v + 1 ); case UInt32 v: return (T)(object)(UInt32)( v + 1 ); case Int64 v: return (T)(object)(Int64)( v + 1 ); case UInt64 v: return (T)(object)(UInt64)( v + 1 ); case float v: return (T)(object)(float)( v + 1 ); case double v: return (T)(object)(double)( v + 1 ); case decimal v: return (T)(object)(decimal)( v + 1 ); } throw new NotImplementedException(); // <-- 吐くぞぉー(実際には今回はたどり着かない }
そんでもって、
// 簡易ベンチマーキングメソッド、その名も Benchmark 君 double Benchmark<T>( T v, Func<T, T> f ) where T : IComparable<T> { var t0 = DateTime.UtcNow; T a = (T)Convert.ChangeType( 0, typeof( T ) ); do a = f( a ); while ( a.CompareTo( v ) < 0 ); return ( DateTime.UtcNow - t0 ).TotalMilliseconds; }
↑で、計測する↓
var result = string.Empty; // 結果をちまちま記録していく { var t = 10_000_000M; // <-- decimal Benchmark( t, PlusOneD ); // <-- CPU あたため兼、キャッシュ不利など簡易的に回避する用 var a = Benchmark( t, PlusOneD ); var b = Benchmark( t, PlusOneGD<decimal> ); var c = Benchmark( t, PlusOneGR<decimal> ); var d = Benchmark( t, PlusOneGE<decimal> ); result += $"decimal: {(a, b, c, d)}\n"; } { var t = 10_000_000; // <-- int Benchmark( t, PlusOneI ); var a = Benchmark( t, PlusOneI ); var b = Benchmark( t, PlusOneGI<int> ); var c = Benchmark( t, PlusOneGR<int> ); var d = Benchmark( t, PlusOneGE<int> ); result += $" int: {(a, b, c, d)}\n"; }
結果、こんなんでました。
Type | <1> ms | <2> ms | <3> ms | <4> ms |
---|---|---|---|---|
decimal | 238.598 | 243.0946 | 358.6147 | 385.4431 |
int | 26.007 | 27.0174 | 134.0307 | 133.547 |
float | 65.0145 | 65.5082 | 163.5449 | 171.1108 |
double | 86.0193 | 89.0203 | 190.0418 | 175.0401 |
float
シリーズと double
シリーズも同様の実装を加えてみました。
↑この結果を型ごとに vs. <1>
規準に正規化するとこんな感じ↓
Type | <1> vs. <1> | <2> vs. <1> | <3> vs. <1> | <4> vs. <1> |
---|---|---|---|---|
decimal | 1.00 | 1.02 | 1.50 | 1.62 |
int | 1.00 | 1.04 | 5.15 | 5.14 |
float | 1.00 | 1.01 | 2.52 | 2.63 |
double | 1.00 | 1.03 | 2.21 | 2.03 |
↓わかりやすいエクセルスクショ
それぞれ何だったかおさらいすると
<1>
: 非ジェネリクス版<2>
: ジェネリクスだけど特定の型だけに対応して<T>
やobject
の介入コストを見たい版<3>
:<T>
をobject
にしてswitch
して型マッチングして計算してobject
を介して<T>
にして返す版(但し非対応型は何もせず返す)<4>
:<3>
の非対応型にはNotImplementedException
を投げつける版
で、わかった事は、
- C# で数値表現の組み込み型については
<T>
の介入コストやobject
の介入コストは考える必要は無い switch
で型マッチングかけるとそれなりに重くなる- (おまけ)実行されない場所には例外スローを書いてもアッセンブリーレベルでの実行時の速度コスト増は気にしなくて良さそうだ
- (もちろん
try
ブロックや実際に例外オブジェクトを飛ばす処理が実行される場合は常識的に考えてコストが増えるがそれはまた別のお話)
- (もちろん
以上は処理系に寄るかもしれんというところはたぶんそうなのだけど、今回の記事ではとりあえず Microsoft の .net Framework 4.7.2 の C# 7.0 でのはなし、という事で。実行は Release ビルド、 CPU は Ryzen Threadripper 2990WX のうちの主力開発機ちゃん(かわいい2018年の新型ちゃん♥)です。