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 是

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

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

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 请移步 github.com/dotnet/corec

.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 的地方。

  • .NET Core 的JIT 编译为hardware intrinsic 的使用和实现提供了更大的便利。因为C/C++ 都是AOT 编译的,所以一般在编译SIMD 程序时开发者需要选用不同的编译器选项来编译多分二进制分发文件来保证各个在硬件平台都达到最优性能。然而JIT 编译就不会有这份顾虑,JIT 编译器会在启动前自动探知当前的硬件参数,来自动生成最有性能的代码。也许有人会说.NET Core 也有AOT 编译啊!可是.NET Core 的AOT 编译器(Crossgen)依然可以从JIT 编译器中获利,比如我们可以AOT 编译一个程序的大部分,但留下硬件相关的代码,待到运行时再JIT 编译这些代码(intrinsic)然后动态插入到原先AOT 编译好的程序中。
  • 当然.NET Core 的hardware intrinsic 相比C/C++ 也有劣势。一般SIMD 计算对内存数据都有对齐要求,CoreCLR 却没有提供完整的对齐内存的接口给用户。但是这一点可以通过unsafe 代码(目前所有和内存交互的intrinsic 都是操作指针)和后续的值类型对齐机制来逐渐解决。还有一点就是managed language 对底层硬件的控制不如native language 灵活。举个例子,在C/C++ 中我们可以这么写代码来节省Load和Store:
// __m256 a, float* b
__m256* v = (__m256*)b;
__m256 result = _mm256_add_ps(a, *v); // vaddps ymm0, ymm0, ymmword ptr [rdi]

上面这段两行代码可以只生成一条memory-flavor 的指令,但在C# 中我们不能持有一个泛型struct 的指针,所以我们必须写成:

// Vector<float> a, float* b
Vector<float> v = Avx.Load(b);
Vector<float> result = Avx.Add(a, v);

直觉上这个程序是两条指令,但可以被编译器优化折叠为和上面C/C++ 程序一样的编译结果。

小福利

能看到这儿还没有关掉文章的一定是对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(用户调用)的问题。

最后

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


本文同步发布在:fiigii.com

文章被以下专栏收录
17 条评论
推荐阅读