65.9K
CodeProject 正在变化。 阅读更多。
Home

将 Intel Intrinsics 移植到 Arm Neon Intrinsics

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2021 年 5 月 4 日

CPOL

14分钟阅读

viewsIcon

9594

在本文中,我们将探讨如何将非便携式 x86 SSE 代码从 x86 移植到 Arm,或反之亦然。

如果您从事维护在 Intel 和 AMD 平台上由 SSE intrinsics 加速的代码的工作,您可能已经研究过如何最好地将 SSE 代码移植到 Arm 设备。多年前,针对 x86 和针对 Arm 的汇编代码通常会根据使用界限进行清晰的划分。特别是,x86 代码通常在桌面和服务器环境中运行,而 Arm 代码通常在边缘设备和移动硬件上运行。

随着 Windows on Arm、macOS M1 以及其他平台的出现,x86 和 Arm 使用场景之间的界限开始变得模糊,支持两者变得越来越重要。虽然 Microsoft 和 Apple 在 Arm 上运行其各自的操作系统时都提供了 x86 仿真模式,但您的程序很可能会遇到性能和热效率下降的问题,至少与原生移植相比是这样。

不幸的是,从 x86 移植到 Arm 或反之亦然可能很困难,这取决于非便携式代码的使用情况。本文旨在介绍几种实现此任务的不同方法,特别是针对非便携式 x86 SSE 代码,并在过程中移植一些示例代码。

移植 Intrinsics 和性能

首先,让我们稍微限制一下本文的范围。我们将专门关注将 SSE intrinsics(在 Intel 和 AMD 硬件上使用)移植到 Neon intrinsics(针对 Arm 的 SIMD 指令集)。也就是说,我们不会涵盖 intrinsics 最终编译到的底层汇编。虽然对于底层程序员来说,最终学习阅读汇编很重要,但先从 intrinsics 入手,可以使用 Compiler Explorer 等工具相对轻松地学习汇编。

顺便说一句,如果您曾使用过 GCC 的早期版本处理 Neon intrinsics,并觉得编译输出不尽如人意,那么值得再次尝试,因为编译器后端对 Arm 的指令生成通常已有改进。

此外,除了指出移植过程中需要避免的一些可能影响性能的事项外,我们不会详细介绍移植后代码的性能特性。这可能看起来是严重的疏漏,但在这方面进行全面覆盖确实非常困难。

对于 x86 上的低级代码优化,研究人员能够将指令性能微基准测试到微操作分发级别(请参阅著名的 uops 研究)。相比之下,Arm 的指令集在大量不同的芯片上可用,这些芯片具有不同的性能特性和优化指南。完成初始移植后,除了进行基准测试外,建议您参考 Arm 的优化手册,了解您打算针对的具体芯片。例如,这是 Cortex-A78 的优化指南。

Intrinsics 概述

快速回顾一下,SSE intrinsics 如下所示:

#include <xmmintrin.h>

__m128 mul(__m128 a, __m128 b)
{
    return _mm_mul_ps(a, b);
}

这个简单的代码片段定义了一个名为 mul 的函数,该函数接受两个 128 位向量作为参数,逐通道(lane-wise)地将它们相乘,并返回结果。

Intrinsics 之所以流行,是因为它们允许编译器协助程序员。特别是,当代码以 intrinsics 而非原始汇编形式表达时,编译器仍然负责控制寄存器分配,在跨越函数调用边界时协商调用约定,并且通常可以进一步优化生成的代码,就像优化器处理典型代码一样。

与上面的 SSE 代码片段相比,使用 Neon intrinsics 的相同函数如下所示:

#include <arm_neon.h>

float32x4_t mul(float32x4_t a, float32x4_t b)
{
    return vmulq_f32(a, b);
}

此代码片段与 SSE 代码片段非常相似,但有一些重要区别。

