麦斯科技 · 2021年05月15日

将Intel Intrinsics移植到Arm Neon Intrinsics

https://www.codeproject.com/Articles/5301747/Porting-Intel-Intrinsics-to-Arm-Neon-Intrinsics

Jeremy C. Ong 2021年5月4日

1df7757e3af3eb010eb516d7ee3d648d.png

在本文中,我们将研究从x86到Arm的转换,或者对于不可移植的x86sse代码从x86到Arm的转换

这篇文章是一篇赞助文章。这类文章旨在为您提供有关我们认为对开发人员有用和有价值的产品和服务的信息

如果您从事的业务是在Intel和AMD平台上维护SSE intrinsics加速的代码,那么您可能已经研究了如何最好地将SSE代码移植到Arm设备。多年前,以x86为目标和以Arm为目标的汇编代码被整齐地划分在使用边界上。特别是,x86代码通常在桌面和服务器环境中运行,而Arm代码通常在边缘设备和移动硬件上运行。

随着Arm上Windows、macosm1和其他平台的出现,x86和Arm使用场景之间的界限开始模糊,支持两者变得越来越重要。虽然微软和苹果在Arm上运行各自的操作系统时都提供x86仿真模式,但您的程序可能会受到性能和热效率降低的影响,至少与本机移植相比是这样。

不幸的是,根据不可移植代码的使用情况,从x86到Arm或从x86到Arm的转换可能很困难。本文旨在介绍完成此任务的几种不同方法,特别是针对不可移植的x86 SSE代码,并在此过程中移植一些示例代码。

移植本质和性能

首先,让我们稍微限制一下文章的范围。我们将特别关注将SSE内部函数(用于Intel和AMD硬件)移植到Neon内部函数(针对Arm的SIMD指令集)。也就是说,我们将不讨论内部函数最终编译到的底层程序集。虽然最终学习如何读取程序集对于低级程序员来说很重要,但首先开始使用内部函数可以使使用编译器资源管理器之类的工具来学习程序集变得相对简单。

另外,如果您在更老版本的GCC上使用过Neon intrinsics,并且觉得编译后的输出很平淡,那么值得再尝试一次,因为Arm编译器后端的指令生成已经得到了普遍的改进。

此外,我们不会详细介绍移植后代码的性能特征,只是指出在为性能代码创建移植时要避免的一些事情。这似乎是一个严重的疏漏,但这方面的适当报道是相当困难的。

为了优化x86上的底层代码,研究人员已经能够将微基准指令性能降低到微操作调度级别(参见著名的uops研究)。相比之下,Arm的指令集可以在大量具有不同性能特征和优化准则的芯片中使用。在您的初始移植之后,除了执行基准测试之外,建议您参考Arm的优化手册,以了解您打算针对的特定芯片。例如,下面是Cortex-A78的优化指南。

Intrinsics刷新

作为快速提醒,SSE内部函数如下所示:

C++


#include <xmmintrin.h>

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

这个简单的代码段定义了一个函数mul,它将两个128位向量作为参数,按通道相乘,然后返回结果。

intrinsic很流行,因为它让编译器帮助程序员。特别是,当代码表示为内部函数而不是原始程序集时,编译器保留控制寄存器分配的责任,在遍历函数调用边界时协商调用约定,并且可能经常进一步优化生成的代码,就像优化器处理典型代码一样。

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

C++

#include <arm_neon.h>

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

尽管有一些重要的区别,但这个片段与SSE片段的押韵非常接近。

首先,请注意输入参数和输出结果的规范是float32x4\u t而不是m128类型。与SSE寄存器类型不同,Neon寄存器类型以组件类型开头,后跟组件的位宽度乘以通道计数。

现在,假设我们想要移植操作128位整数的代码。预期的Neon类型是描述四个32位整数的类型。实际上,与SSE的m128i相对应的Neon寄存器是int32x4。与SSE的m128d对应的霓虹灯类型呢?在本例中,128位寄存器包含两个64位浮点,因此我们可能期望Neon类型为float64x2\t,事实上就是这样!

这里需要记住的重要一点是,SSE类型描述整个向量寄存器的宽度,而Neon类型描述每个分量的宽度和分量计数。

SSE和Neon类型之间的另一个重要区别是对无符号量的处理。在这方面,Neon提供了更多的类型安全性,通过提供诸如uint32x4\t和int32x4\t之类的寄存器类型,对类型本身中数据的有符号与无符号性质进行编码。相反,SSE只提供一个寄存器m128i来存储四个32位有符号和无符号整数。

对于SSE程序员,如果要将数据视为无符号数据,则必须选择适当的intrinsics 函数,如果要将操作数视为无符号整数数据,则必须附加epu*后缀。不过,Neon在类型级别强制执行这一点,程序员需要在必要时显式执行转换。这个组织的一个很好的特性是,由于依赖于参数的查找,您需要记住的intrinsics 函数“名称”更少。

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

