最近一直在進行Doris的向量化計算引擎的開發工作,在進行CPU熱點排查時,發現了儲存層上出現的CPU熱點問題。於是嘗試通過SIMD的指令優化了這部分的CPU熱點程式碼,取得了較好的效能優化效果。借用本篇手記記錄下問題的發現,解決過程一些對於C/C++程式效能問題的一些解決思路,希望各位也能有所收穫。
1.熱點程式碼的發現
最近在進行Doris的部分查詢調優工作,通過perf定位CPU執行熱點時,發現了以下的熱點部分:
這裡通過perf可以看到,將近一半的CPU耗時損耗在BinaryDictPageDecoder::next_batch
與BinaryPlainPageDecoder::next_batch
這兩個函式上。這兩部分都是字串列進行資料讀取的解碼部分,所以我們得研讀一下這部分程式碼,來看看是否有可能得優化空間。
通過Perf進一步進入函式之中,看看哪部分佔用了大量的CPU。由上圖可以看到大量的CPU耗時都在解碼時的記憶體分配之上了。尤其是int64_t RoundUpToPowerOf2
這個函式的計算,這個函式是為了計算記憶體分配時按照對齊的記憶體分配的邏輯。
哪兒來的記憶體分配
這裡得先了解Doris在Page級別是如何儲存字串型別的。這裡有兩種Page:
-
DictPage
字典編碼,適合在字串重複度較高的資料儲存。Doris會將字典寫入PlainPage之中,並記錄每一個字串的偏移量。而實際資料Page之中儲存的不是原始的字串了,而是偏移量了。而實際解碼的時候,則需要分配記憶體,並從字典之中將對應偏移量的記憶體拷貝出來。這就是上面程式碼熱點產生的地方。 -
PlainPage
直接編碼,適合在字串重複度不高時。Doris會自動將DictPage轉為PlainPage。而實際解碼的時候,則需要分配記憶體,並將PlainPage的內容拷貝出來。這也是上面程式碼熱點產生的地方。
無論是DictPage與PlainPage,解碼流程都是這樣。Doris每次讀取的資料量是1024行,所以每次的操作都是
- 取出一行資料
- 通過資料長度,計算分配對齊記憶體長度
- 分配對應的記憶體
- 拷貝資料到分配的記憶體中
2.使用SIMD指令解決問題
好的,確認了問題,就開始研究解決方案。從直覺上說,將1024次零散的記憶體分配簡化為一次大記憶體分配,肯定有較好的效能提升。
但是這樣會導致一個很致命的問題:批量的記憶體分配無法保證記憶體的對齊,這會導致後續的訪存的指令效能低下。但是為了保證記憶體的對齊,上面提到的尤其是int64_t RoundUpToPowerOf2
這個函式的計算是無法繞過的問題。
那既然無法繞過,我們就得想辦法優化它了。這個計算是一個很簡單的函式計算,所以筆者嘗試是否能用SIMD指令優化這個計算流程。
2.1 什麼是SIMD指令
SIMD是(Single instruction multiple data)的縮寫,代表了通過單一一條指令就可以操作一批資料。通過這種方式,在相同的時鐘週期內,CPU能夠處理的資料的能力就大大增加了。
上圖是一個簡單的乘法計算,我們可以看到:4個數字都需要進行乘3的計算。這需要執行
- 4個load記憶體指令
- 4個乘法指令
- 4個記憶體回寫指令
而通過SIMD指令則可以按批的方式來更快的處理資料,由上圖可以看到。原先的12個指令,減少到了3個指令。當代的X86處理器通常都支援了MMX,SSE,AVX等SIMD指令,通過這樣的方式來加快了CPU的計算。
當然SIMD指令也是有一定代價的,從上面的圖中也能看出端倪。
- 處理的資料需要連續,並且對齊的記憶體能獲得更好的效能
- 暫存器的佔用比傳統的SISD的CPU多
更多關於SIMD指令相關的資訊可以參照筆者在文末留下的參考資料。
2.2 如何生成SIMD指令
通常生成SIMD指令的方式通常有兩種:
Auto Vectorized
自動向量化,也就是編譯器自動去分析for迴圈是否能夠向量化。如果可以的話,便自動生成向量化的程式碼,通常我們開始的-O3
優化便會開啟自動向量化。
這種方式當然是最簡單的,但是編譯器畢竟沒有程式設計師那樣智慧,所以對於自動向量化的優化是相對苛刻的,所以需要程式設計師寫出足夠親和度的程式碼。
下面是自動向量化的一些tips:
- 1.簡單的for迴圈
- 2.足夠簡單的程式碼,避免:函式呼叫,分支跳動
- 3.規避資料依賴,就是下一個計算結果依賴上一個迴圈的計算結果
- 4.連續的記憶體與對齊的記憶體
手寫SIMD指令
當然,本身SIMD也通過庫的方式進行了支援。我們也可以直接通過Intel提供的庫來直接進行向量化程式設計,比如SSE
的API的標頭檔案為xmmintrin.h
, AVX
的API標頭檔案為immintrin.h
。這種實現方式最為高效,但是需要程式設計師熟悉SIMD的編碼方式,並且並不通用。比如實現的AVX的向量化演算法並不能在不支援AVX指令集的機器上執行,也無法用SSE指令集代替。
3.開發起來,解決問題
通過上一小節對SIMD指令的分析。接下來就是如何在Doris的程式碼上進行開發,並驗證效果。
3.1 程式碼開發
思路是最難的,寫程式碼永遠是最簡單的。直接上筆者修改Doris的程式碼吧:
// use SIMD instruction to speed up call function `RoundUpToPowerOfTwo`
auto mem_size = 0;
for (int i = 0; i < len; ++i) {
mem_len[i] = BitUtil::RoundUpToPowerOf2Int32(mem_len[i], MemPool::DEFAULT_ALIGNMENT);
mem_size += mem_len[i];
}
這裡利用了GCC的auto vectorized的能力,讓上面的for迴圈能夠進行向量化的計算。由於當前Doris預設的編譯選項並不支援AVX指令集, 而原有的BitUtil::RoundUpToPowerOf2
的函式入參為Int64,這讓只有128位的SSE指令有些捉襟見肘,所以這裡筆者實現了BitUtil::RoundUpToPowerOf2Int32
的版本來加快這個過程.
// speed up function compute for SIMD
static inline size_t RoundUpToPowerOf2Int32(size_t value, size_t factor) {
DCHECK((factor > 0) && ((factor & (factor - 1)) == 0));
return (value + (factor - 1)) & ~(factor - 1);
}
如果是32位的計算,SSE指令支援128位的計算。也就是能夠能夠一次進行4個數字的操作。
完整的程式碼實現請參考這裡的PR。
3.2 效能驗證
Coding完成之後,編譯部署,進行測試。同樣用Perf進行熱點程式碼的觀察,向量化之後,對應的程式碼的CPU佔比顯著下降,執行效能得到了提升。
no vectorized | vectorized | |
---|---|---|
DictPage | 23.42% | 14.82% |
PlainPage | 23.38% | 11.93% |
隨後在單機SSB的模型上測試了一下效果,可以看到不少原先在儲存層較慢的查詢都得到了明顯的加速效果。
接著就是老方式:提出issue,把解決問題的程式碼貢獻給Doris的官方程式碼倉庫。完結撒花
4.小結
Bingo! 到此為止,問題順利解決,得到了一定的效能提升。
本文特別鳴謝社群小夥伴:
- @wangbo的Code Review
- @stdpain在記憶體對齊上的問題的討論。
最後,也希望大家多多支援Apache Doris,多多給Doris貢獻程式碼,感恩~~