编程语言
首页 > 编程语言> > SIMD via C#

SIMD via C#

作者:互联网

简介 TL;DR

我们为C#(准确地说是.NET Core)引入了一套全新的机制,使得C# 以后可以像C/C++ 一样直接使用intrinsic functions 来直接操作Intel CPU 的大多数SIMD 指令了(从SSE 到AVX2)。

(注意是以后!这个项目还没有完成!)

Vectors in .NET

在最开始我想先说一说SIMD 编程在C#/.NET 中的现状,以及为什么我们要引入这套全新的intrinsic。

微软在之前的.NET Framework 和.NET Core 中引入了一个新的库: System.Numerics.Vectors ,其中包含几个重要的值类型(Vector<T>, Vector2, Vector3, 等等)和操作它们的一些静态方法。程序员可以用这个库在.NET 环境中编写SIMD 程序。以下我假定大家都大概知道SIMD 编程的概念,来具体讲讲这个库 的设计与实现。


System.Numerics.Vectors 库中的这些静态方法的实际功能不能用C# 等.NET managed language 直接写出来(虽然它们都有一份C# 的实现),而是由编译器特殊对待从而生成特殊代码(SSE, AVX, AVX2, 等指令集的指令),我们称这些方法(函数)为intrinsic。这些intrinsic 大部分都操作在上面说到的这些值类型上(Vector<T>, Vector2, Vector3, 等等),这些类型的实例也会被编译器特殊对待。其中最主要的是Vector<T>,这个类型的设计不同于传统C/C++ intrinsic 中的vector 类型:

  1. 泛型:.NET 中的这个vector类型采用了泛型设计,泛型Vector<T>的类型参数只接受numeric types,即C# 的基础数字类型(byte, sbyte, short, ushort, long, ulong, float, double)。如果试图创建一个Vector<UserDefinedStruct>的实例,运行时会抛出异常(一大波来自Haskell 的鄙视正在路上……)。

  2. 长度可变(length-agnostic):大家都知道随着微处理器历史的发展SIMD 计算单元和寄存器的长度也在不断地进化,Intel 从最初MMX 的64-bit 寄存器到后来SSE 系列128-bit 寄存器,再到AVX 扩展为256-bit,最新的AVX-512 已经有了512-bit 的SIMD 寄存器。C/C++ intrinsic 使用不同长度的vector 类型来抽象这些SIMD 寄存器,比如__m128, __m256d。然而借助.NET 的JIT 编译,Vector<T> 的长度可以随着程序运行的硬件环境的不同而改变,例如一个使用了System.Numerics.Vectors 来加速的程序在Sandy Bridge 等稍微老一点的CPU 上看到Vector<byte> 的长度为16,而同一个程序运行在Haswell 以上的新CPU 上看到的Vector<byte> 的长度为32,但程序行为保持不变,并且开发者也不需要重新编译他们的源码就可以得到更多的提速。这个设计乍一看起来非常酷,但是也为这个库的命运埋下了巨大的隐患。

System.Numerics.Vectors 的缺陷

System.Numerics.Vectors 库的设计初衷是要做一个跨平台的通用的SIMD 编程库。可以看出它的最终目标是要在统一的API 下支持不同的硬件指令集(SSE, AVX, NEON, etc.),虽然现在只做了x86/x64 平台的支持,但一些设计缺陷已经暴露出来了。

  1. 当『通用』成为设计目的时,『可用』成了重中之重。众所周知,SIMD 编程或者叫向量化编程相对来说是比较困难的,当一个程序想使用SIMD 来加速时开发者关注的第一点肯定是『性能』。然而这个『通用』和『可用』的设计目的并不能保证『性能』。举个最简单的例子,不同硬件提供的指令集一般在功能上是不会完全重合的,当一个指令在Intel CPU 上存在而在ARM CPU 上不存在的时候,通用SIMD 库就要想办法绕着弯来在不直接提供支持的硬件上实现这个API。然而这个『弯儿』一旦开始绕了,性能提升就不能保证了(在一些极端情况下不绕弯都不能保证)。试想一个程序员发现一个函数foo在他的程序中调用非常频繁,并且可以被向量化,于是欣喜地使用Vectors<T> 重写了。然后他发现整个程序在他装备了Skylake CPU的 Macbook Pro 上性能提升了50%,但在发布新版本几天后所有ARM 用户全来骂娘了(这只是个例子,性能退化在所有硬件平台之间都有可能出现,不是针对某些硬件架构)。以下列出的其他缺陷都或多或少来自这一条设计原则。

  2. 可变长度的Vector<T> 上无法抽象某些硬件指令的语义。比如很重要的shuffle 这族指令就没法抽象到变长Vector<T>, Github 已经有人多次要求提供这些API,但最终都没有很好的解决方案。再比如,对于AVX/AVX2 来说,很多时候我们需要同时操作YMM 和XMM 寄存器,但这在Vector<T> 的设计中不被允许。

  3. System.Numerics.Vectors 中的类型和函数在JIT 编译器不支持生成SIMD 指令的环境下会退回到C# 的软件实现。这点对性能是很致命的,尤其是有些时候这种『不支持生成SIMD 指令的环境』是不可避免的,比如反射调用。

  4. 还有很多细节性的缺点我就不一一列举了,比较这篇文章重点不在System.Numerics.Vectors。有兴趣的同学可以去CoreCLR 和CoreFX 的GitHub repo 翻一翻相关的issue。

