64ビット整数のパックされた8ビット整数を並列に1で減算、ハードウェアSIMDなしのSWAR


77

私が64ビット整数を持っている場合、それを8要素のパックされた8ビット整数の配列として解釈しています。1ある要素の結果が別の要素の結果に影響を与えることなくオーバーフローを処理しながら、各パック整数から定数を減算する必要があります。

私は現在このコードを使用していますが、機能しますが、パックされた各8ビット整数の減算を並列に実行し、メモリアクセスを行わないソリューションが必要です。x86では、psubbパックされた8ビット整数を減算するようなSIMD命令を並列で使用できますが、コーディング対象のプラットフォームは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 ++のソリューション、またはその背後にある理論だけを探しています。


5
それらは8ビットである必要がありますか、それとも7ビットにすることができますか?
tadman

彼らは8ビットで申し訳ありません:(
cam-white

12
この種の手法はSWAR
ハロルド

1
関連:セグメント
ごとに

1
あなたはバイトが0xffにラップするためにゼロを含むと期待しますか?
アルニタク

回答:


75

効率的なSIMD命令を備えたCPUがある場合、SSE / MMX paddb_mm_add_epi8)も実行可能です。Peter Cordesの回答では、GNU C(gcc / clang)ベクトル構文、および厳密なエイリアシングUBの安全性についても説明しています。その回答も確認することを強くお勧めします。

自分でそれを行うことuint64_tは完全に移植可能ですが、でuint8_t配列にアクセスするときの整列の問題と厳密なエイリアスのUBを避けるために注意が必要uint64_t*です。あなたはuint64_tすでにあなたのデータから始めてその部分を問題から外しましたが、GNU Cの場合、may_aliastypedefは問題を解決します(それについてのピーターの答えを参照してください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(各Packed整数のすなわち最上位ビット)。減少分については、yです0x0101010101010101U

yMSBがすべてクリアであることはわかっているので、マスクステップの1つをスキップできます(つまり、この場合y & ~Hと同じyです)。計算は次のように行われます。

  1. 各コンポーネントのMSB xを1に設定して、借用がMSBを通過して次のコンポーネントに伝播できないようにします。これを調整済み入力と呼びます。
  2. 0x01010101010101修正された入力から減算することにより、各コンポーネントから1を減算します。これは、ステップ1のおかげでコンポーネント間の借用を引き起こしません。これを調整済み出力と呼びます。
  3. 結果のMSBを修正する必要があります。調整された出力と元の入力のMSBの反転をXORして、結果の修正を完了します。

操作は次のように記述できます。

#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

パフォーマンスの詳細

これは、関数の1回の呼び出し用の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マシンで、デクリメント、xor、compare + jumpを実行すると、反復ごとに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の場合はmovq、XMM regをロードするか、XMM regにロードするだけなpaddbので、RISC-VのようなISAのコンパイル方法を確認するほうが興味深いかもしれません。)


4
MMXのサポートはもちろん、SIMD命令が(まだ)ないRISC-Vマシンでコードを実行する必要があります
cam-white

2
@ cam-whiteわかりました-これがおそらくあなたができる最高の方法です。godboltに飛び乗って、RISCのアセンブリもサニティチェックします。編集::( godboltにはRISC-Vのサポート
ナノファラッド

7
そこgodboltのRISC-Vのサポートのような、たとえば、実際にはこの (E:コンパイラは、マスクを作成する際に過度に創造なっているようです。)
ハロルド

4
パリティ(「キャリーアウトベクトル」とも呼ばれます)トリックをさまざまな状況でどのように使用できるかについてさらに読む:emulators.com/docs/LazyOverflowDetect_Final.pdf
jpa

4
別の編集を行いました。GNU Cネイティブベクトルは厳密なエイリアスの問題を実際に回避します。vector-of- uint8_tuint8_tデータのエイリアスを許可されます。関数の呼び出し元(uint8_tデータをに入れる必要があるuint64_t)は、厳密なエイリアシングを心配する必要があるものです!したがって、おそらく、OPは、ISO C ++で何でもエイリアスできるが、その逆はできないuint64_tため、配列を宣言/割り当てする必要char*があります。
Peter Cordes

16

RISC-Vの場合は、おそらくGCC / clangを使用しています。

おもしろい事実:GCCはこれらのSWARビットハックトリック(他の回答に示されている)のいくつかを知っており、ハードウェアSIMD命令のないターゲットのGNU Cネイティブベクトルでコードをコンパイルするときにそれらを使用できます。(ただし、RISC-Vのclangは単純にスカラー演算に展開するので、コンパイラー間で優れたパフォーマンスが必要な場合は、自分で行う必要があります)。

ネイティブのベクトル構文の利点の1つは、ハードウェアSIMDが搭載さたマシン対象とする場合、ビットハックやそのような恐ろしいものを自動ベクトル化する代わりにそれを使用することです。

vector -= scalar操作を簡単に記述できます。構文Just Worksは、暗黙的にスカラーをスプラッティングしてブロードキャストします。


また、a uint64_t*からのロードuint8_t array[]は厳密なエイリアスのUBであるため、そのことに注意してください。(なぜglibcのstrlenを素早く実行するためにそれほど複雑にする必要があるのですか? re:純粋なCでSWARビットハックを厳密にエイリアス化して安全にする)ISO C / C ++での動作のuint64_tように、ポインターキャストして他のオブジェクトにアクセスできることを宣言するには、このようなものを使用する必要がありますchar*

これらを使用して、他の回答で使用するために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)));

