为不同指令集生成多个版本的关键代码

为不同指令集生成多个版本的关键代码

微处理器制造商不断向指令集中添加新指令,使得某些代码执行的更快,对指令集最重要的补充向量运算

指令集是向后兼容的,向后兼容的指令集顺序如下:

  • 80386: 32位模式
  • SSE:128位单精度浮点向量
  • SSE2:128位整数以及双精度浮点向量
  • SSE3: horizontal add. etc
  • SSSE3: 增加了一些整数向量指令
  • SSE4.1:更多的向量指令
  • SSE4.2:字符串寻找指令
  • AVX:256位单精度和双精度浮点向量
  • AVX2:256位整数向量
  • FMA3:浮点乘和加指令
  • AVX-512:512位整数和浮点向量

最新指令集的缺点在于缺失了与旧版本微处理器的兼容性,可以通过在关键部分为不同的CPU使用多个版本的代码来解决,称为CPU分派

CPU分派策略

开发、测试和维护方面,将一段代码转换为多个版本,每个版本都针对一组特定的CPU进行优化和微调,代价很大。这适用于在多个应用程序只能使用的通用函数库。而不适用于特定应用程序的代码。如果考虑使用CPU分派来生成高度优化的代码,最好以可重用库的形式来实现,这样也使测试和维护更加容易。

CPU分派中的问题是:

  1. 为当前的处理器而不是未来的处理器优化,由于CPU分派开发和函数库发布以及其它一些时间,以及CPU更新的快速,若面向当前处理器优化,大概率代码真正被用户使用时当前的CPU已经过时
  2. 考虑具体的CPU型号而不是通用特性。应当从支持的指令集和一些通用的特性来开发,便于维护
  3. CPU型号可能不连续,一个数字更大的型号并不一定是更新的,因此不能基于CPU系列和型号进行任何假设
  4. 无法正确处理未知处理器,若CPU分派器仅处理已知处理器,而其它处理器仅使用性能最差的通用分支,这样是不合适的,同样是应当根据支持的指令集和通用特性来确定。
  5. 低估维持更新CPU分派程序的成本,还是同样的道理,不要为特定CPU型号来更新,而是依据指令集和通用特性的变化
  6. 创建太多的分支,不必创建太多的分支,通常维护两个分支足够,一个支持最新指令集的CPU,一个则与最多5年前or10年前的CPU兼容。
  7. 忽略虚拟化。由于虚拟CPU的存在,我们应该依赖特性信息而不是CPUID,比如支持的指令集和缓存大小。

综上,核心问题是我们在设计CPU分派的时候,主要应当依据支持的指令集以及通用特性。

指定型号的分派

某些情况下,若特定代码在特定型号的处理器(这不能是最主流的处理器)上表现着实垃圾,通常我们可以忽略,并假设下一代会更好。若实在不行,给出的建议是维护一个程序的负面清单,单独列出表现不好的处理器

棘手的例子

大多数情况下可以根据所支持的指令集、缓存大小等CPUID信息选择最优分支。但是某些情况下不行

  1. strlen函数
  2. 一半大小的执行单元,向量寄存器大小刚更新时,通常并非全速的执行单元,而是由两个半速的一半大小的执行单元组合而成。因此最简单的解决办法是,只有在支持下一个更高指令集的时候才使用次新指令集的寄存器大小。

测试和维护

使用CPU分派时,通常需要测试以下两件事情:

  1. 通过使用特定版本的代码,我们在速度上获得了多少增益?
  2. 检查所有版本代码是否正常工作

另外没有必要时用许多处理器来验证所有代码分支是否正常工作,因为指令集向后兼容,因此可以用一个支持最高版本指令集的CPU测试所有分支的城区恶性,仅需要我们在代码中添加一个测试特性

实现

CPU分派机制可以在不同地方实现

  1. 每次调用时分派,会带来额外的分支成本
  2. 第一次调用时分派,通过函数指针调用函数,指针最开始指向调度器,第一次调用时调度器使其指向正确版本,好处在于函数从未被调用时,分派器不会花时间确定使用什么版本
  3. 初始化时生成指针,程序或者函数库有一个初始化路径,可以在第一次调用关键函数前被调用,如此的好处是函数的响应时间一致
  4. 在初始化时加载库,每个版本都在一个单独的动态链接库,配合程序的初始化路径加载适当版本的库。
  5. 加载阶段分派,程序使用一个过程链接表(PLT),可以在程序加载时初始化。
  6. 在安装时分派,每个代码版本都有一个单独的动态链接库(*.dll or *.so),安装程序创建适当版本的符号链接,应用程序通过符号链接加载库
  7. 使用不同的可执行文件,如果指令集互不相容,可以这样做

如果关键代码的不同版本使用不同的编译器编译,那么可以将通用的库函数使用静态链接,这样子不需要分发应用程序中属于所有编译器的公共代码。

各个指令集的可用性可以调用系统函数来确定。

GNU编译器的CPU分派

Linux中引入了一个名为“Gnu间接函数”的特性,用于CPU分派,并在Gnu C库中使用,需要编译器、链接器和加载器的支持。PLT入口最初指向调度函数,程序加载时,加载器调用调度函数,并使用调度函数获得的指针替换PLT入口。

可以通过调用指令集判断函数,以及函数指针手动进行分派,写起来不难

Intel编译器中的CPU分派

Intel编译器特性,可以为一个函数生成多个版本对应与多个IntelCPU,每次调用方法都使用分派。可以通过*/QaxAVX* 或者 -axAVX来解决。但是存在一些问题,比如在非intel cpu上运行很差,