highway - Highway 是一个提供可移植 SIMD/向量内在函数的 C++ 库。

Created at: 2019-09-06 20:41:23
Language: C++
License: Apache-2.0

高效且高性能的矢量软件

Highway 是一个C++库,提供可移植的 SIMD/矢量内联函数。

为什么

我们对高性能软件充满热情。我们看到CPU(服务器,移动设备,台式机)中尚未开发的主要潜力。Highway 适用于希望可靠、经济地突破软件极限的工程师。

如何

CPU 提供 SIMD/矢量指令,将相同的操作应用于多个数据项。这可以减少能源使用,例如五倍,因为执行的指令更少。我们也经常看到5-10倍的加速。

Highway 根据以下指导原则,Highway 使 SIMD/矢量编程变得实用可行:

做你期望的事情:Highway是一个C++库,具有精心挑选的函数,可以很好地映射到CPU指令,而无需进行广泛的编译器转换。与自动向量化相比,生成的代码对于代码更改/编译器更新更具可预测性和鲁棒性。

适用于广泛使用的平台:高速公路支持四种架构;相同的应用程序代码可以面向八个指令集,包括那些具有“可扩展”向量(编译时大小未知)的指令集。Highway 只需要 C++11,并支持四个编译器系列。如果你想在其他平台上使用高速公路,请提出问题。

部署灵活:使用 Highway 的应用程序可以在异构云或客户端设备上运行,在运行时选择最佳可用指令集。或者,开发人员可以选择以单个指令集为目标,而没有任何运行时开销。在这两种情况下,应用程序代码是相同的,除了交换加一行代码。

HWY_STATIC_DISPATCH
HWY_DYNAMIC_DISPATCH

适用于各种领域:Highway提供了一组广泛的操作,用于图像处理(浮点),压缩,视频分析,线性代数,密码学,排序和随机生成。我们认识到,新的用例可能需要额外的操作,并乐于在有意义的地方添加它们(例如,在某些架构上没有性能悬崖)。如果你想讨论,请提出问题。

奖励数据并行设计:Highway 提供了 Collect、MaskedLoad 和 FixedTag 等工具,以实现传统数据结构的加速。然而,通过为可扩展向量设计算法和数据结构,可以释放最大的收益。有用的技术包括批处理、数组结构布局和对齐/填充分配。

例子

使用编译器资源管理器的在线演示:

使用高速公路的项目:(要添加你的项目,请随时提出问题或通过电子邮件与我们联系)

现状

目标

支持的目标:标量,S-SSE3,SSE4,AVX2,AVX-512,AVX3_DL(~Icelake,需要通过定义选择加入),NEON(ARMv7和v8),SVE,SVE2,WASM SIMD,RISC-V。

HWY_WANT_AVX3_DL

SVE最初是使用farm_sve进行测试的(参见致谢)。

版本控制

高速公路版本旨在遵循 semver.org 系统(MAJOR.次要。PATCH),在向后兼容的添加后递增“次要”,在向后兼容的修复后递增 PATCH。我们建议使用版本(而不是 Git 提示),因为它们经过更广泛的测试,请参见下文。

版本 0.11 被认为足够稳定,可以在其他项目中使用。1.0版将标志着对向后兼容性的更多关注,并且计划在2022H1,因为所有目标都已完成功能。

测试

持续集成测试使用最新版本的 Clang(在本机 x86 上运行,RVV 的 Spike 和 ARM 的 QEMU 上运行)和 VS2015 中的 MSVC(在本机 x86 上运行)构建。

在发布之前,我们还使用Clang和GCC在x86上进行测试,并通过GCC交叉编译和QEMU在ARMv7 / 8上进行测试。有关详细信息,请参阅测试过程

相关模块

该目录包含与 SIMD 相关的实用程序:具有对齐行的图像类、数学库(已实现 16 个函数,主要是三角函数)以及用于计算点积和排序的函数。

contrib

安装

此项目使用 CMake 生成和生成。在基于 Debian 的系统中,你可以通过以下方式安装它:

sudo apt install cmake

Highway的单元测试使用googletest。默认情况下,Highway 的 CMake 会在配置时下载此依赖项。你可以通过将 CMake 变量设置为 ON 并单独安装 gtest 来禁用此功能:

HWY_SYSTEM_GTEST

sudo apt install libgtest-dev

要将 Highway 构建为共享或静态库(取决于BUILD_SHARED_LIBS),可以使用标准 CMake 工作流:

mkdir -p build && cd build
cmake ..
make -j && make test

或者你可以运行(在Windows上)。

run_tests.sh
run_tests.bat

Bazel也支持构建,但它没有被广泛使用/测试。

快速入门

你可以使用内部示例/作为起点。

benchmark

快速参考页面简要列出了所有操作及其参数,instruction_matrix指示每个操作的指令数。

我们建议尽可能使用完整的 SIMD 矢量,以获得最大的性能可移植性。要获取它们,请将(或等效地)标记传递给诸如 .对于需要在通道上设置上限的用例,有两种选择:

ScalableTag<float>
HWY_FULL(float)
Zero/Set/Load

  • 对于最多通道,请指定或等效的 。实际车道数将向下舍入为最接近的 2 次幂,例如,如果为 5,则为 4;如果为 8,则为 8。这对于窄矩阵等数据结构非常有用。仍然需要一个循环,因为矢量实际上可能比通道少。

    N
    CappedTag<T, N>
    HWY_CAPPED(T, N)
    N
    N
    N
    N

  • 对于两个通道的幂,请指定 。支持的最大取决于目标,但保证至少是 。

    N
    FixedTag<T, N>
    N
    16/sizeof(T)

由于 ADL 限制,调用 Highway ops 的用户代码必须:

  • 居住在里面 ;或
    namespace hwy { namespace HWY_NAMESPACE {
  • 为每个 op 添加一个别名前缀,例如 ;或
    namespace hn = hwy::HWY_NAMESPACE; hn::Add()
  • 为使用的每个操作添加 using 声明:。
    using hwy::HWY_NAMESPACE::Add;

此外,调用 Highway 操作的每个函数都必须以 、 或 作为前缀,驻留在 和 之间。Lambda 函数当前需要先打开大括号。

HWY_ATTR
HWY_BEFORE_NAMESPACE()
HWY_AFTER_NAMESPACE()
HWY_ATTR

使用 Highway 进入代码的入口点略有不同,具体取决于它们是使用静态调度还是动态调度。

  • 对于静态调度,将是 中最好的可用目标,即编译器允许使用的那些(参见快速参考)。可以使用定义函数的同一模块中的函数来调用函数。可以通过将函数包装在常规函数中并在标头中声明常规函数来从其他模块调用该函数。

    HWY_TARGET
    HWY_BASELINE_TARGETS
    HWY_NAMESPACE
    HWY_STATIC_DISPATCH(func)(args)

  • 对于动态调度,函数指针表通过宏生成,该宏用于调用当前 CPU 支持的目标的最佳函数指针。如果已定义并包含模块,则会自动为 (请参阅快速参考) 中的每个目标编译模块。

    HWY_EXPORT
    HWY_DYNAMIC_DISPATCH(func)(args)
    HWY_TARGETS
    HWY_TARGET_INCLUDE
    foreach_target.h

编译器标志

应用程序应在启用优化的情况下进行编译 - 如果没有内联,SIMD 代码可能会减慢 10 到 100 倍的速度。对于clang和GCC来说,一般就足够了。

-O2

对于 MSVC,我们建议使用 进行编译,以允许非内联函数在寄存器中传递向量参数。如果打算将 AVX2 目标与半角向量(例如 for )一起使用,则使用 编译也很重要。这似乎是在 MSVC 上生成 VEX 编码的 SSE4 指令的唯一方法。否则,混合使用 VEX 编码的 AVX2 指令和非 VEX SSE4 可能会导致严重的性能下降。不幸的是,生成的二进制文件将需要 AVX2。请注意,clang 和 GCC 不需要这样的标志,因为它们支持特定于目标的属性,我们使用这些属性来确保为 AVX2 目标正确生成 VEX 代码。

/Gv
PromoteTo
/arch:AVX2

带钢开采回路

为了对循环进行矢量化,“条带挖掘”将其转换为外部循环和内部循环,迭代次数与首选矢量宽度匹配。

在本节中,让我们表示元素类型、、要处理的元素数以及完整向量中的通道数。假设循环体作为函数给出。

T
d = ScalableTag<T>
count
N = Lanes(d)
template<bool partial, class D> void LoopBody(D d, size_t index, size_t max_n)

高速公路提供了几种表示不需要分割的环路的方法:

N
count

  • 确保所有输入/输出都已填充。那么循环就是

    for (size_t i = 0; i < count; i += N) LoopBody<false>(d, i, 0);
    

    此处不需要模板参数和第二个函数参数。

    这是首选选项,除非有数千个向量操作,并且矢量操作的流水线延迟很长。90年代的超级计算机就是这种情况,但现在ALU很便宜,我们看到大多数实现将向量分成1,2或4个部分,因此即使我们不需要所有通道,处理整个向量的成本也很小。实际上,这避免了在较旧的目标上进行预测或部分加载/存储的(可能很大的)成本,并且不会重复代码。

    N

  • 使用 hwy/contrib/algo/transform-inl.h 中的函数。这负责循环和余数处理,你只需定义一个通用的lambda函数(C++14)或函子,该函数从输入/输出数组接收当前矢量,以及来自最多两个额外输入数组的可选矢量,并返回值以写入输入/输出数组。

    Transform*

    下面是实现 BLAS 函数 SAXPY 的示例 ():

    alpha * x + y

    Transform1(d, x, n, y, [](auto d, const auto v, const auto v1) HWY_ATTR {
      return MulAdd(Set(d, alpha), v, v1);
    });
    
  • 如上所述处理整个向量,后跟一个标量循环:

    size_t i = 0;
    for (; i + N <= count; i += N) LoopBody<false>(d, i, 0);
    for (; i < count; ++i) LoopBody<false>(CappedTag<T, 1>(), i, 0);
    

    模板参数和第二个函数参数同样不需要。

    这可以避免重复代码,如果代码很大,则是合理的。如果很小,则第二个循环可能比下一个选项慢。

    count
    count

  • 如上所述处理整个向量,然后对带有掩码的修改的单个调用:

    LoopBody

    size_t i = 0;
    for (; i + N <= count; i += N) {
      LoopBody<false>(d, i, 0);
    }
    if (i < count) {
      LoopBody<true>(d, i, count - i);
    }
    

    现在,可以在内部使用模板参数和第三个函数参数,以非原子方式将 第一个通道与后续位置的先前内存内容“混合”:。同样,加载第一个元素并在其他通道中返回零。

    LoopBody
    num_remaining
    v
    BlendedStore(v, FirstN(d, num_remaining), d, pointer);
    MaskedLoad(FirstN(d, num_remaining), d, pointer)
    num_remaining

    这是一个很好的默认值,当它不切实际地确保矢量被填充时,但只是安全的!与标量循环相反,只需要一次最终迭代。从两个循环体增加代码大小是值得的,因为它避免了除最终迭代之外的所有迭代的掩码成本。

    #if !HWY_MEM_OPS_MIGHT_FAULT

其他资源

确认

我们使用了Berenger Bramas的farm-sve;事实证明,它对于检查x86开发计算机上的SVE端口很有用。

这不是官方支持的谷歌产品。联系人:janwas@google.com