C# 使用SIMD向量类型加速浮点数组求和运算,1:使用Vector4、Vector

作者:

目录

目录

一、缘由

从.NET Core 1.0开始,.NET里增加了2种向量类型——

  1. 大小固定的向量(Vectors with a fixed size)。例如 结构体(struct) Vector2、Vector3、Vector4。
  2. 大小与硬件相关的向量(Vectors with a hardware dependent size)。例如 只读结构体(readonly struct) Vector<T>,及辅助的静态类 Vector。

到了 .NET Core 3.0,增加了内在函数(Intrinsics Functions)的支持,并增加了第3类向量类型——

3. 总位宽固定的向量(Vector of fixed total bit width)。例如 只读结构体 Vector64<T>Vector128<T>Vector256<T>,及辅助的静态类 Vector64、Vector128、Vector256。

这3类向量类型,均能利用CPU硬件的SIMD(float Instruction Multiple Data,单指令多数据流)功能,来加速多媒体数据的处理。但是它们名称很接近,对于初学者来说容易混淆,而且应用场景稍有区别,本文致力于解决这些问题。

本章重点解说前2种向量类型(Vector4、Vector<T>),第3种向量类型将由第2章来解说。

本章回答了这些问题——

  • 怎样使用这2种向量类型?以做浮点数组求和运算为例。
  • 这2种向量类型的使用场景,及最佳实践是怎样的?
  • 我们的普通PC机的浮点运算性能,能达到每秒多少 MFLOPS(百万次浮点运算)?
  • 官方文档上,.NET Framework 4.6 才支持大小固定的向量(如Vector4),且Vector<T>未提到.NET Framework的支持版本。难道 .NET Framework用不了Vector<T> 吗? .NET Framework 4.5等版本时是否能使用它们?
  • 官方文档上,仅 .NET Standard 2.1 才支持这2种向量类型。而.NET Standard 2.0应用最广泛,该怎么在.NET Standard 2.0上使用它们?
  • 若在类库里使用了向量类型,那么 .NET Core或.NET Framework引用类库时,向量类型是否仍会有硬件加速?
  • 当没有硬件加速(Vector.IsHardwareAccelerated==false)时,使用向量类型会有什么问题吗?
  • 有人说“仅64位、Release模式编译时”向量类型才会有硬件加速,而其他情况没有硬件加速,是这样的吗?

二、使用向量类型

用高级语言处理数据时,一般是SISD(float instruction float data,单指令流单数据流)模型的,即一个语句只能处理一条数据。

而对于多媒体数据处理,任务的特点是运算相对简单,但是数据量很大,导致SISD模型的效率很低。

若使用SIMD模型的话,一次能处理多条数据,从而能成倍的提高性能。

.NET Core引入了向量数据类型,从而使C#(等.NET中语言)能使用SIMD加速数据的处理。

并不是所有的数据处理工作都适合SIMD处理。一般来说,需满足以下条件,才能充分利用SIMD加速——

  1. 数据量大(至少超过1000)且连续的存放在内存里。若数据规模小,SIMD无法体现性能优势;若数据不是连续存放,那么会遇到内存传输率的瓶颈,无法发挥SIMD的实力。
  2. 每个元素的处理运算需比较简单。因为SIMD的函数,只能处理简单的数学函数。
  3. 每个元素的处理步骤,大致相同。当每个元素的处理运算相同时,便能一个命令同时处理多条数据。当存在差异时,便需要利用掩码与位运算,分别进行处理。当差异很大时,甚至向量代码比起标量代码,没有优势。
  4. 元素的数据类型,必须是.NET的基元类型,如 float、double、int 等。这是.NET向量类型的限制。

对于以下情况,SIMD代码的性能会急剧下降,应尽量避免——

  • 分支跳转。分支跳转会导致流水线失效,导致SIMD性能会急剧下降。故在处理步骤稍有差异时,应尽量利用掩码与位运算分别进行处理,而不是分支。
  • 元素间的数据相关性高。当没有相关性时,才适合SIMD并发处理。若相关性高,那么等待相关处理处理会浪费不少时间,无法发挥SIMD并发处理的优势。很多时候可以使用MapReduce策略来处理数据,先在Map阶段处理并发处理“无相关性的步骤”,最后在Reduce阶段专门处理“有相关性的步骤”。

基于以上原因,发现最适合演示SIMD运算优势的,是做“浮点数组求和运算”。先在Map阶段处理并发的进行分组求和,最后在Reduce阶段将各组结果加起来。

2.1 基本算法

为了对比测试,先用传统的办法来编写一个“单精度浮点数组求和”的函数。

其实算法很简单,写个循环进行累加求和就行。代码如下。

private static float SumBase(float[] src, int count) {
    float rt = 0; // Result.
    for(int i=0; i< count; ++i) {
        rt += src[i];
    }
    return rt;
}

由于.NET向量类型的初始化会有一些开销,为了避免这些开销影响主循环的性能测试结果,于是需要将它们移到循环外。为了测试方便,求和函数可增加一个loops参数,它是测试次数,作为外循环。loops为1时,就是标准的变量求和;为其他值时,是多轮变量求和的累计值。由于浮点精度有限的问题,累计值可能与乘法结果不同。

为了能统一进行测试,于是基本算法也增加了 loops 参数。

private static float SumBase(float[] src, int count, int loops) {
    float rt = 0; // Result.
    for (int j=0; j< loops; ++j) {
        for(int i=0; i< count; ++i) {
            rt += src[i];
        }
    }
    return rt;
}

2.2 使用大小固定的向量(如 Vector4)

2.2.1 介绍

大小固定的向量类型,是以下3种结构体——

  • Vector2:表示一个具有两个单精度浮点值的向量。
  • Vector3:表示一个具有三个单精度浮点值的向量。
  • Vector4:表示一个具有四个单精度浮点值的向量。

它们实际上是对数学(线性代数分支)里“向量”(Vector)的封装。命名规则为“'Vector' + [维数]”,例如 Vector2是数学里的“二维向量”、Vector3是数学里的“三维向量”、Vector4是数学里的“四维向量”。

于是这些类型,除了提供了常见的四则运算函数外,还提供了 向量长度(Length)、向量距离(Distance)、点积(Dot)、叉积(Cross) 等线性代数领域的函数。

它其中元素的数据类型,被限制为 float(32位单精度浮点值)。能用于常见单精度浮点运算场合。

使用这些向量类型时,JIT会尽可能的利用硬件加速,但是没有提供“是否有硬件加速”的标志。

这是因为不同的运算函数,在不同的CPU指令集里,有些能硬件加速,而另一些不能,很难通过简单的标志来区分。于是JIT仅是保证能尽可能的利用硬件加速,让使用者不用关心这些硬件细节。

一般来说,直接用这些类型的封装函数(如点积、叉积 运算等),比手工按数学定义编写的运算函数,效率更高。因为即使没有硬件加速时,这些封装好的函数是高水平的程序员编写的成熟代码。

Vector2、Vector3 比起 Vector4,元素个数要少一些,从数学定义上来看,理论运算量要少一些。

但是硬件的SIMD加速,大多是按“4元素并行处理”来设计。故很多时候,“Vector2、Vector3”运算性能与“Vector4”差不多。甚至在一些特别场合,比“Vector4”性能还低,因为对于硬件来说,可能会有多余的 忽略多余元素处理、数据转换 工作。

于是建议这样使用——

  • 若是开发数学上的向量运算相关的功能,可根据业务上对向量运算的要求,使用维度匹配的向量类。例如 2维向量处理时用Vector2、3维向量处理时用Vector3、3维齐次向量处理时用Vector4。
  • 若是想对数据进行SIMD优化,那么应该用 Vector4。

2.2.2 用Vector4编写浮点数组求和函数

现在,我们使用Vector4,来编写浮点数组求和函数。