C++


 #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的intrinsics vqaddq_32以矢量化的方式添加无符号整数,饱和而不是溢出。注意,A64 GCC将无法编译第二个函数,因为vqaddq_u32 仅为无符号类型定义。

与阅读SSE intrinsics 函数相比,Neon函数也有轻微的学习曲线。SSE内部函数的结构通常如下:


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

例如,_mm_extract_epi32表示在128位寄存器(由宽度前缀\u mm表示)上执行提取操作以产生32位有符号值的intrinsics 操作。intrinsics 的mm256 mul ps对256位寄存器中的压缩标量浮点执行mul操作。

相比之下,许多Neon intrinsics有以下形式:

op_[type]
vaddq_f64
intrinsic 名称中出现“q”表示intrinsics 接受128位寄存器(与64位寄存器相反)。许多op名称都会以“v”开头,意思是“vector”

例如,vaddq_f64执行64位浮点的向量加法。我们可以从“q”推断出这个intrinsics 函数是对128位向量进行运算的。因此,接受的参数必须是float64x2_t,因为只有两个64位的float适合128位向量。

Neon intrinsics 函数的更一般形式还支持作用于SIMD寄存器通道的操作以及其他选项。这里描述了氖本征的完整形式及其规格。

有了这些,你应该能够破译intrinsics 的东西,无论你在哪里遇到他们,都很好,跟随在下一节没有太多的困难。现在让我们研究两种将SSE代码移植到Arm平台上运行的替代方法。

手工移植Intrinsics

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

让我们来看看克莱因的例子,一个使用SSE内蕴编写的C++库,用来计算几何代数中的运算符(特别是用于模拟三维欧氏空间的投影几何代数)。下面的SSE代码片段将表示平面方向的向量与转子(也称为四元数)共轭,从而在空间中旋转平面。

C++



#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分量向量作为um128寄存器。然后,在组合并返回最终结果之前,以“向量”的方式分解出公共子表达式。第一个参数(为简洁起见,这里简称“a”)表示对应于以下隐式方程的平面。

微信图片_20210515212526.png

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

微信图片_20210515212545.png

让我们用函数签名开始我们的Neon移植。

C++


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

接下来,我们需要学习如何用一些常量值初始化float32x4_t 。幸运的是,编译器允许我们使用标准聚合初始化指定初始值:

C++

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

请注意,寄存器中的最低地址排在第一位,这与_mm_set_ps intrinsic地址不同,后者以最高有效字节排在第一位。

与常量寄存器初始化不同,在SSE代码中,使用 _mm_shuffle_ps执行的swizzle操作是一种常见的模式,由于Neon中没有精确的镜像,因此移植起来非常困难。为了模拟功能,我们需要一些工具。

首先是vgetq_lane_f32,它允许我们检索向量中作为标量的指定分量。从标量设置通道的相应intrinsics 函数是vsetq_lane_f32。为了将一个分量从一个向量移到另一个向量,我们有vcopyq_lane_f32.。为了向所有四个组件广播一条线路,我们有vdupq_lane_f32 。这样,我们就可以很清楚地知道如何逐行进行,用相应的车道查询和分配来替换所有的swizzle。

不幸的是,用这种方式替换swizzle不太可能在Arm硬件上产生好的结果。例如,在英特尔硬件上,洗牌有1个周期的延迟惩罚和每条指令1个周期的吞吐量。相反,例如,用于提取通道的DUP指令在Arm Cortex-A78上有3个周期的惩罚。分配一条车道所需的每个MOV将招致另一个2周期延迟惩罚。

为了让Neon获得更好的性能,我们需要接触到的指令不仅仅是一条一条的粒度。有关各种数据排列选项的详细概述,请参阅《Arm的霓虹灯编码指南》的本节。

首先,我们有vextq_f32,它从两个独立的向量中提取分量,从提供的分量索引开始组合它们。此外,我们还有一个rev内部函数族,它允许我们反转组件的顺序。

请注意,我们可以将float32x4_t转换为float64x2_t,并以这种方式反向生成置换。每个REV16、REV32或REV64指令都有2个周期的延迟惩罚,但可能合并许多单独的通道get和set。

在对输入向量进行更仔细的最小排列之后,我们可以得到以下函数:

C++


#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;
}

很好,函数顶部注释中带注释的表达式显示了需要评估表达式的各种临时变量是如何构造的。编译后的输出代码是一个小的指令例程,如下所示:

C++


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硬件上编写高效的内部函数的过程似乎令人望而生畏。SSE代码到Arm代码的许多直接移植都非常耗时,而且并不总是产生所需的结果。

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

