27倍性能之旅 – 以大底庫全庫向量召回為例談Profiling驅動的性能優化
問題
-
Problem
-
kNN(k Nearest Neighbor)定義
給定一個查詢向量,按照某個選定的準則(如歐式距離),從底庫中選擇個與查詢向量最相似或者最鄰近的向量的過程。
-
輸入
-
查詢向量(query):
-
底庫(database):
, 庫中有
個向量,每個向量的維度為
,即
-
-
輸出
-
底庫中與查詢向量點積相似度最大的k個向量:
-
-
演算法
-
Exhaustive Search, 窮搜
-
-
-
用例
只考慮IP計算部分, 參數配置如下:參數 配置 query batch size(bs) 32 底庫大小(n) 1M條向量 向量維度(d) 128 向量數據精度(precision) FP32 測試單核性能。
- 程式碼 //github.com/yao-matrix/mProto/tree/master/snippet/c/dp
實現
[Impl #0] Naive
最樸素的實現就是循環計算每個query向量與底庫中每個向量的內積。實現如下:
常式: for (size_t i = 0; i < bs; i++) { for (size_t j = 0; j < base_vec_num; j++) { dotp_naive(query + i * vec_dim, base + j * vec_dim, vec_dim, &scores[i * base_vec_num + j]); } } 子常式: static void dotp_naive(const float *lhs, const float *rhs, size_t dim, float *score) { float res = 0; for (size_t i = 0; i < dim; i++) { res += lhs[i] * rhs[i]; } *score = res; return; }
性能顯然是不好的,在至強CascadeLake 8269C上單核平均每batch需要5426.046 ms。
祭出 VTune大法後,得到如下的TMAM (Top-down Micro-architecture Analysis Method)分布。
看到這個TMAM圖的時候,一定要先按捺住自己想去拯救Memory Bound那一片紅海的衝動。優化的過程中,要牢牢守住「先計算後數據」的初心,因為數據往往是可以配合計算的,所以首先是要把計算這跟柱子立牢了,然後在這個基礎上做數據優化。
計算優化的第一步是看SIMD有沒有被用上:
-
我們看到整個workload retire的操作中,有38.4%的微操作是FP Arithmetic,即浮點算術操作,而這些操作目前全部都是標量實現的,沒有SIMD化。
-
同時,通過Vector Capacity Usage(FPU) 的利用率 6.2%也可以看出CPU的向量計算能力沒有被充分利用。
所以,雖然現在是Memory Bound,但我們還是不能「頭疼醫頭,腳疼醫腳」,而是應該先做向量化的優化,再來分析。
[Impl #1] AVX2 Vectorization
使用AVX-2來向量化IP運算,其餘保持不變。程式碼如下:
常式: for (size_t i = 0; i < bs; i++) { for (size_t j = 0; j < base_vec_num; j++) { dotp_avx2(query + i * vec_dim, base + j * vec_dim, vec_dim, &scores[i * base_vec_num + j]); } } 子常式: void dotp_avx2(const float *lhs, const float *rhs, size_t size, float *score) { __m256 ymm_sum0 = _mm256_setzero_ps(); __m256 ymm_sum1 = _mm256_setzero_ps(); for (size_t i = 0; i < size; i += 16) { ymm_sum0 = _mm256_fmadd_ps(_mm256_loadu_ps(lhs + i), _mm256_loadu_ps(rhs + i), ymm_sum0); ymm_sum1 = _mm256_fmadd_ps(_mm256_loadu_ps(lhs + i + 8), _mm256_loadu_ps(rhs + i + 8), ymm_sum1); } *score = horizontal_add_v256(_mm256_add_ps(ymm_sum0, ymm_sum1)); return; } static inline float horizontal_add_v256(__m256 v) { __m256 x1 = _mm256_hadd_ps(v, v); __m256 x2 = _mm256_hadd_ps(x1, x1); __m128 x3 = _mm256_extractf128_ps(x2, 1); __m128 x4 = _mm_add_ss(_mm256_castps256_ps128(x2), x3); return _mm_cvtss_f32(x4); }
AVX2向量化帶來了性能提升,在至強CascadeLake 8269C上單核平均每batch延時下降到了2143.29 ms。
同樣,我們把VTune的TMAM數據拉出來端詳端詳。可以看到這下FP Arithmetic 都落到FP Vector上了,同時Vector Capacity Usage也提高到48.8%。只是Memory Bound更嚴重了,飆到了76.1%。這時要做的,還是按捺住自己想要去優化數據部分的小心臟,繼續看看還有沒有計算優化的空間。一個自然的想法是:試試AVX-512。
[Impl #2] AVX-512 Vectorization
使用AVX-512來向量化IP運算,其餘保持不變。程式碼如下:
常式: for (size_t i = 0; i < bs; i++) { for (size_t j = 0; j < base_vec_num; j++) { dotp_avx3(query + i * vec_dim, base + j * vec_dim,
vec_dim, &scores[i * base_vec_num + j]); } } 子常式: void dotp_avx3(const float *lhs, const float *rhs, size_t dim, float *score) { __m512 zmm_sum0 = _mm512_setzero_ps(); __m512 zmm_sum1 = _mm512_setzero_ps(); for (size_t i = 0; i < dim; i += 32) { zmm_sum0 = _mm512_fmadd_ps(_mm512_loadu_ps(lhs + i), _mm512_loadu_ps(rhs + i), zmm_sum0); zmm_sum1 = _mm512_fmadd_ps(_mm512_loadu_ps(lhs + i + 16), _mm512_loadu_ps(rhs + i + 16), zmm_sum1); } *score = horizontal_add_v512(_mm512_add_ps(zmm_sum0, zmm_sum1)); return; } static inline float horizontal_add_v512(__m512 v) { __m256 low = _mm512_castps512_ps256(v); __m256 high = _mm256_castpd_ps(_mm512_extractf64x4_pd( _mm512_castps_pd(v), 1)); return horizontal_add_v256(low + high); }
跑下來, 單核平均每batch延時進一步下降到了1589.99 ms。上TMAM,如下圖。我們可以看到:
-
因為AVX-512的指令位寬是AVX2的加倍,所以處理相同的problem size所需要的指令數大約是AVX2的一半。這個可以通過比較Instructions Retired得到驗證。
-
一半的指令數的減少並沒有等比反映到延時性能中,我們可以看到最終延時僅有25.8%的降低。這裡面有兩個原因:
-
AVX-512造成了CPU的降頻,從Average CPU Frequency可以看到在AVX-512實現中頻率從AVX2時的2.9 GHz降到了2.5 GHz, 降了0.4 GHz.
-
Memory Bound繼續增大到了78.4%, 數據瓶頸阻礙了AVX-512的性能發揮。
-
[Impl #3] AVX-512 Query Batching
到現在為止,基於SIMD的計算優化就告一段落了。我們可以把目光投向Memory Bound。我們再仔細看一下Memory Bound的分布,可以看到幾乎全部都bound到了DRAM, L1/L2/L3 cache事實上失去了作用。
出現這個狀況也是可解釋的,我們目前的實現方案可以如下圖表示。外層是query循環,內層是底庫循環,而且query的向量數是遠小於底庫的向量數的。我們可以從局部性的角度來打量query和資料庫向量在本計算方案中的情況:
-
query向量:有很好的空間局部性和時間局部性,query會連續與與底庫中每個向量計算,cache hierarchy 能夠很好的amortize掉memory read的overhead。
-
db向量:有很好的空間局部性,為SIMD的使用奠定基礎。但時間局部性非常差,每個向量被使用一次後,下一次使用就是在底庫被全庫遍歷後再回到該向量的時候了,而底庫又非常大,所以下一次使用的時候仍能命中cache的可能性為0。 因此,在cache behavior上來講變成了一個streaming數據,擊穿了cache體系。
所以為了降低Memory Bound,主要需要考慮如何提高db向量的時間局部性。一個自然的想法就是交換內外層循環。這樣,每個db向量load進來後都會被使用多次,而不是像以前那樣只有一次,這些計算amortize了一次db向量的memory read overhead,減少了DRAM的access。這裡只有一個問題,交換循環後query的時間局部性會不會被犧牲掉?在這個問題中,我們算一下query總共需要 數據,而一個db向量需要
, 兩個加起來小於L1 DCache的size 32KB,所以query的cache局部性不會被犧牲掉。
程式碼如下:
常式: for (size_t i = 0; i < base_vec_num; i++) { dotp_avx3_qb(query, base + i * vec_dim, bs, vec_dim, scores + i * bs); } 子常式(memalign的部分在正式實現時需要移到初始化API中,並在destroy程式碼中free記憶體。這裡為簡化程式碼忽略記憶體泄漏,不影響性能結論): void dotp_avx3_qb(const float *query, const float *base, size_t bs, size_t dim, float *scores) { static __m512 *zmm_sum0 = NULL; static __m512 *zmm_sum1 = NULL; if (zmm_sum0 == NULL) { zmm_sum0 = (__m512*)memalign(64, 2 * bs * sizeof(__m512)); zmm_sum1 = zmm_sum0 + bs; } memset(zmm_sum0, 0, 2 * bs * sizeof(__m512)); __m512 v_base0, v_base1; __m512 v_q0, v_q1; for (size_t i = 0; i < dim; i += 32) { v_base0 = _mm512_loadu_ps(base + i); v_base1 = _mm512_loadu_ps(base + i + 16); for (size_t j = 0; j < bs; j++) { v_q0 = _mm512_loadu_ps(query + j * dim + i); v_q1 = _mm512_loadu_ps(query + j * dim + i + 16); zmm_sum0[j] = _mm512_fmadd_ps(v_base0, v_q0, zmm_sum0[j]); zmm_sum1[j] = _mm512_fmadd_ps(v_base1, v_q1, zmm_sum1[j]); } } for (size_t j = 0; j < bs; j++) { scores[j] = horizontal_add_v512(_mm512_add_ps(zmm_sum0[j], zmm_sum1[j])); } // free(zmm_sum0); // zmm_sum0 = NULL; return; }
常式: for (size_t i = 0; i < base_vec_num; i++) { dotp_avx3_qb(query, base + i * vec_dim, bs, vec_dim, scores + i * bs); }
子常式(memalign的部分在正式實現時需要移到初始化API中,並在destroy程式碼中free記憶體。這裡為簡化程式碼忽略記憶體泄漏,不影響性能結論): void dotp_avx3_qb(const float *query, const float *base, size_t bs, size_t dim, float *scores) { static __m512 *zmm_sum0 = NULL; static __m512 *zmm_sum1 = NULL; if (zmm_sum0 == NULL) { zmm_sum0 = (__m512*)memalign(64, 2 * bs * sizeof(__m512)); zmm_sum1 = zmm_sum0 + bs; } memset(zmm_sum0, 0, 2 * bs * sizeof(__m512)); __m512 v_base0, v_base1; __m512 v_q0, v_q1; for (size_t i = 0; i < dim; i += 32) { v_base0 = _mm512_loadu_ps(base + i); v_base1 = _mm512_loadu_ps(base + i + 16); for (size_t j = 0; j < bs; j++) { v_q0 = _mm512_loadu_ps(query + j * dim + i); v_q1 = _mm512_loadu_ps(query + j * dim + i + 16); zmm_sum0[j] = _mm512_fmadd_ps(v_base0, v_q0, zmm_sum0[j]); zmm_sum1[j] = _mm512_fmadd_ps(v_base1, v_q1, zmm_sum1[j]); } } for (size_t j = 0; j < bs; j++) { scores[j] = horizontal_add_v512(_mm512_add_ps(zmm_sum0[j], zmm_sum1[j])); } // free(zmm_sum0); // zmm_sum0 = NULL; return; }
單核平均每batch延時進一步從1589.99 ms下降到了348.05 ms, 性能提升了約3.5倍。這個時候再看一下VTune。
這是一個非常有趣的VTune TMAM。首先看到如我們所料,我們通過增加db向量的時間局部性大幅降低了系統的Memory Bound(78.4% -> 5.8%),基本達到我們的目的。進一步分析,可以發現
-
系統的熱點轉移到了」Core Bound」, 各port都有比較大的壓力,特別是Port 2和Port 3這兩個壓力最大, 從下面的微架構圖中可以看出這兩個埠是Load/Store數據的埠,說明數據讀壓力還是較大,只不過這次壓力從數據源(記憶體/Cache體系)轉移到了讀數據的埠。
-
Microcode Sequencer的bound效應開始顯現。這個主要還是來自於我們計算方案中使用的hadd指令。下表給出了HADD指令的uOP數和port使用情況。
[Impl #4] AVX-512 Block計算
沿著上一個section的分析,我們思考有沒有辦法降低core的port的壓力,並減少MicroSequencer的觸發率。
我們目前的計算方案,是每次直接計算一對vector的IP。以vector dimension 8為例,具體方案如下。從中我們可以看出,計算的冗餘度比較大從第二個hadd開始,有一半或以上的計算結果都不會倍最終結果用到,形成了一種「無效向量化」的效果,而且hadd指令的加入也使得MicroSequencer加入了指令的issue,拖慢了運行的效率。
Block優化可以消除計算冗餘度,同時消除hadd指令的使用。想法很簡單,把」1 v 1」的向量計算,變成」1 v N」的向量計算, 這裡N需要取一個AVX512暫存器里能保存的float的個數,也就是16。具體如下:
按資料庫中16條向量來算:從指令數上來講,直接法需要條指令,而block優化需要
條指令,指令數大大降低了,而且也消除了hadd。但是Block優化需要增加一個額外的操作,就是每16行db向量需要做一個轉置,這個可以在資料庫初始化的時候一次性做掉就可以了,不需要每次都做,所以這個時間是可以不算的。
下面給出實現:
常式: for (size_t i = 0; i < base_vec_num; i += 16) { dotp_avx3_block(query, base + i * vec_dim, bs, vec_dim, scores + i * bs); } 子常式: static inline __m512 _mm512_extload_ps(float const* daddr) { __m128 d = _mm_load_ps(daddr); return _mm512_broadcastss_ps(d); } void dotp_avx3_block(const float *query, const float *base, size_t bs, size_t dim, float *scores) { static __m512 *zmm_sum0 = NULL; if (zmm_sum0 == NULL) { zmm_sum0 = (__m512*)memalign(64, bs * sizeof(__m512)); } memset(zmm_sum0, 0, 2 * bs * sizeof(__m512)); __m512 v_base0; __m512 v_q0, v_q1; for (size_t i = 0; i < dim; i += 1) { v_base0 = _mm512_loadu_ps(base + i * 16); for (size_t j = 0; j < bs; j += 2) { v_q0 = _mm512_extload_ps(query + j * dim + i); v_q1 = _mm512_extload_ps(query + (j + 1) * dim + i); zmm_sum0[j] = _mm512_fmadd_ps(v_base0, v_q0, zmm_sum0[j]); zmm_sum0[j + 1] = _mm512_fmadd_ps(v_base0, v_q1, zmm_sum0[j + 1]); } _mm_prefetch(base + (i + 1) * 16, _MM_HINT_T0); } for (size_t j = 0; j < bs; j++) { _mm512_store_ps(scores + j * 16, zmm_sum0[j]); } // free(zmm_sum0); // zmm_sum0 = NULL; return; }
跑下來,單核平均每batch延時進一步從348.05 ms下降到了206.53 ms, 性能提升了約1.7倍。再看一下VTune。可以看到port 2、port 3的壓力下降了,Micro Sequencer的熱點也不見了,FPU的利用率也達到了100%。另外觀察到兩個現象:
-
數據access埠壓力還是最大,包括Port 2, Port 3, Port 4。尤其是Port 4是store埠,與Back-End Bound裡面的Store Bound是相互映證的。目前還沒有想到好的辦法緩解。
-
L2 Bound比較significant, 另有3.7%的DRAM bound, 可以再試試prefetch能不能有幫助。
下面試一下prefetch, code如下。
子常式: void dotp_avx3_block(const float *query, const float *base, size_t bs, size_t dim, float *scores) { static __m512 *zmm_sum0 = NULL; if (zmm_sum0 == NULL) { zmm_sum0 = (__m512*)memalign(64, bs * sizeof(__m512)); } memset(zmm_sum0, 0, 2 * bs * sizeof(__m512)); __m512 v_base0; __m512 v_q0, v_q1; for (size_t i = 0; i < dim; i += 1) { v_base0 = _mm512_loadu_ps(base + i * 16); for (size_t j = 0; j < bs; j += 2) { v_q0 = _mm512_extload_ps(query + j * dim + i); v_q1 = _mm512_extload_ps(query + (j + 1) * dim + i); zmm_sum0[j] = _mm512_fmadd_ps(v_base0, v_q0, zmm_sum0[j]); zmm_sum0[j + 1] = _mm512_fmadd_ps(v_base0, v_q1,zmm_sum0[j + 1]); } _mm_prefetch(base + (i + 1) * 16, _MM_HINT_T0); } for (size_t j = 0; j < bs; j++) { _mm512_store_ps(scores + j * 16, zmm_sum0[j]); } // free(zmm_sum0); // zmm_sum0 = NULL; return; }
跑下來,單核平均每batch延時進一步從206.53 ms下降到了195.78 ms,約5%的性能提升。這個prefetch是prefetch到L1的,從TMAM分析可以看到,prefetch把DRAM bound消除了,但對L2 Bound的部分並沒有什麼影響。
至此,優化基本告一段落。後面只有向hardware提需求了, 如增大L1 cache size, 增加store port等。
總結與思考
我們回顧這次優化之旅,效果還是比較顯著的:
方案 | 單核batch延時(單位:ms) |
Naive | 5426.046 |
AVX2 Baseline | 2143.29 |
AVX512 Baselie | 1589.99 |
AVX512 Query Batching | 348.05 |
AVX512 Block | 206.53 |
AVX512 Block + Prefetch | 195.78 |
一路下來,我們把單核batch延時從5426.046 ms下降到了195.78 ms, 性能提升了27倍。
在優化過程中,我們也收穫不少思考:
-
編譯器自動向量化效率比較低。在Naive演算法中,我們在編譯選項中已經開了native優化的選項,但從結果中我們可以看到,其向量化的效率並不理想,目前還是需要人工向量化。
-
Profiling驅動優化是比較有效的優化方法。通過緊密結合profiling數據和對workload的理解,我們可以一步一步打磨workload和計算平台的適切性,從而獲得較好的性能增益。
-
優化過程中要堅持「計算第一,數據配合」的原則,以計算優化為首要優先順序,待計算優化穩定後再配合數據優化。
在本文中,我們對一個簡單的問題進行了嘗試,這是為了比較容易去說明profiling驅動的優化的過程,我們在實際工作的問題可能比這個更複雜,但主要方法論還是通用的。另外,這裡只說明了單核優化的情況,對多核優化其實並未涉及,但我個人認為只要在單核上把問題理解清楚了,多核擴展是相對比較自然的。