1. 程式人生 > >如何選擇並實現高效能糾刪碼編碼引擎(下)

如何選擇並實現高效能糾刪碼編碼引擎(下)

作者介紹:
徐祥曦,七牛雲工程師,獨立開發了多套高效能糾刪碼/再生碼編碼引擎。柳青,華中科技大學博士,研究方向為基於糾刪碼的分散式儲存系統。
前言:

在上篇《如何選擇糾刪碼編碼引擎》中,我們簡單瞭解了Reed-SolomonCodes(RS碼)的編/解碼過程,以及編碼引擎的評判標準。但並沒有就具體實現進行展開,本篇作為《糾刪碼技術詳解》的下篇,我們將主要探討工程實現的問題。

這裡先簡單提煉一下實現高效能糾刪碼引擎的要點:首先,根據編碼理論將矩陣以及有限域的運算工程化,接下來主要通過SIMD指令集以及快取優化工作來進行加速運算。也就是說,我們可以將RS的工程實現劃分成兩個基本步驟:

將數學理論工程化

進一步的工程優化

這需要相關研發工程師對以下內容有所掌握:

有限域的基本概念,包括有限域的生成與運算

矩陣的性質以及乘法規則

計算機體系結構中關於CPU指令以及快取的理論

接下來,我們將根據這兩個步驟並結合相關基礎知識展開實現過程的闡述。

一理論工程化

以RS碼為例,糾刪碼實現於具體的儲存系統可以分為幾個部分:編碼、解碼和修復過程中的計算都是在有限域上進行的;編碼過程即是計算生成矩陣(範德蒙德或柯西矩陣)和所有資料的乘積;解碼則是計算解碼矩陣(生成矩陣中某些行向量組成的方陣的逆矩陣)和重建資料的乘積。

1.1有限域運算

有限域是糾刪碼中運算的基礎域,所有的編解碼和重建運算都是基於某個有限域的。不止是糾刪碼,一般的編碼方法都在有限域上進行,比如常見的AES加密中也有有限域運算。使用有限域的一個重要原因是計算機並不能精確執行無限域的運算,比如有理數域和虛數域。

此外,在有限域上運算另一個重要的好處是運算後的結果大小在一定範圍內,這是因為有限域的封閉性決定的,這也為程式設計提供了便利。比如在RS中,我們通常使用GF(2^8),即0~255這一有限域,這是因為其長度剛好為1位元組,便於我們對資料進行儲存和計算。

在確定了有限域的大小之後,通過有限域上的生成多項式可以找到該域上的生成元[1],進而通過生成元的冪次遍歷有限域上的元素,利用這一性質我們可以生成相應的指數表。通過指數表我們可以求出對數表,再利用指數表與對數表最終生成乘法表。關於本原多項式的生成以及相關運算表的計算可以參考我在開源庫中的數學工具。[2]

有了乘法表,我們就可以在運算過程中直接查表獲得結果,而不用進行復雜的多項式運算了。同時也不難發現,查表優化將會成為接下來工作的重點與難點。

1.2選擇生成矩陣

生成矩陣(GM,generatormatrix)定義瞭如何將原始資料塊編碼為冗餘資料塊,RS碼的生成矩陣是一個n行k列矩陣,將k塊原始資料塊編碼為n塊冗餘資料塊。如果對應的編碼是系統碼(比如RAID),編碼後包含了原始資料,則生成矩陣中包含一個k×k大小的單位矩陣和(n−k)×k的冗餘矩陣,單位矩陣對應的是原始資料塊,冗餘矩陣對應的是冗餘資料塊。非系統碼沒有單位矩陣,整個生成矩陣都是冗餘矩陣,因此編碼後只有冗餘資料塊。通常我們會使用系統碼以提高資料提取時的效率,那麼接下來我們需要找到合適的冗餘矩陣。

在解碼過程中我們要對矩陣求逆,因此所採用的矩陣必須滿足子矩陣可逆的性質。目前業界應用最多的兩種矩陣是Vandermondematrix(範德蒙矩陣)和Cauchymatrix(柯西矩陣)。其中範德蒙矩陣歷史最為悠久,但需要注意的是我們並不能直接使用範德蒙矩陣作為生成矩陣,而需要通過高斯消元后才能使用,這是因為在編碼引數(k+m)比較大時會存在矩陣不可逆的風險。