エイリアシングに対して安全なロードを行うもう1つの方法は、memcpyintoを使用することです。uint64_tこれにより、alignof(uint64_t)のアライメント要件も削除されます。しかし、効果的な非整列ロードのないISAでは、gcc / clangはインライン化せずmemcpy、ポインターが整列していることを証明できない場合に最適化を行わないため、パフォーマンスに悪影響を及ぼします。

TL:DR:あなたの最善の策は、としてあなたのデータを宣言することであるuint64_t array[...]かのように動的に割り当てるuint64_tまたは好ましくalignas(16) uint64_t array[]; 指定した場合、少なくとも8バイト、または16のことを保証アライメントalignas

以来uint8_tほぼ確実であるunsigned char*、それはバイトにアクセスしても安全ですuint64_tを通じてuint8_t*(しかし、その逆はないuint8_t配列のため)。したがって、ナローエレメントタイプがであるこの特殊なケースではunsigned charcharが特殊であるため、厳密なエイリアスの問題を回避できます。


GNU Cネイティブのベクトル構文の例:

GNU Cネイティブのベクトルは常に例えば(その基本となるタイプでエイリアスに許可されてint __attribute__((vector_size(16)))安全に別名設定できるintではないfloatか、uint8_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.
}

HW SIMDのないRISC-Vのvector_size(8)場合、効率的に使用できる粒度のみを表現し、2倍の小さなベクトルを実行できます。

しかしvector_size(8)、GCCとclangの両方でx86用に非常に愚かにコンパイルされます。GCCは、GP整数レジスターでSWARビットハックを使用し、clangは2バイト要素にアンパックして、16バイトXMMレジスターを埋めてから再パックします。(MMXは非常に古いため、GCC / clangは、少なくともx86-64の場合は、それを使用することさえありません。)

しかし、とvector_size (16)Godbolt)私たちは期待を取得movdqa/をpaddb。(で生成されたすべて1のベクトルを使用pcmpeqd same,same)。-march=skylake我々はまだので、残念ながら現在のコンパイラはまた、より広いベクターにはない「自動ベクトル化」ベクトルOPSを行い、代わりに1 YMM 2つの別個のXMMオプスを取得します:/

AArch64の場合、使用はそれほど悪くありませんvector_size(8)Godbolt); ARM / AArch64は、dまたはqレジスタを使用して、8または16バイトのチャンクでネイティブに動作できます。

したがってvector_size(16)、x86、RISC-V、ARM / AArch64、およびPOWER全体で移植可能なパフォーマンスが必要な場合は、実際にコンパイルする必要があります。しかし、他のいくつかのISAは、MIPS MSAのように、64ビット整数レジスタ内でSIMDを実行します。

vector_size(8)asm(データの1つのレジスター値のみ)を簡単に見ることができます: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サイクルにすぎず、2つの命令の2つのチェーンがそれぞれXORにつながるようです。@Reinstate Monica-ζ--の回答は4サイクルのdepチェーン(x86用)にコンパイルされます。5サイクルのループスループットは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

13

1つ以上のuint64_tを処理し始めると、作成したコードは実際にはベクトル化されます。

https://godbolt.org/z/J9DRzd


1
そこで何が起こっているのかを説明したり、参照したりできますか?それはかなり興味深いようです。
n314159

2
私はSIMD命令なしでこれを実行しようとしましたが、それでもなお興味深いものでした:)
cam-white

8
一方で、そのSIMDコードはひどいものです。コンパイラはここで何が起こっているのかを完全に誤解しました。E:これは、「これは、この愚かな人間がいないため、コンパイラによって明らかに行われた」の例です
ハロルド

1
@PeterCordes:私は__vector_loop(index, start, past, pad)、実装がfor(index=start; index<past; index++)[マクロを定義するだけで、どの実装もそれを使用してコードを処理できることを意味する]として扱うことができる構成の行に沿ってもっと考えていましたが、コンパイラーに何かを処理するように招待するためのより緩いセマンティクスを持つでしょうまでの2の累乗padのチャンクサイズ。チャンクサイズの倍数でない場合は、開始を下に拡張し、終了を上に拡張します。各チャンク内の副作用はシーケンスさbreakれず、ループ内でa が発生した場合、他の担当者は...
supercat

1
@PeterCordes:restrict参考になります(標準が「少なくとも潜在的に基づく」という概念を認識し、「間もなく」と「少なくとも潜在的に基づいて」を、間抜けで機能しないコーナーケースなしに直接定義すると、さらに役立ちます)私の提案では、コンパイラーが要求よりも多くのループの実行を実行できるようにもなります。これは、ベクトル化を大幅に簡略化するものですが、標準では規定されていません。
スーパーキャット

