在大端模式下使用 ARM NEON 指令

简介

为大端 ARM 处理器生成代码在大多数情况下都很简单。但是,NEON 加载和存储具有一些有趣的特性,使得在大端模式下代码生成决策变得不那么明显。

本文档旨在解释 NEON 加载和存储的问题,以及在 LLVM 中实现的解决方案。

在本文件中,“向量”一词指的是 ARM ABI 所称的“短向量”,它是一系列可以放入 NEON 寄存器的项目。此序列可以是 64 位或 128 位长,并且可以构成 8、16、32 或 64 位项目。本文档始终引用 A64 指令,但几乎也适用于 A32/ARMv7 指令集。在 A32 中传递向量的 ABI 格式与 A64 略有不同。除此之外,相同的概念适用。

示例:C 级内联函数 -> 汇编

首先说明如何将 C 级 ARM NEON 内联函数降低到指令可能会有所帮助。

此简单的 C 函数获取四个整数的向量并将第零个通道设置为值“42”

#include <arm_neon.h>
int32x4_t f(int32x4_t p) {
    return vsetq_lane_s32(42, p, 0);
}

arm_neon.h 内联函数在可能的情况下生成“通用”IR(即,普通 IR 指令而不是 llvm.arm.neon.* 内联函数调用)。以上生成

define <4 x i32> @f(<4 x i32> %p) {
  %vset_lane = insertelement <4 x i32> %p, i32 42, i32 0
  ret <4 x i32> %vset_lane
}

然后变成以下简单的汇编

f:                                      // @f
        movz        w8, #0x2a
        ins         v0.s[0], w8
        ret

问题

主要问题是如何在内存和寄存器中表示向量。

首先,回顾一下。“端序”仅影响项目在内存中的表示方式。在寄存器中,数字只是一系列位 - 在 AArch64 通用寄存器的情况下为 64 位。但是,内存是一系列可寻址的 8 位大小的单元。因此,任何大于 8 位的数字都必须分成 8 位块,而端序描述了这些块在内存中排列的顺序。

“小端”布局将最低有效字节放在最前面(内存地址最低)。“大端”布局将最高有效字节放在最前面。这意味着当从大端内存加载项目时,内存中的最低 8 位必须进入最高 8 位,依此类推。

LDRLD1

_images/ARM-BE-ldr.png

图 1 使用 LDR 的大端向量加载。

向量是连续的一系列同时操作的项目。要加载 64 位向量,需要从内存中读取 64 位。在小端模式下,我们可以通过执行 64 位加载来做到这一点 - LDR q0, [foo]。但是,如果我们在大端模式下尝试这样做,由于字节交换,通道索引最终会被交换!内存中排列的第零个项目成为向量中的第 n 个通道。

_images/ARM-BE-ld1.png

图 2 使用 LD1 的大端向量加载。请注意,通道保持正确的顺序。

因此,指令 LD1 执行向量加载,但对整个 64 位不执行字节交换,而是在向量内的各个项目上执行字节交换。这意味着寄存器内容与它在小端系统上的内容相同。

似乎 LD1 应该足以在大端机器上执行向量加载。但是,这两种方法的优缺点使其选择哪种寄存器格式并非易事。

有两种选择

  1. 向量寄存器的内容如同它已使用 LDR 指令加载一样。

  2. 向量寄存器的内容如同它已使用 LD1 指令加载一样。

因为 LD1 == LDR + REV 并且类似地 LDR == LD1 + REV(在大端系统上),我们可以使用另一种类型的加载加上 REV 指令来模拟任一类型的加载。因此,我们不是决定使用哪些指令,而是决定使用哪种格式(然后这将影响最适合使用的指令)。

请注意,在本节中,我们只提到了加载。存储与其关联的加载具有完全相同的问题,因此为了简洁起见,已跳过。

注意事项

LLVM IR 通道排序

LLVM IR 具有头等向量类型。在 LLVM IR 中,向量的第零个元素位于最低内存地址。优化器在某些区域依赖此属性,例如在将向量连接在一起时。目的是使数组和向量具有相同的内存布局 - [4 x i8]<4 x i8> 应该在内存中以相同的方式表示。如果没有此属性,优化器将不得不巧妙地处理许多特殊情况。

使用 LDR 将破坏此通道排序属性。这并不排除使用 LDR,但我们需要执行以下两项操作之一

  1. 在每个 LDR 之后插入 REV 指令以反转通道顺序。

  2. 禁用所有依赖于通道布局的优化,并为对单个通道的每次访问(insertelement/extractelement/shufflevector)反转通道索引。

AAPCS

ARM 过程调用标准 (AAPCS) 定义了在寄存器之间传递向量的 ABI。它指出

