學習 CLR 原始碼:連續記憶體塊資料操作的效能優化

痴者工良 發表於 2021-07-20

本文主要介紹 C# 名稱空間 System.Buffers.Binary 中的一些二進位制處理類和 Span 的簡單使用方法,這些二進位制處理型別是上層應用處理二進位制資料的基礎,掌握這些型別後,我們可以很容易地處理型別和二進位制資料之間的轉換以及提高程式效能。

C# 原語型別

按照記憶體分配來區分,C# 有值型別、引用型別;

按照基礎型別型別來分,C# 有 內建型別、通用型別、自定義型別、匿名型別、元組型別、CTS型別(通用型別系統);

C# 的基礎型別包括:

  1. 整型: sbyte, byte, short, ushort, int, uint, long, ulong
  2. 實數型別: float, double, decimal
  3. 字元型別: char
  4. 布林型別: bool
  5. 字串型別: string

C# 中的原語型別,是基礎型別中的值型別,不包括 string。原語型別可以使用 sizeof() 來獲取位元組大小,除 bool 外,都有 MaxValueMinValue 兩個欄位。

sizeof(uint);
uint.MaxValue
uint.MinValue

我們也可以在泛型上進行區分,上面的教程型別,除了 string,其他型別都是 struct。

<T>() where T : struct
{
}

更多說明,可以戳這裡瞭解:https://www.programiz.com/csharp-programming/variables-primitive-data-types

1,利用 Buffer 優化陣列效能

Buffer 可以操作基元型別(int、byte等)的陣列,利用.NET 中的 Buffer 類,通過更快地訪問記憶體中的資料來提高應用程式的效能。
Buffer 可以直接從基元型別的陣列中,直接取出指定數量的位元組,或者給其某個位元組設定值。

Buffer 主要在直接操作記憶體資料、操作非託管記憶體時,使用 Buffer 可以帶來安全且高效能的體驗。

方法 說明
BlockCopy(Array, Int32, Array, Int32, Int32) 將指定數目的位元組從起始於特定偏移量的源陣列複製到起始於特定偏移量的目標陣列。
ByteLength(Array) 返回指定陣列中的位元組數。
GetByte(Array, Int32) 檢索指定陣列中指定位置的位元組。
MemoryCopy(Void, Void, Int64, Int64) 將指定為長整型值的一些位元組從記憶體中的一個地址複製到另一個地址。此 API 不符合 CLS。
MemoryCopy(Void, Void, UInt64, UInt64) 將指定為無符號長整型值的一些位元組從記憶體中的一個地址複製到另一個地址。此 API 不符合 CLS。
SetByte(Array, Int32, Byte) 將指定的值分配給指定陣列中特定位置處的位元組。

CLS 指公共語言標準,請參考 https://www.cnblogs.com/whuanle/p/14141213.html#5,clscompliantattribute

下面來介紹一下 Buffer 的一些使用方法。

BlockCopy 可以複製陣列的一部分到另一個陣列,其使用方法如下:

        int[] arr1 = new int[] { 1, 2, 3, 4, 5 };
        int[] arr2 = new int[10] { 0, 0, 0, 0, 0, 6, 7, 8, 9, 10 };

        // int = 4 byte
        // index:       0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 16 17 18 19 ... ...
        // arr1:        01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 05 00 00 00
        // arr2:        00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00 09 00 00 00 0A 00 00 00

        // Buffer.ByteLength(arr1) == 20 ,
        // Buffer.ByteLength(arr2) == 40


        Buffer.BlockCopy(arr1, 0, arr2, 0, 19);

        for (int i = 0; i < arr2.Length; i++)
        {
            Console.Write(arr2[i] + ",");
        }