思路:Vector4内有4个元素,于是可以分为4个组分别进行求和(即Map阶段),最后再将4个组的结果加起来(即Reduce阶段)。

我们先可建立SumVector4函数。根据之前所说(为了.NET向量类型的初始化),该函数还增加了1个loops参数。

/// <summary>
/// Sum - Vector4.
/// </summary>
/// <param name="src">Soure array.</param>
/// <param name="count">Soure array count.</param>
/// <param name="loops">Benchmark loops.</param>
/// <returns>Return the sum value.</returns>
private static float SumVector4(float[] src, int count, int loops) {
    float rt = 0; // Result.
    // TODO
    return rt;
}

注意,数组长度可能不是4的整数倍。此时仅能对前面的、4的整数倍的数据用Vector4进行运算,而对于末尾剩余的元素,只能用传统办法来处理。

此时可利用“块”(Block)的概念来简化思路:每次内循环处理1个块,先对能凑齐整块的数据用Vector4进行循环处理(cntBlock),最后再对末尾剩余的元素(cntRem)按传统方式来处理。

Vector4有4个元素,于是块宽度(nBlockWidth)为4。代码摘录如下。

    const int VectorWidth = 4;
    int nBlockWidth = VectorWidth; // Block width.
    int cntBlock = count / nBlockWidth; // Block count.
    int cntRem = count % nBlockWidth; // Remainder count.

C#是强类型的,会严格检查类型是否匹配,为了能使用Vector4,需要先将浮点数组转换为Vector4。这一步骤,一般叫做“Load”(加载)。

再加上相关变量的定义及初始化,“Load”部分的代码摘录如下。

    Vector4 vrt = Vector4.Zero; // Vector result.
    int p; // Index for src data.
    int i;
    // Load.
    Vector4[] vsrc = new Vector4[cntBlock]; // Vector src.
    p = 0;
    for (i = 0; i < vsrc.Length; ++i) {
        vsrc[i] = new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3]);
        p += VectorWidth;
    }

由于 Vector4 的构造函数不支持从数组里加载数据,仅支持“传递4个浮点变量”。于是上面的循环里,使用“传递4个浮点变量”的方式创建Vector4,然后放到vsrc数组中。vsrc数组中的每一项,就是一个块(Block)。

现在已经准备好了,可以用循环进行数据运算(Map阶段:分为4个组分别进行求和)了。代码摘录如下。

    // Body.
    for (int j = 0; j < loops; ++j) {
        // Vector processs.
        for (i = 0; i < cntBlock; ++i) {
            // Equivalent to scalar model: rt += src[i];
            vrt += vsrc[i]; // Add.
        }
        // Remainder processs.
        p = cntBlock * nBlockWidth;
        for (i = 0; i < cntRem; ++i) {
            rt += src[p + i];
        }
    }

外循环loops的作用仅是为了方便测试,关键代码在2个内循环里:

  1. Vector processs(向量处理):以块为单位进行循环处理,利用 Vector4 有4个元素特点,进行4路并发加法,将 vsrc[i] 的值,加到 vrt 里。vrt是Vector4类型的变量,定义时已初始化为0。
  2. Remainder processs(剩余数据处理):先计算一下剩余数据的起始索引(p = cntBlock * nBlockWidth),然后使用传统循环写法,将剩余数据累积到 rt 里。

由于Vector4重载了“+”运算法,所以可以很简单的使用“+=”运算符来做“相加并赋值”操作。代码写法,与传统的标量代码很相似,代码可读性高。

rt += src[i]; // 标量代码.
vrt += vsrc[i]; // 向量代码.

最后我们需要将各组的结果加在一起(Reduce阶段)。代码摘录如下。

    // Reduce.
    rt += vrt.X + vrt.Y + vrt.Z + vrt.W;
    return rt;

因 Vector4 暴露了 X、Y、Z、W 这4个成员,于是可以很方便的用“+”运算符,将结果加在一起。

该函数的完整代码如下。

private static float SumVector4(float[] src, int count, int loops) {
    float rt = 0; // Result.
    const int VectorWidth = 4;
    int nBlockWidth = VectorWidth; // Block width.
    int cntBlock = count / nBlockWidth; // Block count.
    int cntRem = count % nBlockWidth; // Remainder count.
    Vector4 vrt = Vector4.Zero; // Vector result.
    int p; // Index for src data.
    int i;
    // Load.
    Vector4[] vsrc = new Vector4[cntBlock]; // Vector src.
    p = 0;
    for (i = 0; i < vsrc.Length; ++i) {
        vsrc[i] = new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3]);
        p += VectorWidth;
    }
    // Body.
    for (int j = 0; j < loops; ++j) {
        // Vector processs.
        for (i = 0; i < cntBlock; ++i) {
            // Equivalent to scalar model: rt += src[i];
            vrt += vsrc[i]; // Add.
        }
        // Remainder processs.
        p = cntBlock * nBlockWidth;
        for (i = 0; i < cntRem; ++i) {
            rt += src[p + i];
        }
    }
    // Reduce.
    rt += vrt.X + vrt.Y + vrt.Z + vrt.W;
    return rt;
}

