The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

認識 C# 7.2 與 .NET Core 2.1 推出的 Span<T> 結構

其實 C# 的 Span<T> 結構已經問世很多年了,直到昨天有同事問我這個東西要怎麼用,我就把多年前做好的簡報給他研究,然後順便複習一下這個厲害的玩意。

Performance

什麼是 Span<T> 結構

Span<T> 結構是一個泛型結構,它可以用來表示一個連續的記憶體區塊,而且它是一個可變動的結構,也就是說你可以透過它來修改記憶體中的資料。Span<T> 結構是在 .NET Core 2.1 ( C# 7.2 ) 中推出的,它的目的是為了提供一個高效率的記憶體存取方式,避免經常在 Heap 配置新的物件,我們可以在不需要額外配置記憶體的情況下,直接存取記憶體中的資料。由於 Span<T> 可以直接存取記憶體,減少無意義的記憶體取用,相對的也會大幅減少 GC (Garbage Collector) 的發生,這對於提高效能和降低記憶體用量非常有幫助。

若要在 .NET Framework 4.6.1+ 使用 Span<T> 型別,必須安裝 System.Memory 套件。

簡單來說,Span<T> 提供一個安全有效率的方式存取記憶體!

  • 安 全:宣告記憶體空間後就無法存取超出範圍的記憶體
  • 有效率:減少配置記憶體的動作發生

幾個 Span<T> 使用範例

我用一段簡短的程式碼來說明 Span<T> 結構的用法,他可以將一個 int[] 轉成 Span<int> 結構:

// 建立一個整數陣列
int[] numbers = { 1, 2, 3, 4, 5 };

// 使用 Span<T> 建立一個切片(Slice)
Span<int> slice = numbers.AsSpan();

// 修改切片的內容,這也會修改原始陣列元素的內容 (因為直接存取記憶體的關係)
slice[2] = 99;

// 原始陣列的值也已經被修改
Console.WriteLine(numbers[2]); // 輸出 99

在 MemoryExtensions 靜態類別下有個 AsSpan() 擴充方法,支援將許多型別轉換成 Span<T> 結構。我再以一個 string 轉成 ReadOnlySpan<char> 的情境來說明:

// 建立一個字串
string name = "Will";

// 使用 .AsSpan() 建立一個 ReadOnlySpan<T> 唯讀切片(Slice)
ReadOnlySpan<char> slice = name.AsSpan();

// 直接存取記憶體,不用另外建立一個字串物件
Console.WriteLine(slice[0]); // 輸出 "W"

Span<T> 真的很快嗎?

我用 BenchmarkDotNet 對比了一下使用 Span<T> 切片與 StringSplit() 方法的效能。

先看看我們用 StringSplit() 方法的程式碼:

static string date = "2022-04-15";

[Benchmark]
public (string, string, string) ParseDateUsingSplit()
{
    var y = date.Split("-")[0];
    var m = date.Split("-")[1];
    var d = date.Split("-")[2];

    return (y, m, d);
}

再看看使用 Span<T> 切片的程式碼:

static string date = "2022-04-15";

[Benchmark]
public (string, string, string) ParseDateUsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);

    return (y.ToString(), m.ToString(), d.ToString());
}

以下是跑完效能比較的結果,使用 ReadOnlySpan<char> 的執行效率整整快了 4.7 倍:

BenchmarkDotNet

不過,如果我拿掉回傳字串的寫法,單純的比較字串處理效率的話,ReadOnlySpan<char> 的效率將會比 Split 操作快了 557 倍!

static string date = "2024-09-19";

[Benchmark]
public void ParseDateUsingSplit()
{
    var y = date.Split("-")[0];
    var m = date.Split("-")[1];
    var d = date.Split("-")[2];
}

[Benchmark]
public void ParseDateUsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);
}

BenchmarkDotNet

經過網友 土阿 的提醒,我加入了 Substring() 的效能評比,結果意外的發現,使用 Substring() 的處理方法,竟然比 ReadOnlySpan<char>ToString() 還快,還快了 1.26 倍!

[Benchmark]
public (string, string, string) ParseDateReturnString_UsingSubstring()
{
    var y = date.Substring(0, 4);
    var m = date.Substring(5, 2);
    var d = date.Substring(8, 2);

    return (y, m, d);
}

[Benchmark]
public (string, string, string) ParseDateReturnString_UsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);

    return (y.ToString(), m.ToString(), d.ToString());
}

我想應該是 ParseDateReturnString_UsingReadOnlySpan() 最後的輸出的 3 個字串,因為需要將 ReadOnlySpan<char> 轉成 3 個新的字串,需要配置全新的記憶體空間,最終導致了效能變差的結果!

我們之所以要用 Span 或 ReadOnlySpan 結構,其目的不外乎就是有效率的去處理一段連續的記憶體區塊,處理的過程是非常快的,我們從上述的效能檢測看的出來,但是最終如果又會產生新的記憶體空間,那麼效能就會變差,所以我們在使用 Span 結構的時候,一定要注意不要產生新的記憶體空間,否則就會失去效能的優勢。

接著,我又調整了一下寫法如下,利用 Int32.Parse() 方法,將字串轉成 Int32 型別。這次的評測結果就比較符合預期了,使用 ReadOnlySpan<char> 的版本快了 1.67 倍:

[Benchmark]
public (int, int, int) ParseDateReturnInt32_UsingSubstring()
{
    var y = date.Substring(0, 4);
    var m = date.Substring(5, 2);
    var d = date.Substring(8, 2);

    return (Int32.Parse(y), Int32.Parse(m), Int32.Parse(d));
}

[Benchmark]
public (int, int, int) ParseDateReturnInt32_UsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);

    // 注意: Int32.Parse() 方法也有支援 ReadOnlySpan<char> 的重載方法
    return (Int32.Parse(y), Int32.Parse(m), Int32.Parse(d));
}

由於 ParseDateReturnInt32_UsingReadOnlySpan() 方法,在處理字串時,一直都沒有產生新的字串物件,因此記憶體使用率較佳,效能也較好。

總結

自從 .NET Core 2.1 開始,之所以 .NET Core 在效能上能有大幅改進,其中 ref struct typesSpan 的出現功不可沒,事實上 .NET 的 BCL (Base Class Library) 已經有許多方法都採用了 Span<T>ReadOnlySpan<T> 結構,例如:

所以之後有機會遇到陣列或字串操作,都可以先想到用 Span<T> 來處理,相信你會有意想不到的效能提升!

Span 還有許多的跟記憶體相關的開發技巧,如果有興趣深入研究,可以參考本文的相關連結

相關連結

留言評論