2024年6月15日 星期六

在 ARM 平台上使用 Function Multi-Versioning (FMV) - 以使用 Android NDK 為例

Function Multi-Versioning (FMV)

過往的 CPU 發展歷程中, x86 平台由於因應各種應用需求的提出, 而陸陸續續加入了不同的指令集, 此外也可能因為針對市場做等級區隔, 支援的數量與種類也不等. 在 Linux 平台上這些 CPU 資訊可以透過 /proc/cpuinfo 取得. 而對於 CPU 不同的指令集支援去實作軟體最佳化, 是相當繁複的工作. 而這些在過去分享過的 Function Multi-Versioning (FMV) 的出現, 而大幅地簡化.

隨著時間過去, 這十年來發展迅速的 ARM 平台, 在 ARMv8 之後不斷作小幅度的翻新, 也逐漸產生類似 x86 平台上的問題. 以 A-Profile 處理架構而言從 ARMv8.0-A 一路發展到 ARMv9.4-A. 當中有著許多新硬體特性與新增指令, 有些是 core feature 而有些則是 optional, 這些也逐漸造成了 ARM CPU 平台功能性上大小不等的碎片化. 在 ARM 官網有提供了一份 "Feature names in A-profile architecture" 列表. 而這整理僅止於 ARMv9 之前. 此外更麻煩的地方是, 各大 compiler 在先前一直沒有對應支援 FMV 的功能, 如此對於通用的應用程式, 想要能夠有效使用 ARM 這些功能特性同時顧及不支援的平台, 著實是一件不簡單的事.

而事情在 2023 年中開始有了轉機, 在當時所釋出的 Clang / LLVM 16.0 開始在 ARM 平台上支援  FMV 功能. ARM 官方甚至在其系列文的 "What's new in LLVM 16" 中有特別舉例說明 FMV 的使用. 一旦能夠使用 FMV, 這對於不確定運作的目標平台的應用程式開發很有幫助. 但在當時個人第一時間找不到具有支援的 llvm toolchain, 而之後的這段時間因工作繁忙一直沒有時間再繼續做嘗試, 也就沒辦法撰寫心得. 近日回想起心中所惦記的這麼一件事, 於是特別拿了最新的 Android NDK 26d (附的是 clang 17.0.2) 來測試.

Case Study - FP16 GEMM w/ Vector Extension, Gather Auto-Vectorization

首先請安裝 Android NDK 26d 到 /opt 目錄內, 並將 /opt/android-ndk-r26d/toolchains/llvm/prebuilt/linux-x86_64/bin/ 加入 PATH 環境變數中.

由於個人在過去所分享的教材投影片中即談論過 FMV, 本文中就不再多做詳細介紹, 有需要可以參考過去的投影片, 在這裡就透過兩個實際的例子來嘗試 ARM FMV 功能, 第一個 Case 是個人常拿來作為 Compiler Vector Extension 的 Laboratory 範例的 Matrix Multiplication, 這個範例可以顯示出使用 Compiler Vector Extension 搭配 FMV 可以共伴產生廣泛支援又高效的程式碼:

typedef __fp16 fp16_16 __attribute__((ext_vector_type(16)));
#ifdef __HAVE_FUNCTION_MULTI_VERSIONING
__attribute__((target_clones("sve2", "simd")))
#endif
void gemm_vec(
    // buffer, stride
    __bf16 *a, int sa,
    __bf16 *b, int sb,
    __bf16 *c, int sc
){
    fp16_16 vb[16];
    for(int y = 0; y < 16; y++){
        vb[y] = *((fp16_16*)(b + sb*y));
    }
    for(int y = 0; y < 16; y++){
        fp16_16 vc = *((fp16_16*)(c + sc*y));
        fp16_16 va = *((fp16_16*)(a + sa*y));
        for(int x = 0; x < 16; x++)
            vc += va[x]*vb[x];
        *((fp16_16*)(c + sc*y)) = vc;
    }
}

這個範例中, 為了能產生較大的 code generation 的差異, 個人選擇使用 float16 的資料型別. 主要是因為 ARMv8 並非預設支援 fp16, 而是到了 ARMv8.2 以 optional extension 來提供 (而預設支援 SVE2 的 ARMv9 是預設支援的). 將上述這段程式令存為 arm_gemm.c 之後搭配下列指令編譯:

$ aarch64-linux-android34-clang -O3 --save-temps -c arm_gemm.c
如此當中可以觀察產生的 arm_gemm.s 檔案, 當中有 gemm_vec._Msve2 與 gemm_vec._Msimd 兩段程式, 以及用來做選擇的 gemm_vec.resolver, 可以觀察 sve 版本直接使用支援 fp16 的 fmul, 而 AdvSIMD 版本當中不斷使用 fcvt 指令來處理浮點數轉換, 行數落差也相當的大.


接著的第二個範例中是透過 auto-vectorization 來實際展示 ARM 官網上的 "Auto-vectorization examples" 內關於 Scatter and Gather 的部份, 而為了能確實透過 clang/llvm 的組合產生使用 gather 指令, 該段程式經過稍加修改後程式碼如下:

#include <stdint.h>
#ifdef __HAVE_FUNCTION_MULTI_VERSIONING
__attribute__((target_clones("sve2", "simd")))
#endif
float gather_reduce(float *restrict data, int *restrict indices, long c)
{
    float r = 0;
    #pragma clang loop vectorize(enable)
    for (long i = 0; i < c; i++) {
        r += data[indices[i]];
    }
    return r;
}

將上述這段程式令存為 arm_gather.c 之後搭配下列指令編譯:

$ aarch64-linux-android34-clang -O3 --save-temps -c arm_gather.c
同樣地, 透過觀察編譯過程中產生的 arm_gather.s 檔案, 當中有 gather_reduce._Msve2 與 gather_reduce._Msimd 的兩段程式碼, 從中可以看到 SVE 的版本的 gather_reduce function 確實有使用 L1DW 這個 SVE 指令集所專屬的 gather 指令.

ARM Feature List

最後談談 ARM 與 x86 平台在 FMV 在實務使用中不同的地方. 在 ARM 平台的 FMV target 僅止為 feature, 而 x86 FMV 的 target 則能夠是特定的 CPU arch, 特定的 CPU core 或是指定的 ISA. 那麼具體來說在 ARM 上 FMV 有哪些 feature 可以選擇呢? 可以參考ARM 這份 ACLE 文件, 當中有提供對應的 feature 列表與在指定 FMV target 的名稱, 另外如以往的 FMV target 的使用, 這些 feature 是可以透過 + 來合併選擇使用的, 像是 "sve2+memtag" 來合併使用 SVE2 與 Memory Tagging, 同樣地一如以往 "default" 表示的是基本的指令集, 產生完全不會使用到 feature 的 code.

在 ARM 平台上使用 Function Multi-Versioning (FMV) - 以使用 Android NDK 為例

Function Multi-Versioning (FMV) 過往的 CPU 發展歷程中, x86 平台由於因應各種應用需求的提出, 而陸陸續續加入了不同的指令集, 此外也可能因為針對市場做等級區隔, 支援的數量與種類也不等. 在 Linux 平台上這些 CPU 資訊可以透過...