平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“在 .NET 中采用 SIMD的操作做法”来说,它看着像小点,放到项目里常会牵出环境、配置、兼容性和维护成本。下面按实际采用顺序,把思路、关键写法和容易踩坑的地方讲清楚,便于大家直接对照操作。
SIMD(Single Instruction, Multiple Data) 译为 单指令多数据理解这一步时,,是一种同时行计算技术,允许单条指令同时对多个数据元素进行操作,从而提高计算效率。
在这个场景下,与 SIMD 相对的是 SISD(Single Instruction, Single Data,单指令单数据),即每条指令只处理一个数据元素。
理解这一步时,现在的大多数 CPU 都兼容 SIMD 指令集,比如 Intel 的 SSE 和 AVX,ARM 的 NEON 等。
落到代码里,若我们要对两组数组进行加法运算,传统方法(SISD)是逐个元素相加,而采用 SIMD 技术,能够一次性将多个元素加载到向量寄存器中,同时执行单一的加法指令,从而显著提高计算效率。

落到代码里,下面我们借助一个轻松的示例,对比传统的数组加法和采用 SIMD 优化后的数组加法在性能上的差异。例子中会对两个浮点数组进行加法运算,把结果存储在第三个数组中。
using System.Runtime.Intrinsics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class SimdBenchmark
{
private float[] _arrA;
private float[] _arrB;
private float[] _resultArray;
private readonly int _dataSize = 1_000_000;
[GlobalSetup]
public void Setup()
{
var random = new Random();
_arrA = new float[_dataSize];
_arrB = new float[_dataSize];
_resultArray = new float[_dataSize];
for (int i = 0; i < _dataSize; i++)
{
_arrA[i] = (float)random.NextDouble() * 10f;
_arrB[i] = (float)random.NextDouble() * 10f;
}
}
[Benchmark]
public void NormalAdd()
{
for (int i = 0; i < _dataSize; i++)
{
_resultArray[i] = _arrA[i] + _arrB[i];
}
}
[Benchmark]
public void SimdAdd()
{
// 每次处理 4 个元素
int simdLength = Vector128<float>.Count; // 4
int i = 0;
// 处理可被 SIMD 整除的部分
for (; i <= _dataSize - simdLength; i += simdLength)
{
var va = Vector128.Create(_arrA[i], _arrA[i + 1], _arrA[i + 2], _arrA[i + 3]);
var vb = Vector128.Create(_arrB[i], _arrB[i + 1], _arrB[i + 2], _arrB[i + 3]);
(va + vb).CopyTo(_resultArray, i);
}
// 处理尾部不足 4 个的元素
for (; i < _dataSize; i++)
{
_resultArray[i] = _arrA[i] + _arrB[i];
}
}
}
public class Program
{
public static void Main(string[] args)
{
BenchmarkRunner.Run<SimdBenchmark>();
}
}
BenchmarkDotNet v0.15.6, macOS Sequoia 15.7.2 (24G325) [Darwin 24.6.0]
Apple M2 Max, 1 CPU, 12 logical and 12 physical cores
.NET SDK 9.0.100
[Host] : .NET 9.0.0 (9.0.0, 9.0.24.52809), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 9.0.0 (9.0.0, 9.0.24.52809), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Allocated |
|---------- |---------:|--------:|--------:|----------:|
| NormalAdd | 880.4 us | 9.28 us | 7.75 us | - |
| SimdAdd | 568.5 us | 4.18 us | 3.70 us | - |
落到代码里,笔者在 MacBook Pro M2 Max 上测试,采用 SIMD 优化后的数组加法运算相比传统方法提升了约 35% 的性能。
实际处理时,此处所采用的例子可能会受到结果需拷贝到结果数组的影响,实际应用中如果能直接在向量上进行更多计算,性能提升会更加显著。
在这个场景下,此例子也能够在 Windows 和 Linux 上运行,有兴趣的读者能够自行测试不同平台的性能差异。
.NET 为我们提供了下面三个命名空间来采用 SIMD 技术:
System.Runtime.Intrinsics 命名空间中定义了表示不同大小向量的结构体和提供新建及操作这些向量的静态类。
结构体
| 类型 | 描述 |
|---|---|
| Vector64<T> | 表示指定数值类型的 64 位向量,该向量适用来同时行算法的低级别优化。 |
| Vector128<T> | 表示指定数值类型的 128 位向量,该向量适用来同时行算法的低级别优化。 |
| Vector256<T> | 表示指定数值类型的 256 位向量,该向量适用来同时行算法的低级别优化。 |
| Vector512<T> | 表示指定数值类型的 512 位向量,该向量适用来同时行算法的低级别优化。 |
静态类
| 类型 | 描述 |
|---|---|
| Vector64 | 提供静态方法的集合,用来在 64 位向量上新建、操作和以其他方式操作。 |
| Vector128 | 提供静态方法集合,用来在 128 位向量上新建、操作和以其他方式操作。 |
| Vector256 | 提供静态方法集合,用来在 256 位向量上新建、操作和以其他方式操作。 |
| Vector512 | 在这个场景下,提供静态方法的集合,用来在 512 位向量上新建、操作和以其他方式操作。 |
System.Runtime.Intrinsics.X86 和 System.Runtime.Intrinsics.Arm 命名空间中定义了特定于各自架构的 SIMD 指令集的类,这些类提供了访问底层硬件 SIMD 指令的能力。
常用的指令集类比如:
| 类型 | 描述 |
|---|---|
| Sse | 提供对 x86/x64 SSE 指令集的访问。 |
| Sse2 | 提供对 x86/x64 SSE2 指令集的访问。 |
| Avx | 提供对 x86/x64 AVX 指令集的访问。 |
| Avx2 | 提供对 x86/x64 AVX2 指令集的访问。 |
| AdvSimd | 提供对 ARM Advanced SIMD(NEON)指令集的访问。 |
更详细的列表能够参考官方文档:
System.Runtime.Intrinsics.X86 命名空间
System.Runtime.Intrinsics.Arm 命名空间
理解这一步时,向量的大小(如 64 位、128 位、256 位、512 位)指的是向量寄存器能够容纳的数据总位数。每个向量寄存器能够存储多个数据元素,这些数据元素的类型和数量取决于向量的大小和数据类型的位数。
比如开头用到的 Vector128<float>,它表示一个 128 位的向量寄存器,能够存储 4 个 32 位的浮点数(因为 128 / 32 = 4)。
在这个场景下,若是用来存储 64 位的双精度浮点数(double),则 Vector128<double> 能够存储 2 个双精度浮点数(因为 128 / 64 = 2)。
using System.Runtime.Intrinsics;
// 创建一个 128 位的向量,存储 16 个 8 位的 字节
Vector128<byte> vectorByte = Vector128.Create((byte)1, (byte)2, (byte)3, (byte)4,
(byte)5, (byte)6, (byte)7, (byte)8,
(byte)9, (byte)10, (byte)11, (byte)12,
(byte)13, (byte)14, (byte)15, (byte)16);
// 创建一个 128 位的向量,存储 4 个 32 位的 浮点数
Vector128<float> vectorFloat = Vector128.Create(1.0f, 2.0f, 3.0f, 4.0f);
// 创建一个 256 位的向量,存储 8 个 32 位的 浮点数
Vector256<float> vector256Float = Vector256.Create(1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f);
// 创建一个 128 位的向量,存储 2 个 64 位的 双精度浮点数
Vector128<double> vectorDouble = Vector128.Create(1.0, 2.0);
// 创建一个 256 位的向量,存储 4 个 64 位的 双精度浮点数
Vector256<double> vector256Double = Vector256.Create(1.0, 2.0, 3.0, 4.0);
从实现思路看,.NET 的 SIMD 提供了跨平台的实现方法。无论是在 x86/x64 还是 ARM 架构上,.NET 都会根据运行时环境自动选择合适的 SIMD 指令集来执行向量化操作。
在这个场景下,能够采用 VectorXXX.IsHardwareAccelerated 属性来检查当前平台是否兼容特定大小的向量操作。比如:
Console.WriteLine(Vector128.IsHardwareAccelerated ? "128 位向量操作受支持" : "128 位向量操作不受支持");
结合项目来看,但即使硬件不兼容 SIMD,.NET 仍然会回退到非 SIMD 的实现方法,确保代码的兼容性。
在这个场景下,VectorXXX 为我们提供了一组静态方法,用来新建和操作向量。比如,Vector128.Add 方法用来对两个 128 位向量执行加法运算。
从实现思路看,我们也能够直接采用运算符号来进行向量运算,比如 +、-、*、/ 等。VectorXXX<T> 结构体重载了这些运算符,使得向量运算更加直观和简洁。
下面这个例子采用 Vector128<float> 来进行浮点数的 SIMD 运算:
using System.Runtime.Intrinsics;
// 创建两个 128 位的浮点向量
Vector128<float> vectorA = Vector128.Create(1.0f, 2.0f, 3.0f, 4.0f);
Vector128<float> vectorB = Vector128.Create(5.0f, 6.0f, 7.0f, 8.0f);
// 执行加法运算
// 等效于 vectorA + vectorB
var result = Vector128.Add(vectorA, vectorB);
// 输出结果
Console.WriteLine($"Result: {result}");
Result: <6, 8, 10, 12>
理解这一步时,在采用 SIMD 指令集之前,通常需检查当前平台是否兼容特定的指令集。能够借助调用指令集类的 IsSupported 属性来进行检查。比如:
using System.Runtime.Intrinsics.X86;
Console.WriteLine(Sse.IsSupported ? "SSE 指令集受支持" : "SSE 指令集不受支持");
落到代码里,一旦确认指令集受兼容,就能够采用该指令集类提供的静态方法来执行 SIMD 操作。比如,采用 Sse 类的 Add 方法来对两个 128 位向量执行加法运算:
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;
if (Sse.IsSupported)
{
// 创建两个 128 位的浮点向量
Vector128<float> vectorA = Vector128.Create(1.0f, 2.0f, 3.0f, 4.0f);
Vector128<float> vectorB = Vector128.Create(5.0f, 6.0f, 7.0f, 8.0f);
// 使用 SSE 指令集执行加法运算
var result = Sse.Add(vectorA, vectorB);
// 输出结果
Console.WriteLine($"Result: {result}");
}
else
{
Console.WriteLine("SSE 指令集不受支持");
}
Result: <6, 8, 10, 12>
理解这一步时,若是在 ARM 架构上,能够采用 AdvSimd 类来执行类似的操作:
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.Arm;
if (AdvSimd.IsSupported)
{
// 创建两个 128 位的浮点向量
Vector128<float> vectorA = Vector128.Create(1.0f, 2.0f, 3.0f, 4.0f);
Vector128<float> vectorB = Vector128.Create(5.0f, 6.0f, 7.0f, 8.0f);
// 使用 AdvSimd 指令集执行加法运算
var result = AdvSimd.Add(vectorA, vectorB);
// 输出结果
Console.WriteLine($"Result: {result}");
}
else
{
Console.WriteLine("AdvSimd 指令集不受支持");
}
Result: <6, 8, 10, 12>
基于 System.Runtime.Intrinsics,.NET 还提供了别的更高级别的 SIMD 兼容。
比如在 System.Numerics 这个命名空间提供了一些易于采用的类型,如 Vector<T> 和 Matrix4x4,简化了 SIMD 编程。
Vector<T> 是一个通用的向量类型,兼容多种数值类型(如 int、float、double 等)。它会根据硬件能力自动选择最佳的向量大小(如 128 位或 256 位),从而实现跨平台的 SIMD 优化。
下面是一个采用 Vector<T> 进行数组加法的示例:
var vectorSize = Vector<float>.Count; // 获取向量大小(元素个数)
float[] arrayA = new float[1000];
float[] arrayB = new float[1000];
float[] resultArray = new float[1000];
for (int i = 0; i <= arrayA.Length - vectorSize; i += vectorSize)
{
var va = new Vector<float>(arrayA, i);
var vb = new Vector<float>(arrayB, i);
(va + vb).CopyTo(resultArray, i);
}
实际处理时,System.Numerics 命名空间还提供了 Vector2、Vector3 和 Vector4 结构体,分别表示二维、三维和四维向量,常用来图形和物理计算中。
using System.Numerics;
// 创建 Vector3 实例
Vector3 vector1 = new Vector3(1.0f, 2.0f, 3.0f);
Vector3 vector2 = new Vector3(4.0f, 5.0f, 6.0f);
// 向量加法
Vector3 resultAdd = Vector3.Add(vector1, vector2);
Console.WriteLine($"Addition: {resultAdd}"); // 输出: <5, 7, 9>
// 向量点乘
float dotProduct = Vector3.Dot(vector1, vector2);
Console.WriteLine($"Dot Product: {dotProduct}"); // 输出: 32
// 向量归一化
Vector3 normalized = Vector3.Normalize(vector1);
Console.WriteLine($"Normalized: {normalized}"); // 输出: <0.2672612, 0.5345225, 0.8017837>
从实现思路看,若我们去看 Vector3 结构体的源码实现,会发现它内部采用了 SIMD 技术来优化向量运算:
public struct Vector3 : IEquatable<Vector3>, IFormattable
{
/// <summary>The X component of the vector.</summary>
public float X;
/// <summary>The Y component of the vector.</summary>
public float Y;
/// <summary>The Z component of the vector.</summary>
public float Z;
public Vector3(float x, float y, float z) => this = Vector3.Create(x, y, z);
public static Vector3 Create(float x, float y, float z)
{
return Vector128.Create(x, y, z, 0.0f).AsVector3();
}
// 省略其他成员...
}
结合项目来看,System.Numerics 还提供了 Matrix2x2、Matrix3x2 和 Matrix4x4 结构体,用来表示二维和三维空间中的矩阵,常用来变换和投影计算。
using System.Numerics;
// 创建一个 4x4 矩阵
Matrix4x4 matrix = Matrix4x4.CreateRotationX((float)(Math.PI / 4));
Vector3 point = new Vector3(1.0f, 0.0f, 0.0f);
// 使用矩阵变换点
Vector3 transformedPoint = Vector3.Transform(point, matrix);
Console.WriteLine($"Transformed Point: {transformedPoint}");
Transformed Point: <1, 0, 0>
实际处理时,在 ASCII 码表的设计里,大写字母和小写字母之间的差异仅在于第 6 位(从右往左数)。大写字母的第 6 位为 0,而小写字母的第 6 位为 1。所以,我们能够借助对字符的二进制表示进行按位操作来实现大小写转换。
示例:字母 A B C D 的 ASCII 编码对照
| 字符 | ASCII 十进制 | ASCII 十六进制 | 二进制表示 |
|---|---|---|---|
| A | 65 | 0x41 | 01000001 |
| B | 66 | 0x42 | 01000010 |
| C | 67 | 0x43 | 01000011 |
| D | 68 | 0x44 | 01000100 |
| a | 97 | 0x61 | 01100001 |
| b | 98 | 0x62 | 01100010 |
| c | 99 | 0x63 | 01100011 |
| d | 100 | 0x64 | 01100100 |
从实现思路看,能够看到,对应的大写和小写之间,二进制的第 6 位(从右数,值为 32,即 0x20)状态不同:
这样,只需用采用异或操作(XOR)即可实现大小写转换:
Console.WriteLine((char)('a' ^ 0x20)); // 输出 A
Console.WriteLine((char)('A' ^ 0x20)); // 输出 a
在 System.Text 命名空间中,.NET 提供了 Ascii 类,里面包含了一些用来 ASCII 字符处理的静态方法。我们能够借助 SIMD 技术来实现高效的大小写转换。
public static class Ascii
{
public static OperationStatus ToUpper(ReadOnlySpan<byte> source, Span<byte> destination, out int bytesWritten);
public static OperationStatus ToLower(ReadOnlySpan<byte> source, Span<byte> destination, out int bytesWritten);
public static OperationStatus ToUpperInPlace(Span<byte> value, out int bytesWritten);
public static OperationStatus ToLowerInPlace(Span<byte> value, out int bytesWritten);
public static OperationStatus ToUpper(ReadOnlySpan<char> source, Span<char> destination, out int charsWritten);
public static OperationStatus ToLower(ReadOnlySpan<char> source, Span<char> destination, out int charsWritten);
public static OperationStatus ToUpperInPlace(Span<char> value, out int charsWritten);
public static OperationStatus ToLowerInPlace(Span<char> value, out int charsWritten);
}
public enum OperationStatus
{
/// <summary>The entire input buffer has been processed and the operation is complete.</summary>
Done,
/// <summary>The input is partially processed, up to what could fit into the destination buffer. The caller can enlarge the destination buffer, slice the buffers appropriately, and retry.</summary>
DestinationTooSmall,
/// <summary>The input is partially processed, up to the last valid chunk of the input that could be consumed. The caller can stitch the remaining unprocessed input with more data, slice the buffers appropriately, and retry.</summary>
NeedMoreData,
/// <summary>The input contained invalid bytes which could not be processed. If the input is partially processed, the destination contains the partial result. This guarantees that no additional data appended to the input will make the invalid sequence valid.</summary>
InvalidData,
}
参数的重载有 byte 和 char 两种,分别用来处理保存为 byte 和 char 类型的 ASCII 字符数据。
落到代码里,若是从 IO 流中读取数据进行大小写转换,能够采用 Span<byte> 版本;如果是处理 char 数组或字符串,则采用 Span<char> 版本。
ToUpper 和 ToLower 方法会将源数据转换为目标数据,同时得到一个 OperationStatus 枚举值,指示操作的状态。
ToUpperInPlace 和 ToLowerInPlace 方法则会直接在原始数据上进行大小写转换。
在这个场景下,这里需留意,这些方法仅处理 ASCII 范围内的字符(0-127),对于非 ASCII 字符不会进行任何转换。
这些方法同时不能替代 string.ToUpper 等方法来拿到 string 的大写或小写形式。需考虑 string 和 Span<byte>, Span<char> 之间的转换开销,最后性能并不一定优于直接采用 string.ToUpper 等方法。
能够参考微软的开源项目 Garnet 中的采用场景 AsciiUtils.cs
Ascii.ToUpperInPlace(Span<char> value, out int charsWritten) 的核心实现经整理后大致如下所示:
void ToUpperInPlace(Span<char> value)
{
// 将 Span<char> 转换成 Span<ushort>(char 占 2 字节,方便 SIMD 处理)
var buffer = MemoryMarshal.Cast<char, ushort>(value);
// 获取元素数量(ushort 数量)
var elementCount = (uint)buffer.Length;
// 每个向量能处理多少个 ushort
var numElementsPerVector = (uint)(Unsafe.SizeOf<Vector128<byte>>() / sizeof(ushort));
// 如果支持 SIMD 且数据足够,否则走普通循环
if (Vector128.IsHardwareAccelerated && elementCount >= numElementsPerVector)
{
// 有符号最小值 (0x8000) 用于偏移正确比较
ushort sourceSignedMinValue = (ushort)(1 << (8 * sizeof(ushort) - 1));
// 'a' 的基准向量(所有元素都是 0x8000 + 'a')
var subtractionVector = Vector128.Create((ushort)(sourceSignedMinValue + 'a'));
// 26 个字母范围偏移向量(所有元素都是 0x8000 + 26)
var comparisonVector = Vector128.Create((ushort)(sourceSignedMinValue + 26));
// 大小写差值向量(0x20,所有元素都是这个值)
var caseConversionVector = Vector128.Create((ushort)0x20);
// 向量化循环索引
uint i = 0;
// 可以整除的元素个数
uint n = elementCount - (elementCount % numElementsPerVector);
// 向量化批量处理
for (; i < n; i += numElementsPerVector)
{
// 加载当前批次的向量数据
var srcVector = Vector128.LoadUnsafe(ref Unsafe.Add(ref MemoryMarshal.GetReference(buffer), (int)i));
// 计算 src - 'a' 基准,并判断是否小于 26(即在 a..z 范围)
var matches = SignedLessThan(srcVector - subtractionVector, comparisonVector);
// 对匹配的小写字母执行大小写转换(异或 0x20 得到大写)
srcVector ^= matches & caseConversionVector;
// 存回修改后的向量数据
srcVector.StoreUnsafe(ref Unsafe.Add(ref MemoryMarshal.GetReference(buffer), (int)i));
}
// 处理剩余不足一个向量大小的元素
for (; i < elementCount; i++)
{
// 读取当前字符
ushort c = Unsafe.Add(ref MemoryMarshal.GetReference(buffer), (int)i);
// 如果是小写字母则转为大写
if (c is >= 'a' and <= 'z')
{
c = (ushort)(c - 0x20);
}
// 写回结果
Unsafe.Add(ref MemoryMarshal.GetReference(buffer), (int)i) = c;
}
}
else
{
// 非向量化处理,每个元素单独判断
for (int i = 0; i < buffer.Length; i++)
{
ushort c = buffer[i];
if (c is >= 'a' and <= 'z')
{
c = (ushort)(c - 0x20);
}
buffer[i] = c;
}
}
}
// 有符号比较(用于判断 a..z 范围)
Vector128<ushort> SignedLessThan(Vector128<ushort> left, Vector128<ushort> right)
{
// 将 ushort 当成 short 做有符号比较,然后再转换回 ushort 掩码
return Vector128.LessThan(left.AsInt16(), right.AsInt16()).AsInt16().AsUInt16();
}
调用上述方法能够高效地将 ASCII 字符串转换为大写形式:
string input = "Hello World! This is a Test String.";
Span<char> span = input.ToCharArray();
ToUpperInPlace(span);
string result = new string(span);
Console.WriteLine(result); // 输出: "HELLO WORLD! THIS IS A TEST STRING."
在这个场景下,实际的 Ascii 类实现要复杂得多,包含了更多的边界检查和错误处理逻辑,上述代码仅仅是为了说明核心的 SIMD 思路的简化版本。
采用 Ascii.UpperInPlace 的示例:
using System.Text;
string input = "Hello World! This is a Test String. 这部分不会被转换。";
Span<char> span = input.ToCharArray();
Ascii.ToUpperInPlace(span, out int charsWritten);
// charsWritten 表示实际转换的字符数,非 ASCII 字符不会被转换
string result = new string(span[..charsWritten]);
Console.WriteLine(result); // 输出: "HELLO WORLD! THIS IS A TEST STRING."
实际处理时,SIMD 技术很适合处理大量的二进制数据或位操作。比如,BinaryPrimitives.ReverseEndianness 方法借助 SIMD 来高效地反转字节序:
Span<ushort> data = [0x1234, 0xABCD, 0x5678, 0xEF01];
Span<ushort> reversedData = stackalloc ushort[4];
BinaryPrimitives.ReverseEndianness(data, reversedData);
foreach (var value in reversedData)
{
Console.WriteLine(value.ToString("X4"));
}
3412
CDAB
7856
01EF
结合项目来看,SIMD 技术在 .NET 中提供了强大的同时行计算能力,能够显著提升处理大量数据时的性能。借助 System.Runtime.Intrinsics 和 System.Numerics,我们能够便于地借助 SIMD 指令集进行高效的向量化运算。
落到代码里,很多基础库已经内置了 SIMD 优化,开发者在日常编程中能够借助这些库间接受益于 SIMD 技术,而无需深入了解底层实现细节。当然,对于性能敏感的应用场景,理解和直接采用 SIMD 指令集仍然是很有价值的。
到此这篇关于在 .NET 中采用 SIMD的操作方法的文章就介绍到这了,更多相关.net采用simd内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多兼容脚本之家!