Doris開發手記2:用SIMD指令優化存儲層的熱點代碼


最近一直在進行Doris的向量化計算引擎的開發工作,在進行CPU熱點排查時,發現了存儲層上出現的CPU熱點問題。於是嘗試通過SIMD的指令優化了這部分的CPU熱點代碼,取得了較好的性能優化效果。借用本篇手記記錄下問題的發現,解決過程一些對於C/C++程序性能問題的一些解決思路,希望各位也能有所收獲。

1.熱點代碼的發現

最近在進行Doris的部分查詢調優工作,通過perf定位CPU執行熱點時,發現了以下的熱點部分:
perf的結果

這里通過perf可以看到,將近一半的CPU耗時損耗在BinaryDictPageDecoder::next_batchBinaryPlainPageDecoder::next_batch這兩個函數上。這兩部分都是字符串列進行數據讀取的解碼部分,所以我們得研讀一下這部分代碼,來看看是否有可能得優化空間。
perf的熱點分析

通過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能夠處理的數據的能力就大大增加了。

傳統CPU的計算方式

上圖是一個簡單的乘法計算,我們可以看到:4個數字都需要進行乘3的計算。這需要執行

  • 4個load內存指令
  • 4個乘法指令
  • 4個內存回寫指令

SIMD的計算方式

而通過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.hAVX的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的模型上測試了一下效果,可以看到不少原先在存儲層較慢的查詢都得到了明顯的加速效果。

SSB的測試效果

接着就是老方式:提出issue,把解決問題的代碼貢獻給Doris的官方代碼倉庫。完結撒花

4.小結

Bingo! 到此為止,問題順利解決,得到了一定的性能提升。

本文特別鳴謝社區小伙伴:

  • @wangbo的Code Review
  • @stdpain在內存對齊上的問題的討論。

最后,也希望大家多多支持Apache Doris,多多給Doris貢獻代碼,感恩~~

5.參考資料

Vectorization教程
SIMD
Apache Doris源代碼


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM