跳轉到

03.2 現代 SIMD 指令集設計:x86、ARM 與 RISC-V 的取捨#

學習目標#

  1. 梳理 x86 SIMD 演進主線(MMX → SSE → AVX2 → AVX-512),理解每一代擴充的動機與遺留包袱。
  2. 比較 ARM NEON 與 SVE2 的設計哲學,特別是固定寬度與向量長度無關(VLA)兩條路線。
  3. 深入 RISC-V RVV 的可變長度設計,分析其對編譯器、二進位相容性與硬體實作的影響。
  4. 能閱讀並撰寫基本的 SIMD intrinsics 程式,理解 fixed-width 與 vector-length-agnostic 兩種程式撰寫模式。
  5. 建立「ISA 決策 → 編譯器複雜度 → 生態系採用」的因果分析框架。

一、前因:SIMD 回到通用 CPU 的驅動力#

1990 年代中期,多媒體工作負載(影像、音訊、3D)呈現與 Cray-1 時代科學計算相同的特徵:短整數、大量資料平行、控制流簡單。通用 CPU 想吃下這塊市場,最便宜的方案不是加強亂序引擎,而是 在現有 ISA 上貼一層 SIMD。這個決策路徑決定了此後三十年 x86 SIMD 的所有優點與包袱。

1.1 MMX(1996):最小可行產品#

Intel 在 P5 上加入 8 個 64-bit MMX 暫存器——物理上複用 x87 浮點暫存器堆疊。這是典型的省電省面積取捨,但後果深遠:MMX 與 x87 程式碼不能無縫混用,每次切換需 emms 指令清空堆疊狀態。打包整數(如 PADDW:8×16-bit 同時相加)沒有飽和以外的溢出語義問題,因為多媒體採用飽和算術反而符合感知需求。MMX 證明了市場,也暴露了「寄生在舊狀態上」的代價。

1.2 SSE(1999):獨立暫存器與浮點 SIMD#

SSE 引入 8 個獨立的 128-bit XMM 暫存器與全新的浮點語義(flat register file,徹底擺脫 x87 堆疊)。SSE2(2001)補齊雙精度浮點與整數打包運算,使 SSE2 成為 x86-64 的基線 ISA——這是生態系決策:編譯器終於可以假定 SIMD 暫存器存在,自動向量化有了穩定的目標。SSE 系列同時確立了 x86 SIMD 的核心包袱:指令編碼鎖死固定 128-bit 寬度,且非破壞性目的地要到 AVX 才出現。

1.3 AVX/AVX2(2011/2013):加寬與 3-operand#

AVX 將寬度加倍到 256-bit(YMM),改用 VEX 編碼帶來 3-operand 非破壞語法,減少 mov 與假相依。AVX2 關鍵貢獻是 256-bit 整數運算與跨道(cross-lane)整數 shuffle(vpermd、gather 指令),使 AVX2 成為第一代真正適合通用資料平行(不只是浮點)的 x86 SIMD。跨道重排的成本差異是 SIMD 實務的核心難題:道內 shuffle 便宜、跨道 shuffle 昂貴,這直接影響資料布局(AoS vs SoA)的選擇。

1.4 AVX-512(2016+):向 Cray 致敬#

AVX-512 一次補上 Cray-1 早已解決的問題:

能力 AVX-512 Cray-1 對應
32 個 512-bit zmm 加寬 8×64 元素向量暫存器
Opmask k0–k7 逐元素 predication、尾端處理 向量遮罩 + VL
Embedded rounding/BCD 等標誌 每指令控制 向量指令修飾
衝突偵測(vpconflictd)、gather/scatter 記憶體平行 bank 交錯

AVX-512 採用 EVEX 編碼,理論上允許未來加寬到 1024-bit,但生態系推廣坎坷:AMD 直到 Zen 4(2022)才支援;Intel 更在 2022 年宣布後續核心移除 AVX-512、轉向「可變長度」的 APX/AVX10 路線——x86 陣營自己開始向 VLA 模式靠攏,這是本章最重要的產業訊號。

二、ARM:NEON 的固定寬度與 SVE2 的 VLA 轉向#

2.1 NEON(2005,ARMv7-A)#

NEON 提供 32 個 128-bit 暫存器(可視為 16 個 q 暫存器),與 x86 同樣採 固定寬度 路線。設計上較乾淨:一開始就是 AArch64 狀態的一部分(不寄生),且 lane 語義豐富(vaddq_f32、vmlaq_f32、各種結構化載入 vld4)。NEON 在手機生態的成功關鍵是 強制實作(ARMv8 標配)+ 統一基線,編譯器(GCC/Clang 的 auto-vectorizer)都以 NEON 為目標。

2.2 SVE/SVE2(2016/2019,ARMv8.2-S/ARMv9)#

SVE 的革命在於 向量長度無關(Vector Length Agnostic, VLA):

  • 實作可自由選擇 128–2048 bit 向量長度,同一份機器碼在任何實作上語義正確;
  • 首暫存器傳遞(first-faulting load):ldff1d 遇到頁界錯誤時回報已成功載入的元素數,使 VLA 程式能安全地「向未知長度的向量推進」,徹底解決 strip-mining 尾端跨越頁界的老問題;
  • 逐元素 predication 為一等公民:所有指令皆帶 predication 欄位,不是附加延伸。