当短向量在寄存器和内存之间传输时,它被视为不透明对象。也就是说,短向量存储在内存中的方式就像使用整个寄存器的单个 STR 存储一样;短向量使用相应的 LDR 指令从内存加载。在小端系统上,这意味着元素 0 将始终包含短向量的最低地址元素;在大端系统上,元素 0 将包含短向量的最高地址元素。

—ARM 64 位架构 (AArch64) 过程调用标准,4.1.2 短向量

使用 LDRSTR 作为 ABI 定义至少比 LD1ST1 具有一个优点。 LDRSTR 不关心向量的各个通道的大小。 LD1ST1 不是 - 通道大小在其中编码。这在 ABI 边界上很重要,因为需要知道被调用方期望的通道宽度。考虑以下代码

<callee.c>
void callee(uint32x2_t v) {
  ...
}

<caller.c>
extern void callee(uint32x2_t);
void caller() {
  callee(...);
}

如果 callee 将其签名更改为 uint16x4_t,这在寄存器内容中是等效的,如果我们作为 LD1 传递,我们将破坏此代码,直到 caller 被更新并重新编译。

有人认为,如果两个函数的签名不同,则行为应未定义。但可能有一些函数与向量的通道布局无关,并且在不跨 ABI 边界使用通用格式的情况下,将向量视为不透明值(只需加载和存储它)将是不可能的。

因此,为了保持 ABI 兼容性,我们需要在函数调用中使用 LDR 通道布局。

对齐

在严格对齐模式下,LDR qX 要求其地址为 128 位对齐,而 LD1 只要求其对齐方式与通道大小相同。如果我们规范化为使用 LDR,我们仍然需要在某些地方使用 LD1 以避免对齐错误(然后需要使用 REV 反转 LD1 的结果)。

但是,大多数操作系统都没有启用对齐错误,因此这通常不是问题。

总结

下表总结了为上面提到的每个属性为两种解决方案中的每一种发出的指令。

LDR 布局

LD1 布局

车道排序

LDR + REV

LD1

AAPCS

LDR

LD1 + REV

严格模式下的对齐

LDR / LD1 + REV

LD1

两种方法都不完美,选择哪一种归结为选择较小的恶。经过决定,车道排序问题将不得不更改目标无关的编译器传递,并导致一个车道索引被反转的奇怪IR。最终决定,这比为了支持LD1而需要进行的更改更糟糕,因此选择LD1作为规范的向量加载指令(并推断出ST1用于向量存储)。

实现

实现分为三个部分

  1. 预测LDRSTR指令,使其永远不允许被选择用于生成向量加载和存储。例外情况是一车道向量[1] - 它们根据定义不会有车道排序问题,因此可以使用LDR/STR

  2. 为创建REV指令的位转换创建代码生成模式。

  3. 确保创建了适当的位转换,以便向量值作为1元素向量跨越调用边界传递(这与使用LDR加载时相同)。

位转换

_images/ARM-BE-bitcastfail.png

LD1解决方案的主要问题是处理位转换(或位转换、重新解释转换)。这些是伪指令,只更改编译器对数据的解释,而不是底层数据本身。一个要求是,如果数据被加载然后再次保存(称为“往返”),则存储后的内存内容应与加载前相同。如果向量被加载,然后在存储之前被位转换为不同的向量类型,则往返过程当前将被破坏。

例如,这段代码序列

%0 = load <4 x i32> %x
%1 = bitcast <4 x i32> %0 to <2 x i64>
     store <2 x i64> %1, <2 x i64>* %y

这将生成右侧图中所示的代码序列。不匹配的LD1ST1导致存储的数据与加载的数据不同。

当我们看到从类型X到类型Y的位转换时,我们需要做的是更改数据的寄存器内表示,使其如同它刚刚被类型YLD1加载一样。

_images/ARM-BE-bitcastsuccess.png

从概念上讲,这很简单 - 我们可以插入一个REV来撤消类型XLD1(将寄存器内表示转换为与LDR加载时相同),然后插入另一个REV来更改表示,使其如同它被类型YLD1加载一样。

对于前面的示例,这将是

LD1   v0.4s, [x]

REV64 v0.4s, v0.4s                  // There is no REV128 instruction, so it must be synthesizedcd
EXT   v0.16b, v0.16b, v0.16b, #8    // with a REV64 then an EXT to swap the two 64-bit elements.

REV64 v0.2d, v0.2d
EXT   v0.16b, v0.16b, v0.16b, #8

ST1   v0.2d, [y]

事实证明,这些REV对在几乎所有情况下都可以压缩成单个REV。对于上面的示例,REV128 4s + REV128 2d实际上是REV64 4s,如右侧图所示。