C#高效能陣列複製實驗

程式設計實驗室發表於2023-01-30

前言

昨天 wc(Wyu_Cnk) 提了個問題

C# 裡多維陣列複製有沒有什麼比較優雅的寫法?

這不是問對人了嗎?正好我最近在搞影像處理,要和記憶體打交道,我一下就想到了在C#裡面直接像C/C++一樣做記憶體複製。

優雅?no,要的就是裝逼,而且效能還要強?

概念

首先澄清一下

C# 裡的多維陣列 (Multi-dimensional Array) 是這樣的

byte[,] arr = new byte[10, 10];

下面這種寫法是交錯陣列 (Jagged Array),就是陣列裡面套著陣列

byte[][] arr = new byte[10][];

具體區別請看文末的參考資料~

開始

接下來介紹幾種複製陣列的方法,然後再比較一下不同實現的效能

定義一下常量,SIZE 表示陣列大小,COUNT 表示等會要做複製測試的迴圈次數

const int COUNT = 32, SIZE = 32 << 20;

這裡用了移位操作,32左移20位就是在32的二進位制數後面補20個0,相當於 32*2^20,只是用來定義一個比較大的數,現在的電腦效能太強了,小一點的陣列複製起來太快了,看不出區別。

接著定義幾個陣列,這裡寫了五組一維陣列,每個不同的陣列複製方法測試用不同的陣列,這樣可以避免CPU快取。

private static byte[]
    aSource = new byte[SIZE],
    aTarget = new byte[SIZE],
    bSource = new byte[SIZE],
    bTarget = new byte[SIZE],
    cSource = new byte[SIZE],
    cTarget = new byte[SIZE],
    dSource = new byte[SIZE],
    dTarget = new byte[SIZE],
    eSource = new byte[SIZE],
    eTarget = new byte[SIZE];

然後把這幾個陣列複製方法都測試一下

  • Clone方式: array.Clone()
  • Linq: array.Select(x=>x).ToArray()
  • Array.Copy()
  • Buffer.BlockCopy()
  • Buffer.MemoryCopy()

Clone 方式

在C#中,只要實現了 ICloneable 介面的物件,就有 Clone 方法

所以陣列也可以透過這種方式來實現複製

很簡單,直接 var newArray = (byte[])array.Clone() 就行了

程式碼如下

static void TestArrayClone() {
    var sw = Stopwatch.StartNew();
    sw.Start();
    for (var i = 0; i < COUNT; i++) {
        dTarget = (byte[])dSource.Clone();
    }

    sw.Stop();
    Console.WriteLine("Array.Clone: {0:N0} ticks, {1} ms", sw.ElapsedTicks, sw.ElapsedMilliseconds);
}

這裡用了 Stopwatch 來記錄執行時間,後面的其他複製方法裡面也有,等會用這個計算出來的 ticks 和毫秒,可以比較不同實現的效能差距。

Linq方式

其實不用測試也知道這個方式是最慢的

就是一個個元素遍歷,再重新構造個新的陣列

程式碼如下

eTarget = eSource.Select(x => x).ToArray();

Array.Copy()

使用靜態方法 Array.Copy() 來實現陣列複製

提示:效能是不錯的,使用也方便

程式碼如下,只需要指定長度即可

Array.Copy(cSource, cTarget, SIZE);

或者用另一個過載,可以分別指定兩個陣列的偏移值

Array.Copy(cSource, 0, cTarget, 0, SIZE);

Buffer.BlockCopy()

Buffer 類是用來操作基本型別陣列的

Manipulates arrays of primitive types.

程式碼如下

Buffer.BlockCopy(bSource, 0, bTarget, 0, SIZE);

跟上面的 Array.Copy 第二個過載一樣,需要分別指定兩個陣列的偏移值

Buffer.MemoryCopy()

這個是 unsafe 方法,需要用到指標 ? 理論上是效能最好的

我最喜歡的就是這個方法(逼格高)

使用 unsafe 程式碼,請先在編譯選項裡面開啟 allow unsafe code 選項。

這個 MemoryCopy 方法的函式簽名是這樣的

static unsafe void MemoryCopy(void* source, void* destination, long destinationSizeInBytes, long sourceBytesToCopy)

前兩個引數是指標型別,後倆個是長度,注意是bytes位元組數,不是陣列的元素個數