代價是 固定寬度實作的編碼效率:VLA 指令不指定 lane 寬度組合的絕對位置,某些固定寬度可表達的最佳化(如精確的 128-bit lane 內 shuffle)在 VLA 中需繞道。SVE2 補齊了多媒體與字串處理(歷史性呼叫 NEON 領域),ARM 進而以 Scalable Matrix Extension(SME) 延伸到矩陣運算。

三、RISC-V RVV:晚生十年的設計自由#

RISC-V Vector Extension(RVV 1.0,2021 凍結)站在 Cray-1、SVE 與三十年編譯器經驗的肩膀上,把 VLA 推到更極致:

  • vlenb 只約束上限:實作可選 VLEN ≥ 64 的任意值(128、256、1024 皆合法),且 同一二進位可在所有實作執行;
  • vsetvli 動態設定 vl:vsetvli t0, a0, e32, m4 表示「元素 32-bit、LMUL=4、vl = min(a0, MAXVL)」,編譯器寫出的迴圈自動處理任意尾端,strip-mining 由 ISA 語義內建:
# void saxpy(float* x, float* y, float a, size_t n)
saxpy:
    mv   t1, a2            # n
1:  vsetvli t0, t1, e32, m1   # vl = min(t1, VLMAX)
    slli t2, t0, 2
    vle32.v v1, (a0)       # x[i..i+vl]
    vle32.v v2, (a1)       # y[i..i+vl]
    vfmacc.vf v2, fa0, v1  # y += a * x
    vse32.v v2, (a1)
    add  a0, a0, t2
    add  a1, a1, t2
    sub  t1, t1, t0
    bnez t1, 1b
    ret

注意:這段組語不寫任何 VLMAX 數值——在 VLEN=128 或 VLEN=1024 的機器上同樣正確且自動用滿硬體。這是對 Cray-1 「VL 暫存器 + strip-mining」思想的 ISA 級內化。

  • LMUL(register grouping):允許以 2/4/8 個暫存器組成一個「大向量」,在暫存器少的嵌入式實作上仍能維持吞吐量;
  • 代價:vtype 狀態增加解碼複雜度;LMUL 與 tail/mask policy(vta/vma:tail agnostic/undisturbed)的組合語義繁多,對編譯器後端是持續的工程挑戰。

四、固定寬度 vs 可變長度:核心取捨#

面向 固定寬度(MMX/SSE/AVX/NEON) VLA(SVE2/RVV/AVX10)
編碼 可精選 lane 操作,碼點密集 需為任意長度正確,部分指令效率折衷
編譯器 每 ISA 版本一套向量化決策 一套 VLA 決策通吃所有實作
二進位重發布 每代需重新編譯(SSE4→AVX2→AVX-512 函式庫多版本) 一次編譯,硬體升級自動受益
硬體實作彈性 實作寬度被 ISA 釘死 面積/功耗預算自由選 VLEN
尾端處理 遮罩或標量收尾 ISA 語義內建(vl/predication)
生態成本 成熟工具鏈與十多年調校 需重建調校知識

因果鏈條值得強調:固定寬度的每代加寬 → 二進位碎片化 → 函式庫需 runtime dispatch(ifunc/CPUID 分派)→ 維護成本高漲 → 產業轉向 VLA。AVX-512 在 Intel 的退場與 AVX10「統一可變長度向量」的提出、SVE 對 NEON 的補位、RVV 的原生 VLA,三者是同一壓力的三種回應。

五、Intrinsics 實務範例#

5.1 固定寬度:AVX2 實作 8×fp32 加法#

#include <immintrin.h>

void add_f32_avx2(const float *a, const float *b, float *c, int n) {
    int i = 0;
    for (; i + 8 <= n; i += 8) {          // 主迴圈:滿 256-bit
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        _mm256_storeu_ps(c + i, _mm256_add_ps(va, vb));
    }
    __m256i mask = _mm256_setr_epi32(     // 尾端:遮罩式收尾
        -1,-1,-1,-1,-1,-1,-1,-1);
    (void)mask;
    for (; i < n; i++) c[i] = a[i] + b[i]; // 純量收尾
}

觀察「尾端」的尷尬:AVX2 沒有逐 lane predication 的載入/存取,只能退回純量或用遮罩 gather 模擬——這正是 AVX-512 與 VLA 要解決的痛點。

5.2 AVX-512 遮罩版#

#include <immintrin.h>

void add_f32_avx512(const float *a, const float *b, float *c, int n) {
    int i = 0;
    for (; i < n; i += 16) {
        __mmask16 m = (n - i) >= 16 ? 0xFFFF
                    : (1u << (n - i)) - 1;      // 動態尾端遮罩
        __m512 va = _mm512_maskz_loadu_ps(m, a + i);
        __m512 vb = _mm512_maskz_loadu_ps(m, b + i);
        _mm512_mask_storeu_ps(c + i, m, _mm512_add_ps(va, vb));
    }
}

