簡體   English   中英

計算 2 個緩沖區之間的差異似乎太慢

[英]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 好得多?


編譯成不太糟糕的 asm 的便攜式源代碼,從 Jérôme 的微優化:每 4x 32 字節向量 5.5 個周期,假設 L1d 緩存命中,從 7 或 8 個下降。

仍然不好(因為它每 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 ?)而不是vmovdvmovq 因此,前端和后端的端口 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 在微代碼更新中禁用它。 這會對哈斯韋爾有所幫助

TLDRClang 代碼之所以如此緩慢,是因為矢量化方法不佳導致端口 5 飽和(已知這通常是一個問題)。 GCC 在這里做得更好,但離效率還差得很遠。 可以使用不使端口 5 飽和的 AVX-2 編寫更快的基於塊的代碼。


未向量化的Clang代碼分析

要了解發生了什么,最好從一個簡單的例子開始。 事實上,正如您所說,現代處理器是超標量的,因此很難理解在這種架構上生成的某些代碼的速度。

-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 個周期/迭代(假設r9dr9b中的加載不沖突由於使用了同一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代碼分析

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 寄存器項的端口。 因此,指令punpcklbwpshuflwpshufd甚至movd只能在端口 5 上執行。這是 SIMD 代碼的一個很常見的問題。 這是一個大問題,因為每個循環有 20 條指令,處理器甚至可能無法完美地使用它。 這意味着代碼至少需要 10.4 毫秒,這非常接近觀察到的執行時間 (11 毫秒)。


矢量化GCC代碼分析

與 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;
}

GCCClang都生成了相當高效的代碼(雖然對於緩存中的數據擬合不是最優的),尤其是 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 通道)。

如果我錯了,請糾正我,但答案似乎是

  • -march=native 獲勝。
  • 代碼的標量版本是 CPU 瓶頸而不是 RAM 瓶頸
  • 使用 uica.uops.info 來估計每個循環的周期數

我將嘗試編寫自己的 AVX 代碼進行比較。

細節

經過一個下午修改這些建議后,這是我在 clang 中發現的內容:

-O1 大約 10 毫秒,標量代碼
-O3 啟用 SSE2 並且與 O1 一樣慢,可能是糟糕的匯編代碼
-O3 -march=westmere 也啟用 SSE2 但速度更快 (7ms)
-O3 -march=native 啟用 AVX -> 2.5ms,我們可能會限制 RAM 帶寬(接近理論速度)

標量 10ms 現在有意義,因為根據那個很棒的工具 uica.uops.info 它需要

  • 每個循環 2.35 個周期
  • 整個比較的 4700 萬個周期(2000 萬次迭代)
  • 處理器的時鍾頻率為 4.8GHz,這意味着它應該需要大約 9.8 毫秒並且接近測量值。

g++ 似乎在沒有添加標志時生成更好的默認代碼

  • O1 11毫秒
  • O2 標量仍然只有 9 毫秒
  • O3 SSE 4.5ms
  • O3 -march=westmere 7ms 像 clang
  • O3 -march=native 3.4ms,比clang稍慢

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM