web-dev-qa-db-ja.com

Cで、「signed int」が「unsigned int」より高速なのはなぜですか?

Cでは、なぜsigned intunsigned intより速いのですか?確かに、私はこれがこのWebサイトで何度も尋ねられ、答えられたことを知っています(以下のリンク)。しかし、ほとんどの人は違いはないと言いました。コードを書いて、誤って大きなパフォーマンスの違いを見つけました。

コードの「署名なし」バージョンが「署名済み」バージョンよりも遅いのはなぜですか(同じ数をテストする場合でも)? (私はx86-64 Intelプロセッサーを持っています)。

同様のリンク

コンパイルコマンド:gcc -Wall -Wextra -pedantic -O3 -Wl,-O3 -g0 -ggdb0 -s -fwhole-program -funroll-loops -pthread -pipe -ffunction-sections -fdata-sections -std=c11 -o ./test ./test.c && strip --strip-all --strip-unneeded --remove-section=.note --remove-section=.comment ./test


signed intバージョン

注:すべての数値に対してsigned intを明示的に宣言しても違いはありません。

int isprime(int num) {
    // Test if a signed int is prime
    int i;
    if (num % 2 == 0 || num % 3 == 0)
        return 0;
    else if (num % 5 == 0 || num % 7 == 0)
        return 0;
    else {
        for (i = 11; i < num; i += 2) {
            if (num % i == 0) {
                if (i != num)
                    return 0;
                else
                    return 1;
            }
        }
    }
    return 1;
}

unsigned intバージョン

int isunsignedprime(unsigned int num) {
    // Test if an unsigned int is prime
    unsigned int i;
    if (num % (unsigned int)2 == (unsigned int)0 || num % (unsigned int)3 == (unsigned int)0)
        return 0;
    else if (num % (unsigned int)5 == (unsigned int)0 || num % (unsigned int)7 == (unsigned int)0)
        return 0;
    else {
        for (i = (unsigned int)11; i < num; i += (unsigned int)2) {
            if (num % i == (unsigned int)0) {
                if (i != num)
                    return 0;
                else
                    return 1;
            }
        }
    }
    return 1;
}

以下のコードを使用して、これをファイルでテストします。

int main(void) {
    printf("%d\n", isprime(294967291));
    printf("%d\n", isprime(294367293));
    printf("%d\n", isprime(294967293));
    printf("%d\n", isprime(294967241)); // slow
    printf("%d\n", isprime(294967251));
    printf("%d\n", isprime(294965291));
    printf("%d\n", isprime(294966291));
    printf("%d\n", isprime(294963293));
    printf("%d\n", isprime(294927293));
    printf("%d\n", isprime(294961293));
    printf("%d\n", isprime(294917293));
    printf("%d\n", isprime(294167293));
    printf("%d\n", isprime(294267293));
    printf("%d\n", isprime(294367293)); // slow
    printf("%d\n", isprime(294467293));
    return 0;
}

結果(time ./test):

Signed - real 0m0.949s
Unsigned - real 0m1.174s
31

署名されていないバージョンは常に10〜20%遅いコードを生成するので、あなたの質問は本当に興味深いものです。しかし、コードには複数の問題があります。

  • どちらの関数も_0_、_2_、_3_および_5_に対して_7_を返しますが、これは正しくありません。
  • ループ本体は_i < num_に対してのみ実行されるため、テストif (i != num) return 0; else return 1;は完全に役に立ちません。そのようなテストは小さな素数テストには役立ちますが、それらを特別にケーシングすることは実際には役に立ちません。
  • 符号なしバージョンのキャストは冗長です。
  • 端末にテキスト出力を生成するベンチマークコードは信頼性が低いため、clock()関数を使用して、I/Oを介在させずにCPU集中関数の時間を測定する必要があります。
  • プライムテストのアルゴリズムは、ループがsqrt(num)ではなく_num / 2_回実行されるため、まったく非効率的です。

コードを簡略化して、いくつかの正確なベンチマークを実行してみましょう。

_#include <stdio.h>
#include <time.h>

int isprime_slow(int num) {
    if (num % 2 == 0)
        return num == 2;
    for (int i = 3; i < num; i += 2) {
        if (num % i == 0)
            return 0;
    }
    return 1;
}

int unsigned_isprime_slow(unsigned int num) {
    if (num % 2 == 0)
        return num == 2;
    for (unsigned int i = 3; i < num; i += 2) {
        if (num % i == 0)
            return 0;
    }
    return 1;
}

int isprime_fast(int num) {
    if (num % 2 == 0)
        return num == 2;
    for (int i = 3; i * i <= num; i += 2) {
        if (num % i == 0)
            return 0;
    }
    return 1;
}

int unsigned_isprime_fast(unsigned int num) {
    if (num % 2 == 0)
        return num == 2;
    for (unsigned int i = 3; i * i <= num; i += 2) {
        if (num % i == 0)
            return 0;
    }
    return 1;
}

int main(void) {
    int a[] = {
        294967291, 0, 294367293, 0, 294967293, 0, 294967241, 1, 294967251, 0,
        294965291, 0, 294966291, 0, 294963293, 0, 294927293, 1, 294961293, 0,
        294917293, 0, 294167293, 0, 294267293, 0, 294367293, 0, 294467293, 0,
    };
    struct testcase { int (*fun)(); const char *name; int t; } test[] = {
        { isprime_slow, "isprime_slow", 0 },
        { unsigned_isprime_slow, "unsigned_isprime_slow", 0 },
        { isprime_fast, "isprime_fast", 0 },
        { unsigned_isprime_fast, "unsigned_isprime_fast", 0 },
    };

    for (int n = 0; n < 4; n++) {
        clock_t t = clock();
        for (int i = 0; i < 30; i += 2) {
            if (test[n].fun(a[i]) != a[i + 1]) {
                printf("%s(%d) != %d\n", test[n].name, a[i], a[i + 1]);
            }
        }
        test[n].t = clock() - t;
    }
    for (int n = 0; n < 4; n++) {
        printf("%21s: %4d.%03dms\n", test[n].name, test[n].t / 1000), test[n].t % 1000);
    }
    return 0;
}
_

