在写C#的时候,我们经常会遇到一种需求,就是对数组进行复制,在写上层应用且数据量比较小的时候,我们往往不需要太过于关心速度上的问题,但是对于底层代码或者数据量比较大的时候,这一点耗时很可能成为性能瓶颈之一。

C#/.NET中的数组复制方式

  1. Naive Copy (手写循环)
  2. Array Copy (Array.CopyTo)
  3. Span Copy (Span<T>.CopyTo)
  4. Buffer Copy (Buffer.BlockCopy)

当然,还有其它比如Buffer.MemoryCopy等方式,但是常用的主要还是上面几种。

性能测试

环境:Intel 12700,Windows 11 64位, .NET 6

测试框架:BenchmarkDotnet

其中,Span Copy这种方式可以分两种:源数组和目标数组都取Span,或者只有目标数组取Span。当然,在后面源码分析的时候就会发现这两个是一回事,但是这里仍然分开进行Benchmark测试。

测试代码

using System;
using System.Buffers;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

var summary = BenchmarkRunner.Run(typeof(BenchmarkTest).Assembly);
Console.WriteLine(summary);

public class BenchmarkTest
{
    [Params(4, 8, 16, 32, 64, 1024, 8192)]
    public int ArrayLength { get; set; }
    int[] DataArray { get; set; }
    int[] Dst { get; set; }

    [GlobalSetup]
    public void GetRandomArray()
    {
        DataArray = new int[ArrayLength];
        Dst = new int[ArrayLength];
        var rd = new Random();
        for (int i = 0; i < ArrayLength; i++)
        {
            DataArray[i] = rd.Next(int.MinValue, int.MaxValue);
        }
    }

    [Benchmark]
    public void NaiveCopy()
    {
        for(int i = 0; i < ArrayLength; i++)
        {
            Dst[i] = DataArray[i];
        }
    }
    [Benchmark]
    public void ArrayCopy()
    {
        DataArray.CopyTo(Dst, 0);
    }
    [Benchmark]
    public void SingleSpanCopy()
    {
        DataArray.CopyTo(Dst.AsSpan());
    }
    [Benchmark]
    public void DoubleSpanCopy()
    {
        DataArray.AsSpan().CopyTo(Dst.AsSpan());
    }
    [Benchmark]
    public void BufferCopy()
    {
        Buffer.BlockCopy(DataArray, 0, Dst, 0, sizeof(int) * DataArray.Length);
    }
}

测试结果

