search
LoginSignup
15

posted at

.NET7 で LINQ の集計関数がめっちゃ高速化した話 (あるいは、ベクトル化の難しさ)

はじめに

すっかり秋ですが、この時期といえば恒例の .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 を強引に出しましたが、これは想定通りの値です。

AverageMaxの最適化 ~並列化とその難しさ~

ここまで Sum の最適化を見てきましたが、これ以上の高速化を実現した AverageMax はいったいどんなネタなのでしょうか。
特に平均値 Average なんて、 (平均値) = (合計値) / (要素数) ですから、AverageSum より速いのはかなりの怪奇現象のように思われます。

まぁ、例のループの最適化手法の記事でこの辺は掘りまくったので結論はもう明らかですね。そう、ベクトル演算です。
しかし今回の事例では、ベクトル演算による並列化の難しさも浮き彫りになっている点があり、そのへんを解説もしていきます。

ちなみに、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 ではベクトル化が出来なかったのです。

また、Averagelong 型に対しては、合計の計算途中でオーバーフローする可能性があり、並列化できません。
しかし、intlong に拡張すればオーバーフローのリスクがなく、加えて 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

解説

intlong に対するベクトル化の効果が大きく現れており、for による古典的な最大値計算コードを凌駕するパフォーマンスを発揮しています。
一方、floatdouble に関しては、大きく改善はしていますが以前 for よりは低速で、ベクトル化はされていないことがわかるでしょう。

さいごに

今回は .NET 7 における Linq 集計関数のパフォーマンス改善を見てきました。
比較的利用頻度が高い関数かつ、改善幅が大きいため結構役立ちそうです。

勿論、本質的な手法は既に過去に見てきたものが多く、特別目新しいというわけではありません。
しかし、実際のケースにおけるベクトル化の難しさが現れているという観点ではかなり斬新で、最適化も一筋縄ではいかないことを再認識させられました。

なお、紹介した一連の最適化に関しては以下のPRで実装されていたものです。つまり本記事の元ネタです。
ベクトル化に関するディスカッションなども残っており、とても興味深かったのでぜひ。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
15