.SetByte() 則可細粒度地設定陣列的值,即可以直接設定陣列中任意一位的值,其使用方法如下:

        //source data:
        // 0000,0001,0002,00003,0004
        // 00 00 00 00 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00
        int[] a = new int[] { 0, 1, 2, 3, 4 };
        foreach (var item in a)
        {
            Console.Write(item + ",");
        }

        Console.WriteLine("\n------\n");

        // see : https://stackoverflow.com/questions/26455843/how-are-array-values-stored-in-little-endian-vs-big-endian-architecture
        // memory save that data:
        // 0000    1000    2000    3000    4000
        for (int i = 0; i < Buffer.ByteLength(a); i++)
        {
            Console.Write(Buffer.GetByte(a, i));
            if (i != 0 && (i + 1) % 4 == 0)
                Console.Write("    ");
        }

        // 16 進位制
        // 0000    1000    2000    3000    4000

        Console.WriteLine("\n------\n");

        Buffer.SetByte(a, 0, 4);
        Buffer.SetByte(a, 4, 3);
        Buffer.SetByte(a, 8, 2);
        Buffer.SetByte(a, 12, 1);
        Buffer.SetByte(a, 16, 0);

        foreach (var item in a)
        {
            Console.Write(item + ",");
        }

        Console.WriteLine("\n------\n");

建議複製程式碼自行測試,斷點除錯,觀察過程。

2,BinaryPrimitives 細粒度操作位元組陣列

System.Buffers.Binary.BinaryPrimitives 用來以精確的方式讀取或者位元組陣列,只能對 bytebyte 陣列使用,其使用場景非常廣泛。

BinaryPrimitives 的實現原理是 BitConverter,BinaryPrimitives 對 BitConverter 做了一些封裝。BinaryPrimitives 的主要使用方式是以某種形式從 byte 或 byte 陣列中讀取出資訊。

例如,BinaryPrimitives 在 byte 陣列中,一次性讀取四個位元組,其示例程式碼如下:

        // source data:  00 01 02 03 04
        // binary data:  00000000 00000001 00000010 00000011 000001000
        byte[] arr = new byte[] { 0, 1, 2, 3, 4, };

        // read one int,4 byte
        int head = BinaryPrimitives.ReadInt32BigEndian(arr);


        // 5 byte:             00000000 00000001 00000010 00000011 000001000
        // read 4 byte(int) :  00000000 00000001 00000010 00000011
        //                     = 66051

        Console.WriteLine(head);

在 BinaryPrimitives 中有大端小端之分。在 C# 中,應該都是小端在前大端在後的,具體可能會因處理器架構而不同。
你可以使用 BitConverter.IsLittleEndian 來判斷在當前處理器上,C# 程式是大端還是小端在前。


.Read...() 開頭的方法,可以以位元組為定位訪問 byte 陣列上的資料。

.Write...() 開頭的方法,可以向某個位置寫入資料。

下面舉個例子:

        // source data:  00 01 02 03 04
        // binary data:  00000000 00000001 00000010 00000011 000001000
        byte[] arr = new byte[] { 0, 1, 2, 3, 4, };

        // read one int,4 byte
        // 5 byte:             00000000 00000001 00000010 00000011 000001000
        // read 4 byte(int) :  00000000 00000001 00000010 00000011
        //                     = 66051

        int head = BinaryPrimitives.ReadInt32BigEndian(arr);
        Console.WriteLine(head);

        // BinaryPrimitives.WriteInt32LittleEndian(arr, 1);
        BinaryPrimitives.WriteInt32BigEndian(arr.AsSpan().Slice(0, 4), 0b00000000_00000000_00000000_00000001);
        // to : 00000000 00000000 00000000 00000001 |  000001000
        // read 4 byte

        head = BinaryPrimitives.ReadInt32BigEndian(arr);
        Console.WriteLine(head);

建議複製程式碼自行測試,斷點除錯,觀察過程。


提高程式碼安全性

C#和.NET Core 有的許多面向效能的 API,C# 和 .NET 的一大優點是可以在不犧牲記憶體安全性的情況下編寫快速出高效能的庫。我們在避免使用 unsafe 程式碼的情況下,通過二進位制處理類,我們可以編寫出高效能的程式碼和具有安全性的程式碼。

