做者介紹: 前端
徐祥曦,七牛雲工程師,獨立開發了多套高性能糾刪碼/再生碼編碼引擎。
柳青,華中科技大學博士,研究方向爲基於糾刪碼的分佈式存儲系統。git
前言:github
在上篇《如何選擇糾刪碼編碼引擎》中,咱們簡單瞭解了 Reed-Solomon Codes(RS 碼)的編/解碼過程,以及編碼引擎的評判標準。但並無就具體實現進行展開,本篇做爲《糾刪碼技術詳解》的下篇,咱們將主要探討工程實現的問題。算法
這裏先簡單提煉一下實現高性能糾刪碼引擎的要點:首先,根據編碼理論將矩陣以及有限域的運算工程化,接下來主要經過 SIMD 指令集以及緩存優化工做來進行加速運算。也就是說,咱們能夠將 RS 的工程實現劃分紅兩個基本步驟:編程
將數學理論工程化數組
進一步的工程優化緩存
這須要相關研發工程師對如下內容有所掌握:架構
有限域的基本概念,包括有限域的生成與運算併發
矩陣的性質以及乘法規則分佈式
計算機體系結構中關於 CPU 指令以及緩存的理論
接下來,咱們將根據這兩個步驟並結合相關基礎知識展開實現過程的闡述。
以 RS 碼爲例,糾刪碼實現於具體的存儲系統能夠分爲幾個部分:編碼、解碼和修復過程當中的計算都是在有限域上進行的;編碼過程便是計算生成矩陣(範德蒙德或柯西矩陣)和全部數據的乘積;解碼則是計算解碼矩陣(生成矩陣中某些行向量組成的方陣的逆矩陣)和重建數據的乘積。
有限域是糾刪碼中運算的基礎域,全部的編解碼和重建運算都是基某個有限域的。不止是糾刪碼,通常的編碼方法都在有限域上進行,好比常見的AES加密中也有有限域運算。使用有限域的一個重要緣由是計算機並不能精確執行無限域的運算,好比有理數域和虛數域。
此外,在有限域上運算另外一個重要的好處是運算後的結果大小在必定範圍內,這是由於有限域的封閉性決定的,這也爲程序設計提供了便利。好比在 RS 中,咱們一般使用 GF(2^8),即 0~255 這一有限域,這是由於其長度恰好爲1字節,便於咱們對數據進行存儲和計算。
在肯定了有限域的大小以後,經過有限域上的生成多項式能夠找到該域上的生成元[1],進而經過生成元的冪次遍歷有限域上的元素,利用這一性質咱們能夠生成相應的指數表。經過指數表咱們能夠求出對數表,再利用指數表與對數表最終生成乘法表。關於本原多項式的生成以及相關運算表的計算能夠參考我在開源庫中的數學工具。[2]
有了乘法表,咱們就能夠在運算過程當中直接查表得到結果,而不用進行復雜的多項式運算了。同時也不難發現,查表優化將會成爲接下來工做的重點與難點。
生成矩陣(GM, generator matrix) 定義瞭如何將原始數據塊編碼爲冗餘數據塊,RS 碼的生成矩陣是一個 n 行 k 列矩陣,將 k 塊原始數據塊編碼爲 n 塊冗餘數據塊。若是對應的編碼是系統碼(好比 RAID),編碼後包含了原始數據,則生成矩陣中包含一個 k×k 大小的單位矩陣和(n−k)×k 的冗餘矩陣, 單位矩陣對應的是原始數據塊,冗餘矩陣對應的是冗餘數據塊。非系統碼沒有單位矩陣,整個生成矩陣都是冗餘矩陣,所以編碼後只有冗餘數據塊。一般咱們會使用系統碼以提升數據提取時的效率,那麼接下來咱們須要找到合適的冗餘矩陣。
在解碼過程當中咱們要對矩陣求逆,所以所採用的矩陣必須知足子矩陣可逆的性質。目前業界應用最多的兩種矩陣是 Vandermonde matrix (範德蒙矩陣)和Cauchy matrix(柯西矩陣)。其中範德蒙矩陣歷史最爲悠久,但須要注意的是咱們並不能直接使用範德蒙矩陣
做爲生成矩陣,而須要經過高斯消元后才能使用,這是由於在編碼參數(k+m)比較大時會存在矩陣不可逆的風險。
柯西矩陣運算簡單,只不過須要計算乘法逆元,咱們能夠提早計算好乘法逆元表以供生成編碼矩陣時使用。建立以柯西矩陣爲生成矩陣的編碼矩陣的僞代碼以下圖所示:
*// m 爲編碼矩陣* *// rows爲行數,cols爲列數* *// *k×k 的單位矩陣 **for **j := 0; j < cols; j++ { m[j][j] = byte(1) } *//* mxk 的柯西矩陣 **for **i := cols; i < rows; i++ { **for **j := 0; j < cols; j++ { d := i ^ j a := inverseTable[d] *// 查乘法逆元表* m[i][j] = byte(a) } }
有限域上的求逆方法和咱們學習的線性代數中求逆方法相同,常見的是高斯消元法,算法複雜度是 O(n^3)。過程以下:
咱們在實際的測試環境中發現,矩陣求逆的開銷仍是比較大的(大約 6000 ns/op)。考慮到在實際系統中,單盤數據重建每每須要幾個小時或者更長(磁盤I/O 佔據絕大部分時間),求逆計算時間能夠忽略不計。
從上一篇文章可知,有限域上的乘法是經過查表獲得的,每一個字節和生成矩陣中元素的乘法結果經過查表獲得,圖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 指令並行地爲更多數據進行乘法運算。
但每一個元素的乘法表的大小爲 256 Byte ,這大大超出了寄存器容納能力。爲了達到利用並行查表的目的,咱們採用分治的思想將兩個字節的乘法運算進行拆分。
字節 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 的完整乘法表爲:
table = [0 16 32 48 64 80 96 112 128 144 160 176 192 208 224 240 29 13 61 45 93 77 125 109 157 141 189 173 221 205 253 237 58 42 26 10 122 106 90 74 186 170 154 138 250 234 218 202 39 55 7 23 103 119 71 87 167 183 135 151 231 247 199 215 116 100 84 68 52 36 20 4 244 228 212 196 180 164 148 132 105 121 73 89 41 57 9 25 233 249 201 217 169 185 137 153 78 94 110 126 14 30 46 62 206 222 238 254 142 158 174 190 83 67 115 99 19 3 51 35 211 195 243 227 147 131 179 163 232 248 200 216 168 184 136 152 104 120 72 88 40 56 8 24 245 229 213 197 181 165 149 133 117 101 85 69 53 37 21 5 210 194 242 226 146 130 178 162 82 66 114 98 18 2 50 34 207 223 239 255 143 159 175 191 79 95 111 127 15 31 47 63 156 140 188 172 220 204 252 236 28 12 60 44 92 76 124 108 129 145 161 177 193 209 225 241 1 17 33 49 65 81 97 113 166 182 134 150 230 246 198 214 38 54 6 22 102 118 70 86 187 171 155 139 251 235 219 203 59 43 27 11 123 107 91 75]
計算 16 * 100 能夠直接查表獲得:
table[100] = 14
16 的低4位乘法表,也就是16 與 0-15 的乘法結果:
lowtable = [0 16 32 48 64 80 96 112 128 144 160 176 192 208 224 240]
16 的高4位乘法表,爲16 與 0-15 << 4 的乘法結果:
hightable = [0 29 58 39 116 105 78 83 232 245 210 207 156 129 166 187]
將 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 )做爲索引對乘法表數組進行從新排序。這樣咱們就能夠經過排序指令完成查表操做了將重排後的結果進行異或,獲得最終的運算結果
如下是僞代碼:
*// 將原始數據的右移4bit* d2 = raw_data >> 4 *// 將右移後的數據的每字節與15(即1111)作AND操做,獲得數據高位* high_data = d2 AND 1111 *// 原始數據的每字節與15(即1111)作AND操做,獲得數據低位* low_data = raw_data AND 1111 *// 以數據做爲索引對乘法表進行了重排* for i, b = range low_data { low_ret[i]=low_table[b]} for i, b = range high_data {high_ret[i]=high_table[b]} *// 異或兩部分結果獲得最終數據* ret = low_ret XOR high_ret
須要注意的是,要使用 SIMD 加速有限域運算,對 CPU 的最低要求是支持 SSSE3 擴展指令集。另外爲了充分提升效率,咱們應該事先對數據進行內存對齊操做,在 SSSE3 下咱們須要將數據對齊到 16 Bytes,不然咱們只能使用非對齊指令進行數據的讀取和寫入。在這一點上比較特殊的是 Go 語言, 一方面 Go 支持直接調用匯編函數這爲使用 SIMD 指令集提供了語言上的支持;但另一方面 Golang 又隱藏了內存申請的細節,這使得指定內存對齊操做不可控,雖然咱們也能夠經過 cgo 或者彙編來實現,但這增長額外的負擔。所幸,對於 CPU 來講一個 Cache line 的大小爲64byte,這在必定程度上能夠幫助咱們減小非對齊讀寫帶來的懲罰。另外,根據Golang 的內存對齊算法,對於較大的數據塊,Golang 是會自動對齊到 32 byte 的,所以對齊或非對齊指令的執行效果是一致的。
緩存優化經過兩方面進行,其一是減小緩存污染;其二是提升緩存命中率。在嘗試作到這兩點以前,咱們先來分析緩存的基本工做原理。
CPU 緩存的默認工做模式是 Write-Back, 即每一次讀寫內存數據都須要先寫入緩存。上文提到的 Cache line 即爲緩存工做的基本單位,其大小爲固定的 64 byte ,也就說哪怕從內存中讀取 1字節的數據,CPU 也會將其他的63 字節帶入緩存。這樣設計的緣由主要是爲了提升緩存的時間局域性,由於所要執行的數據大小一般遠遠超過這個數字,提早將數據讀取至緩存有利於接下來的數據在緩存中被命中。
矩陣運算的循環迭代中都用到了行與列,所以原始數據矩陣與編碼矩陣的訪問總有一方是非連續的,經過簡單的循環交換並不能改善運算的空間局域性。所以咱們經過分塊的方法來提升時間局域性來減小緩存缺失。
分塊算法不是對一個數組的整行或整列進行操做,而是對其子矩陣進行操做,目的是在緩存中的數據被替換以前,最大限度的利用它。
分塊的尺寸不宜過大,太大的分塊沒法被裝進緩存;另外也不能太小,過小的分塊致使外部邏輯的調用次數大大上升,產生了沒必要要的函數調用開銷,並且也不能充分利用緩存空間。
不難發現的是,編碼矩陣中的係數並不會徹底覆蓋整個 GF(2^8),例如 10+4 的編碼方案中,編碼矩陣中校驗矩陣大小爲 4×10,編碼係數至多(可能會有重複)有10×4=40 個。所以咱們能夠事先進行一個乘法表初始化的過程,好比生成一個新的二維數組來存儲編碼係數的乘法表。縮小表的範圍能夠在讀取表的過程當中對緩存的污染。
另外在定義方法集時須要注意的是避免結構體中的元素浪費。避免將沒必要要的參數扔進結構體中,若是每個方法僅使用其中若干個元素,則其餘元素白白侵佔了緩存空間。
本節主要介紹如何利用 AVX/AVX2 指令集以及指令級並行優化來進一步提升性能表現。除此以外,咱們還能夠對彙編代碼進行微調以取得微小的提高。好比,儘可能避免使用 R8-R15 這 8 個寄存器,由於指令解碼會比其餘通用寄存器多一個字節。但不少彙編優化細節是和 CPU 架構設計相關的,書本上甚至 Intel 提供的手冊也並不能提供最準確的指導(由於有滯後性),並且這些操做帶來的效益並不顯著,在這裏就不作重點說明了。
在上文中咱們已經知道如何將乘法表拆分紅 128bits 的大小以適應 XMM 寄存器,那麼對於 AVX 指令集來講,要充分發揮其做用,須要將乘法表複製到 256 bit 的 YMM 寄存器。爲了作到這一點,咱們能夠利用 XMM 寄存器爲 YMM 寄存器的低位這一特性,僅使用一條指令來完成表的複製(Intel 風格):vinserti128 ymm0, ymm0, xmm0, 1
這條指令做用是將 xmm0 寄存器中的數據拷貝到 ymm0 中,而剩餘 128 位數據經過 ymm0 獲得,其中當即數 1 代表 xmm0 拷貝的目的地是 ymm0 的高位。這條指令提供了兩個 source operand(源操做數)以及一個 destination operand(目標操做數),咱們在這裏使用 ymm0 寄存器同時做爲源操做數
和目標操做數
來實現了表的複製操做。接下來咱們即可以使用與 SSSE3 下一樣的方式來進行單指令 32 byte 的編碼運算過程了。
因爲使用了 SSE 與 AVX 這兩種擴展指令集,咱們須要避免 AVX-SSE Transition Penalties[3]。之因此會有這種性能懲罰主要是因爲 SSE 指令對 YMM 寄存器的高位一無所知,SSE 指令與 AVX 指令的混用會致使機器不斷的執行 YMM 寄存器的高位保存與恢復,這大大影響了性能表現。若是對指令不熟悉,難以免指令混用,那麼能夠在 RET 前使用 VZEROUPPER 指令來清空 YMM 寄存器的高位。
程序分支指令的開銷並不只僅爲指令執行所須要的週期,由於它們可能影響前端流水線和內部緩存的內容。咱們能夠經過以下技巧來減小分支指令對性能的影響,而且提升分支預測單元的準確性:
少的使用分支指令
當貫穿 (fall-through) 更可能被執行時,使用向前條件跳轉
當貫穿代碼不太可能被執行時,使用向後條件跳轉
向前跳轉常常用在檢查函數參數的代碼塊中,若是咱們避免了傳入長度爲 0 的數據切片,這樣能夠在彙編中去掉相關的分支判斷。在個人代碼中僅有一條向後條件跳轉指令,用在循環代碼塊的底部。須要注意的是,以上 2 、 3 點中的優化方法是爲了符合靜態分支預測算法的要求,然而在市場上基於硬件動態預測方法等處理器占主導地位,所以這兩點優化可能並不會起到提升分支預測準確度的做用,更多的是良好的編程習慣的問題。
對於 CPU 的執行引擎來講,其每每包含多個執行單元實例,這是執行引擎併發執行多個微操作的基本原理。另外 CPU 內核的調度器下會掛有多個端口,這意味着每一個週期調度器能夠給執行引擎分發多個微操做。所以咱們能夠利用循環展開來提升指令級並行的可能性。
循環展開就是將循環體複製屢次,同時調整循環的終止代碼。因爲它減小了分支判斷的次數,所以能夠未來自不一樣迭代的指令放在一塊兒調度。
固然,若是循環展開知識簡單地進行指令複製,最後使用的都是同一組寄存器,可能會妨礙對循環的有效調度。所以咱們應當合理分配寄存器的使用。另外,若是循環規模較大,會致使指令緩存的缺失率上升。Intel 的優化手冊中指出,循環體不該當超過 500 條指令。[4]
以上內容較爲完整的還原了糾刪碼引擎的實現過程,涉及到了較多的數學和硬件層面的知識,對於大部分工程師來講可能相對陌生,咱們但願經過本系列文章的介紹可以爲你們的工程實踐提供些許幫助。但受限於篇幅,不少內容沒法全面展開。好比,部分數學工具的理論與證實並無獲得詳細的解釋,還須要讀者經過其餘專業資料的來進行更深刻的學習。
附錄:
Galois Fields and Cyclic Codes
http://user.xmission.com/~rimrock/Documents/Galois%20Fields%20and%20Cyclic%20Codes.pdf
有限域相關計算 https://github.com/templexxx/reedsolomon/tree/master/mathtools
Avoiding AVX-SSE Transition Penalties
https://software.intel.com/en-us/articles/avoiding-avx-sse-transition-penalties
Intel 64 and IA-32 Architectures Optimization Reference Manual :3.4.2.6 Optimization for Decoded ICache