2.3 使用大小与硬件相关的向量(如 Vector<T>

2.3.1 介绍

Vector4的痛点是——元素类型固定为float,且仅有4个元素。导致它的使用范围有限。

Vector<T> 解决了这2大痛点——

  1. 它具有泛型参数T,可以支持各种数值型的基元类型,如 float、double、int 等。
  2. 它的元素个数不止4个,而是由硬件决定的。若硬件支持向量位宽越宽,那么Vector<T>的元素个数便越大。使用Vector<T>在各种向量位宽的硬件上运行时,会以最大向量位宽来运行,而仅需只编写一套代码。

以下是官方文档对 Vector<T> 的介绍。

`Vector<T>` 是一个不可变结构,表示指定数值类型的单个向量。 实例计数是固定的 `Vector<T>` ,但其上限取决于 CPU 寄存器。 它旨在用作向量大型算法的构建基块,因此不能直接用作任意长度向量或张量。
该 `Vector<T>` 结构为硬件加速提供支持。
本文中的术语 基元数值数据类型 是指 CPU 直接支持的数值数据类型,并具有可以操作这些数据类型的说明。 下表显示了哪些基元数值数据类型和操作组合使用内部指令来加快执行速度:
基元类型+-*/
sbyte
byte
short
ushort
int
uint
long
ulong
float
double
2.2.1.1 使用经验

有一个跟 Vector<T> 配合使用的静态类 Vector。它有2大作用——

  1. 提供了 IsHardwareAccelerated 属性,用于检查 Vector<T> 是否有硬件加速。应用程序应该检查该属性,仅在该属性为true,才使用 Vector<T>
  2. 提供了大量的数学函数,能便于 SIMD数据处理。Vector<T> 只是重载了运算符,对于运算符无法办到的一些数学运算,可以去静态类 Vector 里找。

Vector<T> 具有这些属性:

  • Count:【静态】返回存储在向量中的元素数量。
  • Item[int]:获取指定索引处的元素。
  • One:【静态】返回一个包含所有 1 的向量。
  • Zero:【静态】返回一个包含所有 0 的向量。

因为 Vector<T> 长度是与硬件有关的,所以每次在使用 Vector<T> 时,别忘了需要先从 Count 属性里的到元素数量。

一般来说——

  • 若CPU是 x86体系的,且支持 AVX2指令集 时,那么 Vector<T> 长度为256位,即32字节。此时能并行的处理 32个byte,或 16个short、8个int、4个long、8个float、4个double。
  • 若CPU是 x86体系的,不支持AVX2指令集,但支持 SSE2指令集 时,那么 Vector<T> 长度为128位,即16字节。此时能并行的处理 16个byte,或 8个short、4个int、2个long、4个float、2个double。
  • 若CPU不支持向量硬件加速时,那么 Vector<T> 长度仍为128位,即16字节。Vector.IsHardwareAccelerated为false,不建议使用。长度仍为128位,这可能是为了方便代码兼容性。

这些情况的IsHardwareAccelerated、Count属性,一般为这些值——

// If the CPU is x86 and supports the AVX2 instruction set.
Vector.IsHardwareAccelerated = true
Vector<sbyte>.Count = 32
Vector<byte>.Count = 32
Vector<short>.Count = 16
Vector<ushort>.Count = 16
Vector<int>.Count = 8
Vector<uint>.Count = 8
Vector<long>.Count = 4
Vector<ulong>.Count = 4
Vector<float>.Count = 8
Vector<double>.Count = 4

// If the CPU is x86, the AVX2 instruction set is not supported, but the SSE2 instruction set is supported.
Vector.IsHardwareAccelerated = true
Vector<sbyte>.Count = 16
Vector<byte>.Count = 16
Vector<short>.Count = 8
Vector<ushort>.Count = 8
Vector<int>.Count = 4
Vector<uint>.Count = 4
Vector<long>.Count = 2
Vector<ulong>.Count = 2
Vector<float>.Count = 4
Vector<double>.Count = 2

// If the CPU does not support vector hardware acceleration.
Vector.IsHardwareAccelerated = false
Vector<sbyte>.Count = 16
Vector<byte>.Count = 16
Vector<short>.Count = 8
Vector<ushort>.Count = 8
Vector<int>.Count = 4
Vector<uint>.Count = 4
Vector<long>.Count = 2
Vector<ulong>.Count = 2
Vector<float>.Count = 4
Vector<double>.Count = 2

2.3.2 用 Vector<T> 编写浮点数组求和函数

现在,我们使用 Vector<T>,来编写浮点数组求和函数。

思路:先使用Count属性获得元素个数,然后按Count分组分别进行求和(即Map阶段),最后再将这些组的结果加起来(即Reduce阶段)。

根据上面的经验,我们可编写好 SumVectorT 函数。

private static float SumVectorT(float[] src, int count, int loops) {
    float rt = 0; // Result.
    int VectorWidth = Vector<float>.Count; // Block width.
    int nBlockWidth = VectorWidth; // Block width.
    int cntBlock = count / nBlockWidth; // Block count.
    int cntRem = count % nBlockWidth; // Remainder count.
    Vector<float> vrt = Vector<float>.Zero; // Vector result.
    int p; // Index for src data.
    int i;
    // Load.
    Vector<float>[] vsrc = new Vector<float>[cntBlock]; // Vector src.
    p = 0;
    for (i = 0; i < vsrc.Length; ++i) {
        vsrc[i] = new Vector<float>(src, p);
        p += VectorWidth;
    }
    // Body.
    for (int j = 0; j < loops; ++j) {
        // Vector processs.
        for (i = 0; i < cntBlock; ++i) {
            vrt += vsrc[i]; // Add.
        }
        // Remainder processs.
        p = cntBlock * nBlockWidth;
        for (i = 0; i < cntRem; ++i) {
            rt += src[p + i];
        }
    }
    // Reduce.
    for (i = 0; i < VectorWidth; ++i) {
        rt += vrt[i];
    }
    return rt;
}

对比 SumVector4,除了将 Vector4 类型换为 Vector<T>,还有这些变化——

  • VectorWidth不再是一个固定常数,而是通过 Vector<float>.Count 属性来得到。
  • Vector<T> 的构造函数支持数组参数。于是可以用 new Vector<float>(src, p),代替繁琐的 new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3])
  • Vector<T>支持索引器(文档里的Item属性),可以使用索引器运算符 [],简洁的获取它的元素。于是在Reduce阶段,可以写个循环对结果进行累加。

三、搭建测试程序

对于这2类向量类型,计划在以下平台进行测试——

  • .NET Core
  • .NET Framework
  • .NET Standard

开发环境选择VS2017。解决方案名的名称是“BenchmarkVector”。

因需要测试这么多平台,为了避免代码重复问题,故将主测试代码放到共享项目(Shared Project)里。随后各个平台的测试程序,可以引用该共享项目。

3.1 主测试代码(BenchmarkVectorDemo)

共享项目的名称是“BenchmarkVector”。其中的BenchmarkVectorDemo类,是主测试代码。

3.1.1 测试方法(Benchmark)

Benchmark是测试方法,代码如下。

