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

HappenLee 發表於 2021-07-09

最近一直在進行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原始碼