Intel Hardware Intrinsic

说了那么多终于进入正题了。为了探索一个新的SIMD 方案,我代表牙膏厂为.NET 提供了API Proposal: Add Intel hardware intrinsic functions and namespace #22940。总体的设计都在这份API proposal 里了,我简单总结一下:

  1. 加入两个新的namespace:System.Runtime.Intrinsics和System.Runtime.Intrinsics.X86。其中System.Runtime.Intrinsics只包含跨平台类型,目前有两个新的值类型Vector128<T>和Vector256<T> 来抽象SIMD 寄存器。每个硬件平台提供各自平台相关的类型和方法用来操作Vector128<T>和Vector256<T>,比如x86 平台的所有intrinsic 都在System.Runtime.Intrinsics.X86namespace 下,它提供了在managed language 中直接访问以下指令集的能力:SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2, AVX, AVX2, FMA, AES, BMI1, BMI2, LZCNT, POPCNT, PCLMULQDQ。

  2. 每一个指令集封装成一个static class(例如Sse,Aes, Avx2, 等.),每个class 都有一个IsSupported 方法用来检测当前硬件,从而为不同的硬件提供不同的优化方案。


    if (Avx2.IsSupported){// 为AVX2 CPU 优化的算法  }else if (Sse41.IsSupported){// 为SSE4.1 CPU 优化的算法}else if (Neon.IsSupported){// 为ARM NEON CPU 优化的算法}else{// software-fallback}
  3. 要求一个新的C# 语言特性,const 参数。因为Intel hardware intrinsic 直接通过C# 代码来控制最终的代码生成,而一些SIMD 指令明确要求立即数操作数。比如shufpd 对应的C# intrinsic 是

    1
    public static Vector128<double> Shuffle(Vector128<double> left, Vector128<double> right, byte control);

    参数control 对应shufpd 的imm8 操作数,它必须是编译时确定的,如果用户传入一个『变量』可能导致程序无法编译。所以我们向C# 语言特性的开发组请求了一个新的语言特性:将const 关键字用于方法的形式参数。最终Shuffle 的方法签名为:

    1
    public static Vector128<double> Shuffle(Vector128<double> left, Vector128<double> right, const byte control);

    这样C# 编译器(Roslyn)就只允许byte 字面量值流入control 参数。

  4. Intel hardware intrinsic 在.NET Core 中所有环境下都会被编译为直接对应的硬指令,比如JIT编译、AOT编译(Crossgen)、MSCorlib 内部调用(比如用来优化String)、Debugging 调用、反射调用等等。而相对的System.Numerics.Vectors 只能在第三方JIT 编译的普通调用中才会生成SIMD 指令。


具体的API 请移步 https://github.com/dotnet/coreclr/tree/master/src/mscorlib/src/System/Runtime/Intrinsics

.NET Managed Intrinsic 与C/C++ Native Intrinsic

如果有SIMD 编程经验的读者看到这里一定会觉得我们做的这套新的intrinsic 和Intel C/C++ intrinsic 很相似。对,这套新的hardware intrinsic 是比原先System.Numerics.Vectors 更偏底层的一套intrinsic 机制,我们希望可以通过managed language (目前只有C#)来直接对应编译器的代码生成。然而,他还是有一些区别于C/C++ intrinsic 的地方。

小福利

能看到这儿还没有关掉文章的一定是对SIMD 计算和编译器实现都很有兴趣的同学,那我顺便放点编译器实现的细节在这作为坚持到最后的奖励。
如果你点进了我上面给出的API 连接就会发现,所有的hardware intrinsic 有一个C# 的实现:



/// <summary>/// __m256 _mm256_add_ps (__m256 a, __m256 b)/// </summary>public static Vector256<float> Add(Vector256<float> left, Vector256<float> right) => Add(left, right);/// <summary>/// __m256d _mm256_add_pd (__m256d a, __m256d b)/// </summary>public static Vector256<double> Add(Vector256<double> left, Vector256<double> right) => Add(left, right);



每个intrinsic 在C# API 中都是一个直接递归方法。这是为什么呢?
原因是我们需要intrinsic 在某些环境下既是intrinsic 又是function(method)。
首先我们可以将在intrinsic 理解为必须内联的函数(方法),对它的调用会被直接替换为一条或多条汇编指令,而不遵循普通函数/方法的调用约定(calling convention)。然而这一定义在某些情况下是无法工作的,比如deugger 和反射。例如.NET 在反射机制中提供了『方法调用』却没有提供『intrinsic调用』,那么typeof(Avx).GetMethod("Add").Invoke(null, args) 是无法工作的。但是我们可以这么做:

  1. 在某些环境中编译器看到用户调用Avx.Add(a, b) 时不对其进行特殊处理,而只当成是普通的函数调用。

  2. 编译器如果看到Avx.Add(a, b) 是被自身调用的(递归),则强制将其编译为相应的汇编指令。

这样,我们就完美解决了intrinsic 既是intrinsic (递归调用)又是function(用户调用)的问题。

最后

如果大家对这项功能感兴趣,我会在这里持续更新项目进展,也请大家耐心等候!

[object Object]


标签:via,C#,编译,编译器,SIMD,NET,intrinsic
来源: https://blog.51cto.com/u_15127577/2726991