柯西矩陣運算簡單,只不過需要計算乘法逆元,我們可以提前計算好乘法逆元表以供生成編碼矩陣時使用。建立以柯西矩陣為生成矩陣的編碼矩陣的虛擬碼如下圖所示:

柯西矩陣

1.3矩陣求逆運算

有限域上的求逆方法和我們學習的線性代數中求逆方法相同,常見的是高斯消元法,演算法複雜度是O(n^3)。過程如下:

在待求逆的矩陣右邊拼接一個單位矩陣

進行高斯消元運算

取得到的矩陣左邊非單位矩陣的部分作為求逆的結果,如果不可逆則報錯

我們在實際的測試環境中發現,矩陣求逆的開銷還是比較大的(大約6000ns/op)。考慮到在實際系統中,單盤資料重建往往需要幾個小時或者更長(磁碟I/O佔據絕大部分時間),求逆計算時間可以忽略不計。

二進一步的工程優化

2.1利用SIMD加速有限域運算

從上一篇文章可知,有限域上的乘法是通過查表得到的,每個位元組和生成矩陣中元素的乘法結果通過查表得到,圖1給出了按位元組對原始資料進行編碼的過程(生成多項式為x^8+x^4+x^3+x^2+1)。對於任意1位元組來說,在GF(2^8)內有256種可能的值,所以沒有元素對應的乘法表大小為256位元組。每次查表可以進行一個位元組資料的乘法運算,效率很低。

資料編碼

目前主流的支援SIMD相關指令的暫存器有128bit(XMM指令)、256bit(YMM指令)這兩種容量,這意味著對於64位的機器來說,分別提供了2到4倍的處理能力,我們可以考慮採用SIMD指令並行地為更多資料進行乘法運算。

但每個元素的乘法表的大小為256Byte,這大大超出了暫存器容納能力。為了達到利用並行查表的目的,我們採用分治的思想將兩個位元組的乘法運算進行拆分。

位元組y與位元組a的乘法運算過程可表示為,其中y(a)表示從y的乘法表中查詢與x相乘結果的操作:

y(a)=y*a
我們將位元組a拆分成高4位(al)與低4位(ar)兩個部分,即(其中⊕為異或運算):

a=(al<<4)⊕ar

這樣位元組a就表示為0-15與(0-15<<4)異或運算的結果了。

於是原先的y與a的乘法運算可表示為:

y(a)=y(al<<4)⊕y(ar)

由於ar與al的範圍均為0-15(0-1111),位元組y與它們相乘的結果也就只有16個可能的值了。這樣原先256位元組的位元組y的乘法表就可以被2張16位元組的乘法表替換了。

下面以根據本原多項式x^8+x^4+x^3+x^2+1生成的GF(2^8)為例,分別通過查詢普通乘法表與使用拆分乘法表來演示16*100的計算過程。

16的完整乘法表為:

乘法表
計算16*100可以直接查表得到:

table[100] = 14

16 的低 4 位乘法表,也就是 16 與 0-15 的乘法結果:

lowtable=[0163248648096112128144160176192208224240]

16的高4位乘法表,為16與0-15<<4的乘法結果:

hightable=[02958391161057883232245210207156129166187]

將100(01100100)拆分,則:

100 = 0110 << 4 ⊕ 0100

在低位表中查詢0100(4),得:

lowtable[4]=64
在高位表中查詢 0110(6),得:

hightable[6]=78

將兩個查詢結果異或:

result=64^78=1000000^1001110=1110=14