在 C# 中,我們有以下型別可以高效操作位元組/記憶體:

  • Span 和C#型別可以快速安全地訪問記憶體。表示任意記憶體的連續區域。使用 span 使我們可以序列化為託管.NET陣列,堆疊分配的陣列或非託管記憶體,而無需使用指標。.NET可以防止緩衝區溢位。
  • ref structSpan
  • stackalloc 用於建立基於堆疊的陣列。stackalloc 是在需要較小緩衝區時避免分配的有用工具。
  • 低階方法,並在原始型別和位元組之間直接轉換。MemoryMarshal.GetReference()Unsafe.ReadUnaligned()Unsafe.WriteUnaligned()
  • BinaryPrimitives具有用於在.NET基本型別和位元組之間進行有效轉換的輔助方法。例如,讀取小尾數字節並返回無符號的64位數字。所提供的方法經過了最優化,並使用了向量化。BinaryPrimitives.ReadUInt64LittleEndianBinaryPrimitive

.Reverse...() 開頭的方法,可以置換基元型別的大小端。

        short value = 0b00000000_00000001;
        // to endianness: 0b00000001_00000000 == 256
        BinaryPrimitives.ReverseEndianness(0b00000000_00000000_00000000_00000001);

        Console.WriteLine(BinaryPrimitives.ReverseEndianness(value));

        value = 0b00000001_00000000;
        Console.WriteLine(BinaryPrimitives.ReverseEndianness(value));
        // 1

3,BitConverter、MemoryMarshal

BitConverter 可以基元型別和 byte 相互轉換,例如 int 和 byte 互轉,或者任意取出、寫入基元型別的任意一個位元組。
其示例如下:

        // 0b...1_00000100
        int value = 260;
		
        // byte max value:255
        // a = 0b00000100; 丟失 int ... 00000100 之前的位數。
        byte a = (byte)value;

        // a = 4
        Console.WriteLine(a);

        // LittleEndian
        // 0b 00000100 00000001 00000000 00000000
        byte[] b = BitConverter.GetBytes(260);
        Console.WriteLine(Buffer.GetByte(b, 1)); // 4

        if (BitConverter.IsLittleEndian)
            Console.WriteLine(BinaryPrimitives.ReadInt32LittleEndian(b));
        else
            Console.WriteLine(BinaryPrimitives.ReadInt32BigEndian(b));

MemoryMarshal 提供與 Memory<T>ReadOnlyMemory<T>Span<T>ReadOnlySpan<T> 進行互動操作的方法。

MemoryMarshalSystem.Runtime.InteropServices 名稱空間中。

我們先介紹 MemoryMarshal.Cast(),它可以將一種基元型別的範圍強制轉換為另一種基元型別的範圍。

        // 1 int  = 4 byte
        // int [] {1,2}
        // 0001     0002
        var byteArray = new byte[] { 1, 0, 0, 0, 2, 0, 0, 0 };
        Span<byte> byteSpan = byteArray.AsSpan();
        // byte to int 
        Span<int> intSpan = MemoryMarshal.Cast<byte, int>(byteSpan);
        foreach (var item in intSpan)
        {
            Console.Write(item + ",");
        }

最簡單的說法是,MemoryMarshal 可以將一種結構轉換為另一種結構

我們可以將一個結構轉換為位元組:

public struct Test
{
    public int A;
    public int B;
    public int C;
}

... ...

        Test test = new Test()
        {
            A = 1,
            B = 2,
            C = 3
        };
        var testArray = new Test[] { test };
        ReadOnlySpan<byte> tmp = MemoryMarshal.AsBytes(testArray.AsSpan());

        // socket.Send(tmp); ...

