web-dev-qa-db-ja.com

符号付き整数演算オーバーフローを未定義のままにしておくことを正当化する意味のある統計データはありますか?

C標準では、符号付き整数オーバーフローが未定義の動作として明示的に指定されています。しかし、ほとんどのCPUは、オーバーフローのセマンティクスが定義された符号付き算術を実装しています(除算オーバーフローの場合を除いて:x / 0およびINT_MIN / -1)。

コンパイラー作成者は、このようなオーバーフローのundefinednessを利用して、非常に微妙な方法でレガシーコードを破壊する傾向があるより積極的な最適化を追加しています。たとえば、このコードは古いコンパイラで機能した可能性がありますが、現在のバージョンのgccおよびclangでは機能しません。

/* Tncrement a by a value in 0..255, clamp a to positive integers.
   The code relies on 32-bit wrap-around, but the C Standard makes
   signed integer overflow undefined behavior, so sum_max can now 
   return values less than a. There are Standard compliant ways to
   implement this, but legacy code is what it is... */
int sum_max(int a, unsigned char b) {
    int res = a + b;
    return (res >= a) ? res : INT_MAX;
}

これらの最適化が価値があるという確かな証拠はありますか?実際の例や古典的なベンチマークでの実際の改善を記録した比較研究はありますか?

これを見ていたときに私はこの質問を思い付きました: C++ Now 2018:John Regehr“ Closing Keynote:Undefined Behavior and Compiler Optimizations”

問題は両方の言語で類似しているため、cおよびc ++にタグを付けていますしかし、答えは異なる場合があります。

調査や統計については知りませんが、はい、コンパイラーが実際に行うことを考慮して、これを考慮した最適化が確実に行われています。そして、はい、それらは非常に重要です(たとえば、tldrループのベクトル化)。

コンパイラーの最適化の他に、考慮すべきもう1つの側面があります。 UBを使用すると、C/C++の符号付き整数が数学的に予想されるように算術的に動作するようになります。たとえば、_x + 10 > x_は(もちろん、有効なコードの場合)現在trueを保持していますが、ラップアラウンド動作ではありません。

私は優れた記事を見つけました 未定義の署名付きオーバーフローがGCCでの最適化を可能にする方法 署名付きオーバーフローUBを考慮に入れるいくつかの最適化をリストするKrister Walfridssonのブログから。次の例はそれからのものです。私はそれらにc ++とアセンブリの例を追加しています。

最適化が単純すぎる、面白くない、影響がないように見える場合、これらの最適化は、はるかに大きな最適化チェーンの単なるステップであることを覚えておいてください。そして、バタフライ効果は、前のステップで一見重要ではない最適化が後のステップではるかにインパクトのある最適化をトリガーできるために発生します。

例が無意味に見える場合(_x * 10 > 0_を書く人)は、定数、マクロ、テンプレートを使用して、この種のCおよびC++の例に非常に簡単にアクセスできることに注意してください。さらに、コンパイラーは、IRに変換と最適化を適用するときに、この種の例に到達できます。

