てくメモ

trivial な notes

【C#】算術のいくつかの最適化 tips を測ってみる

アルゴリズムではない tips 的な算術の最適化、例えば、除算ではなく逆数を乗算する、のようなものが、どの程度効用があるのかを測って確かめてみる。

なお、体系的なものではなくて、並べているだけ。

除算(逆数の乗算)

除算は乗算に較べて遅いので、逆数を乗算する、というもの。

BenchmarkDotNet で比較。以下同様。

private int value = Random.Shared.Next();
private const float divisor = 7f;
private const float reciprocal = 1 / divisor;

[Benchmark]
public float Div() => value / divisor;

[Benchmark]
public float Multiply() => value * reciprocal;
   Method |      Mean |     Error |    StdDev | Allocated |
--------- |----------:|----------:|----------:|----------:|
      Div | 0.4065 ns | 0.2055 ns | 0.0113 ns |         - |
 Multiply | 0.3615 ns | 0.1303 ns | 0.0071 ns |         - |

よく言われるだけあり、実際差があるように見える。

誤差の話もあるから単純な置き換えではないけれど、差があること自体は知っておきたいと思った。

奇数偶数判断(ビット演算)

奇数偶数判断は剰余算で定義どおり判断できるが、AND演算でも判断できる。
剰余算は遅いと言われるがはたして。

private int value = Random.Shared.Next();

[Benchmark]
public bool Mod() => value % 2 != 0;

[Benchmark]
public bool Bit() => (value & 1) != 0;
 Method |      Mean |     Error |    StdDev |    Median | Allocated |
------- |----------:|----------:|----------:|----------:|----------:|
    Mod | 0.0107 ns | 0.2650 ns | 0.0145 ns | 0.0049 ns |         - |
    Bit | 0.0101 ns | 0.1705 ns | 0.0093 ns | 0.0119 ns |         - |

少なくとも今回はほとんど変わらない、という結果になった。
奇数偶数を取る%に目くじらを立てる必要もなさそう。

コンパイラ側の最適化で同じ処理に合流していそうな雰囲気があるが、一応、ILまででは異なっていた。

// 剰余算抜粋
IL_0006: ldc.i4.2
IL_0007: rem

// ビット演算抜粋
IL_0006: ldc.i4.1
IL_0007: and

絶対値(ビット演算)

絶対値はMath.Absで取れるが、最上位ビットが符号に使われているのを利用してビット演算でも取ることができる。

private int value = Random.Shared.Next(int.MinValue, int.MaxValue);

[Benchmark]
public int MathAbs() => Math.Abs(value);

// int.MinValue がオーバフローするが、今回は処理しない
[Benchmark]
public int BitAbs() => (value ^ (value >> 31)) - (value >> 31);
  Method |      Mean |     Error |    StdDev | Allocated |
-------- |----------:|----------:|----------:|----------:|
 MathAbs | 1.2106 ns | 1.8939 ns | 0.1038 ns |         - |
  BitAbs | 0.0596 ns | 0.3890 ns | 0.0213 ns |         - |

マイクロの世界だけれど二桁違うとは思わなかった。
よく言われているだけあって、ビット演算は高速。

Max(float, float) (Intrinsics)

算術に限らないが、ハードウェアの専用命令が適用できると速い。
C# も Intrinsics としてそれを支援している。

ただ、Max のような単純な(に見える)処理を、単発で任せるとどうか。
何らかのオーバーヘッドの影響が大きかったりするか……?

private float a = Random.Shared.NextSingle();
private float b = Random.Shared.NextSingle();

[Benchmark]
public float MathMax() => MathF.Max(a, b);

[Benchmark]
public float SSeMax()
{
    return Sse.IsSupported
        ? Sse.MaxScalar(Vector128.CreateScalarUnsafe(a), Vector128.CreateScalarUnsafe(b)).ToScalar()
        : (a < b ? b : a);
}
  Method |      Mean |     Error |    StdDev | Allocated |
-------- |----------:|----------:|----------:|----------:|
 MathMax | 2.1389 ns | 0.9096 ns | 0.0499 ns |         - |
  SSeMax | 0.0902 ns | 0.0535 ns | 0.0029 ns |         - |

HW Intrinsics 強し、だった。

もちろん一概には言えないが、単純な(に見える)場合でも可能なら恩恵を受けた方がよいように感じた。(算術には限らないけれど)