從上面的對比中,我們不難發現採用SIMD的新演算法提高查錶速度主要表現在兩個方面:
減少了乘法表大小;
提高查表並行度(從1個位元組到16甚至32個位元組)
採用SIMD指令在大大降低了乘法表的規模的同時多了一次查表操作以及異或運算。由於新的乘法表每一部分只有16位元組,我們可以順利的將其放置於XMM暫存器中,從而利用SIMD指令集提供的指令來進行資料向量運算,將原先的逐位元組查表改進為並行的對16位元組進行查表,同時異或操作也是16位元組並行的。除此之外,由於乘法表的總體規模的下降,在編碼過程中的快取汙染也被大大減輕了,關於快取的問題我們會在接下來的小節中進行更細緻的分析。
以上的計算過程以單個位元組作為例子,下面我們一同來分析利用SIMD技術對多個位元組進行運算的過程。基本步驟如下:
拆分儲存原始資料的XMM暫存器中的資料向量,分別儲存於不同的XMM暫存器中
根據拆分後的資料向量對乘法表進行重排,即得到查表結果。我們可以將乘法表理解為按順序排放的陣列,陣列長度為16,查表的過程可以理解為將拆分後的資料(資料範圍為0-15)作為索引對乘法表陣列進行重新排序。這樣我們就可以通過排序指令完成查表操作了
將重排後的結果進行異或,得到最終的運算結果
以下是虛擬碼:

虛擬碼

需要注意的是,要使用SIMD加速有限域運算,對CPU的最低要求是支援SSSE3擴充套件指令集。另外為了充分提高效率,我們應該事先對資料進行記憶體對齊操作,在SSSE3下我們需要將資料對齊到16Bytes,否則我們只能使用非對齊指令進行資料的讀取和寫入。在這一點上比較特殊的是Go語言,一方面Go支援直接調用匯編函式這為使用SIMD指令集提供了語言上的支援;但另外一方面Golang又隱藏了記憶體申請的細節,這使得指定記憶體對齊操作不可控,雖然我們也可以通過cgo或者彙編來實現,但這增加額外的負擔。所幸,對於CPU來說一個Cacheline的大小為64byte,這在一定程度上可以幫助我們減少非對齊讀寫帶來的懲罰。另外,根據Golang的記憶體對齊演算法,對於較大的資料塊,Golang是會自動對齊到32byte的,因此對齊或非對齊指令的執行效果是一致的。

2.2寫快取友好程式碼

快取優化通過兩方面進行,其一是減少快取汙染;其二是提高快取命中率。在嘗試做到這兩點之前,我們先來分析快取的基本工作原理。

CPU快取的預設工作模式是Write-Back,即每一次讀寫記憶體資料都需要先寫入快取。上文提到的Cacheline即為快取工作的基本單位,其大小為固定的64byte,也就說哪怕從記憶體中讀取1位元組的資料,CPU也會將其餘的63位元組帶入快取。這樣設計的原因主要是為了提高快取的時間局域性,因為所要執行的資料大小通常遠遠超過這個數字,提前將資料讀取至快取有利於接下來的資料在快取中被命中。

2.2.1矩陣運算分塊

矩陣運算的迴圈迭代中都用到了行與列,因此原始資料矩陣與編碼矩陣的訪問總有一方是非連續的,通過簡單的迴圈交換並不能改善運算的空間局域性。因此我們通過分塊的方法來提高時間局域性來減少快取缺失。

分塊演算法不是對一個數組的整行或整列進行操作,而是對其子矩陣進行操作,目的是在快取中的資料被替換之前,最大限度的利用它。

分塊的尺寸不宜過大,太大的分塊無法被裝進快取;另外也不能過小,太小的分塊導致外部邏輯的呼叫次數大大上升,產生了不必要的函式呼叫開銷,而且也不能充分利用快取空間。

2.2.2減少快取汙染

不難發現的是,編碼矩陣中的係數並不會完全覆蓋整個GF(2^8),例如10+4的編碼方案中,編碼矩陣中校驗矩陣大小為4×10,編碼係數至多(可能會有重複)有10×4=40個。因此我們可以事先進行一個乘法表初始化的過程,比如生成一個新的二維陣列來儲存編碼係數的乘法表。縮小表的範圍可以在讀取表的過程中對快取的汙染。

另外在定義方法集時需要注意的是避免結構體中的元素浪費。避免將不必要的引數扔進結構體中,如果每一個方法僅使用其中若干個元素,則其他元素白白侵佔了快取空間。

三指令級並行與資料級並行的深入優化

