向量化程式碼實踐與思考:如何藉助向量化技術給程式碼提速

人工智慧洞察站發表於2023-12-27

向量化程式碼實踐與思考:如何藉助向量化技術給程式碼提速

來源:阿里雲開發者

阿里妹導讀

在不堆機器的情況下,要想使程式碼完全發揮出硬體效能,就需要做加速。其中比較常見的操作是併發處理,本文將深入向量化計算技術,為大家講解SIMD指令,以及如何寫出規範的可向量化的程式碼。

一、計算加速的技術

計算加速可以從多個方面入手。軟體加速/硬體加速:從軟體上來講,儘可能的榨乾硬體的效能;從硬體上講,儘可能的提高主頻。從方向上講,可以橫向擴充套件,使用更高的併發處理能力;或者在縱向上提高單點的效能。併發處理能力,從粒度上區分,從大到小:機器級別的併發,堆機器做同樣的事情;或者執行緒級別的併發,利用多執行緒多核併發計算;或者指令級別的併發,在一個指令上操作多個資料。

其中併發處理方式比較常見,那麼指令級併發該怎麼理解呢?馮·諾伊曼式架構是CPU從儲存系統中載入指令和資料,完成指令並把結果儲存到儲存系統。通常一個指令操作一個資料,生成一份結果。而SIMD(Single Instruction Multiple Data)指令是一類特殊的CPU指令型別,這種指令可以在一條指令中同時操作多個資料。
SIMD指令的作用是向量化執行(Vectorized Execution),中文通常翻譯成向量化,但是這個詞並不是很好,更貼切的翻譯是陣列化執行,表示一次指令運算元組中的多個資料,而不是一次處理一個資料;向量則代表有數值和方向,顯然在這裡的意義用陣列更能準確的表達。在操作SIMD指令時,一次性把多條資料從記憶體載入到寬暫存器中,透過一條並行指令同時完成多條資料的計算。例如一個操作32位元組(256位)的指令,可以同時操作8個int型別,獲得8倍的加速。同時利用SIMD減少迴圈次數,大大減少了迴圈跳轉指令,也能獲得加速。SIMD指令可以有0個引數、1個陣列引數、2個陣列引數。如果有一個陣列引數,指令計算完陣列中的每個元素後,分別把結果寫入對應位置;如果是有兩個引數,則兩個引數對應的位置分別完成指定操作,寫入到對應位置。
編譯器透過SIMD加速的原理是:透過把迴圈語句展開,減少迴圈次數,迴圈展開的作用是減少迴圈時的跳轉語句,跳轉會破壞流水線;而流水線可以預先載入指令,減少CPU停頓時間,因此減少跳轉指令可以提升流水線的效率。
向量化程式碼實踐與思考:如何藉助向量化技術給程式碼提速
SIMD指令同時操作A和B中4對數字,產生4個結果存放到C中

以如下程式碼為例,對4個float計算平方:








void squre( float* ptr ){    for( int i = 0; i < 4; i++ )    {      const float f = ptr[ i ];      ptr[ i ] = f * f;    }}
上述程式碼轉寫成SIMD指令,則可以刪除迴圈,用三條指令即可完成計算,分別是載入到暫存器,計算平方,結果寫回記憶體:






void squre(float * ptr){    __m128 f = _mm_loadu_ps( ptr );     f = _mm_mul_ps( f, f );     _mm_storeu_ps( ptr, f );}

二、SIMD擴充套件指令集

SIMD指令的執行方式時,把一組資料載入到寬暫存器(128位、256位、512位)中,然後生成結果放到另一個寬暫存器中。
SIMD指令需要硬體支援MMX系列,SSE(Streaming SIMD Extensions)系列、AVX(Advanced Vector Extensions)系列擴充套件指令集。SSE1、SSE2、SSE3、SSE4.1、SSE4.2操作的是16位元組暫存器,AVX、AVX2引入了32位元組暫存器,AVX512引入了64位元組暫存器。目前大部分CPU都支援AVX2,只有最新的CPU才支援AVX512。

指令集需要CPU硬體支援,下面列出了支援各個指令集的CPU。

向量化程式碼實踐與思考:如何藉助向量化技術給程式碼提速

