在写C#的时候,我们经常会遇到一种需求,就是对数组进行复制,在写上层应用且数据量比较小的时候,我们往往不需要太过于关心速度上的问题,但是对于底层代码或者数据量比较大的时候,这一点耗时很可能成为性能瓶颈之一。
C#/.NET中的数组复制方式
- Naive Copy (手写循环)
- Array Copy (
Array.CopyTo
) - Span Copy (
Span<T>.CopyTo
) - 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://stackoverflow.com/questions/1389821/array-copy-vs-buffer-blockcopy
https://stackoverflow.com/questions/1389821/array-copy-vs-buffer-blockcopy