在 Big Endian 模式下使用 ARM NEON 指令

簡介

為 Big Endian ARM 處理器產生程式碼大部分都很簡單。然而,NEON 載入和儲存指令有一些有趣的特性,使得在 Big Endian 模式下產生程式碼的決策不太明顯。

本文旨在說明 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 位元的區塊,而位元組序描述了這些區塊在記憶體中的排列順序。

「Little Endian」排列方式將最低有效位元組放在最前面(記憶體位址最低)。「Big Endian」排列方式將*最高*有效位元組放在最前面。這表示從 Big Endian 記憶體載入項目時,記憶體中最低的 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),都反轉通道索引。

ARM 程序呼叫標準 (AAPCS)

ARM 程序呼叫標準 (AAPCS) 定義了在寄存器中傳遞向量函數的 ABI。它規定:

當一個短向量在寄存器和記憶體之間傳輸時,它被視為一個不透明的物件。也就是說,一個短向量存儲在記憶體中,就好像它是使用單個 STR 指令存儲整個寄存器一樣;一個短向量使用相應的 LDR 指令從記憶體中載入。在小端序系統中,這意味著元素 0 將始終包含短向量中地址最低的元素;在大端序系統中,元素 0 將包含短向量中地址最高的元素。

—ARM 64 位架構程序呼叫標準 (AArch64),4.1.2 短向量

使用 ABI 定義的 LDRSTR 指令至少比 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 來避免對齊錯誤(LD1 的結果需要使用 REV 反轉)。

然而,大多數操作系統並未啟用對齊錯誤,因此這通常不是問題。

總結

下表總結了針對上述兩個解決方案中提到的每個屬性,需要發出的指令。

LDR 佈局

LD1 佈局

通道排序

LDR + REV

LD1

AAPCS

LDR

LD1 + REV

嚴格模式的對齊

LDR / LD1 + REV

LD1

兩種方法都不完美,選擇其中一種歸結為兩害相權取其輕。有人認為,通道排序問題必須改變與目標無關的編譯器通道,並且會導致通道索引反轉的奇怪 IR。有人認為這比支援 LD1 所需進行的更改更糟糕,因此選擇了 LD1 作為標準向量載入指令(依此類推,ST1 用於向量儲存)。

實作

實作分為 3 個部分

  1. 斷言 LDRSTR 指令,以便永遠不允許選擇它們來產生向量載入和儲存。單通道向量除外 [1] - 根據定義,這些向量不可能出現通道排序問題,因此可以使用 LDR/STR

  2. 為建立 REV 指令的位元轉換建立程式碼產生模式。

  3. 確保建立適當的位元轉換,以便向量值作為單元素向量(這與使用 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,如右圖所示。