Ответ 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++)
гарантированно завершится при неопределенном переполнении. Это помогает архитектурам, которые имеют определенные инструкции цикла, поскольку они вообще не обрабатывают бесконечные циклы.
Но неопределенное переполнение со знаком помогает оптимизировать циклы. Весь анализ, такой как определение количества итераций, преобразование переменных индукции и отслеживание обращений к памяти, использует все в предыдущих разделах для выполнения своей работы. В частности, набор циклов, которые можно векторизовать, значительно сокращается, если разрешено переполнение со знаком.