Method ArrayLength Mean Error StdDev Median
NaiveCopy 4 2.132 ns 0.0344 ns 0.0322 ns 2.146 ns
ArrayCopy 4 4.151 ns 0.0334 ns 0.0312 ns 4.155 ns
SingleSpanCopy 4 2.665 ns 0.1692 ns 0.4989 ns 2.865 ns
DoubleSpanCopy 4 3.023 ns 0.1771 ns 0.5222 ns 3.295 ns
BufferCopy 4 12.925 ns 0.6567 ns 1.9363 ns 13.939 ns
NaiveCopy 8 6.382 ns 0.3006 ns 0.8862 ns 6.784 ns
ArrayCopy 8 6.945 ns 0.4488 ns 1.3233 ns 7.642 ns
SingleSpanCopy 8 2.791 ns 0.1524 ns 0.4494 ns 2.998 ns
DoubleSpanCopy 8 3.081 ns 0.1806 ns 0.5324 ns 3.342 ns
BufferCopy 8 12.738 ns 0.7503 ns 2.2122 ns 13.768 ns
NaiveCopy 16 11.422 ns 0.8935 ns 2.6344 ns 12.971 ns
ArrayCopy 16 7.302 ns 0.5305 ns 1.5642 ns 8.130 ns
SingleSpanCopy 16 1.691 ns 0.0292 ns 0.0274 ns 1.693 ns
DoubleSpanCopy 16 2.224 ns 0.0223 ns 0.0197 ns 2.221 ns
BufferCopy 16 8.580 ns 0.0565 ns 0.0472 ns 8.566 ns
NaiveCopy 32 14.126 ns 0.2069 ns 0.1935 ns 14.178 ns
ArrayCopy 32 5.397 ns 0.0285 ns 0.0238 ns 5.399 ns
SingleSpanCopy 32 3.104 ns 0.0200 ns 0.0187 ns 3.102 ns
DoubleSpanCopy 32 3.508 ns 0.0203 ns 0.0180 ns 3.504 ns
BufferCopy 32 9.651 ns 0.0507 ns 0.0474 ns 9.646 ns
NaiveCopy 64 30.685 ns 0.2017 ns 0.1887 ns 30.644 ns
ArrayCopy 64 6.394 ns 0.0414 ns 0.0387 ns 6.402 ns
SingleSpanCopy 64 4.587 ns 0.0420 ns 0.0372 ns 4.587 ns
DoubleSpanCopy 64 4.457 ns 0.0196 ns 0.0173 ns 4.455 ns
BufferCopy 64 10.989 ns 0.0688 ns 0.0643 ns 10.970 ns
NaiveCopy 1024 349.381 ns 1.4166 ns 1.2558 ns 349.593 ns
ArrayCopy 1024 29.292 ns 0.1111 ns 0.1975 ns 29.250 ns
SingleSpanCopy 1024 28.412 ns 0.7371 ns 2.1735 ns 26.905 ns
DoubleSpanCopy 1024 28.557 ns 0.7036 ns 2.0745 ns 28.172 ns
BufferCopy 1024 29.166 ns 0.2849 ns 0.4519 ns 28.975 ns
NaiveCopy 8192 2,720.828 ns 16.7619 ns 15.6791 ns 2,720.622 ns
ArrayCopy 8192 537.434 ns 7.2891 ns 6.8183 ns 533.274 ns
SingleSpanCopy 8192 527.781 ns 1.5905 ns 1.4100 ns 527.881 ns
DoubleSpanCopy 8192 537.648 ns 7.9120 ns 7.4009 ns 538.966 ns
BufferCopy 8192 529.988 ns 6.0784 ns 5.6857 ns 527.607 ns

更大尺度比如65536长度也尝试过,但与8192差别已经不大,这里不一一列出。

结论

通过上面的测试可以粗略得出结论:

  • 在数组长度及其小(<=4)的时候,手动写循环是最快的,数据量大一些的时候尽量不要用手写循环。
  • 在任何长度下,都可以优先选择用Span<T>,性能一般不会差。
  • 数据量较小的时候,Array Copy比Span Copy慢一点,Buffer Copy则更慢,但是数据量变大之后,这三者逐渐接近。

分析

为什么Span Copy比Array Copy和Buffer Copy快

在数据量较小的时候,Span Copy是比较快的方式。要搞清楚这个就需要明白在复制的时候分两步走,第一步是预处理,第二步是数据复制。

其中,第二步数据复制这一步,其实除了手写循环外的复制方式都是调用了Buffer.Memmove函数(后面会讲到),所以差异主要是在第一步。Span<T>自带泛型,直接存了数组的地址和长度信息,相比于Array.Copy要做的预处理要少一些。

为什么数据量大的时候,除了手写循环速度都差不多

因为第二步的数据复制并没有我们想象得那么慢,相反,它是一个非常快的处理过程,会调用C实现的Native函数,直接用指针来操作,而这些函数根本上都是调用了Buffer.Memmove——一个调用了native库的函数。然而,随着数据量的增加,数据复制操作耗时增加,预处理时间却不变,自然会趋同。

简单的源码分析

  • Array Copy

Array.CopyTo最终调用了下面这一函数

