はじめに
すっかり秋ですが、この時期といえば恒例の .NETアップデート。もうすぐ .NET 7がやってきます。ということで、.NET 7 紹介記事をば。
本記事執筆時点でまだ .NET 7 は正式リリース前なので、すべてrc2に基づく情報です。
とはいえ、rc2の時点で機能追加は実質打ち切りで、以後はバグ修正がメインとなりますからおそらく正式リリースでも変わらないと思われます。
今回の .NET7の目玉といえば個人的には、静的メソッドにも interface
が張れる機能、通称 Generic Math ですが(そのうち記事にしたい)、今回はそれの調査中に見つけた小ネタの方を紹介します。
Linq の集計関数、具体的には合計 Sum()
、平均 Average()
、最大 Max()
、最小 Min()
の最適化が入りました。
結果として、.NET 6 の場合とくらべて、最大40倍も高速になりました。Linq の集計関数は自分的には結構よく使う機能なので結構恩恵がありそうです。
.NET 6 vs .NET 7
まずは難しい理屈や裏側トークは抜きにして .NET 7 でどれくらい高速化されたか簡単に見ていきましょう。
100万要素の適当な int
型配列に対して、Linq の集計関数を適用するのにかかる時間を計測します。いつも通りですが、計測には BenchmarkDotNet を使用しています。
ベンチマークコードや環境はのちの詳細解説で確認してください。
結果
結果は以下のようになりました。
関数 | .NET 6 | .NET 7 | 比率 |
---|---|---|---|
Sum | 4789.3us | 422.9us | 11.3倍 |
Average | 4374.3us | 206.5us | 21.2倍 |
Max | 4371.9us | 105.9us | 41.3倍 |
と、どのケースもかなりの高速化が達成されていることがわかります。
一方、関数により高速化の度合いがかなり異なることも見えるでしょう。
Sum
の最適化 ~ Span<T>
の抜き出し~
まずは Sum
を見てみましょう。
察しがよい方はもうわかってしまったかもしれませんが、その場合は Average
のとこまで飛ばし読みしてください。
なお、以下に登場するコードは、MITライセンスで提供されている .NET のコードです。
.NET 6
public static int Sum(this IEnumerable<int> source)
{
if (source == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}
int sum = 0;
checked
{
foreach (int v in source)
{
sum += v;
}
}
return sum;
}
.NET 7
public static partial class Enumerable
{
public static int Sum(this IEnumerable<int> source) => Sum<int, int>(source);
private static TResult Sum<TSource, TResult>(this IEnumerable<TSource> source)
where TSource : struct, INumber<TSource>
where TResult : struct, INumber<TResult>
{
if (source.TryGetSpan(out ReadOnlySpan<TSource> span))
{
return Sum<TSource, TResult>(span);
}
TResult sum = TResult.Zero;
foreach (TSource value in source)
{
checked { sum += TResult.CreateChecked(value); }
}
return sum;
}
解説
.NET 7 では、Generic Math を使用するコードに内部レベルでは改められています。どうやら既存のAPIの Generic Math への変更は、オーバーロード解決に関して破壊的変更となるケースがあるようで全体的に保留されているようです。
とはいえ、今回重要なのはそちらではなく、TryGetSpan
の方です。
こちらは private
な拡張メソッドで、実装は以下の通りです。
ソースコード
namespace System.Linq
{
public static partial class Enumerable
{
public static IEnumerable<TSource> AsEnumerable<TSource>(this IEnumerable<TSource> source) => source;
/// <summary>Validates that source is not null and then tries to extract a span from the source.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] // fast type checks that don't add a lot of overhead
private static bool TryGetSpan<TSource>(this IEnumerable<TSource> source, out ReadOnlySpan<TSource> span)
// This constraint isn't required, but the overheads involved here can be more substantial when TSource
// is a reference type and generic implementations are shared. So for now we're protecting ourselves
// and forcing a conscious choice to remove this in the future, at which point it should be paired with
// sufficient performance testing.
where TSource : struct
{
if (source is null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}
// Use `GetType() == typeof(...)` rather than `is` to avoid cast helpers. This is measurably cheaper
// but does mean we could end up missing some rare cases where we could get a span but don't (e.g. a uint[]
// masquerading as an int[]). That's an acceptable tradeoff. The Unsafe usage is only after we've
// validated the exact type; this could be changed to a cast in the future if the JIT starts to recognize it.
// We only pay the comparison/branching costs here for super common types we expect to be used frequently
// with LINQ methods.
bool result = true;
if (source.GetType() == typeof(TSource[]))
{
span = Unsafe.As<TSource[]>(source);
}
else if (source.GetType() == typeof(List<TSource>))
{
span = CollectionsMarshal.AsSpan(Unsafe.As<List<TSource>>(source));
}
else
{
span = default;
result = false;
}
return result;
}
}
}
要は、配列と List<T>
に対して、それを Span<T>
として返すコードとなっています。
この辺の最適化テクニックは少し前に長編記事「ループの最適化手法」で紹介しましたのでそちらを参照ください(宣伝)。
したがってSum
では、.NET 7 において IEnumerable<T>
に対して愚直に MoveNext
~ Current
での列挙だったのが、.NET 7 では Span<T>
による直接的な(しかも境界値チェックを回避する)ループであるのがパフォーマンス改善の中身です。
なお、ループの最適化手法 で計測されたループを回すパフォーマンスは
-
Span<T>
+for
が 294.9us -
IEnumerable<T>
+MoveNext
が 4,570.1 μs
でしたから、今回の結果とも整合性が取れているといえるでしょう(今回は合計計算であり、単なるループより遅いことに注意)。
Sum
のベンチマーク詳細編
ソースコードから考えるに、従来のオーバーヘッドが解消されたことで for
と同じか、若干遅い程度の速度に改善したと考えられるため、for
による実装を組み込んだベンチマークで結果を確認します。
ついでに、int
以外の型の結果も入れておきます。
ベンチマークコード
public class SumBenchmark
{
const int testSize = 1_000_000;
const int maxValue = 1000;
private static readonly int[] testDataInt32;
private static readonly long[] testDataInt64;
private static readonly float[] testDataFloat32;
private static readonly double[] testDataFloat64;
static SumBenchmark()
{
testDataInt32 = Enumerable.Range(0, testSize).Select(x => x & maxValue).ToArray();
testDataInt64 = testDataInt32.Select(x => (long)x).ToArray();
testDataFloat32 = testDataInt32.Select(x => (float)x).ToArray();
testDataFloat64 = testDataInt32.Select(x => (double)x).ToArray();
}
[Benchmark]
public int LinqInt32()
{
var sum = testDataInt32.Sum();
return sum;
}
[Benchmark]
public int ForInt32()
{
var sum = 0;
for (int i = 0; i < testDataInt32.Length; i++)
{
sum += testDataInt32[i];
}
return sum;
}
[Benchmark]
public long LinqInt64()
{
var sum = testDataInt64.Sum();
return sum;
}
[Benchmark]
public long ForInt64()
{
var sum = 0l;
for (int i = 0; i < testDataInt64.Length; i++)
{
sum += testDataInt64[i];
}
return sum;
}
[Benchmark]
public float LinqFloat32()
{
var sum = testDataFloat32.Sum();
return sum;
}
#if NET7_0
[Benchmark]
public float LinqFloat32_Float()
{
var method = typeof(Enumerable).GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic).First(x => x.Name == "Sum" && x.GetGenericArguments().Count() == 2);
var gm = method.MakeGenericMethod(typeof(float), typeof(float));
var sum = (float)gm.Invoke(null, new[] { testDataFloat32 });
return sum;
}
#endif
[Benchmark]
public float ForFloat32()
{
var sum = 0f;
for (int i = 0; i < testDataFloat32.Length; i++)
{
sum += testDataFloat32[i];
}
return sum;
}
[Benchmark]
public double LinqFloat64()
{
var sum = testDataFloat64.Sum();
return sum;
}
[Benchmark]
public double ForFloat64()
{
var sum = 0d;
for (int i = 0; i < testDataFloat64.Length; i++)
{
sum += testDataFloat64[i];
}
return sum;
}
}
.NET 6
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22621
AMD Ryzen 7 1700, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-rc.2.22477.23
[Host] : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT [AttachedDebugger]
DefaultJob : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT
Method | Mean | Error | StdDev |
---|---|---|---|
LinqInt32 | 4,789.3 μs | 44.22 μs | 39.20 μs |
ForInt32 | 379.8 μs | 7.36 μs | 7.88 μs |
LinqInt64 | 4,496.9 μs | 55.02 μs | 51.46 μs |
ForInt64 | 466.5 μs | 8.03 μs | 6.71 μs |
LinqFloat32 | 5,840.7 μs | 112.38 μs | 110.37 μs |
ForFloat32 | 819.2 μs | 1.91 μs | 1.49 μs |
LinqFloat64 | 5,746.8 μs | 26.25 μs | 20.50 μs |
ForFloat64 | 855.7 μs | 13.67 μs | 12.12 μs |
.NET 7
BenchmarkDotNet=v0.13.2, OS=Windows 11 (10.0.22621.754)
AMD Ryzen 7 1700, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-rc.2.22477.23
[Host] : .NET 7.0.0 (7.0.22.47203), X64 RyuJIT AVX2
DefaultJob : .NET 7.0.0 (7.0.22.47203), X64 RyuJIT AVX2
Method | Mean | Error | StdDev |
---|---|---|---|
LinqInt32 | 422.9 μs | 2.20 μs | 1.83 μs |
ForInt32 | 385.1 μs | 1.24 μs | 1.10 μs |
LinqInt64 | 496.4 μs | 9.21 μs | 8.16 μs |
ForInt64 | 467.6 μs | 9.18 μs | 8.59 μs |
LinqFloat32 | 3,006.3 μs | 21.04 μs | 18.65 μs |
LinqFloat32_Float | 822.3 μs | 0.81 μs | 0.72 μs |
ForFloat32 | 816.2 μs | 0.82 μs | 0.73 μs |
LinqFloat64 | 847.8 μs | 2.74 μs | 2.57 μs |
ForFloat64 | 846.5 μs | 1.73 μs | 1.62 μs |
解説
予想どおり、従来の大幅なオーバーヘッドが解消し、シンプルな for
より少し遅い程度に改善したことがわかります。
なお、float
のみ Linq では内部的に double
相当で計算されるため for
より大幅に遅いです。.NET 7 側のみ、内部でも float
を使用した結果 LinqFloat32_Float
を強引に出しましたが、これは想定通りの値です。
Average
と Max
の最適化 ~並列化とその難しさ~
ここまで Sum
の最適化を見てきましたが、これ以上の高速化を実現した Average
と Max
はいったいどんなネタなのでしょうか。
特に平均値 Average
なんて、 (平均値) = (合計値) / (要素数)
ですから、Average
が Sum
より速いのはかなりの怪奇現象のように思われます。
まぁ、例のループの最適化手法の記事でこの辺は掘りまくったので結論はもう明らかですね。そう、ベクトル演算です。
しかし今回の事例では、ベクトル演算による並列化の難しさも浮き彫りになっている点があり、そのへんを解説もしていきます。
ちなみに、Linq ではベクトル演算の活用は既に行われており、今回はそれがさらに拡大した形です。
このへんは過去記事 知られざる比較の高速化手法 で徹底的に検証しているので合わせてご覧ください(宣伝)。
.NET 6
public static double Average(this IEnumerable<int> source)
{
if (source == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}
using (IEnumerator<int> e = source.GetEnumerator())
{
if (!e.MoveNext())
{
ThrowHelper.ThrowNoElementsException();
}
long sum = e.Current;
long count = 1;
checked
{
while (e.MoveNext())
{
sum += e.Current;
++count;
}
}
return (double)sum / count;
}
}
.NET 7
public static double Average(this IEnumerable<int> source)
{
if (source.TryGetSpan(out ReadOnlySpan<int> span))
{
// Int32 is special-cased separately from the rest of the types as it can be vectorized:
// with at most Int32.MaxValue values, and with each being at most Int32.MaxValue, we can't
// overflow a long accumulator, and order of operations doesn't matter.
if (span.IsEmpty)
{
ThrowHelper.ThrowNoElementsException();
}
long sum = 0;
int i = 0;
if (Vector.IsHardwareAccelerated && span.Length >= Vector<int>.Count)
{
Vector<long> sums = default;
do
{
Vector.Widen(new Vector<int>(span.Slice(i)), out Vector<long> low, out Vector<long> high);
sums += low;
sums += high;
i += Vector<int>.Count;
}
while (i <= span.Length - Vector<int>.Count);
sum += Vector.Sum(sums);
}
for (; (uint)i < (uint)span.Length; i++)
{
sum += span[i];
}
return (double)sum / span.Length;
}
using (IEnumerator<int> e = source.GetEnumerator())
{
if (!e.MoveNext())
{
ThrowHelper.ThrowNoElementsException();
}
long sum = e.Current;
long count = 1;
while (e.MoveNext())
{
checked { sum += e.Current; }
count++;
}
return (double)sum / count;
}
}
解説
Sum
で見られた Span<T>
抜き出しの最適化に加え、ベクトル演算が使用されていることがわかります。
Vector<int>
を Vector<long>
に拡張することで、オーバーフローを回避しているのがポイントです。
AVX2命令が有効な環境では、1 命令で4個の long
要素を処理できますから、結構な差が付くのも納得ですね。
ところで、この最適化は int
にしか掛かりませんし、Sum
では掛かっていませんでしたね。
なぜ int
に対する Average
内の合計処理だけ、ベクトル化されるのでしょうか。
ここが最適化リファクタリングの難しいところなのですが、並列化しない場合と同じ挙動でないといけません。
並列化することにより、加算の順序が変わってしまいます。数学では、実数の加算に関して交換法則が成立しますが、計算機ではそうもいかないのです。
オーバーフロー
まず考慮しなければならないのがオーバーフローです。
従来の Sum
関数は、合計の集計途中で算術演算オーバーフローが発生した場合、直ちに OverflowException
が発生しました。
少し戻ってソースコードを確認すると、checked
で囲まれているのが確認できるでしょう。
並列化による加算順序の変更は、この挙動に関して破壊的変更となり得ます。少なくとも、例外が発生するタイミングが変化するのは避けられません。
そのため、Sum
ではベクトル化が出来なかったのです。
また、Average
も long
型に対しては、合計の計算途中でオーバーフローする可能性があり、並列化できません。
しかし、int
は long
に拡張すればオーバーフローのリスクがなく、加えて Average
はもともと long
で合計を計算し、double
で平均値を返していましたから、オーバーフローを回避でき、たまたま加算順序を変更しても挙動の一貫性を保持できるのです。
桁落ち
次に、浮動小数点数に絡む桁落ちの問題です。
浮動小数点数では、極端に大きさが違う2つの数を足すと小さい数側が反映されないことがあります。
したがって、小さな数同士を先に足し集めてから大きな数を足す場合と、いきなり小さな数と大きな数を足す場合で計算結果が異なるケースがあるのです。
よって、浮動小数点数は厳密には交換法則が成り立たず、よって挙動の一貫性を確保した上での並列化が出来ないのです。
Average
のベンチマーク詳細編
では、実際のベンチマークで確認しましょう。
ベンチマークコード
public class AverageBenchmark
{
const int testSize = 1_000_000;
const int maxValue = 1000;
private static readonly int[] testDataInt32;
private static readonly long[] testDataInt64;
private static readonly float[] testDataFloat32;
private static readonly double[] testDataFloat64;
static AverageBenchmark()
{
testDataInt32 = Enumerable.Range(0, testSize).Select(x => x & maxValue).ToArray();
testDataInt64 = testDataInt32.Select(x => (long)x).ToArray();
testDataFloat32 = testDataInt32.Select(x => (float)x).ToArray();
testDataFloat64 = testDataInt32.Select(x => (double)x).ToArray();
}
[Benchmark]
public double LinqInt32()
{
var sum = testDataInt32.Average();
return sum;
}
[Benchmark]
public double ForInt32()
{
var sum = 0;
for (int i = 0; i < testDataInt32.Length; i++)
{
sum += testDataInt32[i];
}
return (double)sum / testDataInt32.Length;
}
[Benchmark]
public double LinqInt64()
{
var sum = testDataInt64.Average();
return sum;
}
[Benchmark]
public double ForInt64()
{
var sum = 0l;
for (int i = 0; i < testDataInt64.Length; i++)
{
sum += testDataInt64[i];
}
return (double)sum / testDataInt64.Length;
}
[Benchmark]
public float LinqFloat32()
{
var sum = testDataFloat32.Average();
return sum;
}
[Benchmark]
public float ForFloat32()
{
var sum = 0f;
for (int i = 0; i < testDataFloat32.Length; i++)
{
sum += testDataFloat32[i];
}
return sum / testDataFloat32.Length;
}
[Benchmark]
public double LinqFloat64()
{
var sum = testDataFloat64.Average();
return sum;
}
[Benchmark]
public double ForFloat64()
{
var sum = 0d;
for (int i = 0; i < testDataFloat64.Length; i++)
{
sum += testDataFloat64[i];
}
return sum / testDataFloat64.Length;
}
}
.NET 6
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22621
AMD Ryzen 7 1700, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-rc.2.22477.23
[Host] : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT
DefaultJob : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT
Method | Mean | Error | StdDev |
---|---|---|---|
LinqInt32 | 4,374.3 μs | 18.46 μs | 15.42 μs |
ForInt32 | 371.7 μs | 0.53 μs | 0.44 μs |
LinqInt64 | 4,400.7 μs | 32.88 μs | 29.15 μs |
ForInt64 | 453.8 μs | 3.06 μs | 2.71 μs |
LinqFloat32 | 5,710.5 μs | 11.48 μs | 9.59 μs |
ForFloat32 | 817.0 μs | 0.94 μs | 0.84 μs |
LinqFloat64 | 5,738.2 μs | 29.52 μs | 27.61 μs |
ForFloat64 | 851.5 μs | 13.99 μs | 10.92 μs |
.NET 7
BenchmarkDotNet=v0.13.2, OS=Windows 11 (10.0.22621.754)
AMD Ryzen 7 1700, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-rc.2.22477.23
[Host] : .NET 7.0.0 (7.0.22.47203), X64 RyuJIT AVX2
DefaultJob : .NET 7.0.0 (7.0.22.47203), X64 RyuJIT AVX2
Method | Mean | Error | StdDev |
---|---|---|---|
LinqInt32 | 206.5 μs | 2.00 μs | 1.56 μs |
ForInt32 | 386.5 μs | 1.86 μs | 1.55 μs |
LinqInt64 | 561.8 μs | 16.61 μs | 48.97 μs |
ForInt64 | 467.3 μs | 9.27 μs | 13.29 μs |
LinqFloat32 | 2,991.2 μs | 1.21 μs | 0.94 μs |
ForFloat32 | 816.2 μs | 0.47 μs | 0.39 μs |
LinqFloat64 | 849.6 μs | 4.40 μs | 3.90 μs |
ForFloat64 | 843.9 μs | 2.71 μs | 2.53 μs |
解説
やはり、ソースコードから予想された通り int
に対してのみ極端に高速化されています。
このケースのみ、ベクトル演算を使用しない単純な for
文による平均値計算コードよりもさらに高速であることに注意してください。それ以外のケースでは、for
による場合と同等か、それより遅くなっています。
Max
のベンチマーク詳細編
最後に、Max
についてです。
加算と異なり、オーバーフローも交換法則も関係ないためベクトル化できそうに感じますが、やはり問題があります。
浮動小数点数には、NaN
や ±INF
などの特殊な値があり、これらへの比較演算に関して特殊処理が必要です。
これがやはり単純なベクトル化を阻害してしまうのです。
とはいえ、int
および long
に対してはベクトル化可能ですし、単純な分効果もより大きいです。
ベンチマークコード
public class MaxBenchmark
{
const int testSize = 1_000_000;
const int maxValue = 1000;
private static readonly int[] testDataInt32;
private static readonly long[] testDataInt64;
private static readonly float[] testDataFloat32;
private static readonly double[] testDataFloat64;
static MaxBenchmark()
{
testDataInt32 = Enumerable.Range(0, testSize).Select(x => x & maxValue).ToArray();
testDataInt64 = testDataInt32.Select(x => (long)x).ToArray();
testDataFloat32 = testDataInt32.Select(x => (float)x).ToArray();
testDataFloat64 = testDataInt32.Select(x => (double)x).ToArray();
}
[Benchmark]
public int LinqInt32()
{
var sum = testDataInt32.Max();
return sum;
}
[Benchmark]
public int ForInt32()
{
var max = int.MinValue;
for (int i = 0; i < testDataInt32.Length; i++)
{
if (testDataInt32[i] > max)
{
max = testDataInt32[i];
}
}
return max;
}
[Benchmark]
public long LinqInt64()
{
var sum = testDataInt64.Max();
return sum;
}
[Benchmark]
public long ForInt64()
{
var max = long.MinValue;
for (int i = 0; i < testDataInt64.Length; i++)
{
if (testDataInt64[i] > max)
{
max = testDataInt64[i];
}
}
return max;
}
[Benchmark]
public float LinqFloat32()
{
var sum = testDataFloat32.Max();
return sum;
}
[Benchmark]
public float ForFloat32()
{
var max = float.MinValue;
for (int i = 0; i < testDataFloat32.Length; i++)
{
if (testDataFloat32[i] > max)
{
max = testDataFloat32[i];
}
}
return max;
}
[Benchmark]
public double LinqFloat64()
{
var sum = testDataFloat64.Max();
return sum;
}
[Benchmark]
public double ForFloat64()
{
var max = double.MinValue;
for (int i = 0; i < testDataFloat64.Length; i++)
{
if (testDataFloat64[i] > max)
{
max = testDataFloat64[i];
}
}
return max;
}
}
.NET 6
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22621
AMD Ryzen 7 1700, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-rc.2.22477.23
[Host] : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT
DefaultJob : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT
Method | Mean | Error | StdDev |
---|---|---|---|
LinqInt32 | 4,371.9 μs | 44.43 μs | 37.10 μs |
ForInt32 | 550.0 μs | 4.70 μs | 4.17 μs |
LinqInt64 | 4,657.7 μs | 35.55 μs | 29.68 μs |
ForInt64 | 607.5 μs | 8.02 μs | 7.50 μs |
LinqFloat32 | 5,727.3 μs | 27.39 μs | 24.28 μs |
ForFloat32 | 820.0 μs | 3.86 μs | 3.01 μs |
LinqFloat64 | 5,746.5 μs | 31.32 μs | 24.45 μs |
ForFloat64 | 599.5 μs | 6.23 μs | 4.87 μs |
.NET 7
BenchmarkDotNet=v0.13.2, OS=Windows 11 (10.0.22621.754)
AMD Ryzen 7 1700, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-rc.2.22477.23
[Host] : .NET 7.0.0 (7.0.22.47203), X64 RyuJIT AVX2
DefaultJob : .NET 7.0.0 (7.0.22.47203), X64 RyuJIT AVX2
Method | Mean | Error | StdDev |
---|---|---|---|
LinqInt32 | 105.9 μs | 2.12 μs | 2.17 μs |
ForInt32 | 549.5 μs | 3.44 μs | 2.69 μs |
LinqInt64 | 365.5 μs | 7.17 μs | 11.38 μs |
ForInt64 | 595.4 μs | 11.68 μs | 13.90 μs |
LinqFloat32 | 821.0 μs | 4.42 μs | 3.92 μs |
ForFloat32 | 547.2 μs | 1.32 μs | 1.17 μs |
LinqFloat64 | 853.5 μs | 11.23 μs | 9.95 μs |
ForFloat64 | 589.7 μs | 7.63 μs | 7.14 μs |
解説
int
と long
に対するベクトル化の効果が大きく現れており、for
による古典的な最大値計算コードを凌駕するパフォーマンスを発揮しています。
一方、float
や double
に関しては、大きく改善はしていますが以前 for
よりは低速で、ベクトル化はされていないことがわかるでしょう。
さいごに
今回は .NET 7 における Linq 集計関数のパフォーマンス改善を見てきました。
比較的利用頻度が高い関数かつ、改善幅が大きいため結構役立ちそうです。
勿論、本質的な手法は既に過去に見てきたものが多く、特別目新しいというわけではありません。
しかし、実際のケースにおけるベクトル化の難しさが現れているという観点ではかなり斬新で、最適化も一筋縄ではいかないことを再認識させられました。
なお、紹介した一連の最適化に関しては以下のPRで実装されていたものです。つまり本記事の元ネタです。
ベクトル化に関するディスカッションなども残っており、とても興味深かったのでぜひ。