36

啥都吃的豆芽 · 2021年07月13日

将 SSE 移植到 Neon:library是未来的方向吗?

image.png
Ben Clark
2021年7月1日

目前,游戏和应用程序越来越多地考虑转向支持 Arm Neon 的设备。比如移动端的游戏(因为它是最大的细分市场),或者那些发现 Windows on Arm 生态系统现在已经足够成熟可以转向原生的游戏。有很多已经采取了行动。例如,已经转移到移动设备的 PC 和控制台游戏,如 Grand Theft Auto、Fortnite 和 Brawlhalla,以及支持 Arm Neon 的 PC 和笔记本电脑应用程序,如 Photoshop、Zoom 和 Visual Studio Code。

当您希望迁移到 Arm Neon 时,应用程序或游戏中的许多代码只需要使用新目标重新编译即可。如果你有需要,框架支持会提供相应的帮助 ,但是手写的英特尔intrinsics code怎么办?
image.png

提醒一下,intrinsics code是编写专门的 SIMD(单指令多数据)指令的一种简单方法。与汇编指令的功能相同,但直接在 C/C++ 中,无需担心使用哪些特定寄存器,以及 ABI 等。

Arm 有自己的内联函数 ——Neon——,但内联函数代码需要时间、精力和大量的思考,尤其是当它是一套你不熟悉的新命令时。有没有更简单的方法?

好消息是:有一种更简单的方法——使用两个伟大的开源库中的任何一个,将英特尔的 SSE(流式 SIMD 扩展)内在函数转换为 Neon。第一个也是较旧的库被准确地称为 – SSE2Neon –因为它就是这样做的,它将 SSE 转换为 Neon。另一个库 ——SIMDe或“SIMD Everywhere” ——希望尽可能多地将 SIMD 代码翻译成尽可能多的不同架构。Blender、OGRE 和 FoundationDB 是使用这些库之一移植到 Arm Neon 的众多产品之一。

您编写了内联函数是因为这些代码需要出色的性能。但是因此,您需要决定手动编写代码以提高效率与轻松移植和维护库的价值有多大。本博客旨在帮助您实现这种平衡。

实践

我们正在移植到当前处理器正在使用的64位ARMv8-A体系结构,为简单起见,我们假定直接使用128位SSE到128位Neon端口。ARMv9-A应该非常类似于这个Neon使用案例。尽管没有具体研究,但是如果您正在转换256位SSE,那么这个建议在很大程度上仍然是合理的。尽管它必须将所有东西一分为二才能转换为128位Neon,但如果您也要手动转换,也需要这样做。

我从结论开始:除非您的项目对内在函数的使用非常小,否则开始您的移植是有意义的。这是有道理的,因为它简化了移植任务,无论您最终是否用手写的 Neon 替换库代码。

这些库允许您用真正的本机端口替换尽可能多或尽可能少的内容。因此,使用库意味着您仍然可以尝试做得比库做得更好。实际上,您可以决定最终完全替换该库,并且在移植过程中使用它仍然有意义。这些库允许您快速编译您的项目并在 Neon 上工作,即使您在那之后仍想提高性能。在任何工作之前,您不需要重写所有 SSE 内在函数代码,并且在开始优化之前,您有一个功能正确的端口工作。

这些库提供了尽可能好的直译,并有一个相当大的社区在不知道您调用这些内在函数的原因的情况下帮助确保尽可能好的映射。但是,当您进行移植时,您知道调用内联函数的算法,并且可以调整周围的代码以适应 Neon 命令并获得更大的改进。此外,了解您的代码以及您想要实现的目标后,可能会有您可以使用的 SSE 中没有的 Neon 内联函数可用。例如,在后来的 Neon 实现中,有复数支持,因此有些函数可以通过一次内部调用来完成,而不是在 SSE 函数及其直接 Neon 转换中进行多次调用。还有一些领域,如 8 位支持,许多功能将能够直接通过 Neon 调用完成,而不是以更复杂的方式使用 SSE(如果可以的话)。在后来的 Neon 实现和后来的 SSE 实现中,有不同的点积内在实现,但库无法将它们相互映射,而是实现了许多乘法累加——因此可能会改进更专业的近期调用。