ARM也引入了SIMD擴充套件指令。典型的SIMD操作包括算術運算(+-*/)以及abs、sqrt等,完整的指令集合請參考英特爾提供的使用文件:

那麼如何生成SIMD指令呢?有以下幾種方式:
  • 編譯器自動向量化
  1. 靜態編譯
  2. 即時編譯(JIT)

  • 手寫SIMD指令
  • 三、編譯器靜態自動向量化

    對於編譯器自動向量化,需要滿足幾個條件:

    1、程式碼滿足一定的正規化,後續會詳細展開介紹各種case;

    2、對於常用的編譯器入gcc和clang,在編譯選項上加上-O3的選項,開啟向量化。

    3.1 編譯器選擇和選項

    在編譯時,編譯選項中加上-O3或者 -mavx2 -march=native -ftree-vectorize,可以開啟向量化。

    只有高版本的編譯器才能實現向量化,gcc 4.9.2及以下經測試不支援向量化,gcc 9.2.1支援。gcc對向量化的支援更加友好,clang對某些程式碼無法轉化成向量化,而在某些情況下,clang生成的向量化程式碼效能比gcc更好(採用更寬的暫存器指令導致的),不一而足。因此,建議編寫符合規範的程式碼,然後分別測試兩種編譯器的效能。



    res[i] = tmpBitPtr[i] & opBitPtr[i];   //使用下標訪問地址,clang和gcc都支援*(res + i) = *(tmpBitPtr + i) & *(opBitPtr + i);  //使用地址運算訪問記憶體,clang不支援,gcc支援

    四、如何寫出可向量化的程式碼

    為了更好的引導編譯器給你的程式碼生成向量化程式碼,程式設計上有一些最佳實踐。
    1. 迴圈的次數要是可計數的

    迴圈的變數初始值和結束值要固定,例如:



    for (int i = 0;i < n ;++i ) //總的次數是可以計數的,這種寫法可以向量化for (int i = 0;i != n;++i) //總的次數不可計數,這種寫法無法向量
    2. 簡單直接的計算,不包含函式呼叫
    計算只包含簡單的加減乘除等數學運算、與或非等邏輯運算,不要包含switch、if、return等語句。

    此處有一些例外是,部分三角函式(sin,cos等)或者算術函式(pow,log等)因為lib提供了內建的向量化實現,是可以自動向量化的。
    3. 在迴圈的最內層
    只有最內層的迴圈可以向量化
    4. 訪問連續的記憶體空間
    函式的計算引數和結果必須存放在連續空間中,透過一條SIMD指令從記憶體載入到暫存器。


    for (int i=0; i<SIZE; i+=2) b[i] += a[i] * x[i];   //訪問連續空間,可以向量化for (int i=0; i<SIZE; i+=2) b[i] += a[i] * x[index[i]] //訪問非連續空間,不能向量化
    5. 資料無依賴
    這是最重要的一條,因為是平行計算,屬於同一條並行指令的多個獨立指令所操作的數字之間不能有關聯,否則就不能並行化處理,只能序列計算。

    資料依賴有幾種場景:










    for (j=1; j<MAX; j++) A[j]=A[j-1]+1;// case 1 先寫後讀,不能向量化for (j=1; j<MAX; j++) A[j-1]=A[j]+1;// case 2 先讀後寫,不能向量化for (j=1; j<MAX; j++) A[j-4]=A[j]+1;// case 3 雖然是先讀後寫,但假如4組資料組成一個向量,那麼同一組資料內無依賴的,因而可以向量化                                     // case 4 先寫後寫,無法向量化(此處無案例)for (j=1; j<MAX; j++) B[j]=A[j]+A[j-1]+1;//case 5 先讀後讀,因為沒有寫操作,不影響向量化for (j=1; j<MAX; j++) sum = sum + A[j]*B[j] //case 6 這種可以向量化,雖然每次都會讀同一個變數,再寫一個變數,因為可以先用一個寬暫存器表示sum,分別累加每一路資料,迴圈結束後再累加寬暫存器中的值。 for (i = 0; i < size; i++) {    c[i] = a[i] * b[i]; }// case 7這種要確認c的記憶體空間和a/b的記憶體空間是否有交集。如果c是a或者b的別名,比如c=a+1,那麼c[i] = a[i+1],那a和c就有記憶體交集了。
    上述幾個例子中,case 3、5、6是可以向量化的,這些case屬於比較特殊的case,正常而言建議還是寫出明確無任何依賴問題的程式碼。如果確定有依賴,仍想使用向量化,可以手動編寫SIMD程式碼。
    6. 使用陣列而不是指標
    儘管使用指標可以達到陣列類似的效果,但是使用陣列可以減少出現意外依賴的可能。而使用指標的時候,有些場景下連編譯器也無法確認是否可以向量化,使用陣列則沒有這種擔憂,編譯器可以很容易的向量化。
    7. 使用迴圈的計數器作為陣列的下標
    直接使用迴圈的計數器作為陣列的下標訪問,可以簡化編譯器的理解。如果額外的使用其他值作為下標,則很難確認能否向量化。例如:


    for(int i = 0;i < 10;i++)  a[i] = b[i] //這種較好for(int i =0,index=0;i < 10;i++)  a[index++]=b[index] //這種無法向量化
    8. 使用更高效的記憶體佈局
    資料最好以16位元組或者32位元組對齊。陣列的元素最好是基礎型別,而不是結構體或類。如果是一個複雜結構,那麼同一個陣列中每個物件的相同元素並不是相鄰儲存的。
    9. 迴圈次數並不需要是指令寬度的整數倍
    在一些老的編譯器中,迴圈的次數需要是指令寬度的整數倍,例如128位指令,操作4位元組的int型別,可以同時操作4個int型別,那麼就要求迴圈次數是4的整數倍。因此寫程式碼時,需要寫成兩個迴圈,第一部分是4的整數倍迴圈,第二部分是末尾多出來的少量資料。
    而最新的編譯器已經能夠自動化處理這種情況,可以按照正常邏輯編寫程式碼,無需拆分成兩部分,編譯器生成的程式碼會自動生成兩部分邏輯。

    五、手寫SIMD程式碼

    編譯器能把直接了當的邏輯轉換為SIMD指令,並且需要我們認真的考慮程式碼風格,避免阻礙向量化。但是有些比較複雜的邏輯,編譯器是無法自動向量化的,而我們人類知道里面的邏輯是每個運算元分別計算,互不干擾,可以使用向量化。遇到這種情況,我們可以手寫SIMD程式碼,舉一個典型的例子,把一個字串轉成全小寫。

    5.1 SIMD程式碼例子和不同編譯器效能對比



















































    const static char not_case_lower_bound = 'A';const static char not_case_upper_bound= 'Z';static void lowerStrWithSIMD(const char * src, const char * src_end, char * dst){       const auto flip_case_mask = 'A' ^ 'a';
    #ifdef __SSE2__    const auto bytes_sse = sizeof(__m128i);    const auto * src_end_sse = src_end - (src_end - src) % bytes_sse;        const auto v_not_case_lower_bound = _mm_set1_epi8(not_case_lower_bound - 1);    const auto v_not_case_upper_bound = _mm_set1_epi8(not_case_upper_bound + 1);    const auto v_flip_case_mask = _mm_set1_epi8(flip_case_mask);        for (; src < src_end_sse; src += bytes_sse, dst += bytes_sse)    {          /// load 16 sequential 8-bit characters        const auto chars = _mm_loadu_si128(reinterpret_cast<const __m128i *>(src));                /// find which 8-bit sequences belong to range [case_lower_bound, case_upper_bound]        const auto is_not_case            = _mm_and_si128(_mm_cmpgt_epi8(chars, v_not_case_lower_bound), _mm_cmplt_epi8(chars, v_not_case_upper_bound));                /// keep lip_case_mask _mm_and_si128(v_flip_case_mask, is_not_case);                /// flip case by applying calculated mask         const auto xor_mask = _mm_and_si128(v_flip_case_mask, is_not_case);        const auto cased_chars = _mm_xor_si128(chars, xor_mask);                /// store result back to destination        _mm_storeu_si128(reinterpret_cast<__m128i *>(dst), cased_chars);    }#endif        for (; src < src_end; ++src, ++dst)        if (*src >= not_case_lower_bound && *src <= not_case_upper_bound)            *dst = *src ^ flip_case_mask;        else            *dst = *src;}static void lowerStr(const char * src, const char * src_end, char * dst){      const auto flip_case_mask = 'A' ^ 'a';
       for (; src < src_end; ++src, ++dst)        if (*src >= not_case_lower_bound && *src <= not_case_upper_bound)            *dst = *src ^ flip_case_mask;        else            *dst = *src;}

    上述兩個函式用於把字串中的大寫字母轉換成小寫字母,第一個函式採用了SIMD實現(採用128位指令),第二個函式採用了普通的做法。第一個是128位指令(16位元組),理論上相比非向量化指令,加速比為16倍。但是由於第二個程式碼在結構上是很清晰的,也可以自動向量化,在這裡我們測試下不同編譯器的編譯效能,g版本9.3.0,clang12.0.0。

    編譯選項
    SIMD/normal>
    解讀(延時比小於1則SIMD佔優,大於1則後者的自動向量化佔優)
    g++>
    1.9
    編譯器自動向量化生成了256的指令,相比128位效能加倍
    g++>
    0.99
    兩者近似,編譯器自動向量化生成了128位指令
    g++>
    0.09
    -O2無法自動向量化
    clang++>
    3.1
    自動向量化生成了512位指令,相比128位效能3倍多
    clang++>
    1.6
    編譯器自動向量化生成了256位指令
    clang++>
    0.93
    編譯器自動生成了128位指令
    clang++>
    0.09
    -O1無法向量化
    結論:在相同的最佳化級別下,clang生成更寬的指令,效能更好。

    5.2 解讀SIMD指令

    最簡單的SIMD指令,實現兩個數字的加法:


    const __m128i dst = _mm_add_epi32(left,right);

    這條指令把4組int型別數字相加,填寫到結果中。__m128i代表是128位寬暫存器,存放的是int型別(4位元組32位),可以存放4個int型別。_mm_and_epi32是一個SIMD指令,_mm開頭表示128暫存器,add表示相加,epi32表示32位整數。SIMD指令的命名規範:在SIMD指令中,需要表達三個含義,分別是暫存器寬度、操作型別和引數寬度。


    各種型別對應到各種寬度的暫存器上的寫法:

    16位元組
    32位元組
    64位元組
    32位float
    __m128
    __m256
    __m512
    64位float
    __m128d
    __m256d
    __m512d
    整型數
    __m128i
    __m256i
    __m512i
    暫存器寬度,例如128位暫存器以_mm開頭,參考如下表格對映關係:
    指令字首
    暫存器位數
    _mm
    128
    _mm256
    256
    _mm512
    512
    操作型別,例如xor、and、intersect等操作。

    引數寬度:引數中單條資料的位數,在指令的字尾中包含該資訊,例如浮點數是32位,雙精度浮點數是64位,那麼在128位暫存器上,可以輸入4個浮點數或者2個雙精度浮點數。有些指令沒有輸入引數,則沒有引數寬度資訊。例如epi16代表16位int,詳細的資訊參考如下表格:
    指令字尾
    單條資料位數
    資料型別
    epi8
    8
    int
    epi16
    16
    int
    pi16
    16
    int
    epi32
    32
    int
    pi32
    32
    int
    epi64
    64
    int
    pu8
    8
    unsigned>
    epu8
    8
    unsigned>
    epu16
    16
    unsigned>
    epu32
    32
    unsigned>
    ps
    32
    float
    pd
    64
    double
    例如函式__m128 _mm_div_ps (__m128 a, __m128 b),根據指令名稱__mm開頭,代表暫存器是128位,div表示除法,ps結尾代表操作的引數是32位浮點數。即同時載入兩個陣列,每個陣列包含了4個32位單精度浮點數,完成兩個陣列對應位置數字的除法運算,返回4個除法結果。
    通常,指令的結果寬度是和引數的寬度是保持一致的,但也有例外。
    兩個向量執行SIMD指令,是兩個向量的對應位置的資料分別執行操作。但也有些例外,比如同一個向量的相鄰資料執行操作,稱為水平操作,例如指令__m128i _mm_hadd_epi16 (__m128i a, __m128i b),指令中的h代表horizontal,依次把a和b相鄰的資料相加,如果a值為[1,2,3,4],b值為[5,6,7,8],那麼結果為[1+2,3+4,5+6,7+8]。
    兩個向量的所有資料都參與計算,但也有例外,透過掩碼控制部分資料參與計算,掩碼的第幾位為1,則代表第幾個數字參與計算。例如函式__m128i _mm_mask_add_epi16 (__m128i src, __mmask8 k, __m128i a, __m128i b),用k作為掩碼,第幾位為1,則返回a和b對應位數的和;如果為0,則返回src對應位置的數。
    SIMD指令集合中包含的功能有:算術、比較、加密、位運算、邏輯運算、統計和機率、位移、記憶體載入和儲存、shuffle。
    1. SIMD記憶體操作
    SIMD記憶體操作把資料載入到暫存器,並且返回對應SIMD型別。載入16位資料指令_mm_load_si128,載入64位資料指令:_mm256_load_ps,這兩個指令要求資料是對齊的。如果是非對齊的資料,則採用_mm_loadu_si128_mm256_loadu_ps
    2. SIMD初始化暫存器指令
    初始化為0的指令。_mm_setzero_ps_mm256_setzero_si256把暫存器初始化為0,初始化操作沒有任何依賴。
    初始化為特定值。_mm[256]_set_XXX把每一個點初始化不同的值,_mm[256]_set1_XXX把每一個點初始化相同的值。[256]代表是否出現256,如果出現256。_mm_set_epi32(1,2,3,4)表示按照順序初始化為整型數[1,2,3,4]。如果時倒序初始化,則使用_mm_setr_epi32(1,2,3,4)
    3. 位運算指令
    Float和int有很多位運算指令,包括AND、OR、XOR。如果要執行NOT指令,則最快的方式就是和全1做XOR,而獲得全1的最快方式就是把兩個0做相等比較。如下程式碼樣例:






    __m128i bitwiseNot(__m128i x){  const __m128i zero = _mm_setzero_si128();  const __128i one = _mm_cmpeq_epi32(zero, zero);  return _mm_xor_si128(x, one);}
    4. 浮點數指令
    浮點數指令支援基礎的運算+-*/,和擴充套件的運算sqrt。一些比較有用的函式有_mm_min_ss(a,b)。對於32位浮點數,如果要完成1/x,對應的SIMD指令是_mm_rcp_ps,而向量化程式碼實踐與思考:如何藉助向量化技術給程式碼提速 對應的SIMD指令是_mm_rsqrt_ps,採用SIMD指令可以在一條指令內完成,速度更快。
    如果想加兩個陣列,例如[a,b,c,d]+[e,f,g,h]=[a+e,b+f,c+g,d+h],對應的SIMD指令是_mm_hadd_ps_mm_hadd_pd_mm256_hadd_pd_mm256_hadd_ps
    5. 非並行指令,也能達到加速效果
    有些指令,在一條資料中只能操作一條資料,但是也能達到加速的效果。例如_mm_min_ss指令,表示取兩個浮點數的最小值,該指令可以用一條指令完成計算,避免跳轉,避免透過分支指令跳轉。同理,取最大值的指令是_mm_max_sd

    5.3 手寫SIMD指令的缺點

    雖然手寫SIMD指令看起來很酷,但是存在一個很大的問題,就是可移植性不強。如果手寫一個512位寬的指令,卻在一個不支援avx指令集的機器上執行,那就會出問題。所以最好的方案還是編寫符合編譯器向量化規範的程式碼,把向量化這件事情交給編譯器,最新的編譯器會幫助我們解決這些事情。

    六、結論

    最新的編譯器已經足夠智慧,能夠自動化地實現向量化。除了提升編譯器版本,也需要開發者提高編寫程式碼的能力,能夠儘可能的編寫出符合上文定義的幾種規範,然後讓編譯器幫助我們生成高效的執行程式碼。

    來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027828/viewspace-3001751/,如需轉載,請註明出處,否則將追究法律責任。

    相關文章