簡體   English   中英

並行將 64 位整數中的壓縮 8 位整數減去 1,SWAR 無需硬件 SIMD

[英]Subtracting packed 8-bit integers in an 64-bit integer by 1 in parallel, SWAR without hardware SIMD

如果我有一個 64 位整數,我將其解釋為一個包含 8 個元素的壓縮 8 位整數數組。 我需要在處理溢出時從每個壓縮整數中減去常量1 ,而一個元素的結果不會影響另一個元素的結果。

我現在有這個代碼並且它可以工作,但我需要一個解決方案來並行地減去每個打包的 8 位整數並且不進行內存訪問。 在 x86 上,我可以使用像psubb這樣的 SIMD 指令psubb減去打包的 8 位整數,但我正在編碼的平台不支持 SIMD 指令。 (在這種情況下為 RISC-V)。

因此,我正在嘗試執行SWAR(寄存器內的 SIMD)以手動取消uint64_t字節之間的進位傳播,執行與此等效的操作:

uint64_t sub(uint64_t arg) {
    uint8_t* packed = (uint8_t*) &arg;

    for (size_t i = 0; i < sizeof(uint64_t); ++i) {
        packed[i] -= 1;
    }

    return arg;
}

我認為你可以用按位運算符來做到這一點,但我不確定。 我正在尋找一種不使用 SIMD 指令的解決方案。 我正在尋找一個非常便攜的 C 或 C++ 解決方案,或者只是它背后的理論,這樣我就可以實現我自己的解決方案。

如果您的 CPU 具有高效的 SIMD 指令,則 SSE/MMX paddb ( _mm_add_epi8 ) 也是可行的。 Peter Cordes 的回答還描述了 GNU C (gcc/clang) 向量語法以及嚴格別名 UB 的安全性。 我也強烈建議您查看該答案。

使用uint64_t自己完成是完全可移植的,但在使用uint64_t*訪問uint8_t數組時仍需要小心避免對齊問題和嚴格別名 UB。 您已經通過uint64_t的數據開始may_alias部分排除在外,但是對於 GNU C, may_alias typedef 解決了這個問題(請參閱 Peter 對此的回答或memcpy )。

否則,您可以將數據分配/聲明為uint64_t並在需要單個字節時通過uint8_t*訪問它。 unsigned char*允許給任何東西做別名,這樣就可以避開 8 位元素的特定情況的問題。 (如果uint8_t存在,假設它是一個unsigned char可能是安全的。)


請注意,這是對先前不正確算法的更改(請參閱修訂歷史)。