還可以逆向還原位元組為結構體:

        // bytes = socket.Accept(); .. 
        ReadOnlySpan<Test> testSpan = MemoryMarshal.Cast<byte,Test>(tmp);

        // or
        Test testSpan = MemoryMarshal.Read<Test>(tmp);

例如,我們要對比兩個結構體陣列中,每個結構體是否相等,可以採用以下程式碼:

        static void Main(string[] args)
        {
            int[] a = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
            int[] b = new int[] { 1, 2, 3, 4, 5, 6, 7, 0, 9 };
            _ = Compare64(a,b);
        }

        private static bool Compare64<T>(T[] t1, T[] t2)
            where T : struct
        {
            var l1 = MemoryMarshal.Cast<T, long>(t1);
            var l2 = MemoryMarshal.Cast<T, long>(t2);

            for (int i = 0; i < l1.Length; i++)
            {
                if (l1[i] != l2[i]) return false;
            }
            return true;
        }

後面有個更好的效能提升方案。

程式設計師基本都學習過 C 語言,應該瞭解 C 語言中的結構體位元組對齊,在 C# 中也是一樣,兩種型別相互轉換,除了 C# 結構體轉 C# 結構體,也可以 C 語言結構體轉 C# 結構體,但是要考慮好位元組對齊,如果兩個結構體所佔用的記憶體大小不一樣,則可能在轉換時出現資料丟失或出現錯誤。

4,Marshal

Marshal 提供了用於分配非託管記憶體,複製非託管記憶體塊以及將託管型別轉換為非託管型別的方法的集合,以及與非託管程式碼進行互動時使用的其他方法,或者用來確定物件的大小。

例如,來確定 C# 中的一些型別大小:

            Console.WriteLine("SystemDefaultCharSize={0}, SystemMaxDBCSCharSize={1}",
         Marshal.SystemDefaultCharSize, Marshal.SystemMaxDBCSCharSize);

輸出 char 佔用的位元組數。

例如,在呼叫非託管程式碼時,需要傳遞函式指標,C# 一般使用委託傳遞,很多時候為了避免各種記憶體問題異常問題,需要轉換為指標傳遞。

IntPtr p = Marshal.GetFunctionPointerForDelegate(_overrideCompileMethod)

Marshal 也可以很方便地獲得一個結構體的位元組大小:

public struct Point
{
    public Int32 x, y;
}

Marshal.SizeOf(typeof(Point));

從非託管記憶體中分配一塊記憶體和釋放記憶體,我們可以避免 usafe 程式碼的使用,程式碼示例:

        IntPtr hglobal = Marshal.AllocHGlobal(100);
        Marshal.FreeHGlobal(hglobal);

實踐

合理利用前面提到的二進位制處理類,可以在很多方面提升程式碼效能,在前面的學習中,我們大概瞭解這些物件,但是有什麼應用場景?真的能夠提升效能?有沒有練習程式碼?

這裡筆者舉個例子,如何比較兩個 byte[] 陣列是否相等?
最簡單的程式碼示例如下:

        public bool ForBytes(byte[] a,byte[] b)
        {
            if (a.Length != b.Length)
                return false;
				
            for (int i = 0; i < a.Length; i++)
            {
                if (a[i] != b[i]) return false;
            }
            return true;
        }

這個程式碼很簡單,迴圈遍歷位元組陣列,一個個判斷是否相等。

如果用上前面的二進位制處理物件類,則可以這樣寫程式碼:

        private static bool EqualsBytes(byte[] b1, byte[] b2)
        {
            var a = b1.AsSpan();
            var b = b2.AsSpan();
            Span<byte> copy1 = default;
            Span<byte> copy2 = default;

            if (a.Length != b.Length)
                return false;

            for (int i = 0; i < a.Length;)
            {
                if (a.Length - 8 > i)
                {
                    copy1 = a.Slice(i, 8);
                    copy2 = b.Slice(i, 8);
                    if (BinaryPrimitives.ReadUInt64BigEndian(copy1) != BinaryPrimitives.ReadUInt64BigEndian(copy2))
                        return false;
                    i += 8;
                    continue;
                }

                if (a[i] != b[i])
                    return false;
                i++;
            }
            return true;
        }

