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

Стандарт 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++, так как проблема одинакова на обоих языках, но ответы могут быть разными.

Ответы

Ответ 1

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

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

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

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

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

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

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

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

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

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

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

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

он определяет, что x имеет диапазон [1, INT_MAX] после оператора if, и, таким образом, может определить, что у y есть диапазон [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++)

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

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

Ответ 2

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

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

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

Ответ 3

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

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

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

Язык 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

Ответ 4

Здесь настоящий маленький тест, пузырьковая сортировка. Я сравнил время без/с -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,85х.

Здесь код. Обратите внимание, что я намеренно выбрал реализацию, которая должна дать большую разницу для этого теста.

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

Ответ 5

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

Если компилятор видит положительное число x к которому вы только добавляете и умножаете другие положительные числа, если он видит, if (x < 0), он может просто отбросить этот код, в то время как если вы гарантируете обтекание, он не сможет выполнить эту оптимизацию.

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