/// <summary>
/// Do Benchmark.
/// </summary>
/// <param name="tw">Output <see cref="TextWriter"/>.</param>
/// <param name="indent">The indent.</param>
public static void Benchmark(TextWriter tw, string indent) {
    if (null == tw) return;
    if (null == indent) indent = "";
    //string indentNext = indent + "\t";
    // init.
    int tickBegin, msUsed;
    double mFlops; // MFLOPS/s .
    double scale;
    float rt;
    const int count = 1024*4;
    const int loops = 1000 * 1000;
    //const int loops = 1;
    const double countMFlops = count * (double)loops / (1000.0 * 1000);
    float[] src = new float[count];
    for(int i=0; i< count; ++i) {
        src[i] = i;
    }
    tw.WriteLine(indent + string.Format("Benchmark: \tcount={0}, loops={1}, countMFlops={2}", count, loops, countMFlops));
    // SumBase.
    tickBegin = Environment.TickCount;
    rt = SumBase(src, count, loops);
    msUsed = Environment.TickCount - tickBegin;
    mFlops = countMFlops * 1000 / msUsed;
    tw.WriteLine(indent + string.Format("SumBase:\t{0}\t# msUsed={1}, MFLOPS/s={2}", rt, msUsed, mFlops));
    double mFlopsBase = mFlops;
    // SumVector4.
    tickBegin = Environment.TickCount;
    rt = SumVector4(src, count, loops);
    msUsed = Environment.TickCount - tickBegin;
    mFlops = countMFlops * 1000 / msUsed;
    scale = mFlops / mFlopsBase;
    tw.WriteLine(indent + string.Format("SumVector4:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
    // SumVectorT.
    tickBegin = Environment.TickCount;
    rt = SumVectorT(src, count, loops);
    msUsed = Environment.TickCount - tickBegin;
    mFlops = countMFlops * 1000 / msUsed;
    scale = mFlops / mFlopsBase;
    tw.WriteLine(indent + string.Format("SumVectorT:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
}

变量说明——

  • count:浮点数组的长度。
  • loops:测试所用的外循环次数。
  • countMFlops:每次测试运算量是多少 MFLOPS(百万次浮点运算)。
  • src:测试所用的浮点数组。
  • tickBegin:记录测试开始的时刻。测试计时用的是 Environment.TickCount,它以毫秒为单位.
  • msUsed:测试所用的毫秒数。
  • mFlops:该函数的浮点性能。单位是 MFLOPS/s(百万次浮点运算/秒)。
  • mFlopsBase:基本算法的浮点性能。单位是 MFLOPS/s(百万次浮点运算/秒)。
  • scale:性能提高倍数。既 当前算法的性能,是基本算法的多少倍。

注:只有一级缓存是在CPU中的,一级缓存的读取需要1-4个时钟周期;二级缓存的读取需要10个左右的时钟周期;而三级缓存需要30-40个时钟周期,但是容量一次增大。

SIMD的数据规模大,一级缓存放不下。为了避免缓存速度干扰运算速度评测,故一般建议测试数据不要超过二级缓存的大小。

于是本范例的数据长度为 4K(1024*4),这是现代CPU的二级缓存大多能接受的长度。

例如在 .NET Core 2.0、lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10 平台运行时,该测试函数的测试结果为:

Benchmark:      count=4096, loops=1000000, countMFlops=4096
SumBase:        6.871948E+10    # msUsed=4937, MFLOPS/s=829.653635811221
SumVector4:     2.748779E+11    # msUsed=1234, MFLOPS/s=3319.2868719611, scale=4.00081037277147
SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8992

输出信息说明——

  • SumBase:浮点性能为 MFLOPS/s=829.653635811221,即约 0.829 GFLOPS/s。
  • SumVector4:浮点性能为 MFLOPS/s=3319.2868719611,即约 3.319 GFLOPS/s。性能是基础算法的 4.00081037277147 倍。
  • SumVectorT:浮点性能为 MFLOPS/s=6553.6,即约 6.553 GFLOPS/s。性能是基础算法的 7.8992 倍。

性能提高倍数(scale),与理论值相符。因为SumVector4能同时处理4个浮点数,支持AVX2指令集时的SumVectorT能同时处理8个浮点数。

i5-8250U是2017年Intel发布的芯片,对于现在来说是老掉牙的配置了。C#代码不使用硬件加速时,是 0.829 GFLOPS/s 的浮点性能;使用 Vector<T> 并有硬件加速时,能达到 6.553 GFLOPS/s 的浮点性能,这样的指标已经很不错了。

而且我们的测试,只是对单核的测试,多核并行处理的浮点性能会更高。编写多线程程序便利用CPU多核,有兴趣的读者可以自己试试。

注意上面的测试结果中,各函数返回的累加结果是不同的。这是主要是因为是分组统计,循环次数(loops)比较多,导致超过单精度浮点数的精度范围。

若临时将loops改回1,会发现各函数的返回值是相同。故在开发时,可将loops改回1,便于检查程序是否有问题;带了测试时,再将loops改为较大的值。

3.1.2 输出环境信息(OutputEnvironment)

因为这次测试了多个平台,不同平台的环境信息信息均不同。于是可以专门用一个函数来输出环境信息,源码如下。

/// <summary>
/// Is release make.
/// </summary>
public static readonly bool IsRelease =
#if DEBUG
    false
#else
    true
#endif
;

/// <summary>
/// Output Environment.
/// </summary>
/// <param name="tw">Output <see cref="TextWriter"/>.</param>
/// <param name="indent">The indent.</param>
public static void OutputEnvironment(TextWriter tw, string indent) {
    if (null == tw) return;
    if (null == indent) indent="";
    //string indentNext = indent + "\t";
    tw.WriteLine(indent + string.Format("IsRelease:\t{0}", IsRelease));
    tw.WriteLine(indent + string.Format("EnvironmentVariable(PROCESSOR_IDENTIFIER):\t{0}", Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER")));
    tw.WriteLine(indent + string.Format("Environment.ProcessorCount:\t{0}", Environment.ProcessorCount));
    tw.WriteLine(indent + string.Format("Environment.Is64BitOperatingSystem:\t{0}", Environment.Is64BitOperatingSystem));
    tw.WriteLine(indent + string.Format("Environment.Is64BitProcess:\t{0}", Environment.Is64BitProcess));
    tw.WriteLine(indent + string.Format("Environment.OSVersion:\t{0}", Environment.OSVersion));
    tw.WriteLine(indent + string.Format("Environment.Version:\t{0}", Environment.Version));
    //tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetSystemVersion:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetSystemVersion())); // Same Environment.Version
    tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetRuntimeDirectory:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory()));
#if (NET47 || NET462 || NET461 || NET46 || NET452 || NET451 || NET45 || NET40 || NET35 || NET20) || (NETSTANDARD1_0)
#else
    tw.WriteLine(indent + string.Format("RuntimeInformation.FrameworkDescription:\t{0}", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription));
#endif
    tw.WriteLine(indent + string.Format("BitConverter.IsLittleEndian:\t{0}", BitConverter.IsLittleEndian));
    tw.WriteLine(indent + string.Format("IntPtr.Size:\t{0}", IntPtr.Size));
    tw.WriteLine(indent + string.Format("Vector.IsHardwareAccelerated:\t{0}", Vector.IsHardwareAccelerated));
    tw.WriteLine(indent + string.Format("Vector<byte>.Count:\t{0}\t# {1}bit", Vector<byte>.Count, Vector<byte>.Count * sizeof(byte) * 8));
    tw.WriteLine(indent + string.Format("Vector<float>.Count:\t{0}\t# {1}bit", Vector<float>.Count, Vector<float>.Count*sizeof(float)*8));
    tw.WriteLine(indent + string.Format("Vector<double>.Count:\t{0}\t# {1}bit", Vector<double>.Count, Vector<double>.Count * sizeof(double) * 8));
    Assembly assembly = typeof(Vector4).GetTypeInfo().Assembly;
    //tw.WriteLine(string.Format("Vector4.Assembly:\t{0}", assembly));
    tw.WriteLine(string.Format("Vector4.Assembly.CodeBase:\t{0}", assembly.CodeBase));
    assembly = typeof(Vector<float>).GetTypeInfo().Assembly;
    tw.WriteLine(string.Format("Vector<T>.Assembly.CodeBase:\t{0}", assembly.CodeBase));
}

例如在 .NET Core 2.0 平台运行时,会输出这些信息:

IsRelease:      True
EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
Environment.ProcessorCount:     8
Environment.Is64BitOperatingSystem:     True
Environment.Is64BitProcess:     True
Environment.OSVersion:  Microsoft Windows NT 10.0.19044.0
Environment.Version:    4.0.30319.42000
RuntimeEnvironment.GetRuntimeDirectory: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\2.0.9\
RuntimeInformation.FrameworkDescription:        .NET Core 4.6.26614.01
BitConverter.IsLittleEndian:    True
IntPtr.Size:    8
Vector.IsHardwareAccelerated:   True
Vector<byte>.Count:     32      # 256bit
Vector<float>.Count:    8       # 256bit
Vector<double>.Count:   4       # 256bit
Vector4.Assembly.CodeBase:      file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
Vector<T>.Assembly.CodeBase:    file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll

输出信息说明——

  • IsRelease: 是不是以 Release方式编译的程序。
  • EnvironmentVariable(PROCESSOR_IDENTIFIER): CPU型号标识。
  • Environment.ProcessorCount: 逻辑处理器数量。
  • Environment.Is64BitOperatingSystem: 是不是64位操作系统。
  • Environment.Is64BitProcess: 当前进程是不是64位的。
  • Environment.OSVersion: 操作系统的版本。
  • Environment.Version: .NET运行环境的版本。
  • RuntimeEnvironment.GetRuntimeDirectory: .NET基础库的运行路径。
  • RuntimeInformation.FrameworkDescription: .NET平台的版本。
  • BitConverter.IsLittleEndian: 是不是小端方式。
  • IntPtr.Size: 指针的大小。32位时为4,64位时为8。
  • Vector.IsHardwareAccelerated: Vector<T> 是否支持硬件加速。
  • Vector<byte>.Count: Vector<byte>的元素个数、总位数。
  • Vector<float>.Count: Vector<float>的元素个数、总位数。
  • Vector<double>.Count: Vector<double>的元素个数、总位数。
  • Vector4.Assembly.CodeBase: Vector4 所属程序集的路径。
  • Vector<T>.Assembly.CodeBase: Vector<T> 所属程序集的路径。

3.1.3 汇总

下面是BenchmarkVectorDemo类的完整代码。

using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Reflection;
using System.Text;

namespace BenchmarkVector {
    /// <summary>
    /// Benchmark Vector Demo
    /// </summary>
    static class BenchmarkVectorDemo {
        /// <summary>
        /// Is release make.
        /// </summary>
        public static readonly bool IsRelease =
#if DEBUG
            false
#else
            true
#endif
        ;

        /// <summary>
        /// Output Environment.
        /// </summary>
        /// <param name="tw">Output <see cref="TextWriter"/>.</param>
        /// <param name="indent">The indent.</param>
        public static void OutputEnvironment(TextWriter tw, string indent) {
            if (null == tw) return;
            if (null == indent) indent="";
            //string indentNext = indent + "\t";
            tw.WriteLine(indent + string.Format("IsRelease:\t{0}", IsRelease));
            tw.WriteLine(indent + string.Format("EnvironmentVariable(PROCESSOR_IDENTIFIER):\t{0}", Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER")));
            tw.WriteLine(indent + string.Format("Environment.ProcessorCount:\t{0}", Environment.ProcessorCount));
            tw.WriteLine(indent + string.Format("Environment.Is64BitOperatingSystem:\t{0}", Environment.Is64BitOperatingSystem));
            tw.WriteLine(indent + string.Format("Environment.Is64BitProcess:\t{0}", Environment.Is64BitProcess));
            tw.WriteLine(indent + string.Format("Environment.OSVersion:\t{0}", Environment.OSVersion));
            tw.WriteLine(indent + string.Format("Environment.Version:\t{0}", Environment.Version));
            //tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetSystemVersion:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetSystemVersion())); // Same Environment.Version
            tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetRuntimeDirectory:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory()));
#if (NET47 || NET462 || NET461 || NET46 || NET452 || NET451 || NET45 || NET40 || NET35 || NET20) || (NETSTANDARD1_0)
#else
            tw.WriteLine(indent + string.Format("RuntimeInformation.FrameworkDescription:\t{0}", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription));
#endif
            tw.WriteLine(indent + string.Format("BitConverter.IsLittleEndian:\t{0}", BitConverter.IsLittleEndian));
            tw.WriteLine(indent + string.Format("IntPtr.Size:\t{0}", IntPtr.Size));
            tw.WriteLine(indent + string.Format("Vector.IsHardwareAccelerated:\t{0}", Vector.IsHardwareAccelerated));
            tw.WriteLine(indent + string.Format("Vector<byte>.Count:\t{0}\t# {1}bit", Vector<byte>.Count, Vector<byte>.Count * sizeof(byte) * 8));
            tw.WriteLine(indent + string.Format("Vector<float>.Count:\t{0}\t# {1}bit", Vector<float>.Count, Vector<float>.Count*sizeof(float)*8));
            tw.WriteLine(indent + string.Format("Vector<double>.Count:\t{0}\t# {1}bit", Vector<double>.Count, Vector<double>.Count * sizeof(double) * 8));
            Assembly assembly = typeof(Vector4).GetTypeInfo().Assembly;
            //tw.WriteLine(string.Format("Vector4.Assembly:\t{0}", assembly));
            tw.WriteLine(string.Format("Vector4.Assembly.CodeBase:\t{0}", assembly.CodeBase));
            assembly = typeof(Vector<float>).GetTypeInfo().Assembly;
            tw.WriteLine(string.Format("Vector<T>.Assembly.CodeBase:\t{0}", assembly.CodeBase));
        }

        /// <summary>
        /// Do Benchmark.
        /// </summary>
        /// <param name="tw">Output <see cref="TextWriter"/>.</param>
        /// <param name="indent">The indent.</param>
        public static void Benchmark(TextWriter tw, string indent) {
            if (null == tw) return;
            if (null == indent) indent = "";
            //string indentNext = indent + "\t";
            // init.
            int tickBegin, msUsed;
            double mFlops; // MFLOPS/s .
            double scale;
            float rt;
            const int count = 1024*4;
            const int loops = 1000 * 1000;
            //const int loops = 1;
            const double countMFlops = count * (double)loops / (1000.0 * 1000);
            float[] src = new float[count];
            for(int i=0; i< count; ++i) {
                src[i] = i;
            }
            tw.WriteLine(indent + string.Format("Benchmark: \tcount={0}, loops={1}, countMFlops={2}", count, loops, countMFlops));
            // SumBase.
            tickBegin = Environment.TickCount;
            rt = SumBase(src, count, loops);
            msUsed = Environment.TickCount - tickBegin;
            mFlops = countMFlops * 1000 / msUsed;
            tw.WriteLine(indent + string.Format("SumBase:\t{0}\t# msUsed={1}, MFLOPS/s={2}", rt, msUsed, mFlops));
            double mFlopsBase = mFlops;
            // SumVector4.
            tickBegin = Environment.TickCount;
            rt = SumVector4(src, count, loops);
            msUsed = Environment.TickCount - tickBegin;
            mFlops = countMFlops * 1000 / msUsed;
            scale = mFlops / mFlopsBase;
            tw.WriteLine(indent + string.Format("SumVector4:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
            // SumVectorT.
            tickBegin = Environment.TickCount;
            rt = SumVectorT(src, count, loops);
            msUsed = Environment.TickCount - tickBegin;
            mFlops = countMFlops * 1000 / msUsed;
            scale = mFlops / mFlopsBase;
            tw.WriteLine(indent + string.Format("SumVectorT:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
        }

        /// <summary>
        /// Sum - base.
        /// </summary>
        /// <param name="src">Soure array.</param>
        /// <param name="count">Soure array count.</param>
        /// <param name="loops">Benchmark loops.</param>
        /// <returns>Return the sum value.</returns>
        private static float SumBase(float[] src, int count, int loops) {
            float rt = 0; // Result.
            for (int j=0; j< loops; ++j) {
                for(int i=0; i< count; ++i) {
                    rt += src[i];
                }
            }
            return rt;
        }

        /// <summary>
        /// Sum - Vector4.
        /// </summary>
        /// <param name="src">Soure array.</param>
        /// <param name="count">Soure array count.</param>
        /// <param name="loops">Benchmark loops.</param>
        /// <returns>Return the sum value.</returns>
        private static float SumVector4(float[] src, int count, int loops) {
            float rt = 0; // Result.
            const int VectorWidth = 4;
            int nBlockWidth = VectorWidth; // Block width.
            int cntBlock = count / nBlockWidth; // Block count.
            int cntRem = count % nBlockWidth; // Remainder count.
            Vector4 vrt = Vector4.Zero; // Vector result.
            int p; // Index for src data.
            int i;
            // Load.
            Vector4[] vsrc = new Vector4[cntBlock]; // Vector src.
            p = 0;
            for (i = 0; i < vsrc.Length; ++i) {
                vsrc[i] = new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3]);
                p += VectorWidth;
            }
            // Body.
            for (int j = 0; j < loops; ++j) {
                // Vector processs.
                for (i = 0; i < cntBlock; ++i) {
                    // Equivalent to scalar model: rt += src[i];
                    vrt += vsrc[i]; // Add.
                }
                // Remainder processs.
                p = cntBlock * nBlockWidth;
                for (i = 0; i < cntRem; ++i) {
                    rt += src[p + i];
                }
            }
            // Reduce.
            rt += vrt.X + vrt.Y + vrt.Z + vrt.W;
            return rt;
        }

        /// <summary>
        /// Sum - Vector<T>.
        /// </summary>
        /// <param name="src">Soure array.</param>
        /// <param name="count">Soure array count.</param>
        /// <param name="loops">Benchmark loops.</param>
        /// <returns>Return the sum value.</returns>
        private static float SumVectorT(float[] src, int count, int loops) {
            float rt = 0; // Result.
            int VectorWidth = Vector<float>.Count; // Block width.
            int nBlockWidth = VectorWidth; // Block width.
            int cntBlock = count / nBlockWidth; // Block count.
            int cntRem = count % nBlockWidth; // Remainder count.
            Vector<float> vrt = Vector<float>.Zero; // Vector result.
            int p; // Index for src data.
            int i;
            // Load.
            Vector<float>[] vsrc = new Vector<float>[cntBlock]; // Vector src.
            p = 0;
            for (i = 0; i < vsrc.Length; ++i) {
                vsrc[i] = new Vector<float>(src, p);
                p += VectorWidth;
            }
            // Body.
            for (int j = 0; j < loops; ++j) {
                // Vector processs.
                for (i = 0; i < cntBlock; ++i) {
                    vrt += vsrc[i]; // Add.
                }
                // Remainder processs.
                p = cntBlock * nBlockWidth;
                for (i = 0; i < cntRem; ++i) {
                    rt += src[p + i];
                }
            }
            // Reduce.
            for (i = 0; i < VectorWidth; ++i) {
                rt += vrt[i];
            }
            return rt;
        }

    }
}

3.2 在 .NET Core 里进行测试

3.2.1 搭建测试项目(BenchmarkVectorCore20)

虽然从.NET Core 1.0开始就支持了向量类型,但本文考虑到需要与.NET Standard进行对比测试,故选择 .NET Core 2.0 比较好。

在解决方案里建立新项目“BenchmarkVectorCore20”,它是 .NET Core 2.0 控制台程序的项目。并让“BenchmarkVectorCore20”引用共享项目“BenchmarkVector”。

随后我们修改一下 Program 类的代码,加上调用测试函数的代码。代码如下。

using BenchmarkVector;
using System;
using System.IO;
using System.Numerics;

namespace BenchmarkVectorCore20 {
    class Program {
        static void Main(string[] args) {
            string indent = "";
            TextWriter tw = Console.Out;
            tw.WriteLine("BenchmarkVectorCore20");
            tw.WriteLine();
            BenchmarkVectorDemo.OutputEnvironment(tw, indent);
            //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
            tw.WriteLine(indent);
            BenchmarkVectorDemo.Benchmark(tw, indent);
            // Vector<int> a = Vector<int>.One;
            // a <<= 1; // CS0019 Operator '<<=' cannot be applied to operands of type 'Vector<int>' and 'int'
        }
    }
}

注:上面代码还测试了一下 Vector<T> 是否支持移位运算符,发现目前不支持。从 .NET 的发展路线图来看,到了 .NET 7Vector<T>会支持移位运算符。

3.2.2 BenchmarkVectorCore20的测试结果

在我的电脑(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上运行时,输出信息为:

BenchmarkVectorCore20

IsRelease:      True
EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
Environment.ProcessorCount:     8
Environment.Is64BitOperatingSystem:     True
Environment.Is64BitProcess:     True
Environment.OSVersion:  Microsoft Windows NT 10.0.19044.0
Environment.Version:    4.0.30319.42000
RuntimeEnvironment.GetRuntimeDirectory: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\2.0.9\
RuntimeInformation.FrameworkDescription:        .NET Core 4.6.26614.01
BitConverter.IsLittleEndian:    True
IntPtr.Size:    8
Vector.IsHardwareAccelerated:   True
Vector<byte>.Count:     32      # 256bit
Vector<float>.Count:    8       # 256bit
Vector<double>.Count:   4       # 256bit
Vector4.Assembly.CodeBase:      file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
Vector<T>.Assembly.CodeBase:    file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll

Benchmark:      count=4096, loops=1000000, countMFlops=4096
SumBase:        6.871948E+10    # msUsed=4937, MFLOPS/s=829.653635811221
SumVector4:     2.748779E+11    # msUsed=1234, MFLOPS/s=3319.2868719611, scale=4.00081037277147
SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8992

3.3 在 .NET Core 里测试 .NET Standard类库里的测试代码

官方文档上,仅 .NET Standard 2.1 才支持这2种向量类型。而.NET Standard 2.0应用最广泛,该怎么在.NET Standard 2.0上使用它们?

在nuget上找了一下,发现 System.Numerics.Vectors 包提供了这2类向量类型,且它支持 .NET Standard 2.0 平台。可以考虑引用该包。

此时有一个疑问——若引用的是nuget的System.Numerics.Vectors 包,向量类型是否仍会有硬件加速?

我们将建立一个测试程序,来检测这一点。

3.4.1 搭建类库项目(BenchmarkVectorLib)

在解决方案里建立新项目“BenchmarkVectorLib”,它是 .NET Standard 2.0 类库项目。并让“BenchmarkVectorLib”引用共享项目“BenchmarkVector”。

随后建立一个 BenchmarkVectorUtil 类,用于暴露测试函数。代码如下。

using BenchmarkVector;
using System;
using System.IO;

namespace BenchmarkVectorLib {
    /// <summary>
    /// Benchmark Vector Util
    /// </summary>
    public static class BenchmarkVectorUtil {

        /// <summary>
        /// Output Environment.
        /// </summary>
        /// <param name="tw">Output <see cref="TextWriter"/>.</param>
        /// <param name="indent">The indent.</param>
        public static void OutputEnvironment(TextWriter tw, string indent) {
            BenchmarkVectorDemo.OutputEnvironment(tw, indent);
        }

        /// <summary>
        /// Do Benchmark.
        /// </summary>
        /// <param name="tw">Output <see cref="TextWriter"/>.</param>
        /// <param name="indent">The indent.</param>
        public static void Benchmark(TextWriter tw, string indent) {
            BenchmarkVectorDemo.Benchmark(tw, indent);
        }
    }
}

3.4.2 搭建测试项目(BenchmarkVectorCore20UseLib)

在解决方案里建立新项目“BenchmarkVectorCore20UseLib”,它是 .NET Core 2.0 控制台程序的项目。并让“BenchmarkVectorCore20”引用刚才建立的.NET Standard 2.0类库“BenchmarkVectorLib”。

随后我们修改一下 Program 类的代码,加上调用测试函数的代码。代码如下。

using BenchmarkVectorLib;
using System;
using System.IO;
using System.Numerics;

namespace BenchmarkVectorCore20UseLib {
    class Program {
        static void Main(string[] args) {
            string indent = "";
            TextWriter tw = Console.Out;
            tw.WriteLine("BenchmarkVectorCore20UseLib");
            tw.WriteLine();
            BenchmarkVectorUtil.OutputEnvironment(tw, indent);
            //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
            tw.WriteLine(indent);
            BenchmarkVectorUtil.Benchmark(tw, indent);
        }
    }
}

3.4.3 BenchmarkVectorCore20UseLib的测试结果

在我的电脑(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上运行时,输出信息为:

BenchmarkVectorCore20UseLib

IsRelease:      True
EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
Environment.ProcessorCount:     8
Environment.Is64BitOperatingSystem:     True
Environment.Is64BitProcess:     True
Environment.OSVersion:  Microsoft Windows NT 10.0.19044.0
Environment.Version:    4.0.30319.42000
RuntimeEnvironment.GetRuntimeDirectory: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\2.0.9\
RuntimeInformation.FrameworkDescription:        .NET Core 4.6.26614.01
BitConverter.IsLittleEndian:    True
IntPtr.Size:    8
Vector.IsHardwareAccelerated:   True
Vector<byte>.Count:     32      # 256bit
Vector<float>.Count:    8       # 256bit
Vector<double>.Count:   4       # 256bit
Vector4.Assembly.CodeBase:      file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
Vector<T>.Assembly.CodeBase:    file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll

Benchmark:      count=4096, loops=1000000, countMFlops=4096
SumBase:        6.871948E+10    # msUsed=4906, MFLOPS/s=834.896045658377
SumVector4:     2.748779E+11    # msUsed=1219, MFLOPS/s=3360.13125512715, scale=4.02461033634126
SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8496

可以发现该程序测得的浮点性能,与BenchmarkVectorCore20的差不多,表示硬件加速生效了。于是可以解答之前的问题了——

  • 若引用的是nuget的System.Numerics.Vectors 包,向量类型仍会有硬件加速。
  • 若在.NET Standard 2.0 类库里使用了向量类型,那么 .NET Core引用类库时,向量类型仍会有硬件加速。

3.4 在 .NET Framework 里进行测试

官方文档上,.NET Framework 4.6 才支持大小固定的向量(如Vector4),且Vector<T>未提到.NET Framework的支持版本。难道 .NET Framework用不了Vector<T> 吗? .NET Framework 4.5等版本时是否能使用它们?

在nuget上找了一下,发现 System.Numerics.Vectors 包支持.NET Framework,最早能支持 .NET Framework 4.5。

而且 System.Numerics.Vectors 包里提供了这2类向量类型。对比官方文档,此时有这些疑惑——

  • 官方文档的Vector<T>未提到.NET Framework的支持版本,当 .NET Framework 下使用System.Numerics.Vectors 包时,是否有硬件加速?
  • 官方文档里说.NET Framework 4.6才支持大小固定的向量(如Vector4),当 .NET Framework 4.5下使用System.Numerics.Vectors 包时,是否有硬件加速?
  • 官方文档里说.NET Framework 4.6才支持大小固定的向量(如Vector4),当 .NET Framework 4.5下使用System.Numerics.Vectors 包时,Vector4是属于哪个程序集的?

下面的测试程序,将回答以上问题。

3.4.1 搭建4.5的测试项目(BenchmarkVectorFw45)

在解决方案里建立新项目“BenchmarkVectorFw45”,它是 .NET Framework 4.5 控制台程序的项目。并让“BenchmarkVectorFw45”引用共享项目“BenchmarkVector”。

随后我们修改一下 Program 类的代码,加上调用测试函数的代码。代码如下:

using BenchmarkVector;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace BenchmarkVectorFw45 {
    class Program {
        static void Main(string[] args) {
            string indent = "";
            TextWriter tw = Console.Out;
            tw.WriteLine("BenchmarkVectorFw45");
            tw.WriteLine();
            BenchmarkVectorDemo.OutputEnvironment(tw, indent);
            //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
            tw.WriteLine(indent);
            BenchmarkVectorDemo.Benchmark(tw, indent);
        }
    }
}

3.4.2 BenchmarkVectorFw45的测试结果

在我的电脑(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上运行时,输出信息为:

BenchmarkVectorFw45

IsRelease:      True
EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
Environment.ProcessorCount:     8
Environment.Is64BitOperatingSystem:     True
Environment.Is64BitProcess:     True
Environment.OSVersion:  Microsoft Windows NT 6.2.9200.0
Environment.Version:    4.0.30319.42000
RuntimeEnvironment.GetRuntimeDirectory: C:\Windows\Microsoft.NET\Framework64\v4.0.30319\
BitConverter.IsLittleEndian:    True
IntPtr.Size:    8
Vector.IsHardwareAccelerated:   True
Vector<byte>.Count:     32      # 256bit
Vector<float>.Count:    8       # 256bit
Vector<double>.Count:   4       # 256bit
Vector4.Assembly.CodeBase:      file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw45/bin/Release/System.Numerics.Vectors.DLL
Vector<T>.Assembly.CodeBase:    file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw45/bin/Release/System.Numerics.Vectors.DLL

Benchmark:      count=4096, loops=1000000, countMFlops=4096
SumBase:        6.871948E+10    # msUsed=4922, MFLOPS/s=832.182039821211
SumVector4:     2.748779E+11    # msUsed=1235, MFLOPS/s=3316.5991902834, scale=3.98542510121457
SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8752

可以发现该程序测得的浮点性能,与BenchmarkVectorCore20的差不多,表示硬件加速生效了。于是可以解答之前的问题了——

  • 官方文档的Vector<T>未提到.NET Framework的支持版本,当 .NET Framework 下使用System.Numerics.Vectors 包时,仍会有硬件加速。
  • 官方文档里说.NET Framework 4.6才支持大小固定的向量(如Vector4),当 .NET Framework 4.5下使用System.Numerics.Vectors 包时,仍会有硬件加速。

这一点貌似有点奇怪——.NET Framework 4.5 标准库未提供向量类型,靠nuget引用第三方库使用向量类型,却也能得到硬件加速。

其实原因并不复杂,让向量类型获得硬件加速,其实是JIT(即时编译器)的工作。具体来说,是 RyuJIT 让向量类型获得了硬件加速的。

.NET Framework 4.5 标准库未提供向量类型,仅是编译无法通过的问题;通过nuget包,可以引入向量类型,解决了编译问题。随后.NET Framework 4.5程序运行时,若用了RyuJIT且硬件支持SIMD时,程序便能用上硬件加速。

3.4.3 搭建4.6.1的测试项目(BenchmarkVectorFw46)

官方文档里说.NET Framework 4.6才支持大小固定的向量(如Vector4),我们来测试一下吧。随后为了便于与 .NET Standard 2.0类库测试做对比,故选择了 .NET Framework 4.6.1。为了使项目名简单,故项目名为“BenchmarkVectorFw46”。

在解决方案里建立新项目“BenchmarkVectorFw46”,它是 .NET Framework 4.6.1 控制台程序的项目。并让“BenchmarkVectorFw46”引用共享项目“BenchmarkVector”。

随后我们修改一下 Program 类的代码,加上调用测试函数的代码。代码如下:

using BenchmarkVector;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Text;
using System.Threading.Tasks;

namespace BenchmarkVectorFw46 {
    class Program {
        static void Main(string[] args) {
            string indent = "";
            TextWriter tw = Console.Out;
            tw.WriteLine("BenchmarkVectorFw46");
            tw.WriteLine();
            BenchmarkVectorDemo.OutputEnvironment(tw, indent);
            //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
            tw.WriteLine(indent);
            BenchmarkVectorDemo.Benchmark(tw, indent);
        }
    }
}

3.4.4 BenchmarkVectorFw46的测试结果

在我的电脑(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上运行时,输出信息为:

BenchmarkVectorFw46

IsRelease:      True
EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
Environment.ProcessorCount:     8
Environment.Is64BitOperatingSystem:     True
Environment.Is64BitProcess:     True
Environment.OSVersion:  Microsoft Windows NT 6.2.9200.0
Environment.Version:    4.0.30319.42000
RuntimeEnvironment.GetRuntimeDirectory: C:\Windows\Microsoft.NET\Framework64\v4.0.30319\
BitConverter.IsLittleEndian:    True
IntPtr.Size:    8
Vector.IsHardwareAccelerated:   True
Vector<byte>.Count:     32      # 256bit
Vector<float>.Count:    8       # 256bit
Vector<double>.Count:   4       # 256bit
Vector4.Assembly.CodeBase:      file:///C:/WINDOWS/Microsoft.Net/assembly/GAC_MSIL/System.Numerics/v4.0_4.0.0.0__b77a5c561934e089/System.Numerics.dll
Vector<T>.Assembly.CodeBase:    file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw46/bin/Release/System.Numerics.Vectors.DLL

Benchmark:      count=4096, loops=1000000, countMFlops=4096
SumBase:        6.871948E+10    # msUsed=4922, MFLOPS/s=832.182039821211
SumVector4:     2.748779E+11    # msUsed=1218, MFLOPS/s=3362.88998357964, scale=4.04105090311987
SumVectorT:     5.497558E+11    # msUsed=609, MFLOPS/s=6725.77996715928, scale=8.08210180623974

可以发现该程序测得的浮点性能,与BenchmarkVectorCore20、BenchmarkVectorFw45的差不多,表示硬件加速生效了。

还可发现 Vector4 与 Vector<T> 的程序集不同,Vector4的程序集在系统目录,而Vector<T>的程序集在程序目录。

这表示官方文档里说的“.NET Framework 4.6才支持大小固定的向量(如Vector4)”,原来是这样的——.NET Framework 4.6内置支持大小固定的向量(如Vector4),于是它们的程序集在系统目录;而 Vector<T> 不是内置支持,是引用nuget包,于是程序集在程序目录。

查了一下资料,.NET Framework 4.6 宣称不再使用已使用10年的JIT64,换成 RyuJIT x64。这可能就是 .NET Framework 4.6 官方文档说支持支持大小固定的向量(如Vector4)的原因。

而对于 Vector<T>,可能是因为它的最新版设计为“只读结构体”(readonly struct)、且很多方法依赖 Span。只读结构体是 C# 7.2、VS2017.4 才支持的功能,比.NET Framework 4.6晚好几年,那时微软已宣布不再继续发展.NET Framework,转为统一的 .NET了。这可能就是 .NET Framework 里不包含 Vector<T> 的原因。

但由于 RyuJIT 是支持 Vector<T> 的,于是引用 nuget 包后,就能通过Vector<T>使用硬件加速了。

3.5 在 .NET Framework 里测试 .NET Standard类库里的测试代码

现在我们来试试,在 .NET Framework 里测试 .NET Standard类库里的测试代码。

先前我们建立了 .NET Standard 2.0类库“BenchmarkVectorLib”,现在可以建立一个.NET Framework 控制台程序引用它,进行测试。

因 .NET Framework 4.6.1 是支持 .NET Standard 2.0 的最低版本。于是测试程序选择了 .NET Framework 4.6.1。

3.5.1 搭建类库测试项目(BenchmarkVectorFw46UseLib)

在解决方案里建立新项目“BenchmarkVectorFw46UseLib”,它是 .NET Framework 4.6.1 控制台程序的项目。并让“BenchmarkVectorFw46UseLib”引用.NET Standard 2.0类库“BenchmarkVectorLib”。

随后我们修改一下 Program 类的代码,加上调用测试函数的代码。代码如下:

using BenchmarkVectorLib;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace BenchmarkVectorFw46UseLib {
    class Program {
        static void Main(string[] args) {
            string indent = "";
            TextWriter tw = Console.Out;
            tw.WriteLine("BenchmarkVectorFw46UseLib");
            tw.WriteLine();
            BenchmarkVectorUtil.OutputEnvironment(tw, indent);
            tw.WriteLine(indent);
            BenchmarkVectorUtil.Benchmark(tw, indent);
        }
    }
}

3.5.2 的测试结果

在我的电脑(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上运行时,输出信息为:

BenchmarkVectorFw46UseLib

IsRelease:      True
EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
Environment.ProcessorCount:     8
Environment.Is64BitOperatingSystem:     True
Environment.Is64BitProcess:     True
Environment.OSVersion:  Microsoft Windows NT 6.2.9200.0
Environment.Version:    4.0.30319.42000
RuntimeEnvironment.GetRuntimeDirectory: C:\Windows\Microsoft.NET\Framework64\v4.0.30319\
RuntimeInformation.FrameworkDescription:        .NET Framework 4.8.4515.0
BitConverter.IsLittleEndian:    True
IntPtr.Size:    8
Vector.IsHardwareAccelerated:   True
Vector<byte>.Count:     32      # 256bit
Vector<float>.Count:    8       # 256bit
Vector<double>.Count:   4       # 256bit
Vector4.Assembly.CodeBase:      file:///C:/WINDOWS/Microsoft.Net/assembly/GAC_MSIL/System.Numerics/v4.0_4.0.0.0__b77a5c561934e089/System.Numerics.dll
Vector<T>.Assembly.CodeBase:    file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw46UseLib/bin/Release/System.Numerics.Vectors.DLL

Benchmark:      count=4096, loops=1000000, countMFlops=4096
SumBase:        6.871948E+10    # msUsed=4922, MFLOPS/s=832.182039821211
SumVector4:     2.748779E+11    # msUsed=1234, MFLOPS/s=3319.2868719611, scale=3.98865478119935
SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8752

可以发现该程序测得的浮点性能,与BenchmarkVectorFw46的差不多,表示硬件加速生效了。

四、测试数据分析

4.1 测试数据

测试环境统一是 lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10。CPU硬件支持AVX2指令集,Vector<T> 理论上能同时处理4个 float。

用上面的测试程序,补上一些 Debug/Release、x86/x64 情况时的测试数据,再加上 .NET Core 3.0的测试数据(下一篇文章会详细说明),可汇总为一个表。为了便于阅读,省略了MFLOPS的小数,且scale保留3位小数,表格如下:

程序及配置加速SumBaseSumVector4SumVectorT
BenchmarkVectorCore20, Debug, x64true368946, scale=2.5701659, scale=4.506
BenchmarkVectorCore20, Release, x64true8293319, scale=4.0016554, scale=7.899
BenchmarkVectorCore20UseLib, Release, x64true8343360, scale=4.0256554, scale=7.850
BenchmarkVectorFw45, Release, x64true8323316, scale=3.9856554, scale=7.875
BenchmarkVectorFw45, Release, x86false1111883, scale=0.795213, scale=0.192
BenchmarkVectorFw46, Release, x86false1101880, scale=0.799214, scale=0.194
BenchmarkVectorFw46, Release, x64true8323363, scale=4.0416726, scale=8.082
BenchmarkVectorFw46UseLib, Release, x64true8323319, scale=3.9896554, scale=7.875
BenchmarkVectorFw46UseLib, Release, x86false1111883, scale=0.794165, scale=0.149
BenchmarkVectorCore30, Debug, x86true368822, scale=2.2311560, scale=4.238
BenchmarkVectorCore30, Debug, x64true370946, scale=2.5591659, scale=4.487
BenchmarkVectorCore30, Release, x86true8351481, scale=1.7742648, scale=3.171
BenchmarkVectorCore30, Release, x64true8293363, scale=4.0546726, scale=8.108

注:“加速”指硬件加速。

从该表中可以看出——

  • 以Debug方式编译时,有时也能获得硬件加速,只是速度要慢一些,且提速比(scale)比理论值要低一些。例如 SumVector4 只能达到2.5倍左右,SumVectorT只能达到4倍左右。
  • 当不支持硬件加速时,使用向量类型的算法仍能运行,只是性能比不上基础算法。例如 SumVector4 只有基础算法的0.8倍左右,有少量衰减;而SumVectorT衰减的很厉害,不足基础算法的0.2倍,既不足1/5。故在使用 Vector<T> 前,一定要检查是否支持硬件加速(Vector.IsHardwareAccelerated).
  • .NET Framework 在x86(32位)下不支持硬件加速,应该是因为它的x86 JIT用的还是旧版,并不是 RyuJIT。这个x86 JIT虽然不支持向量类型的硬件加速,但它经过多年改进,对基础算法的优化很好,能达到 “1111 MFLOPS/s”,比起 RyuJIT x64下测得的最高值“834 MFLOPS/s”,性能要高一些。
  • 到了 .NET Core 3.0 时代,RyuJIT支持了 x86(32位),所以 .NET Core 3.0 的 x86程序也能使用硬件加速。只是目前优化的还不够好,没达到理论值。
  • Release, x64 方式编译的程序,均能使用硬件加速,且与理论值接近。SumVector4的性能约是基础算法的4倍,SumVectorT的性能约是基础算法的8倍。
  • 在.NET Standard 2.0类库里运行的向量类型操作代码,与控制台程序里直接运行的向量类型操作代码,性能差不多。于是可以放心大胆的在.NET Standard 2.0类库里使用向量类型。

4.2 最佳实践

最核心的使用经验就2条——

  1. 若仅需要使用单精度浮点类型(float),且是开发数学上的向量运算相关的功能,可根据业务上对向量运算的要求,使用维度匹配的向量类(例如 2维向量处理时用Vector2、3维向量处理时用Vector3、3维齐次向量处理时用Vector4)。其他情况下,至少应编写一套传统的、不使用向量类型的代码。
  2. 若某项计算任务需要进一步做性能优化、且它的工作比较适合SIMD处理时,可以再开发一套基于 Vector<T> 的向量代码。在使用时别忘了检查是否支持硬件加速,若不支持,应退回到使用传统代码。

采用以上策略,对于一项计算任务,最多只需开发2套代码(数学向量/传统、Vector<T>)就行。

编译选项里的CPU平台,选“Any CPU”就行了。因为向量类型的硬件加速是由JIT处理的。当编译好的程序在 x86、x64等平台下运行时,JIT会使用该平台的向量硬件加速。

当然,若业务需要,也可以固定选择 x86、x64等平台。

4.3 源码地址

源码地址——

https://github.com/zyl910/BenchmarkVector/tree/main/BenchmarkVector1

参考文献