public static unsafe void Copy(Array sourceArray, int sourceIndex, Array destinationArray, int destinationIndex, int length)
{
    if (sourceArray != null && destinationArray != null)
    {
        MethodTable* pMT = RuntimeHelpers.GetMethodTable(sourceArray);
        if (pMT == RuntimeHelpers.GetMethodTable(destinationArray) &&
            !pMT->IsMultiDimensionalArray &&
            length >= 0 && sourceIndex >= 0 && destinationIndex >= 0 &&
            (uint)(sourceIndex + length) <= sourceArray.NativeLength &&
            (uint)(destinationIndex + length) <= destinationArray.NativeLength)
        {
            nuint elementSize = (nuint)pMT->ComponentSize;
            nuint byteCount = (uint)length * elementSize;
            ref byte src = ref Unsafe.AddByteOffset(ref Unsafe.As<RawArrayData>(sourceArray).Data, (uint)sourceIndex * elementSize);
            ref byte dst = ref Unsafe.AddByteOffset(ref Unsafe.As<RawArrayData>(destinationArray).Data, (uint)destinationIndex * elementSize);

            if (pMT->ContainsGCPointers)
                Buffer.BulkMoveWithWriteBarrier(ref dst, ref src, byteCount);
            else
                Buffer.Memmove(ref dst, ref src, byteCount);

            // GC.KeepAlive(sourceArray) not required. pMT kept alive via sourceArray
            return;
        }
    }

    // Less common
    Copy(sourceArray!, sourceIndex, destinationArray!, destinationIndex, length, reliable: false);
}

可以看到,对于一般情况,会经过一系列预处理,最终调用Buffer.Memmove,而预处理还要获取方法表来对多维数组进行处理,然后再计算偏置,最后对GCPointer进行判断(应该是担心在复制的时候GC会移动这个数组),这些操作都是比较耗时的。

对于最下面标注Less Common的特殊情况,其实大同小异,可以参考这里的源码

  • Span Copy

比起Array Copy,Span Copy的代码就简单太多了,如下:

public bool TryCopyTo(Span<T> destination)
{
    bool retVal = false;
    if ((uint)_length <= (uint)destination.Length)
    {
        Buffer.Memmove(ref destination._reference, ref _reference, (uint)_length);
        retVal = true;
    }
    return retVal;
}

可以看到,地址和长度都是现成的,也不需要做额外的预处理(Span<T>不仅是一维的,还是固定的,GC不会移动对应数组),那么就不难解释为什么数据量小的时候比较快了。

  • Buffer Copy

Buffer.BlockCopy的代码如下:

public static unsafe void BlockCopy(Array src, int srcOffset, Array dst, int dstOffset, int count)
{
    ArgumentNullException.ThrowIfNull(src);
    ArgumentNullException.ThrowIfNull(dst);

    nuint uSrcLen = src.NativeLength;
    if (src.GetType() != typeof(byte[]))
    {
        if (!src.GetCorElementTypeOfElementType().IsPrimitiveType())
            throw new ArgumentException(SR.Arg_MustBePrimArray, nameof(src));
        uSrcLen *= (nuint)src.GetElementSize();
    }

    nuint uDstLen = uSrcLen;
    if (src != dst)
    {
        uDstLen = dst.NativeLength;
        if (dst.GetType() != typeof(byte[]))
        {
            if (!dst.GetCorElementTypeOfElementType().IsPrimitiveType())
                throw new ArgumentException(SR.Arg_MustBePrimArray, nameof(dst));
            uDstLen *= (nuint)dst.GetElementSize();
        }
    }

    if (srcOffset < 0)
        throw new ArgumentOutOfRangeException(nameof(srcOffset), SR.ArgumentOutOfRange_MustBeNonNegInt32);
    if (dstOffset < 0)
        throw new ArgumentOutOfRangeException(nameof(dstOffset), SR.ArgumentOutOfRange_MustBeNonNegInt32);
    if (count < 0)
        throw new ArgumentOutOfRangeException(nameof(count), SR.ArgumentOutOfRange_MustBeNonNegInt32);

    nuint uCount = (nuint)count;
    nuint uSrcOffset = (nuint)srcOffset;
    nuint uDstOffset = (nuint)dstOffset;

    if ((uSrcLen < uSrcOffset + uCount) || (uDstLen < uDstOffset + uCount))
        throw new ArgumentException(SR.Argument_InvalidOffLen);

    Memmove(ref Unsafe.AddByteOffset(ref MemoryMarshal.GetArrayDataReference(dst), uDstOffset), ref Unsafe.AddByteOffset(ref MemoryMarshal.GetArrayDataReference(src), uSrcOffset), uCount);
}