本節主要介紹如何利用AVX/AVX2指令集以及指令級並行優化來進一步提高效能表現。除此之外,我們還可以對彙編程式碼進行微調以取得微小的提升。比如,儘量避免使用R8-R15這8個暫存器,因為指令解碼會比其他通用暫存器多一個位元組。但很多彙編優化細節是和CPU架構設計相關的,書本上甚至Intel提供的手冊也並不能提供最準確的指導(因為有滯後性),而且這些操作帶來的效益並不顯著,在這裡就不做重點說明了。

3.1利用AVX2

在上文中我們已經知道如何將乘法表拆分成128bits的大小以適應XMM暫存器,那麼對於AVX指令集來說,要充分發揮其作用,需要將乘法表複製到256bit的YMM暫存器。為了做到這一點,我們可以利用XMM暫存器為YMM暫存器的低位這一特性,僅使用一條指令來完成表的複製(Intel風格):

vinserti128ymm0,ymm0,xmm0,1

這條指令作用是將xmm0暫存器中的資料拷貝到ymm0中,而剩餘128位資料通過ymm0得到,其中立即數1表明xmm0拷貝的目的地是ymm0的高位。這條指令提供了兩個sourceoperand(源運算元)以及一個destinationoperand(目標運算元),我們在這裡使用ymm0暫存器同時作為源運算元和目標運算元來實現了表的複製操作。接下來我們便可以使用與SSSE3下同樣的方式來進行單指令32byte的編碼運算過程了。

由於使用了SSE與AVX這兩種擴充套件指令集,我們需要避免AVX-SSETransitionPenalties[3]。之所以會有這種效能懲罰主要是由於SSE指令對YMM暫存器的高位一無所知,SSE指令與AVX指令的混用會導致機器不斷的執行YMM暫存器的高位儲存與恢復,這大大影響了效能表現。如果對指令不熟悉,難以避免指令混用,那麼可以在RET前使用VZEROUPPER指令來清空YMM暫存器的高位。

3.2指令級並行(ILP)優化

程式分支指令的開銷並不僅僅為指令執行所需要的週期,因為它們可能影響前端流水線和內部快取的內容。我們可以通過如下技巧來減少分支指令對效能的影響,並且提高分支預測單元的準確性:

儘量少的使用分支指令

當貫穿(fall-through)更可能被執行時,使用向前條件跳轉

當貫穿程式碼不太可能被執行時,使用向後條件跳轉

向前跳轉經常用在檢查函式引數的程式碼塊中,如果我們避免了傳入長度為0的資料切片,這樣可以在彙編中去掉相關的分支判斷。在我的程式碼中僅有一條向後條件跳轉指令,用在迴圈程式碼塊的底部。需要注意的是,以上2、3點中的優化方法是為了符合靜態分支預測演算法的要求,然而在市場上基於硬體動態預測方法等處理器占主導地位,因此這兩點優化可能並不會起到提高分支預測準確度的作用,更多的是良好的程式設計習慣的問題。

對於CPU的執行引擎來說,其往往包含多個執行單元例項,這是執行引擎併發執行多個微操做的基本原理。另外CPU核心的排程器下會掛有多個埠,這意味著每個週期排程器可以給執行引擎分發多個微操作。因此我們可以利用迴圈展開來提高指令級並行的可能性。

迴圈展開就是將迴圈體複製多次,同時調整迴圈的終止程式碼。由於它減少了分支判斷的次數,因此可以將來自不同迭代的指令放在一起排程。

當然,如果迴圈展開知識簡單地進行指令複製,最後使用的都是同一組暫存器,可能會妨礙對迴圈的有效排程。因此我們應當合理分配暫存器的使用。另外,如果迴圈規模較大,會導致指令快取的缺失率上升。Intel的優化手冊中指出,迴圈體不應當超過500條指令。[4]

四小結

以上內容較為完整的還原了糾刪碼引擎的實現過程,涉及到了較多的數學和硬體層面的知識,對於大部分工程師來說可能相對陌生,我們希望通過本系列文章的介紹能夠為大家的工程實踐提供些許幫助。但受限於篇幅,很多內容無法全面展開。比如,部分數學工具的理論與證明並沒有得到詳細的解釋,還需要讀者通過其他專業資料的來進行更深入的學習。