Существуют ли какие-либо значимые статистические данные, чтобы оправдать сохранение неопределенного арифметического переполнения со знаком неопределенным? - PullRequest
35 голосов
/ 08 мая 2019

Стандарт C явно определяет целочисленное переполнение со знаком как имеющее неопределенное поведение . Тем не менее, большинство процессоров реализуют знаковую арифметику с определенной семантикой для переполнения (за исключением, может быть, переполнения деления: x / 0 и INT_MIN / -1).

Авторы компиляторов воспользовались неопределенностью таких переполнений для добавления более агрессивных оптимизаций, которые имеют тенденцию разрушать унаследованный код очень тонкими способами. Например, этот код мог работать на старых компиляторах, но больше не работает на текущих версиях 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 ++ Теперь 2018: Джон Регер «Закрытие Keynote: неопределенное поведение и оптимизация компилятора»

Я помечаю c и c ++ , поскольку проблема одинакова для обоих языков, но ответы могут быть разными.

Ответы [ 4 ]

20 голосов
/ 09 мая 2019

Я не знаю об исследованиях и статистике, но да, есть определенная оптимизация, учитывающая это, что на самом деле делают компиляторы. И да, они очень важны (например, векторизация цикла tldr).

Помимо оптимизации компилятора, необходимо учитывать еще один аспект. С UB вы получаете целые числа со знаком C / C ++, которые ведут себя арифметически, как и следовало ожидать математически. Например, x + 10 > x теперь верно (для действительного кода, конечно), но это не относится к поведению с циклическим изменением.

Я нашел отличную статью Как неопределенное переполнение со знаком позволяет оптимизировать GCC из блога Кристера Вальфридссона, в котором перечислены некоторые оптимизации, в которых учитывается UB со знаком переполнения. Следующие примеры взяты из него. Я добавляю в них 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
    

    Второе преобразование действительно, только если c1 <= c2, как это было бы в противном случае возникает переполнение, когда y имеет значение INT_MIN. </p>

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

Арифметика указателя и продвижение типа

Если операция не переполнена, то мы получим тот же результат, если мы делаем операцию в более широком виде. Это часто полезно при выполнении такие вещи, как индексирование массивов на 64-битных архитектурах - индекс вычисления обычно выполняются с использованием 32-битного типа int, но указатели 64-битный, и компилятор может генерировать более эффективный код при подписании переполнение не определено путем перевода 32-битных целых чисел в 64-битные операции вместо генерации расширений типов.

Еще один аспект этого заключается в том, что неопределенное переполнение гарантирует, что [i] и [i + 1] являются смежными. Это улучшает анализ доступа к памяти для векторизация и т. д.

Это очень важная оптимизация, поскольку векторизация цикла является одним из наиболее эффективных и действенных алгоритмов оптимизации.

Демонстрировать хитрее. Но я помню, как на самом деле сталкивался с ситуацией, когда изменение индекса с unsigned на signed резко улучшало сгенерированную сборку. К сожалению, я не могу вспомнить или повторить это сейчас. Вернусь позже, если я это выясню.

Расчет диапазона значений

Компилятор отслеживает диапазон возможных значений переменных в каждая точка в программе, то есть для такого кода, как

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

определяет, что x имеет диапазон [1, INT_MAX] после оператор if и, таким образом, может определить, что у имеет диапазон [6, INT_MAX], поскольку переполнение не допускается. И следующая строка может быть оппереведено на 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

Неопределенное переполнение помогает оптимизировать, которые должны сравнивать два значения (так как случай упаковки даст возможные значения в форме [INT_MIN, (INT_MIN+4)] или [6, INT_MAX], что мешает все полезное сравнения с < или >), например

  • Изменение сравнений x<y на true или false, если диапазоны для x и y не перекрываются
  • Изменение min(x,y) или max(x,y) на x или y, если диапазоны не перекрываются
  • Изменение abs(x) на x или -x, если диапазон не пересекает 0
  • Изменение x/c на x>>log2(c), если x>0 и константа c является степенью 2
  • Изменение x%c на x&(c-1), если x>0 и константа c является степенью 2

Анализ и оптимизация контуров

Канонический пример того, почему неопределенное переполнение со знаком помогает циклу оптимизация заключается в том, что циклы, как

for (int i = 0; i <= m; i++)

гарантированно завершится при неопределенном переполнении. Это помогает архитектуры, которые имеют определенные инструкции цикла, как они делают в вообще не обрабатывать бесконечные циклы.

Но неопределенное переполнение со знаком помогает во многих других оптимизациях цикла. Все анализ, такой как определение числа итераций, преобразование переменные индукции и отслеживания доступа к памяти используют все в предыдущих разделах, чтобы сделать свою работу. В в частности, набор циклов, которые могут быть векторизованы, строго уменьшается, если разрешено переполнение со знаком .

7 голосов
/ 08 мая 2019

Не совсем пример оптимизации, но одно полезное следствие неопределенного поведения - -ftrapv ключ командной строки GCC / clang. Он вставляет код, который приводит к сбою вашей программы при целочисленном переполнении.

Он не будет работать с целыми числами без знака в соответствии с идеей, что переполнение без знака является преднамеренным.

Формулировка стандарта на целочисленное переполнение со знаком гарантирует, что люди не будут писать переполненный код специально, поэтому ftrapv является полезным инструментом для обнаружения непреднамеренного переполнения.

4 голосов
/ 09 мая 2019

Вот небольшой тест, сортировка по пузырькам. Я сравнил время без / с -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);
        }
}
2 голосов
/ 09 мая 2019

Ответ на самом деле на ваш вопрос:

И все же большинство процессоров реализуют знаковую арифметику с определенной семантикой

Я не могу вспомнить процессор, который вы можете купитьсегодня это не использует арифметику с двойным комплиментом для целых чисел со знаком, но это не всегда имело место.

Язык C был изобретен в 1972 году. В то время мейнфреймы IBM 7090 все еще существовали.Не все компьютеры были совместимы с двумя.

Чтобы определить язык (и поведение переполнения) вокруг 2s-комплемента, было бы вредно для генерации кода на машинах, которые не были.

Кроме того,как уже было сказано, указание того, что переполнение со знаком должно быть UB, позволяет компилятору генерировать лучший код, поскольку он может сбрасывать со счетов пути кода, которые следуют из переполнения со знаком, при условии, что это никогда не произойдет.

Если яправильно понимаю, что он предназначен для ограничения суммы a и b до 0 .... INT_MAX без переноса, я могу придумать два способа написания этой функции соответствующим образом.

Во-первых, неэффективный общий случайэто будет работать на всех процессорах:

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

Во-вторых, удивительно эффективный способ компиляции с 2s:

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

...