本文打算介紹下ARM的SIMD指令在C語言下intrinsics函數的使用方法,算是對於NEON的一個入門吧。嚴格來說本文並不是關於ARM彙編的,但是多多少少有關係。
SIMD
什麼是SIMD呢?就是一條指令處理多個數據,可以算作是一種並行計算。比如我們要做一個4維向量的加法,用一般的指令完成必須使用4次加法指令才行,而用SIMD指令可能只需要一次加法,而且花費的時間和一般指令做一次加法的時間相同。很顯然,SIMD可以大大提高一些計算密集型任務的執行效率。這種SIMD指令功能,主流的體系結構一般都用一組特殊的指令子集給予支持,比如x86的SSE,還比如本文講的ARM的NEON。
NEON
NEON是ARM下的一個SIMD指令集合。可實現64位/128位的並行計算。64位/128位並行怎麼理解呢?舉例說,在128位並行的情況下,如果是8位整數,可以並行進行16對整數的加法;如果是16位整數,就可以並行進行8對整數的加法;以此類推。
指令集合自然也離不開寄存器。NEON寄存器分兩種。一種寄存器以D開頭,共32個,每個64位;另一種寄存器以Q開頭,共16個,每個128位。Q0與D0,D1重合(共用128比特),Q1與D2,D3重合,以此類推。因此用D寄存器可並行8個8位整數加法,而用Q寄存器可並行16個8位整數加法。
NEON intrinsics
如果直接用匯編寫NEON固然可以,但是coding的效率不會很高。C編譯器支持將NEON指令封裝成內置函數供程序員直接使用,這樣一來無疑會大大提高開發效率和代碼可維護性。
同時,執行效率也並不會降低很多,因爲使用NEON intrinsics時,雖然像是在調用各種結構體和函數,但將生成的代碼反彙編後可以發現,其實沒有調用函數,只是在使用NEON寄存器和指令罷了。
即便目的是寫彙編代碼,使用intrinsics也有好處。比如先用intrinsics寫好代碼編譯後在反彙編,在此基礎上進行優化,可能比較省力。
數據類型
<基本類型>x<lane個數>x<向量個數>_t,向量個數如果省略表示只有一個。如int8x8_t,uint8x8x3_t。
基本類型int8,int16,int32,int64,uint8,uint16,uint32,uint64,float16,float32
lane個數表示並行處理的基本類型數據的個數。
對於多個向量的類型實際上是結構體
typedef struct {
uint8x8_t val[3];
} uint8x8x3_t;
指令命名
<指令名>[後綴]_<數據基本類型簡寫>
其中後綴如果沒有,表示64位並行;如果後綴是q,表示128位並行。
如果後綴是l,表示長指令,輸出數據的基本類型位數是輸入的2倍;如果後綴是n,表示窄指令,輸出數據的基本類型位數是輸入的一半。
數據基本類型簡寫:s8,s16,s32,s64,u8,u16,u32,u64,f16,f32
例如:
vadd_u16:兩個uint16x4相加爲一個uint16x4
vaddq_u16:兩個uint16x8相加爲一個uint16x8
vaddl_u16:兩個uint8x8相加爲一個uint16x8
指令分類說明
算術和位運算指令
vadd,vsub,vmul,vand,vorr,vshl,vshr等。
但是NEON不直接提供除法和開平方指令,而是提供了對於倒數1/x和開方的倒數1/x^0.5的近似指令。這樣一來除法a/b可以表示爲a*(1/b),開方a^0.5可以表示爲a*(1/a^0.5)。
示例:
//近似求倒數
inline static float32x4_t vrecp(float32x4_t v) {
float32x4_t r = vrecpeq_f32(v); //求得初始估計值
r = vmulq_f32(vrecpsq_f32(v, r), r); //逼近
r = vmulq_f32(vrecpsq_f32(v, r), r); //再次逼近
return r;
}
//近似求開方
inline float32x4_t vsqrt(float32x4_t v) {
float32x4_t r = vrsqrteq_f32(v); //求得開方倒數的初始估計值
r = vmulq_f32(vrsqrtsq_f32(v, r), r); //逼近
return vmulq_f32(v, r); //通過乘法轉爲開方
}
數據移動指令
實際編程中經常要在不同NEON數據類型間轉移數據,有時還要按lane來get/set向量值,NEON intrinsics也提供了這類操作。
vdup[後綴]_n_<數據基本類型簡寫>:用同一個標量值初始化一個向量全部的lane;
vset[後綴]_lane_<數據基本類型簡寫>:對指定的一個lane進行設置
vget[後綴]_lane_<數據基本類型簡寫>:獲取指定的一個lane的值
vmov[後綴]_<數據基本類型簡寫>:數據間移動
訪存指令
NEON訪存指令可以將內存讀到NEON數據類型中去,或者將NEON數據類型寫進內存。可以支持一次讀寫多向量數據類型。
vld<向量數>[後綴]_<數據基本類型簡寫>:讀內存
vst<向量數>[後綴]_<數據基本類型簡寫>:寫內存
例如,vld1_u8從內存讀取一個uint8x8_t數據,vst3q_u8寫入一個u8x16x3_t數據。
需要注意的是,默認情況下對多個向量數據的讀寫使用了interleave模式,可以理解爲向多向量數據讀入或從其寫出時外層按照lane循環,內層再按照向量循環。
例如將一個16像素的RGB圖片解析成R,G,B三個plane的時候,可以寫如下代碼:
void split(uint8_t *rgb, uint8_t *r, uint8_t *g, uint8_t *b) {
uint8x16x3_t v = vld3q_u8(rgb);
vst1q_u8(r, v.val[0]);
vst1q_u8(g, v.val[1]);
vst1q_u8(b, v.val[2]);
}
條件指令
如同非SIMD程序需要分支語句一樣,NEON程序有時候需要對一個向量的各個lane的值的情況來判斷另一個向量對應的lane如何進行處理。
vce[後綴]_<數據基本類型簡寫>:v[n] = v1[n] == v2[n] ? 全0 : 全1
vcle[後綴]_<數據基本類型簡寫>:v[n] = v1[n] <= v2[n] ? 全0 : 全1
vclt[後綴]_<數據基本類型簡寫>:v[n] = v1[n] < v2[n] ? 全0 : 全1
vcge[後綴]_<數據基本類型簡寫>:v[n] = v1[n] >= v2[n] ? 全0 : 全1
vcgt[後綴]_<數據基本類型簡寫>:v[n] = v1[n] > v2[n] ? 全0 : 全1
得出的結果結合位運算即可實現條件判斷。
注意事項
NEON intrinsics的注意事項同時也是NEON彙編的注意事項。
處理數組時要注意數組元素個數不能被NEON向量lane個數整除的情況,多出的元素應補齊或者通過非SIMD方式處理。
NEON不是萬能的,比如把地址放在向量裏讓內存同時讀寫就辦不到。設計算法時應儘量避免這種情況。
對cache友好仍然是最重要的。有時一個算法看上去似乎訪存次數和計算次數都比另一個算法少,但是由於其訪存方式對cache不友好,導致其運行效率不如後者。