web-dev-qa-db-ja.com

効率的な整数比較機能

compare関数は、2つの引数aおよびbを取り、その順序を説明する整数を返す関数です。 abより小さい場合、結果は負の整数になります。 abよりも大きい場合、結果は正の整数になります。それ以外の場合、abは等しく、結果はゼロになります。

この関数は、標準ライブラリからのソートおよび検索アルゴリズムのパラメータ化によく使用されます。

文字にcompare関数を実装するのは非常に簡単です。引数を単に引きます:

_int compare_char(char a, char b)
{
    return a - b;
}
_

これは、2つの文字の差が整数に収まると一般に想定されているために機能します。 (この仮定は、sizeof(char) == sizeof(int)のシステムには当てはまらないことに注意してください。)

通常、2つの整数の差は整数に収まらないため、このトリックでは整数を比較できません。たとえば、INT_MAX - (-1) = INT_MINは、_INT_MAX_が_-1_よりも小さいことを示唆します(技術的には、オーバーフローにより未定義の動作が発生しますが、モジュロ演算を想定します)。

では、整数に対して比較関数を効率的に実装するにはどうすればよいでしょうか?これが私の最初の試みです。

_int compare_int(int a, int b)
{
    int temp;
    int result;
    __asm__ __volatile__ (
        "cmp %3, %2 \n\t"
        "mov $0, %1 \n\t"

        "mov $1, %0 \n\t"
        "cmovg %0, %1 \n\t"

        "mov $-1, %0 \n\t"
        "cmovl %0, %1 \n\t"
    : "=r"(temp), "=r"(result)
    : "r"(a), "r"(b)
    : "cc");
    return result;
}
_

6つ未満の指示で実行できますか?より効率的で簡単ではない方法はありますか?

61
fredoverflow

以下は、私にとって非常に効率的であることが常に証明されています。

return (a < b) ? -1 : (a > b);

gcc -O2 -S、これは次の5つの命令にコンパイルされます。

xorl    %edx, %edx
cmpl    %esi, %edi
movl    $-1, %eax
setg    %dl
cmovge  %edx, %eax

Ambroz Bizjakの優れたコンパニオンの回答 のフォローアップとして、私は彼のプログラムが上記の同じアセンブリコードをテストしたとは確信していませんでした。そして、コンパイラーの出力をより詳細に調べていたとき、コンパイラーがいずれかの回答で投稿されたものと同じ命令を生成していないことに気付きました。そこで、私は彼のテストプログラムを使用し、アセンブリの出力を手作業で修正して、投稿されたものと一致させ、結果の時間を比較しました。 2つのバージョンはほぼ同じように比較されているようです

./opt_cmp_branchless: 0m1.070s
./opt_cmp_branch:     0m1.037s

他の人が同じ実験を試み、私の観察を確認または矛盾させることができるように、各プログラムのアセンブリを完全に投稿しています。

以下は、cmovge命令((a < b) ? -1 : (a > b)):

        .file   "cmp.c"
        .text
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d=0\n"
        .text
        .p2align 4,,15
.globl main
        .type   main, @function
main:
.LFB20:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        pushq   %rbx
        .cfi_def_cfa_offset 24
        .cfi_offset 3, -24
        movl    $arr.2789, %ebx
        subq    $8, %rsp
        .cfi_def_cfa_offset 32
.L9:
        leaq    4(%rbx), %rbp
.L10:
        call    Rand
        movb    %al, (%rbx)
        addq    $1, %rbx
        cmpq    %rbx, %rbp
        jne     .L10
        cmpq    $arr.2789+4096, %rbp
        jne     .L9
        xorl    %r8d, %r8d
        xorl    %esi, %esi
        orl     $-1, %edi
.L12:
        xorl    %ebp, %ebp
        .p2align 4,,10
        .p2align 3
.L18:
        movl    arr.2789(%rbp), %ecx
        xorl    %eax, %eax
        .p2align 4,,10
        .p2align 3
.L15:
        movl    arr.2789(%rax), %edx
        xorl    %ebx, %ebx
        cmpl    %ecx, %edx
        movl    $-1, %edx
        setg    %bl
        cmovge  %ebx, %edx
        addq    $4, %rax
        addl    %edx, %esi
        cmpq    $4096, %rax
        jne     .L15
        addq    $4, %rbp
        cmpq    $4096, %rbp
        jne     .L18
        addl    $1, %r8d
        cmpl    $500, %r8d
        jne     .L12
        movl    $.LC0, %edi
        xorl    %eax, %eax
        call    printf
        addq    $8, %rsp
        .cfi_def_cfa_offset 24
        xorl    %eax, %eax
        popq    %rbx
        .cfi_def_cfa_offset 16
        popq    %rbp
        .cfi_def_cfa_offset 8
        ret
        .cfi_endproc
.LFE20:
        .size   main, .-main
        .local  arr.2789
        .comm   arr.2789,4096,32
        .section        .note.GNU-stack,"",@progbits

以下のバージョンでは、ブランチレスメソッド((a > b) - (a < b)):

        .file   "cmp.c"
        .text
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d=0\n"
        .text
        .p2align 4,,15
.globl main
        .type   main, @function
main:
.LFB20:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        pushq   %rbx
        .cfi_def_cfa_offset 24
        .cfi_offset 3, -24
        movl    $arr.2789, %ebx
        subq    $8, %rsp
        .cfi_def_cfa_offset 32
.L9:
        leaq    4(%rbx), %rbp
.L10:
        call    Rand
        movb    %al, (%rbx)
        addq    $1, %rbx
        cmpq    %rbx, %rbp
        jne     .L10
        cmpq    $arr.2789+4096, %rbp
        jne     .L9
        xorl    %r8d, %r8d
        xorl    %esi, %esi
.L19:
        movl    %ebp, %ebx
        xorl    %edi, %edi
        .p2align 4,,10
        .p2align 3
.L24:
        movl    %ebp, %ecx
        xorl    %eax, %eax
        jmp     .L22
        .p2align 4,,10
        .p2align 3
.L20:
        movl    arr.2789(%rax), %ecx
.L22:
        xorl    %edx, %edx
        cmpl    %ebx, %ecx
        setg    %cl
        setl    %dl
        movzbl  %cl, %ecx
        subl    %ecx, %edx
        addl    %edx, %esi
        addq    $4, %rax
        cmpq    $4096, %rax
        jne     .L20
        addq    $4, %rdi
        cmpq    $4096, %rdi
        je      .L21
        movl    arr.2789(%rdi), %ebx
        jmp     .L24
.L21:
        addl    $1, %r8d
        cmpl    $500, %r8d
        jne     .L19
        movl    $.LC0, %edi
        xorl    %eax, %eax
        call    printf
        addq    $8, %rsp
        .cfi_def_cfa_offset 24
        xorl    %eax, %eax
        popq    %rbx
        .cfi_def_cfa_offset 16
        popq    %rbp
        .cfi_def_cfa_offset 8
        ret
        .cfi_endproc
.LFE20:
        .size   main, .-main
        .local  arr.2789
        .comm   arr.2789,4096,32
        .section        .note.GNU-stack,"",@progbits
54
jxh

これにはブランチがなく、オーバーフローやアンダーフローの影響を受けません。

return (a > b) - (a < b);

gcc -O2 -Sを使用すると、次の6つの命令にコンパイルされます。

xorl    %eax, %eax
cmpl    %esi, %edi
setl    %dl
setg    %al
movzbl  %dl, %edx
subl    %edx, %eax

さまざまな比較実装のベンチマークを行うコードを次に示します。

#include <stdio.h>
#include <stdlib.h>

#define COUNT 1024
#define LOOPS 500
#define COMPARE compare2
#define USE_Rand 1

int arr[COUNT];

int compare1 (int a, int b)
{
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
}

int compare2 (int a, int b)
{
    return (a > b) - (a < b);
}

int compare3 (int a, int b)
{
    return (a < b) ? -1 : (a > b);
}

int compare4 (int a, int b)
{
    __asm__ __volatile__ (
        "sub %1, %0 \n\t"
        "jno 1f \n\t"
        "cmc \n\t"
        "rcr %0 \n\t"
        "1: "
    : "+r"(a)
    : "r"(b)
    : "cc");
    return a;
}

int main ()
{
    for (int i = 0; i < COUNT; i++) {
#if USE_Rand
        arr[i] = Rand();
#else
        for (int b = 0; b < sizeof(arr[i]); b++) {
            *((unsigned char *)&arr[i] + b) = Rand();
        }
#endif
    }

    int sum = 0;

    for (int l = 0; l < LOOPS; l++) {
        for (int i = 0; i < COUNT; i++) {
            for (int j = 0; j < COUNT; j++) {
                sum += COMPARE(arr[i], arr[j]);
            }
        }
    }

    printf("%d=0\n", sum);

    return 0;
}

正の整数(gcc -std=c99 -O2)に対してUSE_Rand=1でコンパイルされた64ビットシステムの結果:

compare1: 0m1.118s
compare2: 0m0.756s
compare3: 0m1.101s
compare4: 0m0.561s

Cのみのソリューションのうち、私が提案したものは最速でした。 user315052のソリューションは、5つの命令のみにコンパイルされていたにもかかわらず低速でした。命令が1つ少ないにもかかわらず、条件付き命令(cmovge)があるため、スローダウンが発生する可能性があります。

全体として、FredOverflowの4命令アセンブリの実装は、正の整数で使用した場合に最速でした。ただし、このコードは、Rand_MAXの整数範囲のみをベンチマークしているため、4回のテストはバイアスを個別に処理します。オーバーフローは個別に処理され、テストでは発生しません。速度は、分岐予測の成功が原因である可能性があります。

整数の全範囲(USE_Rand=0)を使用すると、実際には4命令のソリューションは非常に遅くなります(他は同じです)。

compare4: 0m1.897s
95
Ambroz Bizjak

さて、私はそれを4つの指示にまとめることができました:)基本的な考え方は次のとおりです:

半分の時間で、差は整数に収まるほど小さくなります。その場合は、差を返すだけです。それ以外の場合は、1を右にシフトします。重要な問題は、MSBにどのビットをシフトするかです。

簡単にするために、32ビットではなく8ビットを使用する2つの極端な例を見てみましょう。

 10000000 INT_MIN
 01111111 INT_MAX
---------
000000001 difference
 00000000 shifted

 01111111 INT_MAX
 10000000 INT_MIN
---------
111111111 difference
 11111111 shifted

キャリービットをシフトインすると、最初のケースでは0(INT_MININT_MAXと等しくない)と2番目のケースでは負の数(INT_MAXINT_MIN)。

しかし、シフトを行う前にキャリービットを反転させると、適切な数値が得られます。

 10000000 INT_MIN
 01111111 INT_MAX
---------
000000001 difference
100000001 carry flipped
 10000000 shifted

 01111111 INT_MAX
 10000000 INT_MIN
---------
111111111 difference
011111111 carry flipped
 01111111 shifted

キャリービットを反転することが理にかなっているのは数学的に深い理由があると確信していますが、まだわかりません。

int compare_int(int a, int b)
{
    __asm__ __volatile__ (
        "sub %1, %0 \n\t"
        "jno 1f \n\t"
        "cmc \n\t"
        "rcr %0 \n\t"
        "1: "
    : "+r"(a)
    : "r"(b)
    : "cc");
    return a;
}

100万のランダム入力に加えて、INT_MIN、-INT_MAX、INT_MIN/2、-1、0、1、INT_MAX/2、INT_MAX/2 + 1、INT_MAXのすべての組み合わせでコードをテストしました。すべてのテストに合格しました。私を間違って証明してもらえますか?

15
fredoverflow

それが価値があるために、SSE2実装をまとめました。 vec_compare1compare2と同じアプローチを使用しますが、必要なSSE2算術命令は3つだけです。

#include <stdio.h>
#include <stdlib.h>
#include <emmintrin.h>

#define COUNT 1024
#define LOOPS 500
#define COMPARE vec_compare1
#define USE_Rand 1

int arr[COUNT] __attribute__ ((aligned(16)));

typedef __m128i vSInt32;

vSInt32 vec_compare1 (vSInt32 va, vSInt32 vb)
{
    vSInt32 vcmp1 = _mm_cmpgt_epi32(va, vb);
    vSInt32 vcmp2 = _mm_cmpgt_epi32(vb, va);
    return _mm_sub_epi32(vcmp2, vcmp1);
}

int main ()
{
    for (int i = 0; i < COUNT; i++) {
#if USE_Rand
        arr[i] = Rand();
#else
        for (int b = 0; b < sizeof(arr[i]); b++) {
            *((unsigned char *)&arr[i] + b) = Rand();
        }
#endif
    }

    vSInt32 vsum = _mm_set1_epi32(0);

    for (int l = 0; l < LOOPS; l++) {
        for (int i = 0; i < COUNT; i++) {
            for (int j = 0; j < COUNT; j+=4) {
                vSInt32 v1 = _mm_loadu_si128(&arr[i]);
                vSInt32 v2 = _mm_load_si128(&arr[j]);
                vSInt32 v = COMPARE(v1, v2);
                vsum = _mm_add_epi32(vsum, v);
            }
        }
    }

    printf("vsum = %vd\n", vsum);

    return 0;
}

この時間は0.137秒です。

同じCPUとコンパイラでのcompare2の時間は0.674秒です。

そのため、SSE2の実装は、予想どおり(4ワイドSIMDであるため)約4倍高速です。

10
Paul R

このコードには分岐がなく、5つの命令を使用します。最近のIntelプロセッサでは、cmov *命令が非常に高価であるため、他のブランチレスの選択肢よりも優れている場合があります。欠点は、非対称の戻り値(INT_MIN + 1、0、1)です。

int compare_int (int a, int b)
{
    int res;

    __asm__ __volatile__ (
        "xor %0, %0 \n\t"
        "cmpl %2, %1 \n\t"
        "setl %b0 \n\t"
        "rorl $1, %0 \n\t"
        "setnz %b0 \n\t"
    : "=q"(res)
    : "r"(a)
    , "r"(b)
    : "cc"
    );

    return res;
}

このバリアントは初期化を必要としないため、4つの命令のみを使用します。

int compare_int (int a, int b)
{
    __asm__ __volatile__ (
        "subl %1, %0 \n\t"
        "setl %b0 \n\t"
        "rorl $1, %0 \n\t"
        "setnz %b0 \n\t"
    : "+q"(a)
    : "r"(b)
    : "cc"
    );

    return a;
}
3
Evgeny Kluev

たぶん、あなたは次のアイデアを使用することができます(擬似コードで;構文に慣れていないのでasmコードを書いていません):

  1. 数字を引く(_result = a - b_)
  2. オーバーフローがなければ、完了(jo命令と分岐予測はここで非常にうまく機能するはずです)
  3. オーバーフローが発生した場合は、堅牢な方法(return (a < b) ? -1 : (a > b))を使用します

編集: さらに簡単にするために、オーバーフローがあった場合は、ステップ3の代わりに結果の符号を反転します

0
anatolyg