符号付き整数式の簡略化

  • 0と比較して乗算を排除する

    _(x * c) cmp 0   ->   x cmp 0 
    _
    _bool foo(int x) { return x * 10 > 0 }
    _
    _foo(int):
            test    edi, edi
            setg    al
            ret
    _
  • 乗算後の除算を排除

    (x * c1)/ c2-> x *(c1/c2)c1がc2で割り切れる場合

    _int foo(int x) { return (x * 20) / 10; }
    _
    _foo(int):
            lea     eax, [rdi+rdi]
            ret
    _
  • 否定をなくす

    (-x)/(-y)-> x/y

    _int foo(int x, int y) { return (-x) / (-y); }
    _
    _foo(int, int):
            mov     eax, edi
            cdq
            idiv    esi
            ret
    _
  • 常に真または偽である比較を簡素化する

    _x + c < x       ->   false
    x + c <= x      ->   false
    x + c > x       ->   true
    x + c >= x      ->   true
    _
    _bool foo(int x) { return x + 10 >= x; }
    _
    _foo(int):
            mov     eax, 1
            ret
    _
  • 比較の否定を排除する

    _(-x) cmp (-y)   ->   y cmp x
    _
    _bool foo(int x, int y) { return -x < -y; }
    _
    _foo(int, int):
            cmp     edi, esi
            setg    al
            ret
    _
  • 定数の大きさを減らす

    _x + c > y       ->   x + (c - 1) >= y
    x + c <= y      ->   x + (c - 1) < y
    _
    _bool foo(int x, int y) { return x + 10 <= y; }
    _
    _foo(int, int):
            add     edi, 9
            cmp     edi, esi
            setl    al
            ret
    _
  • 比較で定数を排除する

    _(x + c1) cmp c2         ->   x cmp (c2 - c1)
    (x + c1) cmp (y + c2)   ->   x cmp (y + (c2 - c1)) if c1 <= c2
    _

    2番目の変換は、yの値がINT_MINのときにオーバーフローが発生するため、c1 <= c2の場合にのみ有効です。

    _bool foo(int x) { return x + 42 <= 11; }
    _
    _foo(int):
            cmp     edi, -30
            setl    al
            ret
    _

ポインター演算と型の昇格

操作がオーバーフローしない場合、より広い型で操作を実行しても同じ結果が得られます。これは、64ビットアーキテクチャで配列のインデックス付けなどを行う場合にしばしば役立ちます。インデックスの計算は通常32ビットのintを使用して行われますが、ポインターは64ビットであり、コンパイラーは、署名付きオーバーフローが未定義の場合により効率的なコードを生成します。型拡張を生成する代わりに、32ビット整数を64ビット演算に昇格させます。

これのもう1つの側面は、未定義のオーバーフローにより、a [i]とa [i + 1]が確実に隣接することです。これにより、ベクトル化などのためのメモリアクセスの分析が改善されます。

ループのベクトル化は最も効率的で効果的な最適化アルゴリズムの1つであるため、これは非常に重要な最適化です。

実証するのは難しいです。しかし、実際にインデックスをunsignedからsignedに変更すると、生成されたアセンブリが大幅に改善された状況に遭遇したことを覚えています。残念ながら、私は今それを覚えたり複製したりすることはできません。それがわかれば後で戻ってきます。

値の範囲の計算

コンパイラーは、プログラムの各ポイントでの変数の可能な値の範囲を追跡します。

