てくメモ

trivial な notes

【C#】長さを利用した CopyTo のJIT最適化を試す

最初にきっかけとなったポストを示す。"why the _hacker version is faster?" ということなので、ポスト初見の場合、一度考えてもいいかもしれない。


ちなみに、ポストのベンチマーク .NET 9 で実行されているとのこと。そして、.NET 8 はそのままでは同様の最適化を受けられない。.NET 8 環境下で試したい場合、コンパイラにもう少しわかりやすく長さを示す必要がある。

public class CopyToHackBenchmark
{
    private const int constant = 10;
    private readonly int[] srcArray = new int[constant];
    private readonly int[] dstArray = new int[constant];

    public int[] CopyToSimpleWay()
    {
        srcArray.AsSpan().CopyTo(dstArray);
        return dstArray;
    }

    public int[] CopyToImperfectlyHacked()
    {
        // .NET 8 では冒頭ポストのように単に Span にするだけではダメ
        var src = srcArray.AsSpan();
        var dst = dstArray.AsSpan();

        if (src.Length == constant)
            src.CopyTo(dst);
        else
            src.CopyTo(dst);

        return dstArray;
    }

    public int[] CopyToHacked()
    {
        // このようにすると同様の最適化を受けられる
        var src = srcArray.AsSpan(0, constant);
        var dst = dstArray.AsSpan(0, constant);

        if (src.Length == constant)
            src.CopyTo(dst);
        else
            src.CopyTo(dst);

        return dstArray;
    }
}

試せば分かるが、確かに速くなる。


さて、"why" の答えについて。ポストのリプライで正解を告げられているのは次のポスト。

コンパイラが?)既知の長さについてのコピーは、一連の簡潔なロード/ストアにアンローリングされるから、というもの。アセンブリのレイヤーのお話。

正解を告げたリプライの画像を見ると、vmovdqu命令の連続になっている。


さて、仕組みが分かったところで条件を変えて試してみた。今回は LINQPad の出力で確認した。

  • .NET8, LINQPad 8, x64
#LINQPad optimize+

using System;
using System.Runtime.CompilerServices;

public class CopyToHackBenchmark
{
    // 自分の環境で vmovdqu 命令の連続が消える境界
    // - const を外すとダメ
    // - object[]: ダメ(長さ問わず)
    // - int[]: 32まで
    // - double[]: 16まで
    // - byte[]: 128まで
    private const int constant = 128;
    
    private byte[] srcArray = default!;
    private byte[] dstArray = default!;

    public void Setup()
    {
        srcArray = new byte[constant];
        dstArray = new byte[constant];
    }

    //[MethodImpl(MethodImplOptions.NoInlining)] // ダメ
    private static void CopyToHacked<T>(ReadOnlySpan<T> src, Span<T> dst, int size)
    {
        if (src.Length == size)
            src.CopyTo(dst);
        else
            src.CopyTo(dst);
    }

    public byte[] CopyToSimpleWay()
    {
        srcArray.AsSpan().CopyTo(dstArray);
        return dstArray;
    }

    public byte[] CopyToHacked()
    {
        var src = srcArray.AsSpan(0, constant);
        var dst = dstArray.AsSpan(0, constant);
        CopyToHacked(src, dst, constant); // 少なくともこの場合はインライン化必須

        return dstArray;
    }
}

アセンブラに明るくないので、出力を見つつ手探り。試した限りでは以下だった。

  • object[](参照型の配列)はダメ
  • 長さを示すのに変数はダメ(const(またはリテラル)の必要)
  • 128バイトまで(例えばint[]なら長さ32まで)
  • メソッドの呼び出しを隔てるとダメ(例えば、上記のような場合はインライン化される必要)


仕組みを確認し条件を変えて試してみたが、正直なところ知識を噛み砕けてはいない。常用できる類ではないと言い訳してとりあえずそのまま引き出しの中へ。

書いてあったら最適化であると頭には浮かぶと思うので、その点は前進。


ちなみに、Dynamic PGOで賢く同様の最適化がかかるようPRを出しているよう。

.NET 9 では気にしなくても自動的にこの恩恵を受けれているかもしれない。