Стандарт 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++Now 2018: Джон Регер «Заключительный доклад: неопределенное поведение и оптимизация компилятора»
Я помечаю с и С++, так как проблема похожа на обоих языках, но ответы могут быть разными.
Причина, по которой C говорит, что переполнение целого числа со знаком не определено, заключается в том, что некоторые процессоры используют «дополнение до 2», некоторые используют «дополнение до 1», некоторые используют «знак и величину»; и во всех случаях переполнение может вызвать что угодно (например, процессоры, такие как MIPS, имеют «ловушку при переполнении»). Другими словами, речь идет о переносимости, а не об оптимизации.
Точно. Единственная «содержательная статистика», которая кому-либо нужна, это то, что существуют компьютеры с дополнением до единиц и величиной со знаком.
@ user207421: Да, это хороший вопрос, ответ на который, кажется, больше никогда. Следовательно, текущее предложение удалить поддержку недвух дополнительных представлений: open-std.org/jtc1/sc22/wg14/www/docs/n2330.pdf
@Brendan: архитектура комплементов осталась в прошлом. Можно выбрать ловушку MIPS при переполнении.
@chqrlie: C тоже ушел в прошлое (сейчас ему 47 лет). Есть много дизайнерских решений, которые имели смысл тогда, но больше не имеют смысла, но продолжают существовать, потому что изменение сломает слишком много существующего программного обеспечения.