首先,请注意输入参数和输出结果被指定为 float32x4_t 类型,而不是 __m128 类型。与 SSE 寄存器类型不同,Neon 寄存器类型首先指定组件类型,然后是组件的位宽乘以通道数。

现在,假设我们想移植处理 128 位整数的代码。预期的 Neon 类型是描述四个 32 位整数的类型。确实,与 SSE 的 __m128i 相对应的 Neon 寄存器是 int32x4_t。那么与 SSE 的 __m128d 相对应的 Neon 类型是什么?在这种情况下,128 位寄存器包含两个 64 位浮点数,所以我们可能期望 Neon 类型是 float64x2_t,事实也确实如此!

这里需要记住的关键是,SSE 类型描述的是整个向量寄存器的宽度,而 Neon 类型描述的是每个组件的宽度和组件数量。

SSE 和 Neon 类型之间的另一个重要区别是处理无符号数量的方式。在这方面,Neon 提供了更多的类型安全性,通过提供 uint32x4_tint32x4_t 等寄存器类型,将数据的有符号或无符号性质编码在类型本身中。相比之下,SSE 只提供一个 __m128i 寄存器来存储四个 32 位有符号和无符号整数。

对于 SSE 程序员来说,如果我们要将数据视为无符号,我们必须选择合适的 intrinsic 函数,如果我们要将操作数视为无符号整数数据,则需要附加 _epu* 后缀。然而,Neon 在类型级别强制执行此操作,程序员需要在必要时显式执行转换。这种组织方式的一个好处是,由于参数相关的查找(argument-dependent lookup),你需要记住的 intrinsic 函数“名称”更少。

此外,如果不支持某个重载,编译器将提供一个有用的错误消息,如下面的代码片段所示。

 #include <arm_neon.h>

uint32x4_t sat_add(uint32x4_t a, uint32x4_t b)
{
    return vqaddq_u32(a, b);
}

int32x4_t sat_add(int32x4_t a, int32x4_t b)
{
    // Compile error! "cannot convert 'int32x4_t' to 'uint32x4_t'"
    return vqaddq_u32(a, b);
}

上面的代码片段使用 Arm 特有的 intrinsic vqaddq_u32 以矢量化方式对无符号整数进行加法运算,进行饱和处理而非溢出。请注意,A64 GCC 将无法编译第二个函数,因为 vqaddq_u32 仅为无符号类型定义。

与阅读 SSE intrinsic 函数相比,Neon 函数也有一个小的学习曲线。SSE intrinsics 通常结构如下:

[width-prefix]_[op]_[return-type]
_mm_extract_epi32

例如,_mm_extract_epi32 表示一个在 128 位寄存器(由宽度前缀 _mm 表示)上操作的 intrinsic,该 intrinsic 执行一个提取操作以生成一个 32 位有符号值。intrinsic _mm256_mul_ps 在 256 位寄存器上对打包的标量浮点数执行 mul 操作。

相比之下,许多 Neon intrinsics 的形式如下:

[op][q]_[type]
vaddq_f64

intrinsic 名称中存在的“q”表示该 intrinsic 接受 128 位寄存器(而不是 64 位寄存器)。许多 op 名称会以“v”开头,表示“vector”(向量)。

例如,vaddq_f64 对 64 位浮点数执行向量加法。我们可以从“q”推断出此 intrinsic 在 128 位向量上操作。因此,接受的参数必须是 float64x2_t,因为只有两个 64 位浮点数才能装入 128 位向量。

Neon intrinsic 的更通用形式还支持作用于 SIMD 寄存器通道的操作,以及其他选项。Neon intrinsic 的完整形式及其规范在此 进行了描述。

至此,您应该能够解析您遇到的任何 intrinsics,并且一切顺利的话,可以在下一节中毫不费力地跟上。现在让我们来研究两种将 SSE 代码移植到 Arm 平台运行的替代方法。

手动移植 Intrinsics

