平行計算與Neon簡介

绝对精神的自我展开發表於2024-08-16

一、並行處理資料方式分類

根據處理器的指令處理資料的並行性特點,目前已經大規模實際應用的型別有四種:SISD MIMD SIMD SIMT。

SISD是伴隨計算機誕生之初就出現的資料處理形式,後三種則是在SISD的基礎上,提升晶片處理資料能力而發展出的三種並行化的資料處理方式型別,接下來將對四種型別進行概述。

要注意的是,四種型別並非完全互斥,比如SIMT和SISD可以結合,也可以和SIMD結合,MIMD同樣可以同時和SISD和SIMD結合。

1.1 單指令流單資料流(SISD)

單指令單資料(Single Instruction Single Data),即一條指令處理一個資料,通常就是我們寫的最簡單的單執行緒程式的資料處理形式,在這個形式中,每條CPU指令通常只進行一次資料處理,包括讀寫、計算。

1.2 多指令流多資料流(MIMD)

多指令多資料(Multipe Instructions Multiple Data),即多條指令處理多個資料,通常就是指CPU上的多執行緒程式設計,多個執行緒執行在多個物理或邏輯CPU上,可以同步或非同步地處理資料,從而增加相同時間下的資料處理速度。要注意的是,MIMD和SISD並非完全互斥,因為多執行緒中某個執行緒的執行,就可能是SISD,也可能是SIMD。

1.3 單指令流多資料流(SIMD)

單指令流多資料流(Single Instructions Multiple Data),即單個指令處理多個資料,它常見於CPU的向量指令集,例如X86的SSE、AVX指令集,ARM的Neon指令集,都是SIMD形式的向量指令集,同時某些移動平臺晶片,例如高通、聯發科等公司的SOC整合的DSP協處理器也是SIMD型別的處理器。

SIMD通常可用來加速處理相對簡單的影像任務(通常是二維平面影像任務),或者加速一些矩陣計算,例如神經網路計算等。

本系列主要針對CPU向量指令進行介紹,對其它型別的SIMD不作更多描述。

向量指令集和普通指令集的不同從兩個方面來考量:

  1. 暫存器寬度和讀寫:通常向量指令集使用的暫存器,會比處理器位寬大至少一倍。比如ARM64上,Neon指令集有32個128位的向量暫存器,而通用暫存器都是64位。X86-64上,AVX2指令集的暫存器寬度達到256位,AVX512指令集甚至達到了512位。

    除了寬度更大之外,向量暫存器也支援同時對暫存器上不同位置的資料同時操作。一般通用暫存器,一個暫存器通常會被認為是一個資料,但一個向量暫存器可以分為多個lane,每個lane可以視為單獨的資料,並支援被同時分開讀寫。

  2. 資料操作:一條向量指令通常可以同時讀寫多個向量暫存器,並且可以將一個向量暫存器分為不同lane,當多個資料處理。以Neon為例,一條加法指令可以將兩個128位向量暫存器分為4個32位大小的int型別資料,並將兩個兩個暫存器的4個資料對應相加,存放到第三個向量暫存器上,過程如下圖所示。

    如上過程若使用普通指令集,則需要透過迴圈才能完成:

    int v1[4];
    int v2[4];
    int v3[4];
    for (int i = 0; i < 4; i++ ) {
        v3[i] = v1[i] + v2[i];
    }
    

    顯然,透過向量指令集可以將迴圈壓縮到一條指令,而即便有記憶體讀寫,也可以壓縮到三條向量指令,相比於常規手法,向量指令集的效率無疑非常高。

    Neon就是本系列的研究目標,即如何透過高效利用向量指令集來針對某些型別的計算任務進行最佳化,降低處理時間、提升處理效率。

1.4 單指令流多執行緒流(SIMT)

單指令流多執行緒流(Single Instruction Multiple Threads)是一類比較特殊的平行計算方法,它和前三種不同,是一種是伴隨GPU出現而出現的資料並行處理方法。在GPU或類似的處理器上,一條指令傳送給多個指令流執行單元來同時同步執行,即同時執行多個完全相同的指令流,即同時執行多個程式相同的執行緒。但不同執行緒會透過其特定的標誌來區別待處理的資料物件,從而讓不同的執行緒處理不同資料,達到並行化處理資料的目的。另外,在GPU上一個執行緒內也存在SISD和SIMD,從而更加強化GPU資料處理能力,以適應圖形渲染演算法等複雜的任務。

二、Arm64 Neon向量指令集

前文已經介紹過,Neon是Arm上的SIMD型別指令集,它在arm32時期就已經存在,並在arm64上得到加強。本文主要介紹arm64上的Neon。

2.1 暫存器

