最近一直在進行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貢獻代碼,感恩~~