你可能會在想,第二種方法,這麼多程式碼,這麼多判斷,還有各種函式呼叫,還多建立了一些物件,這特麼能夠提升速度?這樣會不會消耗更多記憶體??? 別急,你可以使用以下完整程式碼測試:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System;
using System.Buffers.Binary;
using System.Runtime.InteropServices;
using System.Text;

namespace BenTest
{
    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [SimpleJob(RuntimeMoniker.CoreRt31)]
    [RPlotExporter]
    public class Test
    {
        private byte[] _a = Encoding.UTF8.GetBytes("5456456456444444444444156456454564444444444444444444444444444444444444444777777777777777777777711111111111116666666666666");
        private byte[] _b = Encoding.UTF8.GetBytes("5456456456444444444444156456454564444444444444444444444444444444444444444777777777777777777777711111111111116666666666666");

        private int[] A1 = new int[] { 41544444, 4487, 841, 8787, 4415, 7, 458, 4897, 87897, 815, 485, 4848, 787, 41, 5489, 74878, 84, 89787, 8456, 4857489, 784, 85489, 47 };
        private int[] B2 = new int[] { 41544444, 4487, 841, 8787, 4415, 7, 458, 4897, 87897, 815, 485, 4848, 787, 41, 5489, 74878, 84, 89787, 8456, 4857489, 784, 85489, 47 };

        [Benchmark]
        public bool ForBytes()
        {
            for (int i = 0; i < _a.Length; i++)
            {
                if (_a[i] != _b[i]) return false;
            }
            return true;
        }

        [Benchmark]
        public bool ForArray()
        {
            return ForArray(A1, B2);
        }

        private bool ForArray<T>(T[] b1, T[] b2) where T : struct
        {
            for (int i = 0; i < b1.Length; i++)
            {
                if (!b1[i].Equals(b2[i])) return false;
            }
            return true;
        }

        [Benchmark]
        public bool EqualsArray()
        {
            return EqualArray(A1, B2);
        }

        [Benchmark]
        public bool EqualsBytes()
        {
            var a = _a.AsSpan();
            var b = _b.AsSpan();
            Span<byte> copy1 = default;
            Span<byte> copy2 = default;

            if (a.Length != b.Length)
                return false;

            for (int i = 0; i < a.Length;)
            {
                if (a.Length - 8 > i)
                {
                    copy1 = a.Slice(i, 8);
                    copy2 = b.Slice(i, 8);
                    if (BinaryPrimitives.ReadUInt64BigEndian(copy1) != BinaryPrimitives.ReadUInt64BigEndian(copy2))
                        return false;
                    i += 8;
                    continue;
                }

                if (a[i] != b[i])
                    return false;
                i++;
            }
            return true;
        }

        private bool EqualArray<T>(T[] t1, T[] t2) where T : struct
        {
            Span<byte> b1 = MemoryMarshal.AsBytes<T>(t1.AsSpan());
            Span<byte> b2 = MemoryMarshal.AsBytes<T>(t2.AsSpan());

            Span<byte> copy1 = default;
            Span<byte> copy2 = default;

            if (b1.Length != b2.Length)
                return false;

            for (int i = 0; i < b1.Length;)
            {
                if (b1.Length - 8 > i)
                {
                    copy1 = b1.Slice(i, 8);
                    copy2 = b2.Slice(i, 8);
                    if (BinaryPrimitives.ReadUInt64BigEndian(copy1) != BinaryPrimitives.ReadUInt64BigEndian(copy2))
                        return false;
                    i += 8;
                    continue;
                }

                if (b1[i] != b2[i])
                    return false;
                i++;
            }
            return true;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run<Test>();
            Console.ReadKey();
        }
    }
}