在移植现有 SSE 代码时,第一个值得考虑的选项是手动移植每个 SSE 例程。当移植简短的孤立代码片段时,这尤其可行。此外,利用较少“奇特”intrinsics 和极宽寄存器(256 位及以上)的代码移植起来会更容易。

让我们来看一个来自 Klein 的例子,这是一个使用 SSE intrinsics 编写的 C++ 库,用于在几何代数中计算算子(特别是用于建模 3D 欧几里得空间的射影几何代数)。以下 SSE 代码片段将表示平面方向的向量与转子(也称为四元数)共轭,从而在空间中旋转平面。

#include <xmmintrin.h>

#define KLN_SWIZZLE(reg, x, y, z, w) \
    _mm_shuffle_ps((reg), (reg), _MM_SHUFFLE(x, y, z, w))

// a := plane (components indicate orientation and distance from the origin)
// b := rotor (rotor group isomorphic to the quaternions)
__m128 rotate_plane(__m128 a, __m128 b) noexcept
{
    // LSB
     //
     //  a0 (b2^2 + b1^2 + b0^2 + b3^2)) e0 +
     //
     // (2a2(b0 b3 + b2 b1) +
     //  2a3(b1 b3 - b0 b2) +
     //  a1 (b0^2 + b1^2 - b3^2 - b2^2)) e1 +
     //
     // (2a3(b0 b1 + b3 b2) +
     //  2a1(b2 b1 - b0 b3) +
     //  a2 (b0^2 + b2^2 - b1^2 - b3^2)) e2 +
     //
     // (2a1(b0 b2 + b1 b3) +
     //  2a2(b3 b2 - b0 b1) +
     //  a3 (b0^2 + b3^2 - b2^2 - b1^2)) e3
     //
     // MSB

     // Double-cover scale
     __m128 dc_scale = _mm_set_ps(2.f, 2.f, 2.f, 1.f);
     __m128 b_xwyz   = KLN_SWIZZLE(b, 2, 1, 3, 0);
     __m128 b_xzwy   = KLN_SWIZZLE(b, 1, 3, 2, 0);
     __m128 b_xxxx   = KLN_SWIZZLE(b, 0, 0, 0, 0);

     __m128 tmp1
         = _mm_mul_ps(KLN_SWIZZLE(b, 0, 0, 0, 2), KLN_SWIZZLE(b, 2, 1, 3, 2));
     tmp1 = _mm_add_ps(
         tmp1,
         _mm_mul_ps(KLN_SWIZZLE(b, 1, 3, 2, 1), KLN_SWIZZLE(b, 3, 2, 1, 1)));
     // Scale later with (a0, a2, a3, a1)
     tmp1 = _mm_mul_ps(tmp1, dc_scale);

     __m128 tmp2 = _mm_mul_ps(b, b_xwyz);

     tmp2 = _mm_sub_ps(tmp2,
                       _mm_xor_ps(_mm_set_ss(-0.f),
                                  _mm_mul_ps(KLN_SWIZZLE(b, 0, 0, 0, 3),
                                             KLN_SWIZZLE(b, 1, 3, 2, 3))));
     // Scale later with (a0, a3, a1, a2)
     tmp2 = _mm_mul_ps(tmp2, dc_scale);

     // Alternately add and subtract to improve low component stability
     __m128 tmp3 = _mm_mul_ps(b, b);
     tmp3        = _mm_sub_ps(tmp3, _mm_mul_ps(b_xwyz, b_xwyz));
     tmp3        = _mm_add_ps(tmp3, _mm_mul_ps(b_xxxx, b_xxxx));
     tmp3        = _mm_sub_ps(tmp3, _mm_mul_ps(b_xzwy, b_xzwy));
     // Scale later with a

     __m128 out = _mm_mul_ps(tmp1, KLN_SWIZZLE(a, 1, 3, 2, 0));
     out = _mm_add_ps(out, _mm_mul_ps(tmp2, KLN_SWIZZLE(a, 2, 1, 3, 0)));
     out = _mm_add_ps(out, _mm_mul_ps(tmp3, a));
     return out;
 }</xmmintrin.h>