5.3 SVE2 的 VLA 版(sizeof 未知,程式不假設長度)#

#include <arm_sve.h>

void add_f32_sve(const float *a, const float *b, float *c, int n) {
    int i = 0;
    svbool_t p;
    do {
        p = svwhilelt_b32((uint64_t)i, (uint64_t)n); // 動態 predicate
        svfloat32_t va = svld1_f32(p, a + i);
        svfloat32_t vb = svld1_f32(p, b + i);
        svst1_f32(p, c + i, svadd_f32_m(p, va, vb));
        i += svcntw();                  // 目前實作的向量寬度(fp32 數)
    } while (svptest_first(svptrue_b32(), p));
}

三份程式碼對照出三種哲學:AVX2 把尾端問題留給程式設計師、AVX-512 用遮罩解決、SVE/RVV 用 「向未知長度推進」 的迴圈結構一次寫對。RISC-V 版本與 SVE 版本結構幾乎同構,可自行對照上文組語。

六、效能數據與實務觀察#

以單核 fp32 FMA 峰值與 SGEMM-like 迴圈實測為例(數量級參考,實際依時脈與記憶體階層而定):

平台/ISA 向量寬度 每週期 fp32 FMA 峰值 GFLOPS @ 3 GHz 備註
NEON (Cortex-A78) 128-bit 4 12 固定寬度、低功耗
AVX2 (Zen 3) 256-bit 8 24 2×FMA 單元
AVX-512 (Zen 4) 512-bit 16 48 全寬執行、無雙待遇
SVE2 (Neoverse V1) 2×256-bit 16 48 VLA,實作可選 128–2048
RVV (Vision/應用核) 128–512-bit 可選 4–16 12–48 VLEN 由實作決定

實務要點:

  1. 峰值 ≠ 實得。AVX-512 在某些 Intel 微架構(Skylake-X)因 512-bit 執行單元與功耗,觸發 頻率降載,512-bit 稼動時時脈可下降數百 MHz,短向量工作負載可能不升反降(詳見 03.3)。
  2. 記憶體階層才是天花板。向量寬度加倍後,單核載入頻寬若未同步提升,L1/L2 頻寬成為真瓶頸——Cray-1 時代的「解一個瓶頸、現下一個瓶頸」規律完全適用。
  3. 跨道操作與資料布局:SoA(structure of arrays)佈局讓連續載入對齊 lane;AoS 佈局需要昂貴 shuffle。這是 SIMD 效能調校的第一原則。

本章小結#

  • x86 SIMD 沿「最小可行 → 加寬 → 修正語義」路徑演進:MMX 寄生 x87、SSE 獨立暫存器並成為 x86-64 基線、AVX2 帶來非破壞性 3-operand 與整數跨道、AVX-512 補上遮罩等 Cray 式能力,但二進位碎片化與降載問題使其推廣受挫。
  • ARM 以 NEON 為固定寬度基線取得生態成功,再以 SVE/SVE2 轉向 VLA,first-faulting load 與逐元素 predication 是其技術亮點。
  • RISC-V RVV 將 VLA 內建於 ISA 語義(vsetvli/vl/LMUL/tail policy),同一二進位自動適配任意 VLEN,是 Cray-1 strip-mining 思想的現代完成式。
  • 固定寬度與 VLA 的取捨貫穿編碼效率、編譯器複雜度、二進位相容性三個層面;產業壓力(多版本函式庫維護成本)正推動 x86 也走向可變長度。
  • 撰寫 intrinsics 時的三個層次:主迴圈向量化、尾端遮罩處理、資料布局(SoA)——效能上限最終由記憶體階層決定。

習題#

  1. (比較) 說明 MMX 複用 x87 暫存器堆疊的利弊,並解釋為何 emms 指令的存在成為混用程式碼的隱性錯誤來源。
  2. (分析) 同一份 VLA 機器碼(SVE 或 RVV)在 VLEN=128 與 VLEN=512 的處理器上都正確,但效能不同。請分析編譯器在「不假設 VLEN」的前提下,哪些最佳化會變得保守(提示:unroll、跨道重排、暫存器壓力與 LMUL)。
  3. (程式設計) 分別以 AVX2 intrinsics 與 C 純量實作「計算 100 萬個 float 的 dot product」,使用 -O3 -mavx2 編譯,測量並解釋兩者差距;再將資料改為 AoS 佈局(交錯存取),觀察並解釋效能變化。
  4. (程式設計) 用 RVV intrinsics(riscv_vector.h 或 QEMU user-mode + vsetvli 組語)實作 SAXPY,驗證同一份程式在模擬 VLEN=128 與 VLEN=256 的環境下皆產生正確結果。
  5. (討論) AVX-512 的 opmask 與 SVE 的 predication 都能處理尾端,但 AVX-512 的 maskz load 在越界頁上仍可能觸發 fault(需軟體分頁處理)。比較 first-faulting load(SVE)與 maskz load(AVX-512)在「處理頁界尾端」上的語義差異與實作成本。