C#中的byte佔8bit,剛好是一個byte,所以直接用元素個數就行,如果是其他型別的陣列,得根據型別長度計算位元組數,然後再傳進去。

程式碼如下,在函式定義裡面加上unsafe關鍵字以使用 fixed 塊和指標

static unsafe void TestBufferMemoryCopy() {
    var sw = Stopwatch.StartNew();
    fixed (byte* pSrc = fSource, pDest = fTarget) {
        for (int i = 0; i < COUNT; i++) {
            Buffer.MemoryCopy(pSrc, pDest, SIZE, SIZE);
        }
    }

    Console.WriteLine("Buffer.MemoryCopy (2d): {0:N0} ticks, {1} ms", sw.ElapsedTicks, sw.ElapsedMilliseconds);
}

然後

我在搜尋資料的過程中還發現了有人用了 Buffer.Memcpy 這個方法,但這個是 internal 方法,沒有開放,得用黑科技去呼叫

我折騰了很久,終於搞出了呼叫非公開方法的程式碼

unsafe delegate void Memcpy(byte* src, byte* dest, int len);

internal class Program {
    private static Memcpy memcpy;
    static Program() {
        var methodInfo = typeof(Buffer).GetMethod(
            "Memcpy",
            BindingFlags.Static | BindingFlags.NonPublic,
            null,
            new Type[] { typeof(byte*), typeof(byte*), typeof(int) },
            null
        );
        if (methodInfo == null) {
            Console.WriteLine("init failed! method is not found.");
            return;
        }

        memcpy = (Memcpy)Delegate.CreateDelegate(typeof(Memcpy), methodInfo);
    }
}

實際測試這個 MemcpyMemoryCopy 的效能是差不多的

看了一下.NetCore的原始碼

果然,這倆個的實現基本是一樣的

// Used by ilmarshalers.cpp
internal static unsafe void Memcpy(byte* dest, byte* src, int len)
{
    Debug.Assert(len >= 0, "Negative length in memcpy!");
    Memmove(ref *dest, ref *src, (nuint)(uint)len /* force zero-extension */);
}

另一個

public static unsafe void MemoryCopy(void* source, void* destination, long destinationSizeInBytes, long sourceBytesToCopy)
{
    if (sourceBytesToCopy > destinationSizeInBytes) { ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.sourceBytesToCopy);
    }
    Memmove(ref *(byte*)destination, ref *(byte*)source, checked((nuint)sourceBytesToCopy));
}

這倆最終都是呼叫的 Memmove 這個方法

區別就是這倆方法的引數不一樣了。

benchmark

效能測試結果

Array.Copy: 49,923,612 ticks, 49 ms
Buffer.BlockCopy: 52,497,377 ticks, 52 ms
Buffer.Memcpy: 49,067,555 ticks, 49 ms
Buffer.MemoryCopy (2d): 48,982,014 ticks, 48 ms
Array.Clone: 360,640,218 ticks, 360 ms
Linq: 1,988,890,052 ticks, 1988 ms

Array.Copy: 48,653,699 ticks, 48 ms
Buffer.BlockCopy: 48,040,093 ticks, 48 ms
Buffer.Memcpy: 47,818,057 ticks, 47 ms
Buffer.MemoryCopy (2d): 49,084,413 ticks, 49 ms
Array.Clone: 406,848,666 ticks, 406 ms
Linq: 1,943,498,307 ticks, 1943 ms

Array.Copy: 48,943,429 ticks, 48 ms
Buffer.BlockCopy: 47,989,824 ticks, 47 ms
Buffer.Memcpy: 48,053,817 ticks, 48 ms
Buffer.MemoryCopy (2d): 49,065,368 ticks, 49 ms
Array.Clone: 364,339,126 ticks, 364 ms
Linq: 1,999,189,800 ticks, 1999 ms

Array.Copy: 49,679,913 ticks, 49 ms
Buffer.BlockCopy: 48,651,877 ticks, 48 ms
Buffer.Memcpy: 48,262,443 ticks, 48 ms
Buffer.MemoryCopy (2d): 49,683,361 ticks, 49 ms
Array.Clone: 429,384,291 ticks, 429 ms
Linq: 1,932,109,712 ticks, 1932 ms

該用哪個方法來複製陣列,一目瞭然了吧~ ?

參考資料

相關文章