上面的代码模式对 SSE 程序员来说应该相当熟悉。一种通用的方法是从要执行的分量计算开始。在这种情况下,我们得到两个 4 分量向量作为 __m128 寄存器。然后,以“向量”方式提取公共子表达式,然后再组合并返回最终结果。第一个参数(为简洁起见,此处简称为“a”)表示一个对应于以下隐式方程的平面。

第二个参数“b”也是一个四分量寄存器,在本例中代表转子的四个分量。我们在这里计算的操作是著名的“三明治算子”,其写法如下:

让我们开始将此移植到 Neon,从函数签名开始。

float32x4_t rotate_plane(float32x4_t a, float32x4_t b) noexcept
{
    // TODO
}

接下来,我们需要学习如何使用标准的聚合初始化来初始化一个 float32x4_t,其中包含一些常量值:

    float32_t tmp[4] = {1.f, 2.f, 2.f, 2.f};
    float32x4_t dc_scale = vld1q_f32(tmp);

请注意,寄存器中最低地址的字节在前,这与 _mm_set_ps intrinsic 不同,后者将最高有效字节放在前面。

与常量寄存器初始化不同,使用 _mm_shuffle_ps 执行的混洗(swizzle)操作是 SSE 代码中常见的模式,但移植起来要困难得多,因为 Neon 中没有完全对应的 intrinsic。要模拟其功能,我们需要一些工具。

首先是 vgetq_lane_f32,它允许我们将向量中的指定组件检索为标量。用于从标量设置通道的相应 intrinsic 是 vsetq_lane_f32。要将组件从一个向量移动到另一个向量,我们有 vcopyq_lane_f32。要将一个通道广播到所有四个组件,我们有 vdupq_lane_f32 intrinsic。有了这些,就很清楚我们如何逐通道地将所有混洗替换为相应的通道查询和赋值。

不幸的是,这样替换混洗不太可能在 Arm 硬件上产生好的结果。例如,在 Intel 硬件上,混洗的延迟为 1 个周期,吞吐量为每个指令 1 个周期。相比之下,在 Arm Cortex-A78 上,用于提取通道的 DUP 指令的延迟为 3 个周期。每个用于分配通道的 MOV 指令还会产生额外的 2 个周期延迟。

为了获得更好的 Neon 性能,我们需要接触到操作粒度大于逐通道的指令。有关数据置换(data permutation)的各种选项的精彩概述,请参阅 Arm 的 Neon 编码指南此部分

作为开始,我们有 vextq_f32,它从两个不同的向量中提取组件,并从提供的组件索引开始将它们组合起来。此外,我们还有一族 rev intrinsics,它们允许我们反转组件的顺序。

请注意,我们可以将 float32x4_t 转换为 float64x2_t 并反转,以这种方式生成置换。每个 REV16REV32REV64 指令都有 2 个周期的延迟,但可能合并了许多单独的通道获取和设置。

在更仔细地最小化置换输入向量后,我们可以得到以下函数:

#include <arm_neon.h>