Не совсем пример оптимизации, но одним полезным последствием неопределенного поведения является -ftrapv переключение командной строки GCC/clang. Он вставляет код, который приводит к сбою вашей программы при целочисленном переполнении.
Это не будет работать с целыми числами без знака, в соответствии с идеей, что переполнение без знака является преднамеренным.
Формулировка стандарта о переполнении целого числа со знаком гарантирует, что люди не будут намеренно писать переполняющий код, поэтому ftrapv является полезным инструментом для обнаружения непреднамеренного переполнения.
да. Создание неправильного значения является серьезной проблемой, а правильное значение еще более проблематично. Во многих случаях нет никаких ожиданий от результата, поэтому любое напечатанное значение может быть воспринято серьезно. Это может даже вызвать уязвимости в системе безопасности.
Стандарт не зависит от того, следует ли считать конструкцию, вызывающую UB, ошибочной или непереносимой. Если программа никогда намеренно не обращается, например. a char[10][10] с внутренним индексом больше девяти, наличие ловушки реализации, если это так, может быть полезно. С другой стороны, некоторые задачи могут быть решены более эффективно в реализации, которая позволяет внутреннему индексу получать доступ к данным в нескольких строках, чем реализация, требующая от программиста замены цикла, перебирающего элементы в нескольких строках, двойным кодом. петля, что...
...обрабатывает каждую строку отдельно. Хотя Стандарт позволяет реализации предполагать, что она никогда не получит ничего, кроме строго соответствующих программ, это не означает, что такое предположение будет разумным для большинства реализаций.
Ответ на самом деле в вашем вопросе:
Yet most CPUs implement signed arithmetics with defined semantics
Я не могу представить процессор, который вы можете купить сегодня, который не использует арифметику двойного комплимента для целых чисел со знаком, но так было не всегда.
Язык C был изобретен в 1972 году. Тогда еще существовали мейнфреймы IBM 7090. Не все компьютеры были двойками — комплимент.
Определить язык (и поведение переполнения) вокруг 2s-compliment было бы вредно для генерации кода на машинах, которых не было.
Кроме того, как уже было сказано, указание того, что подписанное переполнение должно быть 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-compliment:
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 имеет другую семантику: он вернет INT_MAX для отрицательных значений a меньше -b.
@chqrlie Должно быть, я неправильно понял требования функции. Я предположил, что, поскольку мы фиксировали положительные числа, входные данные гарантированно будут положительными.
Глупая функция-пример проверяет только положительное переполнение, потому что b по своей сути является положительным, но a может иметь отрицательное значение. Это всего лишь пример, и ваша версия sum_max — это правильный способ достижения цели переносимым образом, но я видел, как устаревший код использует тест, который я опубликовал, который никогда не был правильным, но только что работал примерно 10 лет назад.
Кстати, C++ совсем недавно удалил альтернативные представления целых чисел со знаком. дополнение two теперь является единственным поддерживаемым.
@chqrlie Это верно для С++ 20, но я не упомянул об этом, потому что переполнение целых чисел со знаком остается UB по всем ранее упомянутым причинам, связанным с оптимизацией. Я не хотел создать неправильное впечатление, что устаревший код станет нормальным. Это не будет.
Не знаю насчёт исследований и статистики, но да, с учётом этого точно есть оптимизации, которые на самом деле делают компиляторы. И да, они очень важны (например, векторизация цикла tldr).
Помимо оптимизации компилятора, необходимо учитывать еще один аспект. С UB вы получаете целые числа со знаком C/C++, которые ведут себя арифметически так, как вы ожидаете математически. Например, x + 10 > x остается верным сейчас (конечно, для действительного кода), но не при циклическом поведении.
Я нашел отличную статью Как неопределенное подписанное переполнение обеспечивает оптимизацию в GCC в блоге Кристера Вальфридссона, в которой перечислены некоторые оптимизации, учитывающие подписанное переполнение UB. Следующие примеры взяты оттуда. Я добавляю к ним примеры на С++ и ассемблере.
Если оптимизации кажутся слишком простыми, неинтересными или неэффективными, помните, что эти оптимизации — всего лишь шаги в гораздо большей цепочке оптимизаций. И эффект бабочки действительно имеет место, поскольку, казалось бы, неважная оптимизация на более раннем этапе может вызвать гораздо более эффективную оптимизацию на более позднем этапе.
Если примеры выглядят бессмысленно (кто бы написал x * 10 > 0), имейте в виду, что вы можете очень легко добраться до такого рода примеров на C и C++ с помощью констант, макросов, шаблонов. Кроме того, к такого рода примерам может попасть компилятор при применении преобразований и оптимизаций в своей ИР.
Исключить умножение по сравнению с 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) if c1 is divisible by 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
The second transformation is only valid if c1 <= c2, as it would otherwise introduce an overflow when y has the value INT_MIN.
bool foo(int x) { return x + 42 <= 11; }
foo(int):
cmp edi, -30
setl al
ret
If an operation does not overflow, then we will get the same result if we do the operation in a wider type. This is often useful when doing things like array indexing on 64-bit architectures — the index calculations are typically done using 32-bit int, but the pointers are 64-bit, and the compiler may generate more efficient code when signed overflow is undefined by promoting the 32-bit integers to 64-bit operations instead of generating type extensions.
One other aspect of this is that undefined overflow ensures that a[i] and a[i+1] are adjacent. This improves analysis of memory accesses for vectorization etc.
Это очень важная оптимизация, так как векторизация цикла является одним из наиболее эффективных алгоритмов оптимизации.
Это пример, когда изменение индекса с беззнакового на знаковый улучшает сгенерированную сборку:
#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
The compiler keeps track of the variables' range of possible values at each point in the program, i.e. for code such as
int x = foo(); if (x > 0) { int y = x + 5; int z = y / 4;it determines that x has the range
[1, INT_MAX]after the if-statement, and can thus determine that y has the range[6, INT_MAX]as overflow is not allowed. And the next line can be optimized toint z = y >> 2;as the compiler knows that y is non-negative.
auto foo(int x)
{
if (x <= 0)
__builtin_unreachable();
return (x + 5) / 4;
}
foo(int):
lea eax, [rdi+5]
sar eax, 2
ret
The undefined overflow helps optimizations that need to compare two values (as the wrapping case would give possible values of the form
[INT_MIN, (INT_MIN+4)]or[6, INT_MAX]that prevents all useful comparisons with<or>), such as
- Changing comparisons
x<yto true or false if the ranges forxandydoes not overlap- Changing
min(x,y)ormax(x,y)toxoryif the ranges do not overlap- Changing
abs(x)toxor-xif the range does not cross0- Changing
x/ctox>>log2(c)ifx>0and the constantcis a power of2- Changing
x%ctox&(c-1)ifx>0and the constantcis a power of2
The canonical example of why undefined signed overflow helps loop optimizations is that loops like
for (int i = 0; i <= m; i++)are guaranteed to terminate for undefined overflow. This helps architectures that have specific loop instructions, as they do in general not handle infinite loops.
But undefined signed overflow helps many more loop optimizations. All analysis such as determining number of iteration, transforming induction variables, and keeping track of memory accesses are using everything in the previous sections in order to do its work. In particular, the set of loops that can be vectorized are severely reduced when signed overflow is allowed.
"х + 10 > х теперь верно" - нет, это не так. Он может отформатировать ваш жесткий диск, если x окажется равным INT_MAX-5.
Очень информативный ответ (блог действительно хорошо читается). Поскольку все эти оптимизации возможны только для операндов со знаком, вывод нелогичен: использование значений индекса size_t для многих вычислений должно привести к менее эффективному коду из-за семантики модульной арифметики. Это хороший аргумент для включения ssize_t в стандарт C или какого-либо беззнакового типа без арифметики по модулю.
очаровательный. хорошая находка.
@immibis Я имел в виду, что это верно для всего действительного кода. UB недействительный код.
@chqrlie Я слышал на cppcon, что видные члены комитета по стандартизации признают, что беззнаковый размер был ошибкой задним числом. То же самое и с циклическим поведением unsigned (мне было бы лучше, чтобы обычные типы имели UB при переполнении и имели отдельные типы с циклическим поведением, когда вам это явно нужно)
@bolov Компилятору разрешено предполагать, что это правда, но это не обязательно, поэтому вы не можете.
@immibis нет, это требуется по стандарту. Точно так же, как стандарт требует, чтобы разыменованный указатель не был нулевым.
@bolov Правда? Компилятор требуется предполагает, что i + 10 > i верно? Итак, int i = INT_MAX; if (i + 10 > i) printf("hello!\n");требуется печатает "hello!\n" и не может форматировать мой жесткий диск?
@immibis, ты не понимаешь. Да!! компилятор должен предположить, что i + 10 > i, поэтому он будет генерировать код, который действителен только при i < INT_MAX - 10. В этом случае i >= INT_MAX код недействителен, стандарт не предписывает какое-либо поведение, и компилятор может сгенерировать код, исходя из предположения i < INT_MAX. Для недопустимого кода - неопределенное поведение может произойти что угодно, потому что компилятору не требуется генерировать какое-либо поведение.
@bolov В этом случае также разрешено генерировать код, где i + 10 > i оценивается как false.
@immibis, честно говоря, это как со стеной разговаривать
Я точно знаю? «С UB вы получаете целые числа со знаком C/C++, которые ведут себя арифметически так, как вы ожидаете математически». довольно четко определяет UB.
@immibis, ты видишь, как компилятор превращает x + 10 >= x; в mov eax, 1? Это именно потому, что он может предположить, что x + 10 >= x всегда будет правдой (и я повторяю это снова в последний раз: для действительного кода)
Компилятор может это предположить. Программист не может.
@immibis ... ты троллишь? 1-й: когда мы говорили о программисте? Все обсуждение, весь пост и комментарии касаются того, что может предположить компилятор (и что, следовательно, может оптимизировать). 2-й также программист может предположить, что на действительном коде x + 10 >= x. .........
@bolov Программист должен знать, будет ли x + 10 >= x истинным для компилятора, который определил поведение переполнения, чтобы знать, действителен ли код. Они не могут рассуждать «этот код действителен, поэтому я знаю x <= INT_MAX+10». Они должны рассуждать «x <= INT_MAX+10 из-за чего-то другого, поэтому этот код действителен».
@immibis, сейчас ты делаешь это нарочно. Вы уводите дискуссию от исходной проблемы. Мое утверждение таково: «компилятор будет предполагать, что для действительного кода x + 10 >= x всегда будет истинным — это предписано стандартом — поэтому компилятор может оптимизировать выражение до true». Это всегда было моим утверждением. Это то, что я обсуждаю (и то, что вы оспаривали изначально). Вы хотите сейчас поговорить о том, как программист должен убедиться, что он не пишет код, который имеет UB и, следовательно, недействителен, это другой разговор.
Вы не сказали "компилятор предположит, что..." - вы просто сказали "...", что является констатацией факта, а не констатацией предположения, что, если оно неверно, все сломается.
Кажется, я наконец понял вашу точку зрения. Да, я не сказал "компилятор" и изначально не сказал "для корректного кода". Однако я думал, что это понятно, так как речь идет исключительно о том, что компилятор может сделать с данным кодом. Теперь мне кажется, что вы взяли «x + 1 >= 1 всегда будет истинным» как утверждение, означающее «всякий раз, когда вы видите x + 1 >= 1 в коде C, вы, программист, можете быть уверены, что это всегда будет истинным». Это никогда не входило в мои намерения, и я думаю, что из контекста поста становится ясно, о чем я говорю. Прошу прощения за предположение о злом умысле.
«Троллинг» и разговоры друг с другом часто ужасно похожи друг на друга.
Существует множество полезных преобразований, которые можно упростить, объявив, что целочисленное переполнение является UB, но также можно упростить, указав, что некоторые аспекты поведения являются Unspecified. Существует разница между разрешением компилятору обрабатывать x+y > z как произвольное получение 0 или 1 без побочных эффектов в случае переполнения и разрешением произвольно разрушительных побочных эффектов. Если единственными ситуациями, в которых имеет значение, возвращает ли функция 0 или 1, являются те, в которых переполнение не происходит, обработка переполнения в этом выражении как произвольное получение 0 или 1...
... может быть достаточно, чтобы разрешить оптимизацию, которая была бы невозможна, если бы программист включил явный код для предотвращения переполнения любой ценой.
Вот реальный маленький эталон, пузырьковая сортировка. Я сравнил тайминги без/с -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);
}
}
Комментарии не для расширенного обсуждения; этот разговор был перешел в чат.