学习 CLR 源码:连续内存块数据操作的性能优化

本文主要介绍 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 源码,对技术提升有很大的帮助。

posted @ 2021-07-20 20:58  痴者工良  阅读(4804)  评论(16编辑  收藏  举报