在arm64上,Neon有32個128位專用的向量暫存器,用Vn表示(V0-V31),每個暫存器可以表示多種型別,包括:

  1. 一個128位型別,以Vn表示(V0-V31)
  2. 兩個64位型別,用D表示,即暫存器被分割為2個通道(plane)
  3. 4個32位型別,用S表示,即暫存器被分割為4個通道(plane)
  4. 8個16位型別,用H表示,即暫存器被分割為8個通道(plane)
  5. 16個8位型別,用B表示,即暫存器被分割為16個通道(plane)

一個或多個暫存器內多個相同型別的資料可以構成“向量”,當然向量指令集也支援單個資料操作,因此也有單個資料的“標量”。

向量指令集可以同時對向量中所有的資料同時進行操作,例如圖1中就是將兩個128位暫存器視為兩個由4個32位int型別資料組成的向量,一條向量加法指令可以同時對兩個向量的所有對應的資料元素進行對應加法,並生成一個新的向量放入第三個暫存器中。

相比之下,在通用暫存器上的資料處理,一條指令一次通常只能將一個暫存器視為一個資料。例如操作一個8位整數,該資料所在的整個64位暫存器都被視為8位整數,剩下56位實際不參與執行,從而產生了“浪費”。

此外,一個向量暫存器被分割為多個通道後,對某個通道的操作具有獨立性,不會影響其它通道,例如不會產生上溢或下溢。

2.2 向量指令

Neon的向量指令種類比較豐富,包括常見的算數操作,例如加減乘除、比較運算、位運算等,也包括資料讀寫操作,包括記憶體和暫存器之間的資料讀寫、暫存器之間的資料讀寫等。

但是和普通指令不同的是,向量指令可以操作多個暫存器和每個暫存器的多個通道,因此除了指定暫存器、記憶體地址或者偏移量等指令所需的資訊外,還需要指定向量暫存器的劃分方式、要對暫存器上幾個通道、哪些通道進行操作。

向量指令運算元最大為4個向量暫存器,即128*4=512位(64位元組),比如向量指令一次可以將4個向量暫存器的資料與另外四個向量暫存器的資料進行對應相加,最終存放到第三組四個向量暫存器中,若資料型別為32位整數,那麼一條加法指令相當於執行了16次32位整數加法,因此在某些場景下,向量指令集的效率遠遠高於普通指令集。

此外,一條向量指令最多可以從記憶體中載入64位元組資料到向量暫存器中,但要注意的是,由於處理器微架構、訪存機制等因素,該指令的實際執行時間也許並不比展開為多條普通記憶體資料載入指令更快,在本系列的後續文章中會進行探討。

2.3 使用方式

由於向量操作的特點,普通程式設計一般無法直接使用向量指令集,但可以利用三種方式使用Neon加速:

  1. 手工編寫Neon彙編程式碼:

這種方式一般可以做到效能最大化。通常使用Neon加速的場景都是針對某些演算法的最佳化,程式設計師一般對演算法比較瞭解,因此可以使用各種技巧最大化利用Neon進行最佳化。而另外兩種方法都要依賴編譯器,編譯器並不知道演算法的特點,因此可能達不到手寫彙編的效果。

  1. 使用Neon內建函式

部分編譯器提供 <arm_neon.h> 標頭檔案來幫助程式設計師使用Neon,該標頭檔案中有與Neon指令和暫存器相似和對應的內建函式和資料型別,它們非常類似類似彙編,被編譯器識別後會優先以Neon進行編譯,因此可以使用內建函式模仿Neon彙編編寫程式。但是要注意的是,編譯器實際可能並不會按開發者的想法編譯出最終機器碼,例如可能使用普通指令,甚至使用標準庫函式的方式來實現具體效果,因此最終效果可能不符預期。

  1. 使用編譯器提供的向量最佳化能力自動對程式碼進行向量化,透過使用編譯選項來啟用:

    1. -ftree-vectorize:執行向量最佳化,預設開啟-ftree-loop-vectorize-ftree-slp-vectorize
    2. -ftree-loop-vectorize:執行迴圈向量最佳化,將迴圈展開以減少迭代次數,並在迴圈中執行更多操作
    3. -ftree-slp-vectorize:將多個標量操作捆綁在一起操作,減少操作次數和指令數量
    4. -O3:O3最佳化會預設開啟-ftree-vectorize

相比於前兩種方式,第三種方式的最佳化範圍更廣,可以對很多細節進行最佳化,但對某些特定的演算法,它的最佳化結果可能並不如前兩者。此外,向量化後程式碼可能會存在某些異常行為,尤其是使用O3最佳化,因此需要謹慎使用。

相關文章