Я не знаю об исследованиях и статистике, но да, определенно есть оптимизации с учетом этого, что компиляторы действительно делают. И да, они очень важны (например, векторизация цикла tldr).
Помимо оптимизации компилятора, следует принять во внимание еще один аспект. С UB вы получаете целые числа со знаком C / C ++, которые ведут себя арифметически так, как вы ожидаете математически. Например, x + 10 > x
теперь верно (для действительного кода, конечно), но не при циклическом поведении.
Я нашел отличную статью Как неопределенное подписанное переполнение позволяет оптимизации в GCC из блога Кристера Вальфридссона, в котором перечислены некоторые оптимизации, которые учитывают подписанный UB переполнения. Следующие примеры взяты из него. Я добавляю к ним примеры c ++ и сборки.
Если оптимизации выглядят слишком простыми, неинтересными или безрезультатными, помните, что эти оптимизации - всего лишь шаги в гораздо большей цепочке оптимизаций. И эффект бабочки действительно возникает, поскольку кажущаяся несущественной оптимизация на более раннем этапе может вызвать гораздо более эффективную оптимизацию на более позднем этапе.
Если примеры выглядят бессмысленными (кто бы написал x * 10 > 0
), имейте в виду, что вы можете очень легко добраться до такого рода примеров на C и C ++ с константами, макросами, шаблонами. К тому же компилятор может добраться до такого рода примеров при применении преобразований и оптимизаций в своем IR.
Упрощение знаковых целочисленных выражений
- # P6 #
(x * c) cmp 0 -> x cmp 0
bool foo(int x) { return x * 10 > 0 }
foo(int):
test edi, edi
setg al
ret
- # P7 #
# P8 #
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
- # P12 #
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-битного int, но указатели являются 64-битными, и компилятор может генерировать более эффективный код, когда подписанное переполнение не определено преобразование 32-битных целых чисел в 64-битные операции вместо создания расширений типов.
Еще один аспект этого заключается в том, что неопределенное переполнение гарантирует, что a [i] и a [i + 1] являются смежными. Это улучшает анализ обращений к памяти для векторизации и т. Д.
Это очень важная оптимизация, поскольку векторизация цикла - один из самых эффективных и действенных алгоритмов оптимизации.
Это пример, когда изменение индекса с беззнакового индекса на подписанный улучшает сгенерированную сборку:
Версия без подписи
#include <cstddef>
auto foo(int* v, std::size_t start)
{
int sum = 0;
for (std::size_t i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
С unsigned необходимо учитывать случай, когда start + 4
оборачивается, и для этого случая создается ветка (ветки плохо сказываются на производительности):
; gcc on x64 with -march=skylake
foo1(int*, unsigned long):
cmp rsi, -5
ja .L3
vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
vpsrldq xmm1, xmm0, 8
vpaddd xmm0, xmm0, xmm1
vpsrldq xmm1, xmm0, 4
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
.L3:
xor eax, eax
ret
; clang on x64 with -march=skylake
foo1(int*, unsigned long): # @foo1(int*, unsigned long)
xor eax, eax
cmp rsi, -4
jae .LBB0_2
vpbroadcastq xmm0, qword ptr [rdi + 4*rsi + 8]
vpaddd xmm0, xmm0, xmmword ptr [rdi + 4*rsi]
vpshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
.LBB0_2:
ret
В качестве примечания, использование более узкого типа приведет к еще худшей сборке, запрещая использование векторизованных инструкций SSE:
#include <cstddef>
auto foo(int* v, unsigned start)
{
int sum = 0;
for (unsigned i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
; gcc on x64 with -march=skylake
foo(int*, unsigned int):
cmp esi, -5
ja .L3
mov eax, esi
mov eax, DWORD PTR [rdi+rax*4]
lea edx, [rsi+1]
add eax, DWORD PTR [rdi+rdx*4]
lea edx, [rsi+2]
add eax, DWORD PTR [rdi+rdx*4]
lea edx, [rsi+3]
add eax, DWORD PTR [rdi+rdx*4]
ret
.L3:
xor eax, eax
ret
; clang on x64 with -march=skylake
foo(int*, unsigned int): # @foo(int*, unsigned int)
xor eax, eax
cmp esi, -5
ja .LBB0_3
mov ecx, esi
add esi, 4
mov eax, dword ptr [rdi + 4*rcx]
lea rdx, [rcx + 1]
cmp rdx, rsi
jae .LBB0_3
add eax, dword ptr [rdi + 4*rcx + 4]
add eax, dword ptr [rdi + 4*rcx + 8]
add eax, dword ptr [rdi + 4*rcx + 12]
.LBB0_3:
ret
Подписанная версия
Однако использование подписанного индекса приводит к хорошему векторизованному безветвленному коду:
#include <cstddef>
auto foo(int* v, std::ptrdiff_t start)
{
int sum = 0;
for (std::ptrdiff_t i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
; gcc on x64 with -march=skylake
foo(int*, long):
vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
vpsrldq xmm1, xmm0, 8
vpaddd xmm0, xmm0, xmm1
vpsrldq xmm1, xmm0, 4
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
; clang on x64 with -march=skylake
foo(int*, long): # @foo(int*, long)
vpbroadcastq xmm0, qword ptr [rdi + 4*rsi + 8]
vpaddd xmm0, xmm0, xmmword ptr [rdi + 4*rsi]
vpshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
Векторизованные инструкции все еще используются при использовании более узкого типа со знаком:
#include <cstddef>
auto foo(int* v, int start)
{
int sum = 0;
for (int i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
; gcc on x64 with -march=skylake
foo(int*, int):
movsx rsi, esi
vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
vpsrldq xmm1, xmm0, 8
vpaddd xmm0, xmm0, xmm1
vpsrldq xmm1, xmm0, 4
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
; clang on x64 with -march=skylake
foo(int*, int): # @foo(int*, int)
movsxd rax, esi
vpbroadcastq xmm0, qword ptr [rdi + 4*rax + 8]
vpaddd xmm0, xmm0, xmmword ptr [rdi + 4*rax]
vpshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
Расчет диапазона значений
Компилятор отслеживает диапазон возможных значений переменных в каждой точке программы, то есть для такого кода, как
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;
, поскольку компилятор знает, что y неотрицательно.
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
на истину или ложь, если диапазоны для 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++)
гарантированно прекращают работу при неопределенном переполнении. Это помогает архитектурам, имеющим определенные инструкции цикла, поскольку они, как правило, не обрабатывают бесконечные циклы.
Но неопределенное подписанное переполнение помогает еще больше оптимизировать цикл. Весь анализ, такой как определение количества итераций, преобразование переменных индукции и отслеживание обращений к памяти, использует все, что описано в предыдущих разделах, для выполнения своей работы. В частности, набор циклов, которые можно векторизовать, значительно сокращается, если разрешено подписанное переполнение.
person
bolov
schedule
09.05.2019