C#で、速度を徹底的に出したいと思って、いろいろ遅くなる要素を避けます。
・ボクシング
・配列のコピー
・ヒープメモリをなるべく使わない
・仮想呼び出し(継承・インターフェイス)
・文字コードの変換
・Lock
まあ、避けれられる時には避けるわけですが、避けると可読性がすごく悪くなる時とか、
避けられないとか、悩ましい時はあります。
と、ここで、どの要素がどれくらい遅くなるものなのか、正確には知らないことに気づきました。
なので、まずは各要素がどれくらい遅いのか、ベンチマークを取ってみたいと思います。
まずは結論から。
| Method | Mean | Error | StdDev |
|--------------------------------------- |-----------:|-----------:|-----------:|
| IntBench | 2.528 ms | 0.0492 ms | 0.0436 ms |
| DoubleBench | 7.420 ms | 0.1484 ms | 0.1766 ms |
| IntNoInlineBench | 17.373 ms | 0.3421 ms | 0.3940 ms |
| IntStructBench | 2.509 ms | 0.0492 ms | 0.0526 ms |
| IntClassBench | 105.393 ms | 2.0978 ms | 3.1399 ms |
| IntBoxingBench | 105.288 ms | 2.1175 ms | 6.0754 ms |
| IntSubClassVirtualBench | 104.455 ms | 2.0619 ms | 4.1652 ms |
| IntSubClassVirtualBench2 | 5.450 ms | 0.0322 ms | 0.0285 ms |
| IntSubClassSealedVirtualBench | 103.931 ms | 1.3346 ms | 1.2484 ms |
| IntInterfaceVirtualBench | 108.854 ms | 2.1549 ms | 4.4501 ms |
| IntLockBench | 97.660 ms | 1.9243 ms | 2.4336 ms |
| AsyncAwaitIntBench | 115.553 ms | 1.8045 ms | 1.6879 ms |
| AwaitFromResultIntBench | 25.641 ms | 0.3899 ms | 0.3647 ms |
| AsyncAwaitValueTaskIntBench | 191.696 ms | 3.7660 ms | 4.1859 ms |
| AwaitFromResultValueTaskIntBench | 84.401 ms | 1.5981 ms | 1.9626 ms |
| MakeUtf8Abcde_StringBench | 561.458 ms | 11.1289 ms | 13.2481 ms |
| MakeUtf8Abcde_StaticStringBench | 548.176 ms | 10.7093 ms | 11.9033 ms |
| MakeUtf8Abcde_StaticByteArrayBench | 358.296 ms | 6.3360 ms | 5.9267 ms |
| MakeUtf8Abcde_StaticByteArraySpanBench | 205.517 ms | 3.0808 ms | 2.8818 ms |
1,000,000回のループを入れてあるので、1回の速度としてはナノ秒単位のはずです。
・Intより、Doubleが遅い 2:7
・メソッドの呼び出し(インライン化を禁止したら)で、+15
・Intを内部に持つstructでは、intと変わらない。
・classを2つnewして、ヒープメモリを使うと、+100
・intをobjectに入れたboxingが起こるケース。+100。classとほぼ同じ。
・仮想呼出(プロパティ)のコストは、+3くらい。
・async awaitを使うメソッド1回で +100
・Task.FromResultを使って、awaitだけなら、+20
・TaskをValueTaskに変えたら、遅くなった。なんでだろう。 +80。理由わかる方いたら、コメントくださるとうれしいです。
・lockを入れたら、+90
次は、utf8文字作成
・stringで結合して、最後にutf8にするのは遅い。580
・stringを、staticに事前にutf8にしておいたら、360で、-220
・結合部分で、byte[]を作らないように、spanで処理したら、200で最速
以下に、各ソースと、簡単な説明を書いていきます。
環境は、
・CPU:AMD Ryzen5 3600 6 Core
・Windows11
・VisulaStudio 2022
・.Net6.0
public class Program
{
public static void Main(string[] args)
{
BenchmarkDotNet.Running.BenchmarkRunner.Run<BenchTarget>();
}
}
public class BenchTarget
{
const int _loopCount = 1_000_0000;
// ベンチマークの中身
}
枠組み
NuGetで、BenchmarkDotNetを入れてあります。
[Benchmark]
public long IntBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
int a = 1;
int b = 2;
sum += SumInt(a, b);
}
return sum;
}
public int SumInt(int a, int b)
{
return a + b;
}
標準形というこで、intを2つメソッド内で足しているだけです。
インライン化されて、メソッド呼出のコストは無くなっているはず。
[Benchmark]
public double DoubleBench()
{
double sum = 0;
for (int i = 0; i < _loopCount; i++)
{
double a = 1;
double b = 2;
sum += SumDouble(a, b);
}
return sum;
}
public double SumDouble(double a, double b)
{
return a + b;
}
同様のをdoubleで。
[Benchmark]
public long IntNoInlineBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
int a = 1;
int b = 2;
sum += SumIntNoInline(a, b);
}
return sum;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public int SumIntNoInline(int a, int b)
{
return a + b;
}
インライン化を禁止することで、メソッド呼出のコストを
計測してみた。
+15と、ある程度コストがかかる。
オブジェクトを一つ作るよりは軽いみたい。
[Benchmark]
public long IntStructBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
var a = new IntStruct() { Value = 1 };
var b = new IntStruct() { Value = 2 };
sum += SumIntStruct(a, b);
}
return sum;
}
public int SumIntStruct(IntStruct a, IntStruct b)
{
return a.Value + b.Value;
}
public struct IntStruct
{
public int Value { get; set; }
}
intの値をラッピングしたstructを作った場合。
intと速度はほぼ変わらず。
[Benchmark]
public long IntClassBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
var a = new IntClass() { Value = 1 };
var b = new IntClass() { Value = 2 };
sum += SumIntClass(a, b);
}
return sum;
}
public int SumIntClass(IntClass a, IntClass b)
{
return a.Value + b.Value;
}
public class IntClass
{
public int Value { get; set; }
}
intを中に持つclassを使った場合。
都度、ヒープメモリにオブジェクトが作られるので、遅い(おそらくGCが)。
[Benchmark]
public long IntBoxingBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
var a = 1;
var b = 2;
sum += SumIntBoxing(a, b);
}
return sum;
}
public int SumIntBoxing(object a, object b)
{
return (int)a + (int)b;
}
引数をobjectにしてあるので、boxingが起こるケース。
classを使った場合と、ほぼ同じ速度。
ただ、今回のシナリオでは同じ回数だけれど、
実際の使用ケースだと、呼出の度にboxingが起こるから、
ダメージが大きい場合の方が多そう。
[Benchmark]
public long IntSubClassVirtualBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
ParentClass1 a = new ChildClass1() { Value = 1 };
ParentClass1 b = new ChildClass1() { Value = 2 };
sum += SumIntSubClassVirtual(a, b);
}
return sum;
}
public int SumIntSubClassVirtual(ParentClass1 a, ParentClass1 b)
{
return a.Value + b.Value;
}
public class ParentClass1
{
public virtual int Value { get; set; }
}
public class ChildClass1 : ParentClass1
{
public override int Value { get; set; }
}
親子クラスを定義して、virtualで呼び出す。
仮想呼出のコスト<ヒープを使うコスト (100倍くらい?)
みたいで、仮想呼出のコストはよくわからない。
[Benchmark]
public long IntSubClassVirtualBench2()
{
long sum = 0;
ParentClass1 a = new ChildClass1();
ParentClass1 b = new ChildClass1();
for (int i = 0; i < _loopCount; i++)
{
a.Value = 1;
b.Value = 2;
sum += SumIntSubClassVirtual(a, b);
}
return sum;
}
仮想呼出のコストだけを調査してみた。
+3という結果がでたけど、インライン阻害のケースより速いのが謎。
virtual化したのが、プロパティだからか。
もっと、調査をしてみたいところ。
[Benchmark]
public long IntSubClassSealedVirtualBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
ParentClass2 a = new ChildClassSealed2() { Value = 1 };
ParentClass2 b = new ChildClassSealed2() { Value = 2 };
sum += SumIntSubClassSealedVirtual(a, b);
}
return sum;
}
public int SumIntSubClassSealedVirtual(ParentClass2 a, ParentClass2 b)
{
return a.Value + b.Value;
}
public class ParentClass2
{
public virtual int Value { get; set; }
}
public sealed class ChildClassSealed2 : ParentClass2
{
public override int Value { get; set; }
}
子クラスをsealedしてみたけど、今回のケースだと速度はほぼ変わらず。
[Benchmark]
public long IntInterfaceVirtualBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
IHasValue a = new ChildClassWithInterface() { Value = 1 };
IHasValue b = new ChildClassWithInterface() { Value = 2 };
sum += SumIntInterfaceVirtual(a, b);
}
return sum;
}
public int SumIntInterfaceVirtual(IHasValue a, IHasValue b)
{
return a.Value + b.Value;
}
public interface IHasValue
{
public int Value { get; set; }
}
public class ChildClassWithInterface : IHasValue
{
public int Value { get; set; }
}
インターフェース経由いした場合。
こちらも、継承とあまり変わらない。
[Benchmark]
public long IntLockBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
int a = 1;
int b = 2;
lock (lockObject)
{
sum += SumInt(a, b);
}
}
return sum;
}
readonly object lockObject = new();
sumを呼び出すときに、lockをかけた場合。
new2つと同じくらいの、結構大きなコストがかかる。
[Benchmark]
public async Task<long> AsyncAwaitIntBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
int a = 1;
int b = 2;
sum += await SumAsyncInt1(a, b);
}
return sum;
}
public async Task<int> SumAsyncInt1(int a, int b)
{
return a + b;
}
async,awaitを使って呼び出した場合。
実際には同期実行されるので、本当はasync,awaitはいらない
プラグラムだが。
内部で、Taskオブジェクトがnewされるからか、new2つと同じくらい
コストがかかっている。
[Benchmark]
public async Task<long> AwaitFromResultIntBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
int a = 1;
int b = 2;
sum += await SumFromResultInt(a, b);
}
return sum;
}
public Task<int> SumFromResultInt(int a, int b)
{
return Task.FromResult(a + b);
}
Task.FromResultに書き換えたケース。
かなり速くなる。
理由は、Task.FromResult内でキャッシュが効いているようで、
Taskオブジェクトが新しく作られないと思う。
[Benchmark]
public async Task<long> AsyncAwaitValueTaskIntBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
int a = 1;
int b = 2;
sum += await SumAsyncAwaitValueTaskInt(a, b);
}
return sum;
}
public async ValueTask<int> SumAsyncAwaitValueTaskInt(int a, int b)
{
return a + b;
}
Sumを、Taskではなく、ValueTaskにしたケース。
速くなるかと思ったのに、なぜか逆に遅くなった。理由は良くわからない。
[Benchmark]
public async Task<long> AwaitFromResultValueTaskIntBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
int a = 1;
int b = 2;
sum += await SumFromResultValueTaskInt(a, b);
}
return sum;
}
public ValueTask<int> SumFromResultValueTaskInt(int a, int b)
{
return ValueTask.FromResult(a + b);
}
ValueTask.FromResult版。
こちらもTaskより遅くなった。
[Benchmark]
public long MakeUtf8Abcde_StringBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
string a = "a";
string b = "b";
string c = "c";
string d = "d";
string e = "e";
var result = MakeUtf8Abcde_String(a, b, c, d, e);
sum += result.Length;
}
return sum;
}
public byte[] MakeUtf8Abcde_String(string a, string b, string c, string d, string e)
{
return Encoding.UTF8.GetBytes(a + b + c + d + e);
}
static string _a = "a";
static string _b = "b";
static string _c = "c";
static string _d = "d";
static string _e = "e";
string5つを、渡してくっつけて、utf8のbyte[]にして返す。
結合の時と、byte[]を作る2回で、ヒープメモリが使われる点、
変換が1回とコストがかかっている。
[Benchmark]
public long MakeUtf8Abcde_StaticStringBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
var result = MakeUtf8Abcde_String(_a, _b, _c, _d, _e);
sum += result.Length;
}
return sum;
}
public byte[] MakeUtf8Abcde_StaticString(string a, string b, string c, string d, string e)
{
return Encoding.UTF8.GetBytes(a + b + c + d + e);
}
stringの値を、メソッド内に持たず、staticで定義するように置き換えたもの。
速度は変わらないと想定したが、想定通りほぼ同じ速度だった。
[Benchmark]
public long MakeUtf8Abcde_StaticByteArrayBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
var result = MakeUtf8Abcde_StaticByteArray(b_a, b_b, b_c, b_d, b_e);
sum += result.Length;
}
return sum;
}
static byte[] b_a = Encoding.UTF8.GetBytes("a");
static byte[] b_b = Encoding.UTF8.GetBytes("b");
static byte[] b_c = Encoding.UTF8.GetBytes("c");
static byte[] b_d = Encoding.UTF8.GetBytes("d");
static byte[] b_e = Encoding.UTF8.GetBytes("e");
public byte[] MakeUtf8Abcde_StaticByteArray(byte[] a, byte[] b, byte[] c, byte[] d, byte[] e)
{
byte[] result = new byte[a.Length + b.Length + c.Length + d.Length + e.Length];
int index = 0;
a.CopyTo(result, index);
index += a.Length;
b.CopyTo(result, index);
index += a.Length;
c.CopyTo(result, index);
index += a.Length;
d.CopyTo(result, index);
index += a.Length;
e.CopyTo(result, index);
index += a.Length;
return result;
}
staticで、各要素をはじめからbyte[]で持ち、byte[]に結合した。
文字コード変換は無しで、byte[]のヒープ確保が1回発生する。
[Benchmark]
public long MakeUtf8Abcde_StaticByteArraySpanBench()
{
long sum = 0;
for (int i = 0; i < _loopCount; i++)
{
sum += MakeUtf8Abcde_StaticByteArraySpan1();
}
return sum;
}
public int MakeUtf8Abcde_StaticByteArraySpan1()
{
Span<byte> buffer = stackalloc byte[128];
var result = MakeUtf8Abcde_StaticByteArraySpan2(buffer, b_a, b_b, b_c, b_d, b_e);
return result.Length;
}
public Span<byte> MakeUtf8Abcde_StaticByteArraySpan2(Span<byte> buffer,byte[] a, byte[] b, byte[] c, byte[] d, byte[] e)
{
int index = 0;
a.AsSpan().CopyTo(buffer[index..]);
index += a.Length;
b.AsSpan().CopyTo(buffer[index..]);
index += b.Length;
c.AsSpan().CopyTo(buffer[index..]);
index += c.Length;
d.AsSpan().CopyTo(buffer[index..]);
index += d.Length;
e.AsSpan().CopyTo(buffer[index..]);
index += e.Length;
return buffer[..index];
}
staticで、各要素をはじめからbyte[]で持ち、Spanで結合した。
文字コード変換、ヒープメモリ共に使用を回避した。
結果、文字結合では最速になった。
仮想呼出については、結果がしっくりこなかったので、
後日、また調査をしてみたいと思います。