通常,SSE 到 Neon 端口的最佳第一步是运行。

如果您的项目的内在函数使用量非常小,那么手工完成可能是有意义的。如果它只是几个小函数,那么制作 SSE 和 Neon 变体并不麻烦,您可以考虑可用的内在函数并围绕它们形成算法以提高效率。

但是,如果您的项目不是那种特殊情况并且您决定使用一个库,那么您应该使用哪个库?

SIMDe 与 SSE2Neon

两个库的工作方式相同,内部函数的 intel #include 被库 #include 替换。然后在实现中,库检测编译器提供的内联函数。这种检测有三个结果:

  • 如果是英特尔,则它直接落入原始实现。
  • 如果是 Arm,则转换为 Neon。
  • 如果两者都不是,则它使用非内部实现。
  • 对于 SSE2Neon,只需要包含更改
  • 对于 SIMDe,有一个定义可以避免代码更改超出不同的包含,但为了清楚起见,建议通过向 SSE 函数添加 SIMDe 前缀来改为进行代码更改

SSE2Neon 支持 MMX 和 SSE,但如果您有 AVX 代码,则需要使用 SIMDe。SIMDe 支持 AVX/AVX2,但仅部分支持 AVX512。

为了做出超越 AVX 支持的选择,SSE2Neon 的卖点是它的简单性。如果您只考虑过这种单向端口,那么它可能是您要选择的方法,而不是拥有 SIMDe 为您提供的所有选项。

不过,SIMDe 为您提供了一种方法来覆盖更多端口的基础,并可能用于未来的技术变革。如果您希望支持 WebAssembly,或在 Neon 中实现某些功能并让它在 SSE 上自动工作,这些选项都包含在内。SIMDe 打算随着时间的推移而扩展,因此随着 Arm 和 Intel 发布新的 SIMD 技术,您也将能够涵盖这些技术。例如,SVE2 是对 最近宣布的Arm v9 架构中Neon 的改进 。

它们都是开源的,因此受益于许多贡献,因为人们已经涵盖了他们需要的所有功能,如果他们找到了更有效的方法,则会对其进行改进。SSE2Neon 较旧,其代码最初用于 SIMDe 以涵盖 SSE 到 Neon 用例,因此实现几乎相同。目前两者都得到维护,因此似乎没有选择一个而不是另一个的性能原因。

初始端口后

所以你已经移植了这个库并在 Arm 上运行了一个功能正确的代码版本,你完成了吗?

如果您对性能感到满意,那么是的,您已经完成了。当您实现内在函数时,因为性能在这些代码段中至关重要,如果从 SSE 到 Neon 的映射不完美,则该代码可能需要改进。

有一些内在函数确实享受完美或接近完美的映射。如果您有包含这些的代码段,那么花在它们上面的任何时间都可能使性能提升为零。那么,在哪里最好地花费我们的精力呢?

显而易见的答案是分析——无论如何,Arm 设备可能与 Intel 设备有不同的瓶颈,因此可能有不同的代码段需要改进。但是对于内联函数,我们可以给出一些关于哪些代码片段最有可能值得仔细研究的指针。

大多数基本的算术和逻辑函数要么是完美的映射,要么接近完美。除了重新解释的内联函数是免费编译器指令外,加载、设置和存储内在函数也与它们的库翻译很好地映射。这意味着简单的数学通常不需要任何额外的工作。所以 2D 距离计算可能会留给库翻译:

void distances(float* xDists, float* yDists, float* results, int size)
{
    __m128 Xs, Ys, m1, m2, m3, res;

    for (size_t index = 0; index < size; index += 4)
    {
        Xs = _mm_load_ps(xDists + index); 
        Ys = _mm_load_ps(yDists + index); 
        m1 = _mm_mul_ps(Xs, Xs);        
        m2 = _mm_mul_ps(Ys, Ys);
        m3 = _mm_add_ps(m1, m2);  
        res = _mm_sqrt_ps(m3);   
        _mm_store_ps(results + index, res);
    }
}
                    用于矢量化 2D 距离计算的 SSE
                    
distances(float*, float*, float*, int):
        sxtw    x4, w3
        cbz     w3, .L1
        sub     x3, x4, #1
        add     x4, x0, 16
        lsr     x3, x3, 2
        add     x3, x4, x3, lsl 4
