微处理器生产商不断向指令集添加新指令。这些新指令可以加快特定类型的代码执行速度。指令集中最重要的添加是第12章中提到的向量操作。
如果代码针对特定的指令集进行编译,那么它将与支持该指令集或任何更高指令集的所有CPU兼容,但与早期的CPU不兼容。向后兼容的指令集序列如下所示:
在手册4:“指令表”中提供了对指令集更详细的解释。根据第117页的说明,使用AVX或更高版本编译的代码与未使用AVX编译的代码存在一定的限制。
使用最新的指令集的一个缺点是与旧微处理器的兼容性丧失。这个困境可以通过为不同的CPU制作代码的最关键部分的多个版本来解决。这被称为CPU分派。例如,您可以制作一个利用AVX512指令集的版本,另一个版本适用于只具有AVX2指令集的CPU,以及一个通用版本,兼容没有这些指令集的旧微处理器。程序应自动检测CPU支持的指令集,并为关键的最内层循环选择适当的子程序版本。
13.1 CPU dispatch strategies
对于为特定一组CPU进行精心优化和微调的多个版本的代码,不论是在开发、测试还是维护方面,都是相当昂贵的。这些成本可以在用于多个应用程序的通用函数库中得到证明,但并非总是适用于特定应用程序代码。如果您考虑使用CPU分派来制作高度优化的代码,建议尽可能将其制作成可重用的库形式。这样也可以使测试和维护更加可管理。
我对CPU分派进行了大量研究,并发现许多常见程序使用了不合适的CPU分派方法。
The most common pitfalls of CPU dispatching are:
• 优化当前处理器而不是未来处理器。考虑开发和发布支持CPU分发功能库所需的时间。再加上应用程序员获取新版本库的时间。再加上开发和推广使用更新功能库的应用程序的时间。再加上最终用户获取最新应用程序版本的时间。总而言之,通常需要几年时间才能使您的代码在大多数最终用户的计算机上运行。此时,您优化的任何处理器都可能已经过时。程序员往往低估了这种时间差。
• 以特定处理器型号为基础而不是处理器特性进行思考。程序员通常会想“在处理器X上效果最好”而不是“在具有这个指令集的处理器上效果最好”。对于每个处理器型号使用哪个代码分支的列表将变得非常长且难以维护。而且很可能最终用户没有使用最新版本的程序。CPU分发器不应该看处理器品牌名称和型号,而是要看它具备的指令集和其他特性。
• 假设处理器型号按照逻辑顺序排列。如果您知道处理器型号N支持特定的指令集,那么不能假设型号N+1至少支持相同的指令集。也不能假设型号N-1更低级别。高型号的处理器不一定是新型号。CPU系列和型号编号并不总是连续的,您不能根据处理器的系列和型号对未知的处理器做任何假设。
• 不能正确处理未知处理器。许多CPU分发器设计用于处理已知处理器。在编程时未知的其他品牌或型号通常会选择通用分支,这是性能最差的分支。我们必须记住,许多用户倾向于在最新的处理器上运行速度关键的程序,而这款处理器很可能是在编程时未知的型号。如果未知的处理器品牌或型号具有与某个分支兼容的指令集,CPU分发器应该选择最佳分支。常见的“我们不支持处理器X”的借口在这里并不适用。它暴露了一个根本上有缺陷的CPU分发方式。
• 低估保持CPU分发器更新的成本。精确调优代码以适应特定的CPU型号,然后认为您可以在新型号上市时进行更新是很诱人的。但是,调试、测试、验证和维护新代码分支的成本非常高,几乎不现实地可以在未来几年内每次新处理器上市时都进行这样的更新。即使是大型软件公司也经常无法保持其CPU分发器的更新。一个更现实的目标是仅当新指令集为显著改进提供可能性时才创建新分支。
• 忽视虚拟化。CPUID指令可以准确表示已知CPU型号的时代已经过去。虚拟化在今天非常普遍。虚拟处理器可能具有较少的核心数,以便为同一台机器上的其他虚拟处理器保留资源。虚拟处理器可能会给予一个虚假的型号编号以反映这一点,或者为了与某些传统软件兼容而给出虚假的供应商字符串。甚至可能会出现不对应任何已知硬件CPU的模拟处理器和FPGA软核。这些虚拟处理器可以具有任何品牌名称和型号。我们唯一可以确信的CPUID信息是特性信息,例如支持的指令集和缓存大小。
幸运的是,在大多数情况下,这些问题的解决方案非常简单:CPU分发器应该只有几个分支,并且分发应该基于CPU支持的指令集,而不是品牌、系列和型号。我见过很多错误的CPU分发例子。例如,Mathcad(v. 15.0)使用的是Intel数学核心库(MKL v. 7.2)的旧版本。这个库的CPU分发器在处理当前的CPU时效果并不理想。当将CPUID人为地更改为旧的奔腾4时,当前英特尔CPU上某些任务的速度可以提高超过33%。原因是Intel MKL库中的CPU分发器依赖于CPU系列号,在旧的奔腾4上为15,而所有新的英特尔CPU都是6系列号。对于该任务,当将CPUID伪装成英特尔奔腾4时,非英特尔CPU的速度超过翻倍。更糟糕的是,许多软件产品无法识别VIA处理器,因为在开发软件时,该品牌的知名度较低。一个将不同品牌的CPU区别对待的CPU分发机制可能会成为一个严重的法律问题,你可以在我的博客中阅读相关内容。在那里,你还可以找到更多关于错误CPU分发的例子。显然,你应该只将CPU分发应用于程序中最关键的部分-最好是隔离成一个单独的函数库。只有当指令集彼此不兼容时,才应该采用将整个程序制作成多个版本的激进解决方案。具有明确定义的功能和与调用程序之间明确定义的接口的函数库比在源文件的各个位置散布分支的程序更易于管理、测试、维护和验证。
13.2 Model-specific dispatching
在某些情况下,特定的代码实现可能在特定处理器型号上表现很差。你可以忽略这个问题,假设下一个处理器型号会有更好的效果。如果这个问题太重要而不能忽视,那么解决方案就是创建一个性能较差的处理器型号的负面列表。不建议创建一个代码版本在哪些处理器型号上表现良好的正面列表。原因是正面列表需要在市场上出现新的处理器时进行更新。这样的列表几乎肯定会在软件生命周期内过时。另一方面,负面列表不需要更新,因为下一代处理器很可能会更好。每当处理器存在特定的缺陷或瓶颈时,生产商很可能会努力解决问题,使下一代型号表现更好。
再次记住,大多数软件大部分时间都在未知于软件编写时的处理器上运行。如果软件包含一个正面列表,以确定在哪些处理器型号上运行最高级别的代码版本,那么它将在当初编程时未知的处理器上运行较低级别的版本。但是,如果软件包含一个负面列表,以确定在哪些处理器型号上避免运行高级版本,那么它将在所有在编程时未知的较新型号上运行高级版本。
13.3 Difficult cases
大多数情况下,可以根据CPUID信息中支持的指令集、缓存大小等信息选择最佳分支。然而,有一些情况下,对于相同的操作方式存在不同的方法,CPUID指令无法提供足够信息来确定哪个实现是最佳的。在这些情况下,有时需要使用汇编语言来解决。以下是一些例子:
• strlen函数。字符串长度函数通过扫描一个字节串查找第一个0字节来获取字符串长度。优秀的实现会使用向量寄存器一次测试16个或更多字节,然后使用BSF(比特位扫描)指令在16字节块中定位第一个0字节。一些旧CPU具有特别慢的实现的比特扫描指令。在孤立测试strlen函数的程序员发现在具有慢比特扫描指令的CPU上,该函数的性能表现不佳,并为特定的CPU型号实现了一个单独的版本。然而,我们必须考虑到比特扫描指令仅在每个函数调用中执行一次,因此您必须调用该函数数十亿次才会发现性能问题,而这只有少数程序会做到。因此,为具有较慢比特扫描指令的CPU制作strlen函数的特殊版本几乎没有太大意义。我的建议是使用比特扫描指令,并期待这在未来的CPU上是最快速的解决方案。
• 半尺寸执行单元。向量寄存器的大小从64位MMX增加到128位XMM,256位YMM和512位ZMM寄存器。支持128位向量寄存器的第一批处理器实际上只有64位执行单元。每个128位操作都分为两个64位操作,因此在使用较大的向量大小时几乎没有速度优势。后来的模型拥有完整的128位执行单元,因此具有更高的速度。同样,支持256位指令的第一批处理器将256位读操作拆分为两个128位读取。未来的寄存器大小扩展也可能出现类似的情况。通常情况下,对于仅在具有全尺寸执行单元的CPU上进行向量实现的情况来说,新寄存器大小的全部优势只能在支持它的第二代处理器中实现。问题在于CPU调度程序很难知道最大向量寄存器大小是否以半速或全速处理。解决此问题的简单方法是仅在支持下一个更高指令集时使用新的寄存器大小。例如,在此类应用程序中仅在支持AVX2时使用AVX。或者,使用一个处理器的负面列表,在其中不利于使用最新指令集的处理器上不使用新的寄存器大小。
• 高精度数学计算。用于高精度数学计算的库可以对具有大量比特的整数进行相加。通常情况下,这是在一个ADC(带进位的加法)指令循环中完成的,其中进位位必须从一次迭代保存到下一次迭代。进位位可以保存在进位标志中或者保存在一个寄存器中。如果将进位位保存在进位标志中,则循环分支必须依赖于使用零标志并且不修改进位标志的指令(例如DEC,JNZ)。这种解决方案可能会因为所谓的部分标志停滞而导致较大的延迟,在某些处理器上,因为CPU在旧的Intel CPU上无法将标志寄存器分割为进位和零标志,但在AMD CPU上可以分割(参见手册3:“Intel,AMD和VIA CPU的微体系结构”)。这是为数不多的几种情况之一,其中根据CPU型号进行分派是有意义的。在特定品牌的最新CPU上运行最佳的版本可能是未来同一品牌型号的最佳选择。更新的处理器支持ADX指令用于高精度数学计算。
• 内存复制。有几种不同的方式可以复制内存块。这些方法在手册2:“汇编语言中的优化子程序”第17.9节“移动数据块”中进行了讨论,其中还讨论了不同处理器上哪种方法最快。在C++程序中,应选择一个具有良好memcpy函数实现的最新函数库。由于复制的数据块具有不同的微处理器、不同的对齐方式和不同的大小,因此唯一合理的解决方案是使用一个标准的函数库来处理CPU分派。这个函数非常重要且通用,大多数函数库都针对该函数进行了CPU分派,尽管并非所有函数库都具有最佳和最新的解决方案。在复制大对象时,编译器可能会隐式地使用memcpy函数,除非有一个指定其他方式的复制构造函数。
在这些困难情况下,重要的是要记住,代码在大多数时间里很可能在编写时未知的处理器上运行。因此,重要的是考虑哪种方法在未来的处理器上可能效果最好,并选择这种方法适用于所有支持所需指令集的未知处理器。如果问题可能会在未来由于微处理器硬件设计的整体改进而消失,基于复杂的标准或特定CPU型号列表制作CPU分派器的努力很少值得。最终的解决方案是包括一个性能测试,该测试测量临界代码的每个版本速度,以确定在实际处理器上哪种解决方案最优。然而,这涉及到时钟频率可能会动态变化以及由于中断和任务切换导致的测量不稳定的问题,因此需要交替多次测试不同版本,以作出可靠的决策。
13.4 Test and maintenance
在软件中使用CPU分派时,有两个需要测试的事项:
1. 通过使用特定的代码版本能够提升多少速度。
2. 检查所有代码版本是否正常工作。
速度测试最好是在每个特定代码分支所针对的CPU类型上进行。换句话说,如果要针对多个不同的CPU进行优化,就需要在多台不同的CPU上进行测试。
另一方面,并不需要有多个不同的CPU来验证所有代码分支是否正常工作。低指令集的代码分支仍然可以在具有更高指令集的CPU上运行。因此,您只需要一个具有最高指令集的CPU即可测试所有分支的正确性。因此,建议在代码中添加一个测试特性,允许您覆盖CPU分派并运行任何代码分支以进行测试。
如果代码是作为函数库或单独的模块实现的,那么最好创建一个测试程序,可以单独调用所有代码分支并测试它们的功能。这将在以后的维护工作中非常有帮助。然而,这不是关于测试理论的教科书。有关如何测试软件模块的正确性的建议需要在其他地方找到。
13.5 Implementation
CPU分派机制可以在不同的位置实现,在不同的时间做出分派决策:
• 每次调用时进行分派。通过分支树或switch语句导向关键函数的适当版本。每次调用关键函数时都会进行分支。这种方法的缺点是分支需要时间。
• 第一次调用时进行分派。该函数通过一个初始指向调度器的函数指针进行调用。调度器更改函数指针并将其指向正确的函数版本。这种方法的优点是如果从未调用过该函数,则不需要花费时间来决定使用哪个版本。下面的示例13.1说明了这种方法。
• 在初始化时创建指针。程序或库在第一次调用关键函数之前有一个初始化例程。初始化例程将函数指针设置为正确的函数版本。这种方法的优点是函数调用的响应时间是一致的。
• 在初始化时加载库。每个代码版本都在单独的动态链接库(*.dll或*.so)中实现。程序有一个初始化例程,用于加载适当版本的库。如果库非常大或不同的版本必须使用不同的编译器编译,则此方法很有用。
• 在加载时进行分派。程序使用一个在程序加载时初始化的过程链接表(PLT)。这种方法需要操作系统的支持,可在较新的Linux版本和可能的Mac OS版本中使用。请参见下面的第142页。
• 在安装时进行分派。每个代码版本都在单独的动态链接库(*.dll或*.so)中实现。安装程序创建到库的适当版本的符号链接。应用程序通过符号链接加载库。
• 使用不同的可执行文件。如果指令集不兼容,可以使用此方法。可以为32位和64位系统制作单独的可执行文件。在安装过程中或通过可执行文件存根选择程序的适当版本。
如果关键代码的不同版本是由不同的编译器编译的,则建议对关键代码调用的任何库函数指定静态链接,以便不必将属于每个编译器的所有动态库(*.dll或*.so)与应用程序一起分发。
可以使用系统调用(例如在Windows中的IsProcessorFeaturePresent)来确定各种指令集的可用性。或者,可以直接调用CPUID指令,或使用库www.agner.org/optimize/asmlib.zip中的InstructionSet()或https://github.com/vectorclass/version2/blob/master/instrset_detect.cpp中的instrset_detect()函数。以下示例显示了如何使用函数InstructionSet()实现第一次调用时的分派方法。有关使用向量类库进行CPU分派的示例,请参见第128页的示例12.6。
// Example 13.1
// CPU dispatching on first call
// Header file for InstructionSet()
#include "asmlib.h"
// Define function type with desired parameters
typedef int CriticalFunctionType(int parm1, int parm2);
// Function prototype
CriticalFunctionType CriticalFunction_Dispatch;
// Function pointer serves as entry point.
// After first call it will point to the appropriate function version
CriticalFunctionType * CriticalFunction = &CriticalFunction_Dispatch;
// Lowest version
int CriticalFunction_386(int parm1, int parm2) {...}
// SSE2 version
int CriticalFunction_SSE2(int parm1, int parm2) {...}
// AVX version
int CriticalFunction_AVX(int parm1, int parm2) {...}
// Dispatcher. Will be called only first time
int CriticalFunction_Dispatch(int parm1, int parm2)
{
// Get supported instruction set, using asmlib library
int level = InstructionSet();
// Set pointer to the appropriate version (May use a table
// of function pointers if there are many branches):
if (level >= 11)
{ // AVX supported
CriticalFunction = &CriticalFunction_AVX;
}
else if (level >= 4)
{ // SSE2 supported
CriticalFunction = &CriticalFunction_SSE2;
}
else
{ // Generic version
CriticalFunction = &CriticalFunction_386;
}
// Now call the chosen version
return (*CriticalFunction)(parm1, parm2);
}
int main()
{
int a, b, c;
...
// Call critical function through function pointer
a = (*CriticalFunction)(b, c);
...
return 0;
}
函数库asmlib中提供了InstructionSet()函数,该函数可在不同编译器的不同版本中使用。该函数与操作系统无关,可以检查CPU和操作系统是否支持不同的指令集。
如果有需要,可以将示例13.1中不同版本的CriticalFunction放在单独的模块中,每个模块针对特定的指令集进行编译。
13.6 CPU dispatching at load time in Linux
在2010年,Linux引入了一种名为"Gnu indirect function"的功能,并在2015年得到了GNU工具和Clang的支持。这个功能在程序加载时调用一个分派函数。分派函数返回所选版本函数的指针,并将该指针放置在普通的过程链接表(PLT)中。间接函数特性需要编译器、链接器和加载器的支持(需要binutils 2.20版本、glibc 2.11版本的ifunc分支)。这个特性只适用于使用ELF文件格式的平台,即Linux和FreeBSD,而不适用于Windows和MacOS。
请注意,分派函数在程序开始运行之前被调用,也在任何构造函数被调用之前被调用。因此,分派函数不能依赖于其他已初始化的内容,并且无法访问环境变量。即使未调用分派函数,分派函数也会被调用。示例13.2展示了如何使用Gnu间接函数特性。
// Example 13.2. CPU dispatching with Gnu indirect function
// The function instrset_detect() is borrowed from:
// github.com/vectorclass/version2/blob/master/instrset_detect.cpp
#include "instrset_detect.cpp"
// Declare myfunc as a dispatched function
// The name of the dispatcher is given in quotes
int myfunc (int) __attribute__ ((ifunc ("myfunc_dispatch")));
// Generic version
int myfuncGeneric (int a) {
puts("Generic");
return a;
}
// AVX2 version
int myfuncAVX2 (int a) {
puts("AVX2");
return a;
}
// AVX512 version
int myfuncAVX512 (int a) {
puts("AVX512");
return a;
}
// declare function type
// (replace parameter types and return type to fit your case)
typedef int functype(int);
// avoid name mangling
extern "C" {
// The dispatch function returns a pointer to the selected version
functype * myfunc_dispatch() {
// Detect supported instruction set
int instrset = instrset_detect();
// Return a pointer to the selected function
if (instrset >= 10) {
return myfuncAVX512;
}
else if (instrset >= 8) {
return myfuncAVX2;
}
else {
return myfuncGeneric;
}
}
}
Gnu C函数库中使用间接函数特性来处理特别关键的函数。
13.7 CPU dispatching in Intel compiler
英特尔编译器有两个版本,一个是传统版称为“Classic”,另一个是新版标记为“基于LLVM”的版本,它是Clang编译器的衍生。英特尔编译器 “Classic” 有一个功能,可以为不同的英特尔CPU制作多个版本的函数。它使用每次调用时的分派方法。当调用函数时,将分派至所需版本的函数。可以通过使用 /QaxAVX 或 -axAVX 等选项来编译模块来为所有适合的函数制作自动分派。这将制作所有相关函数的多个版本。通过使用指令 __declspec(cpu_dispatch(...)) 可以仅针对速度关键函数进行分派。详见英特尔 C++ 编译器文档。目前,英特尔编译器 Classic 是唯一可以自动在用户代码上生成 CPU 分派的编译器。不幸的是,英特尔编译器中的 CPU 分派机制仅适用于英特尔 CPU,而不适用于 AMD 或其他品牌。由英特尔编译器提供的 CPU 分派机制不如 Gnu 编译器机制效率高,因为它在关键函数的每次调用上进行分派,而 Gnu 机制在过程链接表中存储所需版本的指针。如果使用 Intel 编译器调用分派函数,则后者的分派分支会执行,即使在此处已经知道了 CPU 类型。可以通过内联后者的函数来避免这种情况,但最好像第13.1页的示例(显式做CPU分派)一样明确地进行 CPU 分派。英特尔函数库具有自动 CPU 分派的功能。许多英特尔库函数具有针对不同处理器和指令集的几个版本。不幸的是,英特尔编译器 Classic 中的 CPU 检测机制存在一些缺陷:•仅在运行在英特尔处理器上时选择最佳版本的代码。CPU 调度程序会在检查处理器支持哪个指令集之前检查处理器是否为英特尔。如果处理器不是英特尔,即使处理器与代码的更好版本兼容,也会选择较差的“通用”代码版本。这可能导致 AMD 和 VIA 处理器性能显着降低。• 显示 CPU 分派仅适用于英特尔处理器。非英特尔处理器会使分派程序通过执行非法操作导致程序崩溃或打印错误消息。• CPU 调度程序不检查操作系统是否支持 XMM 寄存器。它将在不支持 SSE 的旧操作系统上崩溃。由英特尔发布的几个函数库具有类似的CPU分派机制,并且其中一些对非英特尔CPU的处理方式也不太优秀。
由于使用英特尔编译器或英特尔函数库构建的软件在其他品牌上性能表现不佳,这已成为许多争议和法律纠纷的源头。有关详细信息,请参阅我的博客。
较新的“基于LLVM”的英特尔编译器行为更好。该编译器不检查CPU品牌,只检查支持的指令集。然而,“基于LLVM”的英特尔编译器不支持对用户代码的自动CPU分派,只对函数库中的代码进行分派。
英特尔函数库中的CPU分派器有两个版本,一个版本检查CPU品牌,并在CPU不是英特尔时提供较差的版本,另一个版本仅检查支持的指令集。例如,标准库中的内部分派器分别命名为__intel_cpu_features_init()和__intel_cpu_features_init_x()。其他英特尔函数库也有类似的分派器,其中不检查CPU品牌的版本具有后缀_x。在使用英特尔编译器的Classic版本时,使用检查CPU品牌的不公平版本,而在使用基于LLVM的英特尔编译器和非英特尔编译器时,使用对待非英特尔处理器更公平的_x版本。请注意,这些信息基于我的实验,可能并不适用于所有情况。
结论是,不应将英特尔编译器的Classic版本用于可能在非英特尔处理器上运行的软件。可以将基于LLVM的英特尔编译器用于非英特尔处理器,但您也可以使用纯粹的Clang编译器,因为这两个编译器几乎相同。
LLVM-based英特尔编译器的手册说明,使用-m或/arch:选项生成的代码应该在具有对应指令集支持的兼容非英特尔处理器上执行。使用以-x或/Qx开头的选项编译的代码仅适用于英特尔处理器。
英特尔函数库经过优化,适用于英特尔处理器,但随LLVM-based英特尔编译器提供的版本似乎在非英特尔处理器上也能提供良好的性能。然而,这基于我的有限实验。到目前为止,英特尔尚未明确确认他们的函数库在非英特尔处理器上是否会降低性能(请参阅此处的讨论)。
我先前发布了各种技巧,以覆盖英特尔函数库中不公平的CPU分派器。只要它们不与英特尔编译器的Classic版本一起使用,最新版本的函数库似乎不再需要这些技巧。