C++ ときどき ごはん、わりとてぃーぶれいく☆

USAGI.NETWORKのなかのひとのブログ。主にC++。

C#: generics<T> + object 黒魔法 vs. 非ジェネリクスメソッド 実行速度編

前回の記事「C#: 組み込みの数値型を扱いたい generics の 型と IConvertible の肝っぽい事、それと object 型を暗躍させる必要性のメモ」で C#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

↓わかりやすいエクセルスクショ

f:id:USAGI-WRP:20181230022824p:plain

それぞれ何だったかおさらいすると

  • <1>: 非ジェネリクス
  • <2>: ジェネリクスだけど特定の型だけに対応して <T>object の介入コストを見たい版
  • <3>: <T>object にして switch して型マッチングして計算して object を介して <T> にして返す版(但し非対応型は何もせず返す)
  • <4>: <3> の非対応型には NotImplementedException を投げつける版

で、わかった事は、

  1. C# で数値表現の組み込み型については <T> の介入コストや object の介入コストは考える必要は無い
    • カリカリチューニングレベルで考える必要がある時はそもそも C# 使わない選択肢を考えた方がいいと思う😅)
  2. switch で型マッチングかけるとそれなりに重くなる
    • (と、言ってもカリカリチューニングの数値計算でもなければ人間には気にする事すらできない程度の性能差だろう)
    • (そんでもって、カリカリチューニングを C# の"上"でやる必要があるとしたらだたのコードゴルフみたいなものなのでまあどうでもいっかなっと😅)
  3. (おまけ)実行されない場所には例外スローを書いてもアッセンブリーレベルでの実行時の速度コスト増は気にしなくて良さそうだ
    • (もちろん try ブロックや実際に例外オブジェクトを飛ばす処理が実行される場合は常識的に考えてコストが増えるがそれはまた別のお話)

以上は処理系に寄るかもしれんというところはたぶんそうなのだけど、今回の記事ではとりあえず Microsoft .net Framework 4.7.2 の C# 7.0 でのはなし、という事で。実行は Release ビルド、 CPU は Ryzen Threadripper 2990WX のうちの主力開発機ちゃん(かわいい2018年の新型ちゃん♥)です。