Neon 本地函数优化数学、网络和字符串操作





0/5 (0投票)
在本文中,我们将探讨在应用程序中集成 Neon 本地函数的多种方法。
自 2015 年以来,Arm 一直在 GitHub 上维护一个优化例程的存储库。这些例程涵盖了从数学运算符、网络相关函数到字符串操作的各种功能。这些例程利用 Neon 本地函数和汇编代码来提高运行速度。
在本文中,我们将首先概述 Arm 提供的优化例程。然后,我们将讨论 Neon 本地函数本身及其性能特征。最后,我们将展示如何使用 Neon 本地函数来加速应用程序中的自定义例程,并提供一些关于如何构建适合向量化的代码的实用指导。
SIMD 快速概述
让我们从快速回顾 CPU 在执行程序时所做的工作开始。CPU 以流水线方式运行,其中程序的每个指令在最终执行之前都要经历一系列阶段。
这些阶段从指令获取和解码开始。在理想情况下,指令将驻留在指令缓存中或从分支预测表中解析。否则,指令将从可执行文件的内存映射中获取。
之后,指令引用的寄存器将被重命名以支持乱序 (OOO) 执行。根据指令类型,指令将被分派到可用的发布队列之一。
最后,指令在目标执行单元上运行,执行单元本身也是一个流水线。执行单元要么是用于整数加/乘/除硬件的算术逻辑单元 (ALU) 流水线,要么是用于分支和条件逻辑的流水线,要么是单指令多数据 (SIMD) 和浮点流水线。
提高程序吞吐量的一种常见方法是采用额外的核心,有效地在可用硅片上扩展。许多 Arm CPU(低功耗微控制器除外)都拥有多个核心可供多线程应用程序使用。在理想情况下,所提供的速度提升与核心数量成线性关系(即,四个可用核心最多可提供 4 倍的速度提升)。然而,这种倍增通常是理想化的,因为不同的核心必须进行外部同步。
SIMD 提供了一个完全独立的维度,用于实现更高的吞吐量,与多线程正交。与多线程不同,SIMD 指令在单个 CPU 核心的上下文中运行,因此无需担心同步问题。基本思想是,在分派指令时,我们可以要求该操作作用于多个数据流,而不是仅作用于一个。
SIMD 使用(也称为向量化)与多线程完全互补,如果需要最大化系统吞吐量,则应同时使用这两种技术。
Neon 是专门针对 Arm CPU 的 SIMD 指令集。可在此处的搜索注册表中找到完整的 Neon 本地函数列表。我们将很快编写一些 Neon 代码,但首先,让我们来 survey 一下 Arm 提供的例程。
使用 Arm 优化例程
首先,您需要将例程的库集成到您的工具链中。如果您目标是 Android 或 Linux 上的 glibc,则不需要这样做,因为 C 运行时库 (CRT) 已经在其实现中使用了这些例程。对于其他工具链,建议的方法是使用提供的 Makefile 编译库,并将生成的库链接到您的应用程序。
默认情况下,Makefile 目标是 AArch64 (ARM64),因此如果您希望将代码部署到 32 位 CPU,您需要更改 config.mk 文件中的目标 ARCH 变量。请注意,该存储库实际上包含三个单独的库,分别对应数学例程、字符串处理例程和网络例程(目前,网络例程仅包含一个优化的校验和)。这些库可以单独编译和链接。
要查看提供的例程,请参考以下头文件:mathlib.h、networking.h 和 stringlib.h(请注意,网络头文件名缺少“lib”后缀)。
数学库实现了单精度和双精度的函数(exp, log, pow, sin, cos)。
网络库提供了一个校验和例程。
字符串库提供了熟悉的内存例程:memcpy, memmove, memset, memchr, memrchr,以及它们的字符串等效函数。
由于代码享有慷慨的 MIT 许可,阅读其实现尤其具有启发性,特别是对于刚接触该指令集的新手。
采用向量化的选项
Arm 提供的、CRT 中使用的向量化例程操作的是标量或矢量量。例如,调用 cosf(x) 会计算单个值的余弦,但内部使用了 SIMD 指令。或者,如果自动向量化允许,或者直接调用了矢量变体(例如 __v_cosf),编译器可以选择矢量变体。通常,如果我们希望为其他例程使用 SIMD 指令,我们有三个选择。
首先,我们可以指示编译器启用自动向量化,并寄希望于我们的代码适合向量化。对于 GCC,在用 -O3 编译时,自动向量化默认是开启的。对于其他优化级别,可以传递 -ftree-vectorize 标志。
依赖编译器进行向量化的好处是,编写的代码将保持最大的可移植性,并支持编译器支持的任何指令集。此外,代码通常不包含内联汇编和本地函数,这使得代码更易于维护。
虽然自动向量化是一个不断发展的研究领域,但仍有许多领域无法实现自动向量化。例如,当编译具有迭代间依赖性、break 子句或复杂分支条件的循环时,自动向量化通常会中断。有关使用自动向量化为 Neon 编译的更多信息,请参阅 Arm 的指南。
其次,我们可以使用汇编,无论是作为独立的代码模块还是作为内联汇编。可用的浮点和 SIMD 指令已在此在线参考中列出。与使用本地函数(我们将要探索的最后一个选项)相比,直接汇编允许您控制寄存器分配和加载/存储对齐。在这些选项中,汇编是最不便携、最难维护的,但可能是性能最佳的途径。
第三,我们可以选择使用 Neon 本地函数编写向量化代码。本地函数在源代码中看起来像函数调用,但尽管本地函数是使用汇编映射定义的,但它们仍然会经过编译器优化。因此,不能保证您会得到文档中的确切指令,只能保证您得到至少与本地函数定义的指令一样高效的指令。
与编写纯汇编相比,本地函数直接操作变量而不是寄存器。这意味着您可以继续让编译器执行寄存器分配,并且可以忽略函数调用约定的复杂性。因此,本地函数比隐式自动向量化提供了更明确的向量化,比纯汇编控制力更小,但比编写和维护纯汇编所需的工作量也更少。
对于许多要求高性能的应用程序来说,本地函数是在简洁性和效率之间取得理想平衡的折衷方案。要开始使用本地函数编程,有两个指南将引导您完成设置以及如何应用 Neon 本地函数来实现和基准测试点积,以及实现一维信号卷积和阈值操作。
使用 Neon 本地函数进行简单的碰撞检测
优化例程存储库中的一些例程(例如 cosf 和 logf)演示了如何使用矢量本地函数来加速原本是标量操作的函数。也就是说,执行接受单个标量参数的函数。
另一种常见的向量化方法是数组结构 (SoA) 风格的向量化。与前一种方法相比,计算操作的算法本身没有改变。取而代之的是,我们仅使用本地函数在多个通道上复制相同的算法。
考虑以下两个圆之间的简单碰撞检测例程
struct circle
{
float radius;
float center_x;
float center_y;
};
bool does_collide(circle& c1, circle& c2)
{
// Two circles collide if the distance from c1 to c2 is less
// than the sum of their radii, or equivalently if the squared
// distance is less than the square of the radii sum.
float dx = c1.center_x - c2.center_x;
float dy = c1.center_y - c2.center_y;
float d2 = dx * dx + dy * dy;
float r2 = c1.radius * c1.radius + c2.radius * c2.radius;
return d2 < r2;
}
/* Disassembly
ldr s0, [x0, 4]
ldr s1, [x1, 4]
fsub s0, s0, s1
ldr s2, [x0, 8]
ldr s1, [x1, 8]
fsub s2, s2, s1
ldr s1, [x0]
ldr s3, [x1]
fmul s0, s0, s0
fmul s2, s2, s2
fadd s0, s0, s2
fmul s1, s1, s1
fmul s3, s3, s3
fadd s1, s1, s3
fcmpe s0, s1
cset w0, mi
ret
*/
一种加速方法是注意到许多操作是重复的,并进行如下向量化
#include <arm_neon.h> // assume this is included for snippets below
bool does_collide_neon(circle const& c1, circle const& c2)
{
// Pack the circle centers into registers with 2 float lanes
// Note that while unaligned loads into SIMD registers are supported,
// you are responsible for ensuring that the struct packing and layout
// is done in a way that leaves the register contents well-defined
float32x2_t c1_center = vld1_f32(&c1.center_x);
float32x2_t c2_center = vld1_f32(&c2.center_x);
// Compute the deltas and square them
float32x2_t d = vsub_f32(c1_center, c2_center);
float32x2_t dxd = vmul_f32(d, d);
float d2 = vpadds_f32(dxd);
float r_sum = c1.radius + c2.radius;
float r_sum2 = r_sum * r_sum;
return d2 < r_sum2;
}
/* Disassembly
ldr d0, [x0, 4]
ldr d1, [x1, 4]
fsub v0.2s, v0.2s, v1.2s
fmul v0.2s, v0.2s, v0.2s
faddp s0, v0.2s
ldr s1, [x0]
ldr s2, [x1]
fadd s1, s1, s2
fmul s1, s1, s1
fcmpe s0, s1
cset w0, mi
ret
*/
在上面,我们通过注意到在计算平方距离时可以并行化减法和乘法操作来向量化实现。
但是,上面的函数可能不如我们原来的实现快。任何吞吐量的提升都会受到输入内存布局的阻碍,这需要许多指令才能将矢量寄存器打包。此外,我们只能执行两个数据并行操作(一个减法和一个乘法),然后就需要执行跨通道操作。
总而言之,circle 结构体的声明意味着数据是交错的,这阻碍了向量化。
另一种方法是重新考虑我们的内存布局,提前执行解交错。假设我们知道,在大多数情况下,我们希望将一个圆与一组其他圆进行测试。作为示例,设想您有一个具有一定半径的瞄准视线,并且您想知道哪些边界圆与瞄准视线相交。以下是我们如何使用本地函数来加速这种情况
struct circles
{
size_t size;
// When allocating the arrays below, always round up to a multiple of 4.
float* radii;
float* center_xs;
float* center_ys;
};
// Check if collider collides with each circle within input
// out should point to an array of input.size booleans
void does_collide_neon_soa(circles const& input, circle& collider, bool* out)
{
// Duplicate the collider properties in 3 separate 4-lane vector registers
float32x4_t c1_x = vdupq_n_f32(collider.center_x);
float32x4_t c1_y = vdupq_n_f32(collider.center_y);
float32x4_t c1_r = vdupq_n_f32(collider.radius);
for (size_t offset = 0; i != input.size; offset += 4)
{
// Perform 4 collision tests at a time
float32x4_t x = vld1q_f32(input.center_xs + offset);
float32x4_t y = vld1q_f32(input.center_ys + offset);
float32x4_t dx = vsubq_f32(c1_x, x);
float32x4_t dy = vsubq_f32(c1_y, y);
float32x4_t dx2 = vmulq_f32(dx, dx);
float32x4_t dy2 = vmulq_f32(dy, dy);
float32x4_t d2 = vaddq_f32(dx2, dy2);
float32x4_t r = vld1q_f32(input.radii + offset);
float32x4_t r_sum = vaddq_f32(c1_r, r);
float32x4_t r_sum2 = vmulq_f32(r_sum, r_sum);
uint32x4_t mask = vcltq_f32(d2, r_sum2);
// Unpack each lane and avoid uint32_t to bool conversion
// using a masking operation
out[offset] = 1 & vgetq_lane_u32(mask, 0);
out[offset + 1] = 1 & vgetq_lane_u32(mask, 1);
out[offset + 2] = 1 & vgetq_lane_u32(mask, 2);
out[offset + 3] = 1 & vgetq_lane_u32(mask, 3);
}
}
在这里,我们没有尝试加速单个碰撞计算,而是提前解交错数据,然后执行与之前相同的算法,只是这次,我们将一个圆与四个圆同时碰撞。与我们第一次尝试使用 Neon 本地函数相比,这次尝试不再需要付出高昂的内存复制成本来打包寄存器,并且大部分操作都是以向量化的方式执行的。
在对上述函数进行性能分析时,所有函数都用 GCC 的 `noinline` 属性进行装饰,以抑制可能发生在代码可内联时发生的自动向量化。这更符合实际世界的函数,但我们鼓励您对内联场景进行基准测试,因为这会影响调用上下文中的寄存器分配和自动向量化。下表总结了结果
16384 次圆-圆测试 | 每次调用的时间 | 加速比 |
does_collide | 2.724 纳秒 | 1x |
does_collide_neon | 2.717 纳秒 | 1.003x |
does_collide_neon_soa | 0.925 纳秒 | 2.945x |
对于上面的每个测试,左栏中的函数用于在 100,000 次试验中执行 16,384 次碰撞测试,以计算中间栏中的每次调用时间。在所有情况下,代码都使用 -O3 编译并在三星 S20 上运行。正如您所看到的,使用非 SoA Neon 实现,速度提升很小。然而,将数据重构为 SoA 形式可带来近 3 倍的惊人速度提升。
总结
在本文中,我们探讨了在应用程序中集成 Neon 本地函数的多种方法。应首先考虑的第一种方法是使用现有的预优化例程。如果您不针对已包含 Arm优化例程的工具链的 Android 设备,您应该认真考虑将该库集成到您的项目中。
如果重构数据不是一个选项,通常有机会向量化函数的实现,前提是未发生自动向量化。这种方法对算法的结构侵入性更强,但对函数的调用者来说是透明的。
如果数据重构是一个选项,这种方法可以通过将原始算法复制到多个通道中来实现任何算法的向量化。导航所有可用选项无疑是一项投资,需要对各种权衡和基准测试有清晰的理解。然而,在吞吐量和能源效率方面带来的回报是引人注目的。
如需进一步阅读,请参阅以下页面