Nugine 的个人博客关于

关于可移植 SIMD 库的设计

SIMD 全称为 Single Instruction Multiple Data,即单指令多数据。支持 SIMD 的处理器会提供宽度为多倍字长的寄存器组和相应的向量指令,包括位运算、算术运算、融合算术运算、存取、排列、查表、聚合等操作。将通用算法改成利用 SIMD 的特殊算法通常可以获得非常大的性能提升。

然而,不是所有指令集和处理器都支持 SIMD。旧处理器不支持新的指令,不同架构的处理器具有不同的向量长度,支持的SIMD操作也不一样。相比通用计算,编写 SIMD算法需要处理这些不一致性,给开发者带来了很大麻烦。

具体来说,SIMD在各大平台上的不一致性有三点。

第一,编译器绑定不一致。编译器为各指令集提供了内置函数与数据类型绑定,基本上可以直接对应到机器指令。清晰是清晰,但换个编译目标就不能用了。编译器自动向量化局限性很大,有经验的开发者能在 SIMD 上轻松吊打编译器。

第二,指令集版本不一致。以英特尔为例,SSE系列有SSE2、SSE3、SSE4等,AVX系列有AVX、AVX2、AVX512等,此处列举并不完全。编译好的代码可能在新的处理器上能跑,在稍微老一些的处理器上直接报非法指令。

第三,向量长度不一致。

64 位的有英特尔的 MMX。

最广泛的是 128 位,代表选手是英特尔的 SSE 和 ARM 的 NEON,还有 WASM 的 SIMD128。至于 MIPS,各位可以抽空去祭奠一下。

英特尔分别在 AVX2 和AVX512 中给了 256 位和 512 位长度。

ARM 的 SVE 和 RISC-V 的 RVV 支持可变向量长度,SVE 的向量最大可达 2048 位,RVV 能自由划分寄存器空间。

八仙过海,各显神通,开发者可就倒了大霉。有些向量化算法对向量长度敏感,有些不敏感,最佳选择显然是当前处理器支持的最大长度,但想让同一套代码在不同向量长度下工作可没那么简单。

不一致怎么办?当然是抽象,没有什么是加个中间层不能解决的,如果有,就加两层。

对于第一点,我们可以找出各指令集所支持操作的交集,定义为一种虚拟指令集,基于这个虚拟指令集来编写算法。

或者定义泛型向量结构 Simd<T, N>,为不同的特化去移植,本质上与前者一样。LLVM 对向量操作有支持,如果有相应的编译通路,这种极度重复的移植就可以省掉。

看起来非常好,但实际效果取决于这个交集有多大。交集太小——啥都写不了,交集太大——部分操作无法保证性能。

对于第二点,我们有条件编译和动态检测。条件编译是仅当开启了对应指令集才会编译,而在动态检测中,加速函数会被强制编译到对应指令集,当程序通过cpuid或操作系统检测到处理器支持对应特性时,才能调用加速函数,否则会报非法指令挂掉。

这里要注意的是,就算处理器支持,只要操作系统不保护向量寄存器,用户态程序还是不能擅自用 SIMD。

应用动态检测时,同一份函数可能需要为不同指令集编译多次,可用手段有宏、泛型单态化等。为了满足单元测试和基准测试的要求,这里生成的多个函数副本应当能被显式调用,不然在有 AVX 的机器上永远测不了 SSE。

对于第三点,合适的常量泛型系统可以解决一部分问题。向量长度敏感的算法则要另外考虑。

相对于固定向量长度,可变向量长度会更难抽象,如何在既有的类型系统里体现出这种特殊硬件功能,仍然是个难题。

抽象的 API 会屏蔽细节,损失具体的操控力,具象的 API 过于琐碎,做不到通用。把握好其中的度,才能造出好用的轮子。

发布于 2021-11-28地址: GitHub, 知乎