11

減算がオーバーフローしないことを確認してから、上位ビットを修正できます。

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

1バイトの256のすべての可能な値に対して機能すると思います。Godbolt(RISC-V clangを使用)godbolt.org/z/DGL9aqに配置して、0x0、0x7f、0x80、0xff(数値の中央にシフト)などのさまざまな入力の一定の伝播結果を確認します。いいね。上の答えは結局同じことになると思いますが、より複雑な方法で説明しています。
Peter Cordes

コンパイラーは、ここでレジスターに定数を作成するより良い仕事をすることができます。clangはシフトで1つずつ取得するのではなく、splat(0x01)およびを構築するために多くの命令を費やしsplat(0x80)ます。ソースgodbolt.org/z/6y9v-uにそのように書いても、より良いコードを作成するためにコンパイラーを手にしているわけではありません。一定の伝播を行うだけです。
Peter Cordes

なぜメモリから定数をロードしないのでしょうか。これは、Alpha(同様のアーキテクチャ)のコンパイラが行うことです。
FalkHüffner

GCC for RISC-V 、メモリから定数をロードします。データキャッシュミスが予想され、命令のスループットと比較して高価でない限り、clangはいくつかの調整を必要とするようです。(そのバランスは確かにAlphaから変更された可能性があり、おそらくRISC-Vの異なる実装が異なるでしょう。コンパイラが1つのLUI / addで開始した後にシフト/ ORを拡張できる反復パターンであることを認識した場合も、コンパイラははるかにうまく機能します。 20 + 12 = 32ビットのイミディエイトデータAArch64のビットパターンイミディエイトは、これらをAND / OR / XORのイミディエイトとしても使用でき、スマートデコードと密度の選択)
Peter Cordes

GCCのRISC-V向けネイティブベクトルSWARを示す回答を追加
Peter Cordes

7

これがあなたが望むものかどうかはわかりませんが、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から始まります。私たちはそれを私たちの引数でxorします。この場所に1があった場合、1を引いて停止する必要があります。これは、new_maskの対応するビットを0に設定することによって行われます。0がある場合は、1に設定してキャリーを実行する必要があるため、ビットは1のままで、マスクを左にシフトします。新しいマスクの生成が意図したとおりに機能するかどうかを自分で確認した方がいいと思いますが、セカンドオピニオンは悪くないでしょう。

PS:mask_cpループでnullでないことを確認するとプログラムの速度が低下するかどうかは、実際にはわかりません。それがなくても、コードは依然として正しく(0マスクは何もしないため)、コンパイラーがループのアンロールを実行する方がはるかに簡単です。


for並行して実行されません、あなたは混乱していfor_eachますか?
LTPCGO

3
@LTPCGOいいえ、これを並列化するつもりはありません。これは実際にはアルゴリズムを壊します。ただし、このコードは64ビット整数の異なる8ビット整数で並列に機能します。つまり、8つの減算はすべて同時に実行されますが、最大8ステップが必要です。
n314159

私が求めていたものは少し無理だったかもしれないと思いますが、これは私が必要としているものにかなり近かったです:)
cam-white

4
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つの8ビット値に分割する方法からとったものです。上記の機能を追加して

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

誰かがこれに遭遇した方法に関係なく、それは有効なCまたはC ++です


5
ただし、これは作業を並列化しません。これはOPの質問です。
Nickpro

ええ、@ nickelproは正しいです。これは、それぞれの減算を次々に実行します。すべての8ビット整数を同時に減算します。おかげで仲間の答えに感謝します
cam-white

2
@nickelpro私が回答を開始したとき、編集は行われていなかったため、質問の並行部分が記述されていなかったため、提出後まで気付かなかったため、少なくとも他の人に役立つ場合に備えて、ビット演算を行う部分であり、for_each(std::execution::par_unseq,...
whilesの

2
それは私の悪いです、私は質問を提出しましたが、並行して編集する必要があるとは言わなかったことに気付きました
cam-white

2

コードを考え出そうとはしていませんが、1ずつ減らす場合は、8の1のグループで減らしてから、結果のLSBが「反転」していることを確認してください。トグルしていないLSBは、隣接する8ビットからキャリーが発生したことを示します。分岐なしで、これを処理するためにANDs / ORs / XORsのシーケンスを計算することが可能であるべきです。


それはうまくいくかもしれませんが、キャリーが8ビットの1つのグループを介して別のグループに伝搬する場合を考慮してください。キャリーが伝播しないようにするための(MSBまたは何かを最初に設定する)良い答えの戦略は、おそらくこれが可能な限り効率的です。ビートする現在のターゲット(つまり、良好な非ループブランチレスアンサー)は、クリティカルパスを3サイクルだけにし、2つの64ビット定数を使用する命令レベルの並列処理を備えた5 RISC-V asm ALU命令です。
Peter Cordes

0

各バイトに完全に単独で焦点を合わせ、それを元の場所に戻します。

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;
   }
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.