float32x4_t rotate_plane(float32x4_t a, float32x4_t b) noexcept
{
    // LSB
    //
    //  a0 (b0^2 + b1^2 + b2^2 + b3^2)) e0 + // tmp 4
    //
    // (2a2(b0 b3 + b2 b1) +                 // tmp 1
    //  2a3(b1 b3 - b0 b2) +                 // tmp 2
    //  a1 (b0^2 + b1^2 - b3^2 - b2^2)) e1 + // tmp 3
    //
    // (2a3(b0 b1 + b3 b2) +                 // tmp 1
    //  2a1(b2 b1 - b0 b3) +                 // tmp 2
    //  a2 (b0^2 + b2^2 - b1^2 - b3^2)) e2 + // tmp 3
    //
    // (2a1(b0 b2 + b1 b3) +                 // tmp 1
    //  2a2(b3 b2 - b0 b1) +                 // tmp 2
    //  a3 (b0^2 + b3^2 - b2^2 - b1^2)) e3   // tmp 3
    //
    // MSB

    // Broadcast b[0] to all components of b_xxxx
    float32x4_t b_0000 = vdupq_laneq_f32(b, 0); // 3:1

    // Execution Latency : Execution Throughput in trailing comments

    // We need b_.312, b_.231, b_.123 (contents of component 0 don’t matter)
    float32x4_t b_3012 = vextq_f32(b, b, 3);                // 2:2
    float32x4_t b_3312 = vcopyq_laneq_f32(b_3012, 1, b, 3); // 2:2
    float32x4_t b_1230 = vextq_f32(b, b, 1);                // 2:2
    float32x4_t b_1231 = vcopyq_laneq_f32(b_1230, 3, b, 1); // 2:2

    // We also need a_.231 and a_.312
    float32x4_t a_1230 = vextq_f32(a, a, 1);                // 2:2
    float32x4_t a_1231 = vcopyq_laneq_f32(a_1230, 3, a, 1); // 2:2
    float32x4_t a_2311 = vextq_f32(a_1231, a_1231, 1);      // 2:2
    float32x4_t a_2312 = vcopyq_laneq_f32(a_2311, 3, a, 2); // 2:2

    // After the permutations above are done, the rest of the port is more natural
    float32x4_t tmp1 = vfmaq_f32(vmulq_f32(b_0000, b_3312), b_1231, b);
    tmp1 = vmulq_f32(tmp1, a_1231);

    float32x4_t tmp2 = vfmsq_f32(vmulq_f32(b, b_3312), b_0000, b_1231);
    tmp2 = vmulq_f32(tmp2, a_2312);

    float32x4_t tmp3_1 = vfmaq_f32(vmulq_f32(b_0000, b_0000), b, b);
    float32x4_t tmp3_2 = vfmaq_f32(vmulq_f32(b_3312, b_3312), b_1231, b_1231);
    float32x4_t tmp3 = vmulq_f32(vsubq_f32(tmp3_1, tmp3_2), a);

    // tmp1 + tmp2 + tmp3
    float32x4_t out = vaddq_f32(vaddq_f32(tmp1, tmp2), tmp3);

    // Compute 0 component and set it directly
    float32x4_t b2 = vmulq_f32(b, b);
    // Add the top two components and the bottom two components
    float32x2_t b2_hadd = vadd_f32(vget_high_f32(b2), vget_low_f32(b2));
    // dot(b, b) in both float32 components
    float32x2_t b_dot_b = vpadd_f32(b2_hadd, b2_hadd);

    float32x4_t tmp4 = vmulq_lane_f32(a, b_dot_b, 0);
    out = vcopyq_laneq_f32(out, 0, tmp4, 0);

    return out;
}

一切顺利的话,函数顶部注释中的注释表达式显示了评估表达式所需的各种临时变量是如何构造的。生成的编译代码是一个简短的指令序列,如下所示:

rotate_plane(__Float32x4_t, __Float32x4_t):
ext v16.16b, v0.16b, v0.16b, #4
ext v3.16b, v1.16b, v1.16b, #12
mov v6.16b, v0.16b
fmul v4.4s, v1.4s, v1.4s
ins v16.s[3], v0.s[1]
ins v3.s[1], v1.s[3]
dup v2.4s, v1.s[0]
ext v7.16b, v1.16b, v1.16b, #4
ext v0.16b, v16.16b, v16.16b, #4
fmul v19.4s, v1.4s, v3.4s
fmul v18.4s, v2.4s, v3.4s
ins v7.s[3], v1.s[1]
ins v0.s[3], v6.s[2]
dup d17, v4.d[1]
dup d5, v4.d[0]
fmul v3.4s, v3.4s, v3.4s
mov v4.16b, v0.16b
mov v0.16b, v19.16b
fadd v5.2s, v5.2s, v17.2s
mov v17.16b, v18.16b
fmla v3.4s, v7.4s, v7.4s
fmls v0.4s, v2.4s, v7.4s
fmul v2.4s, v2.4s, v2.4s
faddp v5.2s, v5.2s, v5.2s
fmla v17.4s, v7.4s, v1.4s
fmul v0.4s, v4.4s, v0.4s
fmla v2.4s, v1.4s, v1.4s
fmul v5.4s, v6.4s, v5.s[0]
fmla v0.4s, v17.4s, v16.4s
fsub v2.4s, v2.4s, v3.4s
fmla v0.4s, v6.4s, v2.4s
ins v0.s[0], v5.s[0]
ret

在启用优化设置的情况下,Armv8 Clang 选择生成稍微更好的指令序列来置换向量。虽然依赖优化器是另一种更粗暴的方法,但不能保证优化器会注意到可能的代码改进。

使用平台无关的头文件

在 Neon 硬件上编写高效 intrinsics 的过程可能令人望而生畏。许多 SSE 代码到 Arm 代码的直接移植最终耗时,并且并不总是能产生预期的结果。

幸运的是,至少有一个成熟的抽象来简化移植任务,或者甚至一次性完成移植工作。即 SIMD Everywhere 项目(简称 SIMDe)。

SIMDe 的前提是,您的代码只需要进行的唯一更改是替换您通常包含平台 intrinsics 的头文件。您不必包含 xmmintrin.h,而是应包含与您最初目标指令集匹配的 SIMDe 变体(例如,x86/sse2.h)。

在内部,SIMDe 头文件会检测您正在编译的目标架构,并生成与编写原始目标代码时使用的 intrinsics 相匹配的指令。

例如,假设在我们的原始代码中,我们有一个 _mm_mul_ps intrinsic。在将头文件更改为包含 SIMDe 的 sse.h 头文件后,当针对 x86 硬件编译时,调用 _mm_mul_ps 的代码将继续这样做。但是,当为 Arm 编译时,它也**会**成功,因为 SIMDe 头文件会将 _mm_mul_ps 调用转换为 vmulq_f32

要直接查看此 intrinsic “重写”是如何发生的,您可以参阅 此处 SIMDe 对 _mm_mul_ps 的实现。对于所有支持的 intrinsics,都采取了相同的方法,SIMDe 实现会尝试选择尽可能高效的替换实现。像 这样一次提交 可能就是您快速上手 Neon 所需的。

现在的计划很简单。只需将每个包含 SSE 头文件的文件中的一行进行更改,改为指向 SIMDe 头文件,您的代码库现在就可以完全为 Arm 硬件编译了。

下一步是分析结果,以查看 SIOMDe 直接替换移植的性能是否可接受。虽然使用 SIMDe 移植要快得多,但我们已经看到,直接将 x86 intrinsics 替换为其 Arm 等效项可能会导致代码效率低下。通过分析移植后的代码,您可以根据具体情况,逐渐将有问题的代码部分迁移到手动编写的原生移植。

为了查看 SIMDe 对我们旋转平面的函数的影响,我们可以将包含 SSE 头文件的行替换为以下代码片段:

#include <arm_neon.h>
typedef float32x4_t __m128;

inline __attribute__((always_inline)) __m128 _mm_set_ps(float e3, float e2, float e1, float e0)
{
    __m128 r;
    alignas(16) float data[4] = {e0, e1, e2, e3};
    r = vld1q_f32(data);
    return r;
}

#define _MM_SHUFFLE(z, y, x, w) (((z) << 6) | ((y) << 4) | ((x) << 2) | (w))