_int x = foo();
if (x > 0) {
  int y = x + 5;
  int z = y / 4;
_

ifステートメントの後、xの範囲が_[1, INT_MAX]_であると判断し、オーバーフローが許可されないため、yの範囲が_[6, INT_MAX]_であると判断できます。また、コンパイラーはyが負でないことを知っているので、次の行を_int z = y >> 2;_に最適化できます。

_auto foo(int x)
{
    if (x <= 0)
        __builtin_unreachable();

    return (x + 5) / 4;
}
_
_foo(int):
        lea     eax, [rdi+5]
        sar     eax, 2
        ret
_

未定義のオーバーフローは、2つの値を比較する必要がある最適化に役立ちます(折り返しの場合、[INT_MIN, (INT_MIN+4)]または_[6, INT_MAX]_の形式の可能な値が与えられ、_<_または_>_)など

  • xyの範囲が重なっていない場合の比較_x<y_のtrueまたはfalseへの変更
  • 範囲が重複しない場合は、min(x,y)またはmax(x,y)xまたはyに変更します
  • 範囲が_-x_を超えない場合は、abs(x)xまたは_0_に変更します。
  • _x/c_および定数cが_x>0_の累乗である場合に_2_をx>>log2(c)に変更する
  • _x%c_および定数cが_x>0_の累乗である場合に_2_をx&(c-1)に変更する

ループ分析と最適化

未定義の符号付きオーバーフローがループの最適化に役立つ理由の標準的な例は、次のようなループです。

_for (int i = 0; i <= m; i++)
_

未定義のオーバーフローで終了することが保証されています。これは、一般に無限ループを処理しないため、特定のループ命令を持つアーキテクチャに役立ちます。

しかし、未定義の符号付きオーバーフローは、さらに多くのループ最適化に役立ちます。反復回数の決定、誘導変数の変換、メモリアクセスの追跡など、すべての分析では、その作業を行うために前のセクションのすべてを使用しています。特に、符号化されたオーバーフローが許可されている場合、ベクトル化できるループのセットは大幅に削減されます。

21
bolov

最適化の例ではありませんが、未定義の動作の1つの有用な結果は、GCC/clangの-ftrapvコマンドラインスイッチです。整数オーバーフローでプログラムをクラッシュさせるコードを挿入します。

符号なしオーバーフローは意図的なものであるという考えに従って、符号なし整数では機能しません。

符号付き整数オーバーフローに関する標準の表現は、人々が意図的にオーバーフローコードを記述しないようにするため、ftrapvは意図しないオーバーフローを発見するための便利なツールです。

7
anatolyg

これが実際の小さなベンチマーク、バブルソートです。 -fwrapvなし/ありのタイミングを比較しました(つまり、オーバーフローはUBではなくUBです)。結果(秒)は次のとおりです。

                   -O3     -O3 -fwrapv    -O1     -O1 -fwrapv
Machine1, clang    5.2     6.3            6.8     7.7
Machine2, clang-8  4.2     7.8            6.4     6.7
Machine2, gcc-8    6.6     7.4            6.5     6.5

ご覧のように、非UB(-fwrapv)バージョンはほとんど常に遅く、最大の違いは1.85xとかなり大きいです。

これがコードです。私が意図的に実装を選択したことに注意してください。これにより、このテストでより大きな違いが生じるはずです。

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

void bubbleSort(int *a, long n) {
        bool swapped;
        for (int i = 0; i < n-1; i++) {
                swapped = false;
                for (int j = 0; j < n-i-1; j++) {
                        if (a[j] > a[j+1]) {
                                int t = a[j];
                                a[j] = a[j+1];
                                a[j+1] = t;
                                swapped = true;
                        }
                }

                if (!swapped) break;
        }
}

int main() {
        int a[8192];

        for (int j=0; j<100; j++) {
                for (int i=0; i<8192; i++) {
                        a[i] = Rand();
                }

                bubbleSort(a, 8192);
        }
}
4
geza

答えは実際にあなたの質問にあります:

しかし、ほとんどのCPUは定義されたセマンティクスで符号付き演算を実装します

符号付き整数に2の補数演算を使用しない、今日購入できるCPUは考えられませんが、常にそうであるとは限りませんでした。

C言語は1972年に発明されました。当時、IBM 7090メインフレームはまだ存在していました。すべてのコンピューターが2の補数であるわけではありません。

2s-complimentを中心に言語(およびオーバーフロー動作)を定義することは、そうでないマシンでのコード生成に害を及ぼします。

さらに、すでに述べたように、署名付きオーバーフローがUBであることを指定すると、コンパイラーはより優れたコードを生成できます。

ラップアラウンドなしでaとbの合計を0 .... INT_MAXにクランプすることを意図していることを正しく理解している場合、この関数を準拠した方法で記述する2つの方法を考えることができます。

まず、すべてのCPUで機能する非効率的な一般的なケース:

int sum_max(int a, unsigned char b) {
    if (a > std::numeric_limits<int>::max() - b)
        return std::numeric_limits<int>::max();
    else
        return a + b;
}

第二に、驚くほど効率的な2の補数固有の方法:

int sum_max2(int a, unsigned char b) {
    unsigned int buffer;
    std::memcpy(&buffer, &a, sizeof(a));
    buffer += b;
    if (buffer > std::numeric_limits<int>::max())
        buffer = std::numeric_limits<int>::max();
    std::memcpy(&a, &buffer, sizeof(a));
    return a;
}

結果のアセンブラはここにあります: https://godbolt.org/z/F42IXV

2
Richard Hodges