使用 BenchmarkDotNet 的測試結果如下:

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19043.1052 (21H1/May2021Update)
Intel Core i7-10700 CPU 2.90GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=5.0.301
  [Host]        : .NET Core 3.1.16 (CoreCLR 4.700.21.26205, CoreFX 4.700.21.26205), X64 RyuJIT
  .NET Core 3.1 : .NET Core 3.1.16 (CoreCLR 4.700.21.26205, CoreFX 4.700.21.26205), X64 RyuJIT


|      Method |           Job |       Runtime |     Mean |    Error |   StdDev |
|------------ |-------------- |-------------- |---------:|---------:|---------:|
|    ForBytes | .NET Core 3.1 | .NET Core 3.1 | 76.95 ns | 0.064 ns | 0.053 ns |
|    ForArray | .NET Core 3.1 | .NET Core 3.1 | 66.37 ns | 1.258 ns | 1.177 ns |
| EqualsArray | .NET Core 3.1 | .NET Core 3.1 | 17.91 ns | 0.027 ns | 0.024 ns |
| EqualsBytes | .NET Core 3.1 | .NET Core 3.1 | 26.26 ns | 0.432 ns | 0.383 ns |

可以看到,byte[] 比較中,使用了二進位制物件的方式,耗時下降了近 60ns,而在 struct 的比較中,耗時也下降了 40ns。

在第二種程式碼中,我們使用了 Span、切片、 MemoryMarshal、BinaryPrimitives,這些用法都可以給我們的程式效能帶來很大的提升。

這裡示例雖然使用了 Span 等,其最主要是利用了 64位 CPU ,64位 CPU 能夠一次性讀取 8個位元組(64位),因此我們使用 ReadUInt64BigEndian 一次讀取從位元組陣列中讀取 8 個位元組去進行比較。如果位元組陣列長度為 1024 ,那麼第二種方法只需要 比較 128次。

當然,這裡並不是這種程式碼效能是最強的,因為 CLR 有很多底層方法具有更猛的效能。不過,我們也看到了,合理使用這些型別,能夠很大程度上提高程式碼效能。上面的陣列對比只是一個簡單的例子,在實際專案中,我們也可以挖掘更多使用場景。

更高效能

雖然第二種方法,快了幾倍,但是效能還不夠強勁,我們可以利用 Span 中的 API,來實現更快的比較。

        [Benchmark]
        public bool SpanEqual()
        {
            return SpanEqual(_a,_b);
        }
        private bool SpanEqual(byte[] a, byte[] b)
        {
            return a.AsSpan().SequenceEqual(b);
        }

可以試試

StructuralComparisons.StructuralEqualityComparer.Equals(a, b);

效能測試結果:

|      Method |           Job |       Runtime |      Mean |     Error |    StdDev |
|------------ |-------------- |-------------- |----------:|----------:|----------:|
|    ForBytes | .NET Core 3.1 | .NET Core 3.1 | 77.025 ns | 0.0502 ns | 0.0419 ns |
|    ForArray | .NET Core 3.1 | .NET Core 3.1 | 66.192 ns | 0.6127 ns | 0.5117 ns |
| EqualsArray | .NET Core 3.1 | .NET Core 3.1 | 17.897 ns | 0.0122 ns | 0.0108 ns |
| EqualsBytes | .NET Core 3.1 | .NET Core 3.1 | 25.722 ns | 0.4584 ns | 0.4287 ns |
|   SpanEqual | .NET Core 3.1 | .NET Core 3.1 |  4.736 ns | 0.0099 ns | 0.0093 ns |

可以看到,Span.SequenceEqual() 的速度簡直是碾壓。
對於 C# 中的二進位制處理技巧就介紹到這裡,閱讀 CLR 原始碼 時,我們可以學習到很多騷操作,讀者可以多閱讀 CLR 原始碼,對技術提升有很大的幫助。