這是可能的,無需循環進行任意減法,並且對於每個字節中的已知常量(如1更有效。 主要技巧是通過設置高位來防止每個字節的進位,然后糾正減法結果。

我們將稍微優化這里給出的減法技術。 他們定義:

 SWAR sub z = x - y z = ((x | H) - (y &~H)) ^ ((x ^~y) & H)

H定義為0x8080808080808080U (即每個壓縮整數的 MSB)。 對於遞減, y0x0101010101010101U

我們知道y所有 MSB 都已清除,因此我們可以跳過掩碼步驟之一(即y & ~H在我們的情況下與y相同)。 計算過程如下:

  1. 我們將x的每個組件的 MSB 設置為 1,以便借用不能通過 MSB 傳播到下一個組件。 稱其為調整后的輸入。
  2. 我們通過從校正后的輸入中減去0x01010101010101 ,從每個分量中減去 1。 由於第 1 步,這不會導致組件間借用。將其稱為調整后的輸出。
  3. 我們現在需要更正結果的 MSB。 我們將調整后的輸出與原始輸入的倒置 MSB 進行異或以完成修復結果。

操作可以寫成:

#define U64MASK 0x0101010101010101U
#define MSBON 0x8080808080808080U
uint64_t decEach(uint64_t i){
      return ((i | MSBON) - U64MASK) ^ ((i ^ MSBON) & MSBON);
}

最好是由編譯器內聯(使用編譯器指令強制執行),或者將表達式作為另一個函數的一部分內聯編寫。

測試用例:

in:  0000000000000000
out: ffffffffffffffff

in:  f200000015000013
out: f1ffffff14ffff12

in:  0000000000000100
out: ffffffffffff00ff

in:  808080807f7f7f7f
out: 7f7f7f7f7e7e7e7e

in:  0101010101010101
out: 0000000000000000

性能詳情

這是用於單個函數調用的 x86_64 程序集。 為了獲得更好的性能,它應該內聯,希望常量可以盡可能長時間地存在於寄存器中。 在常量位於寄存器中的緊密循環中,實際遞減需要 5 條指令:優化后的 or+not+and+add+xor。 我沒有看到可以擊敗編譯器優化的替代方案。

uint64t[rax] decEach(rcx):
    movabs  rcx, -9187201950435737472
    mov     rdx, rdi
    or      rdx, rcx
    movabs  rax, -72340172838076673
    add     rax, rdx
    and     rdi, rcx
    xor     rdi, rcx
    xor     rax, rdi
    ret

通過對以下代碼段的一些 IACA 測試:

// Repeat the SWAR dec in a loop as a microbenchmark
uint64_t perftest(uint64_t dummyArg){
    uint64_t dummyCounter = 0;
    uint64_t i = 0x74656a6d27080100U; // another dummy value.
    while(i ^ dummyArg) {
        IACA_START
        uint64_t naive = i - U64MASK;
        i = naive + ((i ^ naive ^ U64MASK) & U64MASK);
        dummyCounter++;
    }
    IACA_END
    return dummyCounter;
}


我們可以證明,在 Skylake 機器上,執行遞減、異或和比較+跳轉可以在每次迭代不到 5 個周期的情況下執行:

Throughput Analysis Report
--------------------------
Block Throughput: 4.96 Cycles       Throughput Bottleneck: Backend
Loop Count:  26
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  1.5     0.0  |  1.5  |  0.0     0.0  |  0.0     0.0  |  0.0  |  1.5  |  1.5  |  0.0  |
--------------------------------------------------------------------------------------------------

(當然,在 x86-64 上,您只需將paddb加載或movq到 XMM reg 中,因此查看它如何為 RISC-V 之類的 ISA 編譯可能會更有趣。)

對於 RISC-V,您可能正在使用 GCC/clang。

有趣的事實:GCC 知道其中一些 SWAR bithack 技巧(顯示在其他答案中),並且可以在使用GNU C 本機向量為沒有硬件 SIMD 指令的目標編譯代碼時為您使用它們。 (但是 RISC-V 的 clang 只會天真地將其展開為標量操作,因此如果您希望跨編譯器獲得良好的性能,則必須自己進行操作)。

原生向量語法的一個優點是,當以硬件 SIMD 為目標機器,它將使用它而不是自動向量化你的 bithack 或類似的東西。

它使編寫vector -= scalar操作變得容易; 語法 Just Works,隱式廣播又名 splatting 標量。


另請注意,從uint8_t array[]加載的uint64_t*是嚴格別名 UB,因此要小心。 (另請參閱為什么 glibc 的 strlen 需要如此復雜才能快速運行? re:在純 C 中使 SWAR bithacks 嚴格別名安全)。 您可能需要這樣的東西來聲明一個uint64_t ,您可以通過指針轉換來訪問任何其他對象,例如char*在 ISO C/C++ 中的工作方式。

使用這些將 uint8_t 數據放入 uint64_t 以與其他答案一起使用:

// GNU C: gcc/clang/ICC but not MSVC
typedef uint64_t  aliasing_u64 __attribute__((may_alias));  // still requires alignment
typedef uint64_t  aliasing_unaligned_u64 __attribute__((may_alias, aligned(1)));

執行別名安全加載的另一種方法是將memcpy放入uint64_t ,這也消除了alignof(uint64_t ) 對齊要求。 但是在沒有高效未對齊加載的 ISA 上,gcc/clang 在無法證明指針對齊時不會內聯和優化memcpy ,這對性能來說是災難性的。

TL:DR:最​​好的辦法是將數據聲明為uint64_t array[...]或將其動態分配為uint64_t或者最好是alignas(16) uint64_t array[]; 這確保對齊到至少 8 個字節,如果指定alignas 16 個字節。

由於uint8_t幾乎可以肯定是unsigned char* ,因此通過uint8_t*訪問uint64_t的字節是安全的(但對於 uint8_t 數組則相反)。 因此,對於窄元素類型為unsigned char這種特殊情況,您可以避開嚴格別名問題,因為char是特殊的。


GNU C 原生向量語法示例:

GNU C 本機向量總是允許使用它們的底層類型別名(例如int __attribute__((vector_size(16)))可以安全地別名int但不能為floatuint8_t或其他任何東西。

#include <stdint.h>
#include <stddef.h>

// assumes array is 16-byte aligned
void dec_mem_gnu(uint8_t *array) {
    typedef uint8_t v16u8 __attribute__ ((vector_size (16), may_alias));
    v16u8 *vecs = (v16u8*) array;
    vecs[0] -= 1;
    vecs[1] -= 1;   // can be done in a loop.
}

對於沒有任何硬件 SIMD 的 RISC-V,您可以使用vector_size(8)來表達您可以有效使用的粒度,並執行兩倍的較小向量。

但是vector_size(8)為 x86 和 GCC 和 clang 編譯非常愚蠢:GCC 在 GP 整數寄存器中使用 SWAR bithacks,clang 解包為 2 字節元素以填充 16 字節 XMM 寄存器,然后重新打包。 (MMX 已經過時了,以至於 GCC/clang 甚至都懶得使用它,至少對於 x86-64 來說不是。)

但是使用vector_size (16) ( Godbolt ) 我們得到了預期的movdqa / paddb (使用由pcmpeqd same,same生成的全 1 向量)。 使用-march=skylake我們仍然會得到兩個單獨的 XMM 操作而不是一個 YMM,所以不幸的是,當前的編譯器也不-march=skylake向量操作“自動向量化”為更寬的向量:/

對於 AArch64,使用vector_size(8) ( Godbolt ) 還不錯 ARM/AArch64 可以在帶有dq寄存器的 8 或 16 字節塊中本地工作。

因此,如果您想要跨 x86、RISC-V、ARM/AArch64 和 POWER 的可移植性能,您可能希望使用vector_size(16)進行實際編譯 但是,其他一些 ISA 在 64 位整數寄存器中執行 SIMD,例如我認為的 MIPS MSA。

vector_size(8)可以更輕松地查看 asm(只有一個寄存器的數據): Godbolt 編譯器資源管理器

# GCC8.2 -O3 for RISC-V for vector_size(8) and only one vector

dec_mem_gnu(unsigned char*):
        lui     a4,%hi(.LC1)           # generate address for static constants.
        ld      a5,0(a0)                 # a5 = load from function arg
        ld      a3,%lo(.LC1)(a4)       # a3 = 0x7F7F7F7F7F7F7F7F
        lui     a2,%hi(.LC0)
        ld      a2,%lo(.LC0)(a2)       # a2 = 0x8080808080808080
                             # above here can be hoisted out of loops
        not     a4,a5                  # nx = ~x
        and     a5,a5,a3               # x &= 0x7f... clear high bit
        and     a4,a4,a2               # nx = (~x) & 0x80... inverse high bit isolated
        add     a5,a5,a3               # x += 0x7f...   (128-1)
        xor     a5,a4,a5               # x ^= nx  restore high bit or something.

        sd      a5,0(a0)               # store the result
        ret

我認為這是與其他非循環答案相同的基本思想; 防止進位然后修正結果。

這是 5 個 ALU 指令,比我認為的最佳答案還要糟糕。 但看起來關鍵路徑延遲只有 3 個周期,有兩條指令鏈,每條指令通向異或。 @Reinstate Monica - ζ-- 的答案編譯為 4 周期 dep 鏈(適用於 x86)。 5 周期循環吞吐量的瓶頸在於在關鍵路徑上還包括一個 naive sub ,並且循環在延遲上確實存在瓶頸。

但是,這對 clang 沒用。 它甚至沒有按照加載的順序添加和存儲,所以它甚至沒有做好軟件流水線!

# RISC-V clang (trunk) -O3
dec_mem_gnu(unsigned char*):
        lb      a6, 7(a0)
        lb      a7, 6(a0)
        lb      t0, 5(a0)
...
        addi    t1, a5, -1
        addi    t2, a1, -1
        addi    t3, a2, -1
...
        sb      a2, 7(a0)
        sb      a1, 6(a0)
        sb      a5, 5(a0)
...
        ret

我要指出的是,一旦您開始處理多個 uint64_t,您編寫的代碼實際上會進行矢量化。

https://godbolt.org/z/J9DRzd

您可以確保減法不會溢出,然后修復高位:

uint64_t sub(uint64_t arg) {
    uint64_t x1 = arg | 0x80808080808080;
    uint64_t x2 = ~arg & 0x80808080808080;
    // or uint64_t x2 = arg ^ x1; to save one instruction if you don't have an andnot instruction
    return (x1 - 0x101010101010101) ^ x2;
}

不確定這是否是您想要的,但它會並行執行 8 次減法:

#include <cstdint>

constexpr uint64_t mask = 0x0101010101010101;

uint64_t sub(uint64_t arg) {
    uint64_t mask_cp = mask;
    for(auto i = 0; i < 8 && mask_cp; ++i) {
        uint64_t new_mask = (arg & mask_cp) ^ mask_cp;
        arg = arg ^ mask_cp;
        mask_cp = new_mask << 1;
    }
    return arg;
}

說明:位掩碼在每個 8 位數字中以 1 開頭。 我們用我們的論點對它進行異或。 如果我們在這個地方有一個 1,我們就減去 1 並且必須停止。 這是通過將 new_mask 中的相應位設置為 0 來完成的。 如果我們有一個 0,我們將它設置為 1 並且必須進行進位,因此該位保持為 1,我們將掩碼向左移動。 你最好自己檢查一下新面具的生成是否按預期工作,我認為是這樣,但第二個意見也不錯。

PS:我實際上不確定循環中對mask_cp的檢查是否不為空可能會減慢程序的速度。 沒有它,代碼仍然是正確的(因為 0 掩碼什么也不做),編譯器執行循環展開會容易得多。

int subtractone(int x) 
{
    int f = 1; 

    // Flip all the set bits until we find a 1 at position y
    while (!(x & f)) { 
        x = x^f; 
        f <<= 1; 
    } 

    return x^f; // return answer but remember to flip the 1 at y
} 

你可以使用上面的按位運算來完成,你只需要將你的整數分成 8 位塊來發送 8 次到這個函數中。 以下部分摘自如何將 64 位數字拆分為八個 8 位值? 與我添加上述功能

uint64_t v= _64bitVariable;
uint8_t i=0,parts[8]={0};
do parts[i++] = subtractone(v&0xFF); while (v>>=8);

無論有人如何遇到它,它都是有效的 C 或 C++

不打算想出代碼,但要減少 1,您可以減少 8 個 1 的組,然后檢查以確保結果的 LSB 已“翻轉”。 任何未切換的 LSB 表示相鄰 8 位發生進位。 應該可以計算出一系列 AND/OR/XOR 來處理這個問題,而沒有任何分支。

完全專注於每個字節的工作,然后將其放回原處。

uint64_t sub(uint64_t arg) {
   uint64_t res = 0;

   for (int i = 0; i < 64; i+=8) 
     res += ((arg >> i) - 1 & 0xFFU) << i;

    return res;
   }

暫無
暫無

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

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