OS/Xで_clang -O2_を使用してコンパイルされたコードは、次の出力を生成します。

_         isprime_slow:  788.004ms
unsigned_isprime_slow:  965.381ms
         isprime_fast:    0.065ms
unsigned_isprime_fast:    0.089ms
_

これらのタイミングは、別のシステムでのOPの観察された動作と一致していますが、より効率的な反復テストによって劇的な改善が見られます:10000倍より速く!

質問について、なぜ符号なしで関数が遅いのですか?、生成されたコード( gcc 7.2 -O2 )を見てみましょう。

_isprime_slow(int):
        ...
.L5:
        movl    %edi, %eax
        cltd
        idivl   %ecx
        testl   %edx, %edx
        je      .L1
.L4:
        addl    $2, %ecx
        cmpl    %esi, %ecx
        jne     .L5
.L6:
        movl    $1, %edx
.L1:
        movl    %edx, %eax
        ret

unsigned_isprime_slow(unsigned int):
        ...
.L19:
        xorl    %edx, %edx
        movl    %edi, %eax
        divl    %ecx
        testl   %edx, %edx
        je      .L22
.L18:
        addl    $2, %ecx
        cmpl    %esi, %ecx
        jne     .L19
.L20:
        movl    $1, %eax
        ret
       ...
.L22:
        xorl    %eax, %eax
        ret
_

内部ループは非常によく似ており、同じ数の命令、同じような命令です。ただし、いくつかの潜在的な説明は次のとおりです。

  • cltdeaxレジスタの符号をedxレジスタに拡張します。これは、eaxが直前の命令によって変更されるため、命令遅延を引き起こす可能性があります_movl %edi, %eax_。しかし、これは署名されたバージョンを署名されていないバージョンより遅くし、速くはしません。
  • ループの最初の命令は、署名されていないバージョンに対して誤って調整されている可能性がありますが、ソースコードの順序を変更してもタイミングに影響がないため、そうなる可能性はほとんどありません。
  • レジスタの内容は、符号付きと符号なしの除算オペコードで同一ですが、idivl命令はdivl命令よりもサイクル数が少ない可能性があります。実際、符号付き除算は、符号なし除算よりも1ビット少ない精度で動作しますが、この小さな変更の場合、差は非常に大きくなります。
  • 符号付きの除算は符号なしの除算よりも一般的であるため、idivlのシリコン実装により多くの労力が費やされたと思います(Intelでの長年のコーディング統計により測定)。
  • rcgldrがコメントしているように、Intelプロセスの命令テーブルを見ると、Ivy Bridgeの場合、DIV 32ビットは10マイクロ演算、19〜27サイクル、IDIV 9マイクロ演算、19〜26サイクルかかります。ベンチマーク時間はこれらのタイミングと一致しています。余分なマイクロオペレーションは、IDIV(63/31ビット)とは対照的に、DIV(64/32ビット)の長いオペランドが原因である可能性があります。

この驚くべき結果から、いくつかの教訓が得られます。

  • 最適化は難しい芸術であり、謙虚で先延ばしになります。
  • 正確さは、最適化によってしばしば破壊されます。
  • より優れたアルゴリズムを選択することは、最適化に勝るものはありません。
  • 常にベンチマークコードです。本能を信頼しないでください。
15
chqrlie

符号付き整数のオーバーフローは定義されていないため、コンパイラーは符号付き整数を含むコードに対して多くの仮定と最適化を行うことができます。符号なし整数オーバーフローはラップアラウンドするように定義されているため、コンパイラーはそれほど最適化できません。 http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html#signed_overflow および http://www.airsも参照してください。 .com/blog/archives/12

6
shadow_map

AMD/Intelの命令仕様 から(K7の場合):

Instruction Ops Latency Throughput
DIV r32/m32 32  24      23
IDIV r32    81  41      41
IDIV m32    89  41      41 

I7の場合、レイテンシとスループットはIDIVLDIVLで同じですが、µopにはわずかな違いがあります。

-O3アセンブリコードは私のマシンの署名(DIVLとIDIVL)によってのみ異なるので、これは違いを説明するかもしれません。

有意な時間差を示す場合と示さない場合がある代替のWiki候補テスト。

#include <stdio.h>
#include <time.h>

#define J 10
#define I 5

int main(void) {
  clock_t c1,c2,c3;
  for (int j=0; j<J; j++) {
    c1 = clock();
    for (int i=0; i<I; i++) {
      isprime(294967241);
      isprime(294367293);
    }
    c2 = clock();
    for (int i=0; i<I; i++) {
      isunsignedprime(294967241);
      isunsignedprime(294367293);
    }
    c3 = clock();
    printf("%d %d %d\n", (int)(c2-c1), (int)(c3-c2), (int)((c3-c2) - (c2-c1)));
    fflush(stdout);
  }
  return 0;
}

出力例

2761 2746 -15
2777 2777 0
2761 2745 -16
2793 2808 15
2792 2730 -62
2746 2730 -16
2746 2730 -16
2776 2793 17
2823 2808 -15
2793 2823 30