SIMDe的前提是,对代码所需的唯一更改是替换通常包含平台内部函数的头。例如,不包括xmmintrin.h,而是包括与最初目标指令集(例如x86/sse2.h)匹配的SIMDe变量。

在内部,SIMDe头检测要编译的目标体系结构,并生成与为原始目标编写代码时使用的内部函数匹配的指令。

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

要直接了解这种intrinsic 的“重写”是如何发生的,您可以在这里参考SIMDe的实现。所有受支持的内部函数都采用相同的方法,SIMDe实现尝试选择最有效的替换实现。一个像这样的承诺可能是所有你需要起来运行霓虹灯迅速。

现在的计划很简单。对每个带有SSE头的文件进行单行更改,改为指向SIMDe头,您应该有一个现在完全可以为Arm硬件编译的代码库。

下一步是分析结果,看看SIOMDe直接替换移植的性能是否可以接受。虽然使用SIMDe进行移植要快得多,但我们已经看到,直接用它们的Arm等价物替换x86内部函数会导致代码效率低下。通过分析移植的代码,您可以根据具体情况慢慢地将有问题的代码部分迁移到本机手写移植。

要查看SIMDe对我们的平面旋转函数的影响,我们可以用以下代码片段交换行以包含SSE头:

C++


#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内部函数和shuffles如何映射到Neon内部函数。由此生成的AArch64汇编代码如下:

C++


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)内部函数。

SIMDe对您的代码库的影响将取决于几个因素,其中一个重要因素是使用的SSE内部函数不能很好地映射到Arm架构。

移植到统一的向量库

另一个值得一提的方法是使用中间库来表示向量操作和编译。采用这种方法的最成熟的选择之一可能是xsimd。

这种方法背后的思想是,实现者不应该试图为每个指令集维护一组定制的例程和算法,而应该使用一个公共抽象层,该层在每个支持的体系结构上都有一个有效的实现。

这种方法的主要缺点是,集成像xsimd这样的库是非常具有侵略性的。与SIMDe一样,一旦失去接近硬件的能力,优化机会很可能会被错过。在某些情况下,如果某些操作在一个体系结构上表现良好,而在另一个体系结构上表现较差,那么xsimd就不支持这些操作。

尽管存在这些问题,但对于没有时间为每种体系结构分析和优化的工程师来说,使用xsimd这样的库可能比使用糟糕的手动移植要好得多。

结论

手工将SSE代码移植到Neon对于那些没有太多代码要移植(相对于您的时间承诺),或者已知所需的性能会推动硬件边界的人来说可能更可取。

对于较小的代码库,如果需要太多的研究和维护来优化每个体系结构的定制实现,可以使用xsimd之类的库来简化矢量化代码的工作。

SIMDe可以用于将x86代码移植到Arm体系结构,而不是编写或重新编写代码来使用xsimd之类的抽象层,对于没有直接的x86到Arm功能映射或可以从性能优化中获益的部分,可以使用自定义代码替换部分源代码。

无论您选择哪种方法来移植代码,让代码可以在任何地方运行现在都是典型的,即使对于低级别的工程师也是如此。在获得更多差异化的平台(例如AVX512)和同时在它们以前可能没有繁荣过的领域(例如Arm in the cloud)中增殖的平台之间存在着一种有趣的紧张关系。

幸运的是,随着需求的增长,支持多体系结构目标的工具正在迅速成熟。除了SIMDe和xsimd之类的抽象之外,Spir-V和WebAssembly之类的可移植指令集也将继续存在。也就是说,在移植代码时,您可以自由地在选择敏捷性和靠近硬件、尽可能回收每一个浪费的周期之间做出一些判断。

为了进一步阅读,一定要看看Arm的霓虹灯系列编码。请考虑与Intel的Intrinsics指南等效的Neon Intrinsics参考。如果您选择在任何地方使用SIMD,GitHub上都可以找到它们的文档。在GitHub上还提供了xsimd项目和其他web文档。此外,免费的Arm性能库可用于编译和运行应用程序

许可证

本文以及任何相关的源代码和文件都是在代码项目开放许可证(CPOL)下授权的

关于作者

Jeremy C. Ong

WB游戏技术负责人

美国

Jeremy是WBGames的首席工程师。他在整个游戏引擎技术栈工作,涉及从渲染和动画,游戏脚本和虚拟机,网络代码和服务器代码的一切。他最热衷于应用数学和计算机科学之间的界限,你经常会发现他在大致相等的部分对其中一个或另一个感到困惑。当杰里米不编码的时候,他可能会花时间和他的妻子和狗在一起,爬山,玩象棋,或者以上这些的结合

推荐阅读
关注数
5822
内容数
525
定期发布Arm相关软件信息,微信公众号 ArmSWDevs,欢迎关注~
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息