inline __attribute__((always_inline)) __m128 _mm_mul_ps(__m128 a, __m128 b) {
    return vmulq_f32(a, b);
}

inline __attribute__((always_inline)) __m128 _mm_add_ps(__m128 a, __m128 b) {
    return vaddq_f32(a, b);
}

inline __attribute__((always_inline)) __m128 _mm_sub_ps(__m128 a, __m128 b) {
    return vaddq_f32(a, b);
}

inline __attribute__((always_inline)) __m128 _mm_set_ss(float a) {
    return vsetq_lane_f32(a, vdupq_n_f32(0.f), 0);
}

inline __attribute__((always_inline)) __m128 _mm_xor_ps(__m128 a, __m128 b) {
    return veorq_s32(a, b);
}

#define _mm_shuffle_ps(a, b, imm8)                                   \
   __extension__({                                                        \
      float32x4_t ret;                                                   \
      ret = vmovq_n_f32(                                                 \
          vgetq_lane_f32(a, (imm8) & (0x3)));     \
      ret = vsetq_lane_f32(                                              \
          vgetq_lane_f32(a, ((imm8) >> 2) & 0x3), \
          ret, 1);                                                       \
      ret = vsetq_lane_f32(                                              \
          vgetq_lane_f32(b, ((imm8) >> 4) & 0x3), \
          ret, 2);                                                       \
      ret = vsetq_lane_f32(                                              \
          vgetq_lane_f32(b, ((imm8) >> 6) & 0x3), \
          ret, 3);                                                                    \
  }

这些例程直接来自 SIMDe 头文件,因此您可以看到各种 SSE intrinsics 和混洗如何映射到 Neon intrinsics。由此生成的 AArch64 汇编代码如下:

rotate_plane(__Float32x4_t, __Float32x4_t):      // @rotate_plane(__Float32x4_t, __Float32x4_t)
        dup     v3.4s, v1.s[2]
        ext     v3.16b, v1.16b, v3.16b, #4
        dup     v2.4s, v1.s[0]
        ext     v20.16b, v1.16b, v3.16b, #12
        dup     v4.4s, v1.s[1]
        dup     v5.4s, v1.s[3]
        adrp    x8, .LCPI0_1
        ext     v7.16b, v1.16b, v2.16b, #4
        ext     v19.16b, v3.16b, v2.16b, #12
        ext     v3.16b, v3.16b, v20.16b, #12
        dup     v6.4s, v0.s[0]
        ext     v16.16b, v1.16b, v4.16b, #4
        ext     v5.16b, v1.16b, v5.16b, #4
        ext     v17.16b, v1.16b, v7.16b, #12
        ext     v18.16b, v1.16b, v7.16b, #8
        fmul    v3.4s, v19.4s, v3.4s
        ldr     q19, [x8, :lo12:.LCPI0_1]
        ext     v6.16b, v0.16b, v6.16b, #4
        ext     v17.16b, v7.16b, v17.16b, #12
        ext     v7.16b, v7.16b, v18.16b, #12
        ext     v18.16b, v1.16b, v16.16b, #8
        ext     v20.16b, v1.16b, v5.16b, #8
        ext     v2.16b, v5.16b, v2.16b, #12
        ext     v16.16b, v16.16b, v18.16b, #12
        ext     v18.16b, v0.16b, v6.16b, #8
        ext     v5.16b, v5.16b, v20.16b, #12
        ext     v20.16b, v0.16b, v6.16b, #12
        adrp    x8, .LCPI0_0
        ext     v18.16b, v6.16b, v18.16b, #12
        ext     v6.16b, v6.16b, v20.16b, #12
        fmul    v20.4s, v1.4s, v1.4s
        fmul    v2.4s, v2.4s, v5.4s
        fmul    v5.4s, v17.4s, v1.4s
        mov     v1.s[0], v4.s[0]
        ldr     q4, [x8, :lo12:.LCPI0_0]
        eor     v2.16b, v2.16b, v19.16b
        fmul    v1.4s, v16.4s, v1.4s
        fadd    v2.4s, v5.4s, v2.4s
        fmul    v5.4s, v17.4s, v17.4s
        fadd    v5.4s, v20.4s, v5.4s
        dup     v16.4s, v20.s[0]
        fadd    v1.4s, v3.4s, v1.4s
        fmul    v7.4s, v7.4s, v7.4s
        fadd    v5.4s, v16.4s, v5.4s
        fmul    v2.4s, v2.4s, v4.4s
        fmul    v1.4s, v1.4s, v4.4s
        fadd    v3.4s, v7.4s, v5.4s
        fmul    v2.4s, v6.4s, v2.4s
        fmul    v1.4s, v18.4s, v1.4s
        fadd    v1.4s, v1.4s, v2.4s
        fmul    v0.4s, v3.4s, v0.4s
        fadd    v0.4s, v0.4s, v1.4s
        ret

即使使用与之前相同的优化设置(-O2),我们最终得到的代码也有 53 条指令,并且与我们手动移植的版本相比,包含更多的置换(DUP/EXT)intrinsics。

SIMDe 对您代码库的影响将取决于几个因素,其中一个重要因素是 SSE intrinsics 的使用情况,这些 intrinsics 与 Arm 架构的映射效果不佳。

移植到统一向量库

还有一种值得一提的方法是使用中间库来表达向量操作和编译。也许采取这种方法最成熟的选择之一是 xsimd

这种方法背后的思想是,与其为每种指令集维护一套定制的例程和算法,不如让实现者使用一个通用的抽象层,该层在每个支持的架构上都有高效的实现。

这种方法的主要缺点是集成 xsimd 这样的库侵入性很强。与 SIMDe 一样,一旦您失去了更接近硬件的能力,优化机会可能会被错过。在某些情况下,xsimd 不支持某些操作,如果它们在一个架构上表现良好但在另一个架构上表现不佳。

尽管存在这些问题,但对于没有时间为每种架构进行分析和优化的工程师来说,使用 xsimd 这样的库比使用低效的手动移植要好得多。

结论

对于代码量不多(相对于您的时间投入)的人来说,手动将 SSE 代码移植到 Neon 可能是更好的选择,或者如果所需的性能已知会突破硬件极限。

对于较小的代码库,如果为每种架构优化定制实现所需的研究和维护过多,可以使用 xsimd 等库来简化向量化代码的处理。

SIMDe 可以用于将 x86 代码移植到 Arm 架构,而不是编写或重写代码以使用 xsimd 这样的抽象层,而是用自定义代码替换源代码中那些没有直接 x86 到 Arm 功能映射的或可以从性能优化中受益的部分。

无论您选择哪种方法来移植代码,即使是低级工程师,现在也能编写出可以到处运行的代码也已成为常态。平台之间存在一种有趣的张力,一方面平台获得了**更多**的差异化(例如 AVX512),另一方面它们同时在以前可能没有蓬勃发展的领域(例如云中的 Arm)扩散。

幸运的是,随着需求的增长,支持多架构定位的工具正在迅速成熟。除了 SIMDe 和 xsimd 等抽象外,Spir-V 和 WebAssembly 等便携式指令集也将继续存在。也就是说,在移植代码时,您可以自由地在选择敏捷性与尽可能接近硬件、挽回每一个浪费的周期之间行使一定的判断。

如需进一步阅读,请务必查看 Arm 的 Neon 编码 系列。请参考相当于 Intel Intrinsics GuideNeon Intrinsics Reference。如果您选择使用 SIMD Everywhere,其文档可在 GitHub 上找到。xsimd 项目也 在 GitHub 上提供,并有额外的 Web 文档。此外,还可以使用 免费的 Arm Performance Libraries 来编译和运行您的应用程序。

© . All rights reserved.