SIMD 多平台动态转发

2023-11-12 language

对于 SIMD 优化来说,不同平台支持不同的指令,如果使用宏定义,那么必然会导致多个发行二进制版本,可以通过如下方式实现单个二进制发行包。

简介

通常为了维护简单,希望是以单独的二进制包进行发布,但是不同平台可能会有差异性,所以需要根据平台动态选择对应实现,其中有两类方法:

  • 运行时,代码中动态检测当前 CPU 支持的特性,以此选择不同的代码实现,适用于分支选择相对计算量要小很多的场景。
  • 加载时,用于分支选择会影响性能的场景。

运行时

简单来说,编译时包含所有的实现,在运行时检测 CPU 支持的特性,然后根据 CPU 特性跳转到不同的函数执行。

核心是如何快速检测 CPU 特性,当前已存在 Google CPU Features 库,用来检测 CPU 相关的特性,也可以通过内部的实现,如下是两种方式。

#include <cpuid.h>
int cpu_info[4] = {-1};
__cpuid(0, cpu_info[0], cpu_info[1], cpu_info[2], cpu_info[3]);

__builtin_cpu_init();
__builtin_cpu_supports("sse");

加载时

某个函数多版本实现,在加载过程中会调用解析函数完成加载,而且在后面的程序运行过程中会一直使用对应的函数,也就是所谓的 Indirect Function 实现。

Indirect Function, IFUNC

实际上,标准库已经在使用这种方式,可以通过如下命令查看。

# nm -D /usr/lib64/libc.so.6 | grep -w memcpy
000000000008b460 i memcpy
00000000000a42c0 T memcpy
# readelf -s /usr/lib64/libc.so.6 | grep -w IFUNC

有很多的符号通过 i 标识,也就是 indirect 函数,此时提供了一个函数的多种实现,在程序加载阶段由 LD 决定具体使用那个实现,如下是一个简单示例。

void hello_world() __attribute__((ifunc("_hello_world_resolver")));

static void hello_world_sse4_2() {
    std::cout << "sse4.2" << std::endl;
}

static void hello_world_default() {
    std::cout << "default" << std::endl;
}

extern "C" void *_hello_world_resolver() {
    __builtin_cpu_init();
    if (__builtin_cpu_supports("sse4.2"))
        return reinterpret_cast<void *>(&hello_world_sse4_2);
    return reinterpret_cast<void *>(&hello_world_default);
}

因为此时还未执行运行态代码,只能使用内部函数调用,如上的 __builtin_cpu_supports() 函数。

注意,这种方式是有限制的:

  1. 仅适用于 GNU/Linux 平台。
  2. 对应的 Resolver 必须在当前单元内,如果是 C++ 函数则需要提供 Mangle 后的名字。
  3. 此时未进入运行态,如果要检测 CPU 则需要使用内部的实现。

更多细节可以参考官方 What is an indirect function 中的介绍。

Function Multiversioning

在 x86-64 上,GCC 实际上提供了更便捷的 IFUNC 实现方案。

__attribute__ ((target("sse4.2")))
static void hello_world() {
    std::cout << "sse2" << std::endl;
}

__attribute__((target("default")))
static void hello_world() {
    std::cout << "default" << std::endl;
}

void hello_world_wrap() {
    hello_world();
}

可以通过如下命令查看。

$ nm --demangle simd | grep hello_world

另外,还可以使用如下方式指定多个目标 __attribute__((target("sse4.2,popcnt")))

注意,测试发现,如果 main 函数和实现不在相同文件中,那么调用会失败,需要添加一个 wrap 函数才可以,或者在改文件中直接调用。另外,当放到头文件中会导致在链接时报错,怀疑是由于自动生成 resolver 异常导致。

还可以通过 __attribute__((target_clones("default", "sse4.2"))) 指定多个,详见官方 Function Multiversioning 中的介绍。

C++

FMV 只支持 GNU C11 ABI 平台使用,这就会导致 C++ 中使用成员变量函数是不支持的,例如如下方式。

#include <iostream>

class Hello {
public:
    explicit Hello(std::string name) : name(std::move(name)) {};
    void hello();
private:
    std::string name;
};

__attribute__((target("sse4.2")))
void Hello::hello() {
    std::cout << "sse4.2 " << name << std::endl;
}

__attribute__((target("default")))
void Hello::hello() {
    std::cout << "default " << name << std::endl;
}

int main() {
    Hello hello("andy");
    hello.hello();
}

但是可以转换为下面方式。

#include <iostream>

class Hello {
public:
    explicit Hello(std::string name) : name(std::move(name)) {};
    void hello();
private:
    std::string name;
};

__attribute__((target("sse4.2")))
static void hello_impl(const std::string &name) {
    std::cout << "sse4.2 " << name << std::endl;
}

__attribute__((target("default")))
static void hello_impl(const std::string &name) {
    std::cout << "default " << name << std::endl;
}

void Hello::hello() {
    hello_impl(name);
}

int main() {
    Hello hello("andy");
    hello.hello();
}

另外,在 LLVM 7 中开始支持 target 特性,可以参考 CLang 7 Documentation 中的介绍,但是没有验证过 target_clones 特性是否支持,估计是暂不支持。

注意事项

如果编译时开启了 -Wall -Werror 参数时,那么会出现 unused function 'xxx' [-Wunused-function] 的报错,可以添加 -Wno-unused-function 或者类似如下只封装部分代码。

#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function"
// Your Function Multi-Version Here.
#pragma GCC diagnostic pop

注意,这种方式可以兼容 GCCCLang 编译器。

ARM 兼容

当前只有 x86 实现了 Multi-Version,可以通过如下方式适配。注意,_Pragma#pragma 的区别,前者是类似 sizeof 的函数,可以用在宏定义中。

#if defined(__GNUC__) && defined(__x86_64__)
#define MFV_IMPL(IMPL, ATTR)                                 \
    _Pragma("GCC diagnostic push")                           \
    _Pragma("GCC diagnostic ignored \"-Wunused-function\"")  \
    ATTR static inline IMPL                                  \
    _Pragma("GCC diagnostic pop")                            \

#define MFV_SSE42(IMPL)  MFV_IMPL(IMPL, __attribute__((target("sse4.2"))))
#define MFV_AVX2(IMPL)   MFV_IMPL(IMPL, __attribute__((target("avx2"))))
#define MFV_AVX512(IMPL) MFV_IMPL(IMPL, __attribute__((target("avx512f,avx512bw"))))
#define MFV_DEFAULT(IMPL) MFV_IMPL(IMPL, __attribute__((target("default"))))
#else
#define MFV_SSE42(IMPL)
#define MFV_AVX2(IMPL)
#define MFV_AVX512(IMPL)
#define MFV_DEFAULT(IMPL) IMPL
#endif

然后通过如下方式调用。

MFV_SSE42(void foobar(int args) {
})

另外,还可以通过 sse2neon 适配。