万有斥力 发表于 2022-9-16 17:18:29

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

作者:
目录

[*]一、缘由
[*]二、使用向量类型

[*]2.1 基本算法
[*]2.2 使用大小固定的向量(如 Vector4)

[*]2.2.1 介绍
[*]2.2.2 用Vector4编写浮点数组求和函数

[*]2.3 使用大小与硬件相关的向量(如 Vector)

[*]2.3.1 介绍

[*]2.2.1.1 使用经验

[*]2.3.2 用 Vector 编写浮点数组求和函数


[*]三、搭建测试程序

[*]3.1 主测试代码(BenchmarkVectorDemo)

[*]3.1.1 测试方法(Benchmark)
[*]3.1.2 输出环境信息(OutputEnvironment)
[*]3.1.3 汇总

[*]3.2 在 .NET Core 里进行测试

[*]3.2.1 搭建测试项目(BenchmarkVectorCore20)
[*]3.2.2 BenchmarkVectorCore20的测试结果

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

[*]3.4.1 搭建类库项目(BenchmarkVectorLib)
[*]3.4.2 搭建测试项目(BenchmarkVectorCore20UseLib)
[*]3.4.3 BenchmarkVectorCore20UseLib的测试结果

[*]3.4 在 .NET Framework 里进行测试

[*]3.4.1 搭建4.5的测试项目(BenchmarkVectorFw45)
[*]3.4.2 BenchmarkVectorFw45的测试结果
[*]3.4.3 搭建4.6.1的测试项目(BenchmarkVectorFw46)
[*]3.4.4 BenchmarkVectorFw46的测试结果

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

[*]3.5.1 搭建类库测试项目(BenchmarkVectorFw46UseLib)
[*]3.5.2 的测试结果


[*]四、测试数据分析

[*]4.1 测试数据
[*]4.2 最佳实践
[*]4.3 源码地址

[*]参考文献

目录
一、缘由

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

[*]大小固定的向量(Vectors with a fixed size)。例如 结构体(struct) Vector2、Vector3、Vector4。
[*]大小与硬件相关的向量(Vectors with a hardware dependent size)。例如 只读结构体(readonly struct) Vector,及辅助的静态类 Vector。
到了 .NET Core 3.0,增加了内在函数(Intrinsics Functions)的支持,并增加了第3类向量类型——
3. 总位宽固定的向量(Vector of fixed total bit width)。例如 只读结构体 Vector64、Vector128、Vector256,及辅助的静态类 Vector64、Vector128、Vector256。
这3类向量类型,均能利用CPU硬件的SIMD(float Instruction Multiple Data,单指令多数据流)功能,来加速多媒体数据的处理。但是它们名称很接近,对于初学者来说容易混淆,而且应用场景稍有区别,本文致力于解决这些问题。
本章重点解说前2种向量类型(Vector4、Vector),第3种向量类型将由第2章来解说。
本章回答了这些问题——

[*]怎样使用这2种向量类型?以做浮点数组求和运算为例。
[*]这2种向量类型的使用场景,及最佳实践是怎样的?
[*]我们的普通PC机的浮点运算性能,能达到每秒多少 MFLOPS(百万次浮点运算)?
[*]官方文档上,.NET Framework 4.6 才支持大小固定的向量(如Vector4),且Vector未提到.NET Framework的支持版本。难道 .NET Framework用不了Vector 吗? .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加速——

[*]数据量大(至少超过1000)且连续的存放在内存里。若数据规模小,SIMD无法体现性能优势;若数据不是连续存放,那么会遇到内存传输率的瓶颈,无法发挥SIMD的实力。
[*]每个元素的处理运算需比较简单。因为SIMD的函数,只能处理简单的数学函数。
[*]每个元素的处理步骤,大致相同。当每个元素的处理运算相同时,便能一个命令同时处理多条数据。当存在差异时,便需要利用掩码与位运算,分别进行处理。当差异很大时,甚至向量代码比起标量代码,没有优势。
[*]元素的数据类型,必须是.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;
    }
    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;
      }
    }
    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; // Vector src.
    p = 0;
    for (i = 0; i < vsrc.Length; ++i) {
      vsrc = new Vector4(src, src, src, src);
      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;
            vrt += vsrc; // Add.
      }
      // Remainder processs.
      p = cntBlock * nBlockWidth;
      for (i = 0; i < cntRem; ++i) {
            rt += src;
      }
    }外循环loops的作用仅是为了方便测试,关键代码在2个内循环里:

[*]Vector processs(向量处理):以块为单位进行循环处理,利用 Vector4 有4个元素特点,进行4路并发加法,将 vsrc 的值,加到 vrt 里。vrt是Vector4类型的变量,定义时已初始化为0。
[*]Remainder processs(剩余数据处理):先计算一下剩余数据的起始索引(p = cntBlock * nBlockWidth),然后使用传统循环写法,将剩余数据累积到 rt 里。
由于Vector4重载了“+”运算法,所以可以很简单的使用“+=”运算符来做“相加并赋值”操作。代码写法,与传统的标量代码很相似,代码可读性高。
rt += src; // 标量代码.
vrt += vsrc; // 向量代码.最后我们需要将各组的结果加在一起(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; // Vector src.
    p = 0;
    for (i = 0; i < vsrc.Length; ++i) {
      vsrc = new Vector4(src, src, src, src);
      p += VectorWidth;
    }    // Body.
    for (int j = 0; j < loops; ++j) {
      // Vector processs.
      for (i = 0; i < cntBlock; ++i) {
            // Equivalent to scalar model: rt += src;
            vrt += vsrc; // Add.
      }
      // Remainder processs.
      p = cntBlock * nBlockWidth;
      for (i = 0; i < cntRem; ++i) {
            rt += src;
      }
    }    // Reduce.
    rt += vrt.X + vrt.Y + vrt.Z + vrt.W;
    return rt;}2.3 使用大小与硬件相关的向量(如 Vector)

2.3.1 介绍

Vector4的痛点是——元素类型固定为float,且仅有4个元素。导致它的使用范围有限。
而 Vector 解决了这2大痛点——

[*]它具有泛型参数T,可以支持各种数值型的基元类型,如 float、double、int 等。
[*]它的元素个数不止4个,而是由硬件决定的。若硬件支持向量位宽越宽,那么Vector的元素个数便越大。使用Vector在各种向量位宽的硬件上运行时,会以最大向量位宽来运行,而仅需只编写一套代码。
以下是官方文档对 Vector 的介绍。
`Vector<T>` 是一个不可变结构,表示指定数值类型的单个向量。 实例计数是固定的 `Vector<T>` ,但其上限取决于 CPU 寄存器。 它旨在用作向量大型算法的构建基块,因此不能直接用作任意长度向量或张量。
该 `Vector<T>` 结构为硬件加速提供支持。
本文中的术语 基元数值数据类型 是指 CPU 直接支持的数值数据类型,并具有可以操作这些数据类型的说明。 下表显示了哪些基元数值数据类型和操作组合使用内部指令来加快执行速度:基元类型+-*/sbyte是是否否byte是是否否short是是是否ushort是是否否int是是是否uint是是否否long是是否否ulong是是否否float是是是是double是是是是2.2.1.1 使用经验

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

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

[*]Count:【静态】返回存储在向量中的元素数量。
[*]Item:获取指定索引处的元素。
[*]One:【静态】返回一个包含所有 1 的向量。
[*]Zero:【静态】返回一个包含所有 0 的向量。
因为 Vector 长度是与硬件有关的,所以每次在使用 Vector 时,别忘了需要先从 Count 属性里的到元素数量。
一般来说——

[*]若CPU是 x86体系的,且支持 AVX2指令集 时,那么 Vector 长度为256位,即32字节。此时能并行的处理 32个byte,或 16个short、8个int、4个long、8个float、4个double。
[*]若CPU是 x86体系的,不支持AVX2指令集,但支持 SSE2指令集 时,那么 Vector 长度为128位,即16字节。此时能并行的处理 16个byte,或 8个short、4个int、2个long、4个float、2个double。
[*]若CPU不支持向量硬件加速时,那么 Vector 长度仍为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 = 22.3.2 用 Vector 编写浮点数组求和函数

现在,我们使用 Vector,来编写浮点数组求和函数。
思路:先使用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>; // Vector src.
    p = 0;
    for (i = 0; i < vsrc.Length; ++i) {
      vsrc = 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; // Add.
      }
      // Remainder processs.
      p = cntBlock * nBlockWidth;
      for (i = 0; i < cntRem; ++i) {
            rt += src;
      }
    }
    // Reduce.
    for (i = 0; i < VectorWidth; ++i) {
      rt += vrt;
    }
    return rt;
}对比 SumVector4,除了将 Vector4 类型换为 Vector,还有这些变化——

[*]VectorWidth不再是一个固定常数,而是通过 Vector.Count 属性来得到。
[*]Vector 的构造函数支持数组参数。于是可以用 new Vector(src, p),代替繁琐的 new Vector4(src, src, src, src)。
[*]Vector支持索引器(文档里的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;
    for(int i=0; i< count; ++i) {
      src = 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 并有硬件加速时,能达到 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 是否支持硬件加速。
[*]Vector.Count:   Vector的元素个数、总位数。
[*]Vector.Count:    Vector的元素个数、总位数。
[*]Vector.Count:   Vector的元素个数、总位数。
[*]Vector4.Assembly.CodeBase:      Vector4 所属程序集的路径。
[*]Vector.Assembly.CodeBase:    Vector 所属程序集的路径。
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;
            for(int i=0; i< count; ++i) {
                src = 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;
                }
            }
            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; // Vector src.
            p = 0;
            for (i = 0; i < vsrc.Length; ++i) {
                vsrc = new Vector4(src, src, src, src);
                p += VectorWidth;
            }
            // Body.
            for (int j = 0; j < loops; ++j) {
                // Vector processs.
                for (i = 0; i < cntBlock; ++i) {
                  // Equivalent to scalar model: rt += src;
                  vrt += vsrc; // Add.
                }
                // Remainder processs.
                p = cntBlock * nBlockWidth;
                for (i = 0; i < cntRem; ++i) {
                  rt += src;
                }
            }
            // 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>; // Vector src.
            p = 0;
            for (i = 0; i < vsrc.Length; ++i) {
                vsrc = 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; // Add.
                }
                // Remainder processs.
                p = cntBlock * nBlockWidth;
                for (i = 0; i < cntRem; ++i) {
                  rt += src;
                }
            }
            // Reduce.
            for (i = 0; i < VectorWidth; ++i) {
                rt += vrt;
            }
            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 a = Vector.One;            // a
页: [1]
查看完整版本: C# 使用SIMD向量类型加速浮点数组求和运算(1):使用Vector4、Vector<T>