.L3:
        ldr     q0, [x1], 16
        ldr     q1, [x0], 16
        fmul    v0.4s, v0.4s, v0.4s
        fmla    v0.4s, v1.4s, v1.4s
        fsqrt   v0.4s, v0.4s
        str     q0, [x2], 16
        cmp     x0, x3
        bne     .L3
.L1:
        ret
                  Godbolt上 SIMDe Neon 翻译的GCC编译
                  

加载和存储并不是很完美,部分原因是 SIMD 类型中向量的 SSE 排序与 Neon 相反,但这一根本差异导致的问题比人们预期的要少。

要浏览一些函数的类别-在类别中有个别的例外-我们可以创建以下列表:

  • 如前所述,基本的算术和逻辑函数要么是一个完美的映射或者十分接近。加载、设置和存储大部分映射都很好。
  • 许多更复杂的数学函数(sqrt, avg, min, max)可以很好地从SSE映射到Neon,尽管绝对值通常不是很好(但偶尔是完美的)。
  • Bit shifts是一个你更可能通过编写一些原生Neon以及否定和对齐而获得收益的领域。
  • 转换、插入和提取通常都能很好地映射,因此对于任何专门的Neon代码来说都应该是低优先级的。
  • 比较大多数良好的映射,尽管在与 NaN、0 或 1 进行特定比较时可能会有所收获,但其中英特尔具有专门的功能。
  • move大多能很好地映射,但movemask却不能。
  • 更高精度的除法和根号会降低映射效率-你需要精度吗?
  • “成对”函数(一个内在向量中的数字一起使用的水平运算)可能效率较低,尤其是减法,因此有可能通过专门的 Neon 代码进行改进。
  • 非常特殊的SSE函数,如_mm_dp_ps和_mm_minpos_epu16是最糟糕的情况,在这种情况下,Neon的不同算法可能会有显著的改进。
  • 加密函数和位测试同样值得研究是否应该实现专门的 Neon 代码。
  • Blends、shuffles 和 rounding 比算术类型的函数有更大的增益潜力。
  • 类似地,Narrowing和Widening并不像某些那样糟糕,但是对于手写代码仍然有一些潜在的好处。
  • 最近向 Neon 或 SSE 添加的功能不太可能被很好地映射 - 但可能是为开源库做出贡献的机会。

更换多少不仅取决于效率,还取决于可用于港口的时间以及维护成本。让 SSE 函数在 Neon 上“run”几乎不需要任何开销,而拥有专门的代码涉及需要实现和测试两次的任何更改。这些决定以及要编写多少专门的代码,需要在逐个项目的基础上做出。

结论

我很早就给出了答案,但对于使用多个内在函数的任何项目,使用这两个开源库之一——SSE2Neon 和 SIMDe——是移植时的方法。在初始移植之后,需要做多少进一步的工作——从无到有,到完全替换库,以及介于两者之间的任何事情——是每个项目做出的决定。在代码的效率和速度与端口可用的时间和可接受的维护开销之间取得平衡。

一旦决定了专门的端口走多远,希望这个博客可以帮助您了解可能会取得更大收益的地方。最常见的数学函数以及从内在函数中插入和删除数据与库配合得很好,因此其他领域是实现单独的专用代码的更有用的目标。尽管在库中内在函数之间的音译尽可能好,但在使用适合 Neon 中可用内在函数的算法编写代码的情况下,仍有可能提高性能。Neon 具有 SSE 所没有的内在特性,这将是最大的潜在改进。

为了能够编写专门的 Neon 代码,您需要了解 Neon 内联函数,因此请查看 Arm 的其他 Neon 资源。 使用 C/C++ 内在函数进行优化 是一个很好的起点,并且完整的可搜索内在函数列表非常有用。如果你想回归基础,这里有 对Neon 概念的 一般介绍,对于更高级的原则,在Neon assembly文档中是相同的 。最后,您还可以找到一篇关于如何最好地让编译器在 没有内在函数的情况下自动矢量化的文章 。移植快乐!

推荐阅读
关注数
23554
内容数
1003
Arm相关的技术博客,提供最新Arm技术干货,欢迎关注
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息