GNU GCC 为编写跨平台代码提供了多种支持,今天要讲的就是其中一种叫"多版本函数"的技术。
什么是多版本函数
多版本函数指的是可以为同一个函数在不同的处理器平台或者指令集下编写不同的实现,程序在运行时会自动选择一个最合适的实现作为这个函数真正运行的实体。
文字解释可能比较抽象,我们拿具体的cpp代码举个例子:
c++
#include <cassert>
#include <cstdio>
__attribute__ ((target ("default")))
int foo ()
{
// The default version of foo.
std::printf("default\n");
return 0;
}
__attribute__ ((target ("sse4.2")))
int foo ()
{
// foo version for SSE4.2
std::printf("sse4.2\n");
return 1;
}
__attribute__ ((target ("arch=atom")))
int foo ()
{
// foo version for the Intel ATOM processor
std::printf("atom\n");
return 2;
}
__attribute__ ((target ("avx")))
int foo ()
{
// foo version for the avx.
std::printf("avx\n");
return 3;
}
int main ()
{
int (*p)() = &foo;
assert ((*p) () == foo ()); // 注意这里,指针和通过函数名应该调用相同的函数
return 0;
}
在例子中我们编写了4个foo函数,虽然它们函数体各不相同,但函数签名是完全相同的------不管是标准c语言还是c++,这都是不允许的。
但GNU扩展允许这种写法,我们可以正常编译运行示例代码:
console
$ g++ -Wall example.cpp
$ ./a.out
avx
avx
我们为foo创建了四个不同的版本:两个为SSE4.2和AVX指令集特化的版本,一个为atom处理器特化的版本,以及一个默认实现。
前面说过,当我们使用这个多版本函数时,程序会在运行时动态选择一个最符合要求的版本,然后让所有的操作作用到这个被选择的版本上。
我的系统上支持avx指令,因此运行时程序选择了avx特化版本,所以取函数指针时取到的是avx特化版本,通过函数名调用也会调用到avx版本。
多版本函数的工作原理
如果不知道工作原理,很多人会以为多版本函数和条件编译是类似的东西。但两者没有什么关系:
- 多版本函数中每一个版本的代码都会被编译进程序或者库,而条件编译只会把符合条件的代码编译进产物;
- 因为要编译所有版本的代码,因此多版本函数的每一个实现都得是当前编译条件下合法的代码,条件编译则比这灵活的多;
- 多版本函数是在运行时选择版本的,有轻微性能开销;
- 条件编译通常能主动控制哪个路径上的代码被编译;而多版本函数的版本选择受到一定限制,尤其是编译器自动生成的。
等我们了解了多版本函数的工作原理,上面列出的这些区别就都能自然理解了。
多版本函数利用了一个叫做IFUNC的技术,它的全称是GNU indirect function。
如同它的名字"间接函数",IFUNC类似普通的函数,但调用它只会返回一个指向被选择的函数的指针。程序在获得其返回结果后才会调用真正要执行的函数。
所以多版本函数的执行流程是这样的:
- 开发者自己手动编写或者编译器自动生成一个转发函数,它会根据执行环境的硬件特性自动从多个版本中选出最符合条件的函数实现
- 开发者或者链接器按要求把转发函数放进
IFUNC对应的二进制文件的section中 - 在编译器自动生成的情况下,所有对多版本函数的操作都会重定向到
IFUNC对应的符号上;开发者自己编写时则需要手动处理 - 在程序开始执行并加载动态链接库或者第一次使用多版本函数前(取决于环境变量
LD_BIND_NOW的值),IFUNC会被调用,它会返回最合适的函数实现 - 程序会缓存这个返回结果来减轻运行时开销,通常是写入PLT,后续调用可以直接调用而不必走间接函数。
我们以上一节的代码编译生成的程序为例,看下它的符号表:
console
$ nm ./example
0000000000002240 T _Z3foov
0000000000002280 T _Z3foov.arch_atom
00000000000022a0 T _Z3foov.avx
0000000000002320 i _Z3foov.ifunc
0000000000002320 W _Z3foov.resolver
0000000000002260 T _Z3foov.sse4.2
_Z3foo是我们的函数经过名称变换后的名字。名字后边的v.xxx则表示它是个多版本函数。默认版本的函数符号名以v结尾,其他的则把target指定的内容拼接在了尾部。
除了我们编写的那些,还有两个符号比较特殊:_Z3foov.ifunc和_Z3foov.resolver。
其中_Z3foov.ifunc的类型是i,这是间接函数,看它的符号名也能猜到。而_Z3foov.resolver是实际的转发函数。这两个符号在二进制中的偏移量相同,因此调用间接函数时会直接调用_Z3foov.resolver,它的返回结果则会被程序特殊处理。
上面是编译器自动生成时的例子,手动编写时的情况是类似的,具体可以参考glibc源码路径下的sysdeps/x86_64/multiarch/目录里的代码。
这就是多版本函数的工作原理。
如何编写多版本函数
有两种方法编写多版本函数。第一种是类似glibc那样完全手动操作,包括编写转发函数和插入符号表。
第二种则是和文章开头的例子一样按编译器的规则自动生成。
第一种方案需要我们自己编写转发函数并设置ifunc:
c
#include <stddef.h>
extern void foo(unsigned *data, size_t len);
void foo_c(unsigned *data, size_t len) { /* ... */ }
void foo_sse42(unsigned *data, size_t len) { /* ... */ }
void foo_avx2(unsigned *data, size_t len) { /* ... */ }
extern int cpu_has_sse42(void);
extern int cpu_has_avx2(void);
void foo(unsigned *data, size_t len) __attribute__((ifunc ("resolve_foo")));
static void *resolve_foo(void)
{
if (cpu_has_avx2())
return foo_avx2;
else if (cpu_has_sse42())
return foo_sse42;
else
return foo_c;
}
属性__attribute__((ifunc ("resolve_foo")));指定间接函数调用的转发函数,剩下的编译器会处理。resolver的函数签名必须是void *f(void),函数本身不能是weak属性的,且需要和ifunc属性修饰的函数原型在同一编译单元。
手动处理对于大部分开发者来说都过于繁琐,因此我们更倾向于第二种编译器自动生成。
想要编译器生成多版本函数,在x86平台我们需要编译器属性__attribute__((target(string, ...)))和__attribute__((target_clones(string, ...)))。
多版本函数的每一个版本需要有相同的函数签名,然后使用上面两个属性中的一种进行修饰。
__attribute__((target(string, ...)))属性可以接受多个条件,并且需要每一个条件都被满足时才使用这个版本的实现。条件中可以是指令集名称,或者-march=命令行选项后面可以跟随的值,也可以是编译器自己规定的其他值:
c++
// 运行环境支持sse4.2指令集时可以启用该版本
__attribute__((target("sse4.2")))
int f()
{
return 0;
}
// 运行环境的cpu是Intel Raptor Lake
// 给出错误的值编译会报错
__attribute__((target("arch=raptorlake")))
int f()
{
return 1;
}
// GCC 针对不同架构和平台提供了一些其他的选项,这些需要参考GCC自己的文档
__attribute__((target("fpmath=387")))
int f()
{
return 2;
}
// 多个条件需要同时满足:处理器是amdfam10同时要支持sse4a指令集
__attribute__((target("sse4a,arch=amdfam10")))
int f()
{
return 3;
}
// 默认版本,其他版本都不满足条件时返回这个版本的函数实现
__attribute__((target("default")))
int f()
{
return 3;
}
在arm平台上对应的属性是__attribute__((target_version(string,...))),用法完全一致就不赘述了。
__attribute__((target_clones(string, ...)))相对简单,它的多个条件是并集,满足任何一个就会利用这个版本的实现。编译器会把这个实现的代码每个条件复制一份,可以简化一些开发者的工作,但生成的二进制产物和自己手写多遍是一样的:
c++
// 运行环境支持sse4.2或avx指令集时可以启用该版本
// 函数会被复制成两份
__attribute__((target_clones("sse4.2,avx")))
int f()
{
return 0;
}
arm平台上使用相同属性。
使用这两个属性就能简单编写多版本函数了,编译器会处理剩下的所有细节。Clang同样支持这些属性,但选项的值不同。
限制
IFUNC间接函数支持x86/arm平台,不过编译器自动生成的有一些限制。
第一个是只能在cpp代码中使用。
第二个是编译器并不会检测你的代码能不能在特定平台上正常运行,因此如果选择出的实现调用了目标系统上无法使用的功能或者内联了错误的汇编代码,这会导致运行时崩溃并且调试排错极为困难。
第三个,间接函数是ELF文件的扩展,因此只能在可执行文件格式是ELF的平台上才能使用,所以Windows和macOS上无法使用。
最后一个是编译器自动生成的转发函数无法控制,在有多个版本都满足条件时选用哪个版本是完全编译器说了算的,有时会选择到我们不想要的版本。
如果定制化程度比较高且要在c代码中使用,我们只能选择手动编写多版本函数。
总结
多版本函数能让开发者在不依赖复杂构建系统或条件编译的情况下,根据目标系统特性实现最佳优化,因此glibc和gcc的代码中都有不少应用。
不过多版本函数并不是条件编译的替代品,它带来的代码膨胀问题有时候也会成为明显的性能瓶颈,在选择方案的时候需要多加注意。