(A + B + C) ≠ (A + C + B) и переупорядочение компилятора


108

Добавление двух 32-битных целых чисел может привести к целочисленному переполнению:

uint64_t u64_z = u32_x + u32_y;

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

uint64_t u64_z = u32_x + u64_a + u32_y;

Однако, если компилятор решит изменить порядок добавления:

uint64_t u64_z = u32_x + u32_y + u64_a;

целочисленное переполнение все еще может произойти.

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


15
На самом деле вы не показываете целочисленное переполнение, потому что вы кажетесь добавленными uint32_tзначениями - которые не переполняются, а переносятся. Это не разные модели поведения.
Мартин Боннер поддерживает Монику

5
См. Раздел 1.9 стандартов C ++, он прямо отвечает на ваш вопрос (есть даже пример, почти такой же, как ваш).
Холт

3
@Tal: Как уже было сказано другими: нет переполнения целых чисел. Беззнаковые определены для переноса, для подписанных это неопределенное поведение, поэтому подойдет любая реализация, включая назальных демонов.
слишком честно для этого сайта

5
@Tal: Ерунда! Как я уже писал: стандарт очень ясен и требует обертывания, а не насыщения (это было бы возможно с подписью, поскольку это стандарт UB.
слишком честно для этого сайта

15
@rustyx: Независимо от того, назовете ли вы это переносом или переполнением, остается точка, которая ((uint32_t)-1 + (uint32_t)1) + (uint64_t)0приводит к результату 0, тогда как (uint32_t)-1 + ((uint32_t)1 + (uint64_t)0)результаты возникают 0x100000000, и эти два значения не равны. Поэтому важно, может ли компилятор применить это преобразование. Но да, стандарт использует слово «переполнение» только для целых чисел со знаком, а не для беззнаковых.
Стив Джессоп,

Ответы:


84

Если оптимизатор выполняет такое переупорядочение, он все еще привязан к спецификации C, поэтому такое переупорядочение будет выглядеть следующим образом:

uint64_t u64_z = (uint64_t)u32_x + (uint64_t)u32_y + u64_a;

Обоснование:

Начнем с

uint64_t u64_z = u32_x + u64_a + u32_y;

Сложение выполняется слева направо.

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

Таким образом, для того , чтобы быть совместимым со спецификацией C, любое Оптимизатор должны способствовать u32_xи u32_yдо 64 бит без знака значения. Это эквивалентно добавлению приведения. (Фактическая оптимизация не выполняется на уровне C, но я использую нотацию C, потому что это нотация, которую мы понимаем.)


Разве это не левоассоциативно (u32_x + u32_t) + u64_a?
Useless

12
@Useless: Клас перевел все на 64 бит. Теперь порядок вообще не имеет значения. Компилятору не нужно следовать ассоциативности, он просто должен выдавать точно такой же результат, как если бы он это делал.
gnasher729

2
Кажется, предполагается, что код OP будет оцениваться таким образом, что неверно.
Useless

@Klas - объясните, почему это так и как именно вы дойдете до своего образца кода?
rustyx

1
@rustyx Это действительно нуждалось в объяснении. Спасибо, что подтолкнули меня добавить его.
Klas Lindbäck

28

Компилятору разрешено изменять порядок только в соответствии с правилом as if . То есть, если переупорядочение всегда будет давать тот же результат, что и указанное упорядочение, то это разрешено. В противном случае (как в вашем примере) нет.

Например, учитывая следующее выражение

i32big1 - i32big2 + i32small

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

(i32small - i32big2) + i32big1

и полагаться на тот факт, что целевая платформа использует арифметику с двумя дополнениями с циклическим циклом для предотвращения проблем. (Такое переупорядочение может быть разумным, если компилятор требует регистров, а i32smallрегистр уже есть).


Пример OP использует беззнаковые типы. i32big1 - i32big2 + i32smallподразумевает подписанные типы. В игру вступают дополнительные опасения.
chux

@chux Совершенно верно. Я пытался (i32small-i32big2) + i32big1сказать, что, хотя я не мог писать (потому что это могло вызвать UB), компилятор может эффективно изменить его, потому что компилятор может быть уверен, что поведение будет правильным.
Мартин Боннер поддерживает Монику

3
@chux: Дополнительные проблемы, такие как UB, не входят в игру, потому что мы говорим о переупорядочении компилятора в соответствии с правилом as-if. Конкретный компилятор может воспользоваться знанием своего собственного поведения при переполнении.
MSalters

16

В C, C ++ и Objective-C есть правило «как если бы»: компилятор может делать все, что хочет, до тех пор, пока никакая соответствующая программа не может отличить.

В этих языках a + b + c определяется как то же самое, что (a + b) + c. Если вы можете определить разницу между этим и, например, a + (b + c), то компилятор не сможет изменить порядок. Если вы не можете увидеть разницу, компилятор может изменить порядок, но это нормально, потому что вы не можете заметить разницу.

В вашем примере с b = 64 бит, a и c 32 бит компилятору будет разрешено оценивать (b + a) + c или даже (b + c) + a, потому что вы не можете заметить разницу, но не (a + c) + b, потому что вы можете заметить разницу.

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


Но с большой оговоркой; компилятор может не допускать неопределенного поведения (в данном случае переполнения). Это похоже на то, как if (a + 1 < a)можно оптимизировать проверку переполнения .
csiz

7
@csiz ... для подписанных переменных. Беззнаковые переменные имеют четко определенную семантику переполнения (циклический переход).
Гэвин С. Янси

7

Цитата из стандартов :

[Примечание: операторы могут быть перегруппированы в соответствии с обычными математическими правилами только в том случае, если операторы действительно ассоциативны или коммутативны.7 Например, в следующем фрагменте int a, b;

/∗ ... ∗/
a = a + 32760 + b + 5;

оператор выражения ведет себя точно так же, как

a = (((a + 32760) + b) + 5);

из-за ассоциативности и приоритета этих операторов. Таким образом, результат суммы (a + 32760) затем добавляется к b, и этот результат затем добавляется к 5, что приводит к значению, присвоенному a. На машине, на которой переполнение вызывает исключение и в которой диапазон значений, представляемых int, равен [-32768, + 32767], реализация не может переписать это выражение как

a = ((a + b) + 32765);

поскольку, если бы значения для a и b были соответственно -32754 и -15, сумма a + b создала бы исключение, а исходное выражение - нет; также нельзя переписать это выражение как

a = ((a + 32765) + b);

или

a = (a + (b + 32765));

поскольку значения для a и b могли быть, соответственно, 4 и -8 или -17 и 12. Однако на машине, на которой переполнение не вызывает исключения и в котором результаты переполнения обратимы, приведенный выше оператор выражения может быть переписанным реализацией любым из вышеперечисленных способов, потому что будет получен тот же результат. - конец примечания]


4

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

Компилятор может изменить порядок, только если он дает тот же результат - здесь, как вы заметили, это не так.


При желании можно написать шаблон функции, который продвигает все аргументы std::common_typeперед добавлением - это было бы безопасно и не полагалось бы ни на порядок аргументов, ни на ручное приведение, но это довольно неуклюже.


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

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

Ваше объяснение правила «как если бы» совершенно неверно. Например, язык C определяет, какие операции должны выполняться на абстрактной машине. Правило «как если бы» позволяет ему делать абсолютно все, что захочет, до тех пор, пока никто не заметит разницы.
gnasher729

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

1

Это зависит от разрядности unsigned/int.

Ниже 2 не то же самое (когда unsigned <= 32биты). u32_x + u32_yстановится 0.

u64_a = 0; u32_x = 1; u32_y = 0xFFFFFFFF;
uint64_t u64_z = u32_x + u64_a + u32_y;
uint64_t u64_z = u32_x + u32_y + u64_a;  // u32_x + u32_y carry does not add to sum.

Они такие же (когда unsigned >= 34биты). Целочисленные продвижения привели u32_x + u32_yк тому, что в 64-битной математике произошло сложение. Порядок не имеет значения.

Это UB (когда unsigned == 33бит). Целочисленные повышения привели к тому, что сложение произошло в 33-битной математике со знаком, а подписанное переполнение - UB.

Разрешено ли компиляторам делать такое переупорядочивание ...?

(32-битная математика): переупорядочить да, но должны быть те же результаты, а не то, что предлагает переупорядочение OP. Ниже такие же

// Same
u32_x + u64_a + u32_y;
u64_a + u32_x + u32_y;
u32_x + (uint64_t) u32_y + u64_a;
...

// Same as each other below, but not the same as the 3 above.
uint64_t u64_z = u32_x + u32_y + u64_a;
uint64_t u64_z = u64_a + (u32_x + u32_y);

... можем ли мы доверить им заметить несоответствие результата и сохранить порядок выражений как есть?

Поверьте, да, но цель кодирования OP не совсем ясна. Следует u32_x + u32_yнести вклад? Если OP хочет этот вклад, код должен быть

uint64_t u64_z = u64_a + u32_x + u32_y;
uint64_t u64_z = u32_x + u64_a + u32_y;
uint64_t u64_z = u32_x + (u32_y + u64_a);

Но нет

uint64_t u64_z = u32_x + u32_y + u64_a;
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.