[英]Counting differences between 2 buffers seems too slow
我有 2 個相同大小的相鄰字節緩沖區(每個緩沖區大約 20 MB)。 我只想數一數它們之間的差異。
在配備 3600MT RAM 的 4.8GHz Intel I7 9700K 上運行此循環需要多長時間?
我們如何計算最大理論速度?
uint64_t compareFunction(const char *const __restrict buffer, const uint64_t commonSize)
{
uint64_t diffFound = 0;
for(uint64_t byte = 0; byte < commonSize; ++byte)
diffFound += static_cast<uint64_t>(buffer[byte] != buffer[byte + commonSize]);
return diffFound;
}
在我的 PC (9700K 4.8Ghz RAM 3600 Windows 10 Clang 14.0.6 -O3 MinGW ) 上需要 11 毫秒,我覺得它太慢了,我錯過了一些東西。
在 CPU 上讀取 40MB 應該不到 2ms(我的 RAM 帶寬在 20 到 30GB/s 之間)
我不知道如何計算執行一次迭代所需的周期(特別是因為現在的 CPU 是超標量的)。 如果我假設每個操作有 1 個周期,並且如果我沒有弄亂我的計數,那么每次迭代應該有 10 次操作 -> 2 億次操作 -> 在 4.8 Ghz 下只有一個執行單元 -> 40 毫秒。 顯然我在如何計算每個循環的周期數上是錯誤的。
有趣的事實:我嘗試了 Linux PopOS GCC 11.2 -O3,它的運行時間為 4.5 毫秒。 為什么會有這樣的差異?
以下是 clang 生成的向量化和標量反匯編:
compareFunction(char const*, unsigned long): # @compareFunction(char const*, unsigned long)
test rsi, rsi
je .LBB0_1
lea r8, [rdi + rsi]
neg rsi
xor edx, edx
xor eax, eax
.LBB0_4: # =>This Inner Loop Header: Depth=1
movzx r9d, byte ptr [rdi + rdx]
xor ecx, ecx
cmp r9b, byte ptr [r8 + rdx]
setne cl
add rax, rcx
add rdx, 1
mov rcx, rsi
add rcx, rdx
jne .LBB0_4
ret
.LBB0_1:
xor eax, eax
ret
Clang14 O3:
.LCPI0_0:
.quad 1 # 0x1
.quad 1 # 0x1
compareFunction(char const*, unsigned long): # @compareFunction(char const*, unsigned long)
test rsi, rsi
je .LBB0_1
cmp rsi, 4
jae .LBB0_4
xor r9d, r9d
xor eax, eax
jmp .LBB0_11
.LBB0_1:
xor eax, eax
ret
.LBB0_4:
mov r9, rsi
and r9, -4
lea rax, [r9 - 4]
mov r8, rax
shr r8, 2
add r8, 1
test rax, rax
je .LBB0_5
mov rdx, r8
and rdx, -2
lea r10, [rdi + 6]
lea r11, [rdi + rsi]
add r11, 6
pxor xmm0, xmm0
xor eax, eax
pcmpeqd xmm2, xmm2
movdqa xmm3, xmmword ptr [rip + .LCPI0_0] # xmm3 = [1,1]
pxor xmm1, xmm1
.LBB0_7: # =>This Inner Loop Header: Depth=1
movzx ecx, word ptr [r10 + rax - 6]
movd xmm4, ecx
movzx ecx, word ptr [r10 + rax - 4]
movd xmm5, ecx
movzx ecx, word ptr [r11 + rax - 6]
movd xmm6, ecx
pcmpeqb xmm6, xmm4
movzx ecx, word ptr [r11 + rax - 4]
movd xmm7, ecx
pcmpeqb xmm7, xmm5
pxor xmm6, xmm2
punpcklbw xmm6, xmm6 # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm4, xmm6, 212 # xmm4 = xmm6[0,1,1,3,4,5,6,7]
pshufd xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3]
pand xmm4, xmm3
paddq xmm4, xmm0
pxor xmm7, xmm2
punpcklbw xmm7, xmm7 # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm0, xmm7, 212 # xmm0 = xmm7[0,1,1,3,4,5,6,7]
pshufd xmm5, xmm0, 212 # xmm5 = xmm0[0,1,1,3]
pand xmm5, xmm3
paddq xmm5, xmm1
movzx ecx, word ptr [r10 + rax - 2]
movd xmm0, ecx
movzx ecx, word ptr [r10 + rax]
movd xmm1, ecx
movzx ecx, word ptr [r11 + rax - 2]
movd xmm6, ecx
pcmpeqb xmm6, xmm0
movzx ecx, word ptr [r11 + rax]
movd xmm7, ecx
pcmpeqb xmm7, xmm1
pxor xmm6, xmm2
punpcklbw xmm6, xmm6 # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm0, xmm6, 212 # xmm0 = xmm6[0,1,1,3,4,5,6,7]
pshufd xmm0, xmm0, 212 # xmm0 = xmm0[0,1,1,3]
pand xmm0, xmm3
paddq xmm0, xmm4
pxor xmm7, xmm2
punpcklbw xmm7, xmm7 # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm1, xmm7, 212 # xmm1 = xmm7[0,1,1,3,4,5,6,7]
pshufd xmm1, xmm1, 212 # xmm1 = xmm1[0,1,1,3]
pand xmm1, xmm3
paddq xmm1, xmm5
add rax, 8
add rdx, -2
jne .LBB0_7
test r8b, 1
je .LBB0_10
.LBB0_9:
movzx ecx, word ptr [rdi + rax]
movd xmm2, ecx
movzx ecx, word ptr [rdi + rax + 2]
movd xmm3, ecx
add rax, rsi
movzx ecx, word ptr [rdi + rax]
movd xmm4, ecx
pcmpeqb xmm4, xmm2
movzx eax, word ptr [rdi + rax + 2]
movd xmm2, eax
pcmpeqb xmm2, xmm3
pcmpeqd xmm3, xmm3
pxor xmm4, xmm3
punpcklbw xmm4, xmm4 # xmm4 = xmm4[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3,4,5,6,7]
pshufd xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3]
movdqa xmm5, xmmword ptr [rip + .LCPI0_0] # xmm5 = [1,1]
pand xmm4, xmm5
paddq xmm0, xmm4
pxor xmm2, xmm3
punpcklbw xmm2, xmm2 # xmm2 = xmm2[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm2, xmm2, 212 # xmm2 = xmm2[0,1,1,3,4,5,6,7]
pshufd xmm2, xmm2, 212 # xmm2 = xmm2[0,1,1,3]
pand xmm2, xmm5
paddq xmm1, xmm2
.LBB0_10:
paddq xmm0, xmm1
pshufd xmm1, xmm0, 238 # xmm1 = xmm0[2,3,2,3]
paddq xmm1, xmm0
movq rax, xmm1
cmp r9, rsi
je .LBB0_13
.LBB0_11:
lea r8, [r9 + rsi]
sub rsi, r9
add r8, rdi
add rdi, r9
xor edx, edx
.LBB0_12: # =>This Inner Loop Header: Depth=1
movzx r9d, byte ptr [rdi + rdx]
xor ecx, ecx
cmp r9b, byte ptr [r8 + rdx]
setne cl
add rax, rcx
add rdx, 1
cmp rsi, rdx
jne .LBB0_12
.LBB0_13:
ret
.LBB0_5:
pxor xmm0, xmm0
xor eax, eax
pxor xmm1, xmm1
test r8b, 1
jne .LBB0_9
jmp .LBB0_10
是的,如果你的數據在緩存中不熱,即使是 SSE2 也應該跟上 memory 帶寬。 如果數據在 L1d 緩存中很熱,或者緩存外部級別可以提供的任何帶寬,則每個周期(來自兩個 32 字節加載)的 32 個比較結果的比較和求和是完全可能的。
如果不是,則編譯器做得不好。 不幸的是,對於像這樣減少到更廣泛變量的問題來說,這很常見; 編譯器不知道用於求和字節的好的矢量化策略,尤其是必須為 0/-1 的比較結果字節。 它們可能會立即使用pmovsxbq
到 64 位(如果 SSE4.1 指令不可用,甚至更糟)。
因此,即使-O3 -march=native
也無濟於事; 這是一個很大的優化失誤; 希望 GCC 和 clang 會在某個時候學習如何向量化這種循環,總結比較結果可能會出現在足夠多的代碼庫中,值得識別這種模式。
高效的方法是使用psadbw
橫向求和成 qwords。 但僅在內部循環執行vsum -= cmp(p, q)
的一些迭代之后,減去 0 或 -1 以增加計數器或不增加計數器。 8 位元素可以進行 255 次迭代而不會有溢出的風險。 通過展開多個向量累加器,每個向量有很多 32 字節,因此您不必經常跳出內部循環。
有關手動矢量化 AVX2 代碼,請參閱如何使用 SIMD 計算字符出現次數。 (一個答案有一個指向 SSE2 版本的 Godbolt 鏈接。)對比較結果求和是同一個問題,但是你加載兩個向量來提供 pcmpeqb 而不是在循環外廣播一個字節來查找單個 char 的出現.
在 i7-6700 Skylake(僅 3.4GHz,可能他們禁用了 Turbo 或只是報告額定速度)上,AVX2 的基准測試報告為 28 GB/s,SSE2 的報告為 23 GB/s。DRAM 速度未提及。 )
我希望 2 個輸入數據流能夠實現與一個數據流大致相同的持續帶寬。
如果您對適合 L2 緩存的較小的 arrays 進行基准重復傳遞,則優化起來會更有趣,那么 ALU 指令的效率很重要。 (該問題的答案中的策略非常好,並且針對該案例進行了很好的調整。)
快速計算兩個 arrays 之間的相等字節數是使用較差策略的較舊的問答,而不是使用psadbw
將字節求和為 64 位。 (但不像 GCC/clang 那樣糟糕,在擴展到 32 位時仍然會發出嗡嗡聲。)
多線程/核心在現代桌面上幾乎沒有幫助,尤其是在像你這樣的高核心時鍾下。 Memory 延遲足夠低,每個內核都有足夠的緩沖區來保持足夠的請求在運行中,幾乎可以使雙通道 DRAM 控制器飽和。
在大型 Xeon 上,情況會大不相同; 您需要大多數核心來實現峰值聚合帶寬,即使只是 memcpy 或 memset 所以 ALU 工作為零,只是加載/存儲。 更高的延遲意味着單核的可用帶寬比台式機少得多(即使是絕對意義上的,更不用說占 6 個通道而不是 2 個通道的百分比)。 另請參閱針對 memcpy 的增強型 REP MOVSB和為什么 Skylake 對於單線程 memory 吞吐量比 Broadwell-E 好得多?
仍然不好(因為它每 128 個字節減少到標量,或者如果你想嘗試的話減少到 192 個),但是@Jérôme Richard 想出了一個聰明的方法來給 clang 一些它可以用一個好的策略向量化一個短的東西,用uint8_t sum
,使用它作為一個足夠短的內部循環,不會溢出。
但是 clang 仍然在該循環中做了一些愚蠢的事情,正如我們在他的回答中看到的那樣。 我修改了循環控制以使用指針增量,這減少了一點循環開銷,只有一個指針添加和比較/jcc,而不是 LEA/MOV。 我不知道為什么 clang 使用 integer 索引效率低下。
並且它避免了vpcmpeqb
memory 源操作數的索引尋址模式, 讓它們在 Intel CPU 上保持微融合。 (Clang 似乎根本不知道這很重要!在源代碼中將操作數反轉為!=
足以使其對vpcmpeqb
使用索引尋址模式而不是vmovdqu
純負載。)
// micro-optimized version of Jérôme's function, clang compiles this better
// instead of 2 arrays, it compares first and 2nd half of one array, which lets it index one relative to the other with an offset if we hand-hold clang into doing that.
uint64_t compareFunction_sink_fixup(const char *const __restrict buffer, const size_t commonSize)
{
uint64_t byteChunk = 0;
uint64_t diffFound = 0;
const char *endp = buffer + commonSize;
const char *__restrict ptr = buffer;
if(commonSize >= 127) {
// A signed type for commonSize wouldn't avoid UB in pointer subtraction creating a pointer before the object
// in practice it would be fine except maybe when inlining into a function where the compiler could see a compile-time-constant array size.
for(; ptr < endp-127 ; ptr += 128)
{
uint8_t tmpDiffFound = 0;
#pragma omp simd reduction(+:tmpDiffFound)
for(int off = 0 ; off < 128; ++off)
tmpDiffFound += ptr[off + commonSize] != ptr[off];
// without AVX-512, we get -1 for ==, 0 for not-equal. So clang adds set1_epi(4) to each bucket that holds the sum of four 0 / -1 elements
diffFound += tmpDiffFound;
}
}
// clang still auto-vectorizes, but knows the max trip count is only 127
// so doesn't unroll, just 4 bytes per iter.
for(int byte = 0 ; byte < commonSize % 128 ; ++byte)
diffFound += ptr[byte] != ptr[byte + commonSize];
return diffFound;
}
Godbolt與 clang15 -O3 -fopenmp-simd -mavx2 -march=skylake -mbranches-within-32B-boundaries
# The main loop, from clang 15 for x86-64 Skylake
.LBB0_4: # =>This Inner Loop Header: Depth=1
vmovdqu ymm2, ymmword ptr [rdi + rsi]
vmovdqu ymm3, ymmword ptr [rdi + rsi + 32] # Indexed addressing modes are fine here
vmovdqu ymm4, ymmword ptr [rdi + rsi + 64]
vmovdqu ymm5, ymmword ptr [rdi + rsi + 96]
vpcmpeqb ymm2, ymm2, ymmword ptr [rdi] # non-indexed allow micro-fusion without un-lamination
vpcmpeqb ymm3, ymm3, ymmword ptr [rdi + 32]
vpcmpeqb ymm4, ymm4, ymmword ptr [rdi + 64]
vpaddb ymm2, ymm4, ymm2
vpcmpeqb ymm4, ymm5, ymmword ptr [rdi + 96]
vpaddb ymm3, ymm4, ymm3
vpaddb ymm2, ymm2, ymm3
vpaddb ymm2, ymm2, ymm0 # add a vector of set1_epi8(4) to turn sums of 0 / -1 into sums of 1 / 0
vextracti128 xmm3, ymm2, 1
vpaddb xmm2, xmm2, xmm3
vpshufd xmm3, xmm2, 238 # xmm3 = xmm2[2,3,2,3]
vpaddb xmm2, xmm2, xmm3 # reduced to 8 bytes
vpsadbw xmm2, xmm2, xmm1 # hsum to one qword
vpextrb edx, xmm2, 0 # extract and zero-extend
add rax, rdx # accumulate the chunk sum
sub rdi, -128 # pointer increment (with a sign_extended_imm8 instead of +imm32)
cmp rdi, rcx
jb .LBB0_4 # }while(p < endp)
這可以使用192
而不是128
來進一步分攤循環開銷,代價是需要執行%192
(不是 2 的冪),並使清理循環最壞情況為 191 字節。 我們不能將 go 變為 256,或任何高於 UINT8_MAX (255) 的值,並且必須堅持使用 32 的倍數。 或者 64 為好。
有一個修復常量的額外vpaddb
set1_epi8(4)
,它將四個 0 / -1 的總和轉換為來自 C !=
運算符的四個 1 / 0 結果的總和。
我不認為有任何方法可以擺脫它或將其從循環中取出,同時仍然累積到uint8_t
中,這是 clang 以這種方式矢量化所必需的。 它不知道如何使用vpsadbw
來擴大(非截斷)字節總和,這具有諷刺意味,因為這正是它在針對全零寄存器使用時實際執行的操作。 如果你做類似sum += ptr[off + commonSize] == ptr[off]? -1: 0
sum += ptr[off + commonSize] == ptr[off]? -1: 0
你可以讓它直接使用vpcmpeqb
結果,將 4 個向量加到一個有 3 個加法,並最終在一些減少步驟后將其提供給vpsadbw
。 因此,對於每個 128 字節的塊,您會得到一個matches * 0xFF
截斷為 uint8_t 的總和。 或者作為int8_t
,這是-1 * matches
的總和,所以0..-128
不會溢出有符號字節。 這很有趣。 但是將零擴展添加到 64 位計數器中可能會破壞信息,並且在外部循環內進行符號擴展會花費另一條指令。 這將是一個標量movsx
指令而不是vpaddb
,但這對 Skylake 來說並不重要,可能只有在將 AVX-512 與 512 位向量一起使用時(clang 和 GCC 都表現不佳,不使用掩碼添加)。 我們可以做128*n_chunks - count
以恢復匹配總和的差異嗎? 不,我不這么認為。
uiCA static 分析預測,如果 L1d 緩存中的數據很熱,Skylake(例如您的 CPU)將以5.51 周期/迭代器(4 個向量)運行主循環,或者在 Ice Lake / Rocket Lake 上以 5.05 運行。 (我不得不手動調整 asm 以模擬填充效果-mbranches-within-32B-boundaries
會產生,因為 uiCA 的默認假設是循環頂部相對於 32 字節 alignment 邊界的位置。我可以只而是在 uiCA 中更改了該設置。:/)
實施此次優策略時唯一遺漏的微優化是它使用vpextrb
(因為它不能證明不需要截斷到uint8_t
?)而不是vmovd
或vmovq
。 因此,前端和后端的端口 5 需要額外的 uop。 經過優化(在鏈接中注釋 + 取消注釋),Skylake 上的 5.25c / iter,或 Ice Lake 上的 4.81,非常接近 2 負載/時鍾瓶頸。
(每個迭代器執行 6 個向量,192 字節,預測 SKL 上每個迭代器有 7 個周期,或每個向量 1.166 個,低於每個向量的 5.5 / iter = 1.375。或者在 ICL/RKL = 1.08 c/vec 上大約 6.5,命中后端ALU 端口瓶頸。)
這對於我們能夠從便攜式 C++ 源中誘導 clang 生成的東西來說還不錯,相對於每 4 個 32 字節的向量進行 4 個周期比較,每個向量進行高效的手動向量化。 這很可能會跟上 memory 或甚至來自 L2 緩存的緩存帶寬,因此它非常有用,並且在 L1d 中熱數據時不會慢很多。 多采用幾個 uops 確實會損害亂序執行,並占用更多共享物理核心的另一個邏輯核心可以使用的執行資源。 (超線程)。
不幸的是,gcc/clang沒有為此充分利用 AVX-512。 如果您使用的是 512 位向量(或 256 位向量上的 AVX-512 特征),您將與掩碼寄存器進行比較,然后執行類似vpaddb zmm0{k1}, zmm0, zmm1
merge-masking 的操作以有條件地遞增向量,其中 zmm1 = set1_epi8( 1 )
。 (或帶有sub
的-1
常量。)如果操作正確,每個向量的指令和 uop 計數應該與 AVX2 大致相同,但 gcc/clang 使用的數量大約是其兩倍,因此唯一的節省是減少標量,這似乎是獲得任何可用的東西的代價。
這個版本也避免了清理循環的展開,只是用它的 dumb 4 bytes per iter 策略進行矢量化,這對於size%128
字節的清理來說是正確的。 它同時使用vpxor
翻轉和vpand
將 0xff 轉換為 0x01 是非常愚蠢的,而它本可以使用vpandn
在一條指令中完成這兩項操作。 這將使清理循環下降到 8 微指令,只是 Haswell / Skylake 管道寬度的兩倍,因此它會從循環緩沖區更有效地發出,除了 Skylake 在微代碼更新中禁用它。 這會對哈斯韋爾有所幫助
TLDR : Clang 代碼之所以如此緩慢,是因為矢量化方法不佳導致端口 5 飽和(已知這通常是一個問題)。 GCC 在這里做得更好,但離效率還差得很遠。 可以使用不使端口 5 飽和的 AVX-2 編寫更快的基於塊的代碼。
要了解發生了什么,最好從一個簡單的例子開始。 事實上,正如您所說,現代處理器是超標量的,因此很難理解在這種架構上生成的某些代碼的速度。
-O1
使用 -O1 優化標志生成的代碼是一個好的開始。 這是您問題中提供的 GodBold 生成的熱循環代碼:
(instructions) (ports)
.LBB0_4:
movzx r9d, byte ptr [rdi + rdx] p23
xor ecx, ecx p0156
cmp r9b, byte ptr [r8 + rdx] p0156+p23
setne cl p06
add rax, rcx p0156
add rdx, 1 p0156
mov rcx, rsi (optimized)
add rcx, rdx p0156
jne .LBB0_4 p06
像 Coffee Lake 9700K 這樣的現代處理器的結構分為兩大部分:前端獲取/解碼指令(並將它們拆分為微指令,也稱為uops ),以及后端調度/執行它們。 后端在許多端口上調度微指令,每個微指令都可以執行一些特定的指令集(例如,僅 memory 加載,或僅算術指令)。 對於每條指令,我都放置了可以執行它們的端口。 p0156+p23
表示指令分為兩個微指令:第一個可以由端口 0 或 1 或 5 或 6 執行,第二個可以由端口 2 或 3 執行。注意前端可以以某種方式優化代碼因此不會為循環中的mov
之類的基本指令生成任何微指令(由於稱為寄存器重命名的機制)。
對於每個循環迭代,處理器需要從 memory 讀取 2 個值。像 9700K 這樣的 Coffee Lake 處理器每個周期可以加載兩個值,因此循環至少需要 1 個周期/迭代(假設r9d
和r9b
中的加載不沖突由於使用了同一r9
64 位寄存器的不同部分)。 這個處理器有一個 uops 緩存,循環有很多指令,所以解碼部分應該不是問題。 也就是說,有 9 個微指令要執行,處理器每個周期只能執行其中的 6 個微指令,因此循環不能少於 1.5 個周期/迭代。 更准確地說,端口 0、1、5 和 6 處於壓力之下,因此即使假設處理器完美地負載平衡 uops,也需要 2 個周期/迭代。 這是一個樂觀的下限執行時間,因為處理器可能無法完美地安排指令,並且有很多事情可能 go 錯誤(比如我沒有看到的偷偷摸摸的隱藏依賴項)。 在 4.8GHz 的頻率下,最終執行時間至少為 8.3 毫秒。 3 次循環/迭代可以達到 12.5 毫秒(請注意,由於 uops 到端口的調度,2.5 次循環/迭代是可能的)。
可以使用unrolling改進循環。 實際上,只需要執行循環而不是實際計算就需要大量指令。 展開有助於增加有用指令的比例,從而更好地利用可用端口。 盡管如此,這 2 個負載仍會阻止循環快於 1 個循環/迭代,即 4.2 毫秒。
Clang生成的向量化代碼比較復雜。 人們可以嘗試應用與先前代碼相同的分析,但這將是一項乏味的任務。
可以注意到,即使代碼是矢量化的,負載也不是矢量化的。 這是一個問題,因為每個周期只能完成 2 次加載。 也就是說,加載是由兩個連續的 char 值成對執行的,因此與之前生成的代碼相比,加載並不那么慢。
Clang 這樣做是因為只有兩個 64 位值可以放入一個 128 位 SSE 寄存器和一個 64 位寄存器中,它需要這樣做,因為diffFound
是一個 64 位 integer。8位到 64 位的轉換是代碼中最大的問題,因為它需要幾個 SSE 指令來進行轉換。 此外,一次只能計算 4 個整數,因為 Coffee Lake 上有 3 個 SSE integer 單元,每個單元一次只能計算兩個 64 位整數。 最后,Clang 只在每個 SSE 寄存器中放入 2 個值(並使用其中的 4 個,以便在每次循環迭代中計算 8 個項目)因此應該期望代碼運行速度快兩倍以上(特別是由於 SSE 和循環展開),但由於 SSE 端口少於 ALU 端口以及類型轉換所需的更多指令,因此情況並非如此。 簡而言之,向量化顯然是低效的,但是在這種情況下 Clang 生成高效代碼並不是那么容易。 盡管如此,使用 28 個 SSE 指令和 3 個 SSE integer 單元每個循環計算 8 個項目,應該期望代碼的計算部分大約需要28/3/8 ~= 1.2
周期/項目,這與您可以觀察到的相去甚遠(並且這不是由於其他指令,因為它們大多可以並行執行,因為它們大多可以安排在其他端口上)。
事實上,性能問題肯定來自端口 5 的飽和。 實際上,這個端口是唯一可以洗牌 SIMD 寄存器項的端口。 因此,指令punpcklbw
、 pshuflw
、 pshufd
甚至movd
只能在端口 5 上執行。這是 SIMD 代碼的一個很常見的問題。 這是一個大問題,因為每個循環有 20 條指令,處理器甚至可能無法完美地使用它。 這意味着代碼至少需要 10.4 毫秒,這非常接近觀察到的執行時間 (11 毫秒)。
與 Clang 相比,GCC 生成的代碼實際上相當不錯。首先,GCC 直接使用 SIMD 指令加載項目,這更加高效,因為每條指令(和迭代)計算 16 個項目:它每次只需要 2 個加載微指令迭代減少端口 2 和 3 上的壓力(為此 1 個循環/迭代,因此 0.0625 個循環/項目)。 其次,GCC 僅使用 14 punpckhwd
指令,每次迭代計算 16 項,減少了端口 5 的臨界壓力(0.875 周期/項)。 第三,SIMD 寄存器幾乎被完全使用,至少在比較方面是這樣,因為pcmpeqb
比較指令一次比較 16 個項目(與 Clang 的 2 個項目相反)。 其他指令如paddq
很便宜(例如, paddq
可以調度在 3 個 SSE 端口上)並且它們不會對執行時間產生太大影響。 最后,這個版本應該還是受端口5限制,但是應該比Clang版本快很多。 實際上,應該期望執行時間達到 1 個周期/項(因為端口調度肯定不是完美的,memory 負載可能會引入一些停滯周期)。 這意味着 4.2 毫秒的執行時間。 這與觀察到的結果很接近。
GCC 實現並不完美。
首先,它不使用處理器支持的 AVX2,因為未提供-mavx2
標志(或任何類似的標志,如-march=native
)。 事實上,GCC 和其他主流編譯器一樣,為了與以前的架構兼容,默認情況下只使用 SSE2:SSE2 可以安全地用於所有 x86-64 處理器,但其他指令集如 SSE3、SSSE3、SSE4.1、SSE4.2、 AVX,AVX2。 有了這樣的標志,GCC 應該能夠生成 memory 綁定代碼。
此外,編譯器理論上可以執行多級總和縮減。 這個想法是使用大小為 1024 項(即 64x16 項)的塊在 8 位寬 SIMD 通道中累積比較結果。 這是安全的,因為每個通道的值不能超過 64。為避免溢出,累加值需要存儲在更寬的 SIMD 通道(例如 64 位通道)中。 使用此策略, punpckhwd
指令的開銷減少了 64 倍。 這是一個很大的改進,因為它消除了端口 5 的飽和。這種策略應該足以生成內存綁定代碼,即使只使用 SSE2。 這是一個未經測試的代碼示例,需要標志-fopenmp-simd
才能有效。
uint64_t compareFunction(const char *const __restrict buffer, const uint64_t commonSize)
{
uint64_t byteChunk = 0;
uint64_t diffFound = 0;
if(commonSize >= 127)
{
for(; byteChunk < commonSize-127; byteChunk += 128)
{
uint8_t tmpDiffFound = 0;
#pragma omp simd reduction(+:tmpDiffFound)
for(uint64_t byte = byteChunk; byte < byteChunk + 128; ++byte)
tmpDiffFound += buffer[byte] != buffer[byte + commonSize];
diffFound += tmpDiffFound;
}
}
for(uint64_t byte = byteChunk; byte < commonSize; ++byte)
diffFound += buffer[byte] != buffer[byte + commonSize];
return diffFound;
}
GCC和Clang都生成了相當高效的代碼(雖然對於緩存中的數據擬合不是最優的),尤其是 Clang。例如,這里是 Clang 使用 AVX2 生成的代碼:
.LBB0_4:
lea r10, [rdx + 128]
vmovdqu ymm2, ymmword ptr [r9 + rdx - 96]
vmovdqu ymm3, ymmword ptr [r9 + rdx - 64]
vmovdqu ymm4, ymmword ptr [r9 + rdx - 32]
vpcmpeqb ymm2, ymm2, ymmword ptr [rcx + rdx - 96]
vpcmpeqb ymm3, ymm3, ymmword ptr [rcx + rdx - 64]
vpcmpeqb ymm4, ymm4, ymmword ptr [rcx + rdx - 32]
vmovdqu ymm5, ymmword ptr [r9 + rdx]
vpaddb ymm2, ymm4, ymm2
vpcmpeqb ymm4, ymm5, ymmword ptr [rcx + rdx]
vpaddb ymm3, ymm4, ymm3
vpaddb ymm2, ymm3, ymm2
vpaddb ymm2, ymm2, ymm0
vextracti128 xmm3, ymm2, 1
vpaddb xmm2, xmm2, xmm3
vpshufd xmm3, xmm2, 238
vpaddb xmm2, xmm2, xmm3
vpsadbw xmm2, xmm2, xmm1
vpextrb edx, xmm2, 0
add rax, rdx
mov rdx, r10
cmp r10, r8
jb .LBB0_4
所有加載都是 256 位 SIMD 的。 vpcmpeqb
的數量是最優的。 vpaddb
的數量比較好。 其他指令很少,但它們顯然不應成為瓶頸。 該循環每次迭代對 128 個項目進行操作,我希望對於緩存中已有的數據,每次迭代花費的周期少於十幾個周期(否則它應該完全受內存限制)。 這意味着<0.1 cycle/item,也就是遠低於之前的實現。 事實上,uiCA 工具指示大約 0.055 周期/項,即 81 GiB/s,可以使用 SIMD 內在函數手動編寫更好的代碼,但代價是可移植性明顯變差。 維護性和可讀性。
請注意,生成順序內存限制並不總是意味着 RAM 吞吐量將飽和。 事實上,在一個內核上,有時沒有足夠的並發來隱藏 memory 操作的延遲,盡管它在你的處理器上應該沒問題(就像在我的 i5-9600KF 上有 2 個交錯的 3200 MHz DDR4 memory 通道)。
如果我錯了,請糾正我,但答案似乎是
我將嘗試編寫自己的 AVX 代碼進行比較。
細節
經過一個下午修改這些建議后,這是我在 clang 中發現的內容:
-O1 大約 10 毫秒,標量代碼
-O3 啟用 SSE2 並且與 O1 一樣慢,可能是糟糕的匯編代碼
-O3 -march=westmere 也啟用 SSE2 但速度更快 (7ms)
-O3 -march=native 啟用 AVX -> 2.5ms,我們可能會限制 RAM 帶寬(接近理論速度)
標量 10ms 現在有意義,因為根據那個很棒的工具 uica.uops.info 它需要
g++ 似乎在沒有添加標志時生成更好的默認代碼
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.