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

🚀 优质资源分享 🚀

学习路线指引(点击解锁) 知识定位 人群定位
🧡 Python实战微信订餐小程序 🧡 进阶级 本课程是python flask+微信小程序的完美结合,从项目搭建到腾讯云部署上线,打造一个全栈订餐系统。
💛Python量化交易实战💛 入门级 手把手带你打造一个易扩展、更安全、效率更高的量化交易系统

作者:

目录* 一、缘由

目录

一、缘由

从.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,及辅助的静态类 Vector。

到了 .NET Core 3.0,增加了内在函数(Intrinsics Functions)的支持,并增加了第3类向量类型——
3. 总位宽固定的向量(Vector of fixed total bit width)。例如 只读结构体 Vector64Vector128Vector256,及辅助的静态类 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加速——

  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参数。

/// 
/// Sum - Vector4.
/// 
/// Soure array.
/// Soure array count.
/// Benchmark loops.
/// Return the sum value.
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;
    fo
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值