情况与ArrayCopy大同小异,都是预处理+调用Memmove,至于为什么数据量小的时候比Array Copy慢,目前我只能说预处理部分用时更长,但是具体哪一个耗时长还待深入研究,我猜是GetCorElementTypeOfElementType这里的操作。

为什么Double Span Copy比Single Span Copy慢

一开始看到这个结果有一点意外,但只要查看一下函数定义,就不难明白,Single Span Copy用到了下面的函数:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void CopyTo<T>(this T[]? source, Span<T> destination)
{
    new ReadOnlySpan<T>(source).CopyTo(destination);
}

这里用到了ReadOnlySpan<T>.CopyTo,而不是Span<T>,而ReadOnlySpan<T>的Copy实现和Span<T>有细微的差别,如下:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void CopyTo(Span<T> destination)
{
    // Using "if (!TryCopyTo(...))" results in two branches: one for the length
    // check, and one for the result of TryCopyTo. Since these checks are equivalent,
    // we can optimize by performing the check once ourselves then calling Memmove directly.

    if ((uint)_length <= (uint)destination.Length)
    {
        Buffer.Memmove(ref destination._reference, ref _reference, (uint)_length);
    }
    else
    {
        ThrowHelper.ThrowArgumentException_DestinationTooShort();
    }
}

这一点差别就会导致上面Benchmark的结果,为了验证这一点,我们把Double Span Copy部分的代码改成new ReadOnlySpan<int>(DataArray).CopyTo(Dst.AsSpan());再进行测试,结果如下:

Method ArrayLength Mean Error StdDev Median
SingleSpanCopy 8 1.689 ns 0.0315 ns 0.0295 ns 1.693 ns
DoubleSpanCopy 8 1.851 ns 0.0501 ns 0.0468 ns 1.828 ns
SingleSpanCopy 64 4.608 ns 0.0563 ns 0.0526 ns 4.620 ns
DoubleSpanCopy 64 4.729 ns 0.0952 ns 0.0891 ns 4.722 ns
SingleSpanCopy 1024 27.324 ns 0.8789 ns 2.5914 ns 25.253 ns
DoubleSpanCopy 1024 26.293 ns 0.4324 ns 0.4247 ns 26.286 ns

显然,此时已经几乎一样,可以验证我们的结论。

总结

这里的实验只做了一维int数组的情况,其实对于String等类型也是差不多的结果,但如果是object或者多维数组则情况大不相同,Span Copy在这两种情况下是不方便使用的,这时候,看似更慢的Array Copy和Buffer Copy就有了其作用所在。

关于object数组和多维数组的拷贝暂时没有仔细做实验,并且开发里面其实也很不建议用这两种方式,object数组会带来装箱拆箱的额外开销,而多维数组本身就有额外开销,在注重性能的地方应当用一维数组存储数据,然后加一个Layout来对数组形状进行描述。

部分参考资源

https://github.com/dotnet/runtime/issues/4847

https://social.msdn.microsoft.com/Forums/vstudio/en-US/e3a08e63-7188-4f87-bb0a-fed6c8acf553/why-bufferblockcopy-runs-slower-than-arraycopy?forum=netfxbcl

https://stackoverflow.com/questions/1389821/array-copy-vs-buffer-blockcopy

https://stackoverflow.com/questions/1389821/array-copy-vs-buffer-blockcopy