Почему компилятор не может (или не делает) оптимизировать предсказуемый цикл сложения в умножение?

это вопрос, который пришел на ум, читая блестящий ответ Mysticial на вопрос: почему быстрее обрабатывать отсортированный массив, чем несортированный массив?

контекст для вида:

const unsigned arraySize = 32768;
int data[arraySize];
long long sum = 0;

в своем ответе он объясняет, что компилятор Intel (ICC) оптимизирует это:

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            sum += data[c];

...во что-то эквивалентное этому:

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

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

но почему он этого не делает?

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

надеюсь, Мистик (или кто-нибудь еще) может дать столь же блестящий ответ. Я никогда раньше не слышал об оптимизации, обсуждаемой в этом другом вопросе, поэтому я очень благодарен за это.

6 ответов


компилятор обычно не может преобразовать

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

на

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

потому что последнее может привести к переполнению целых чисел со знаком, где первое-нет. Даже при гарантированном поведении обтекания для переполнения целых чисел дополнения signed two это изменит результат (если data[c] это 30000, продукт станет -1294967296 для типичного 32-битного ints с оберткой вокруг, в то время как 100000 раз добавление 30000 в sum будет, если это не переполнение, увеличение sum по 3000000000). Обратите внимание, что то же самое справедливо для неподписанных величин с разными номерами, переполнение 100000 * data[c] обычно вводит уменьшение по модулю 2^32 это не должно отображаться в конечном результате.

он может превратить его в

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000LL * data[c];  // resp. 100000ull

хотя, если, как обычно, long long достаточно больше, чем int.

почему он этого не делает, я не могу сказать, я думаю, что это то, что Mysticial сказал, "по-видимому, он не запускает цикл-коллапсирующий проход после цикла-обмена".

обратите внимание, что сам цикл-обмен обычно недействителен (для целых чисел со знаком), так как

for (int c = 0; c < arraySize; ++c)
    if (condition(data[c]))
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

может привести к переполнению где

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (condition(data[c]))
            sum += data[c];

не будет. Это кошерно здесь, так как условие гарантирует все data[c] которые добавляются имеют один и тот же знак, поэтому, если один переполняется, оба делают.

я бы не был слишком уверен, что компилятор принял это во внимание, хотя (@Mysticial, не могли бы вы попробовать с условием, как data[c] & 0x80 или так, что может быть верно для положительных и отрицательных значений?). У меня были компиляторы, которые делали недопустимые оптимизации (например, пару лет назад у меня был ICC (11.0, iirc), использующий преобразование signed-32-bit-int-to-double в 1.0/n здесь n был unsigned int. Примерно в два раза быстрее, чем выход gcc. Но неправильно, многие значения были больше, чем 2^31, упс.).


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

из-за конечной точности повторное сложение с плавающей запятой не эквивалентно умножению. Подумайте:

float const step = 1e-15;
float const init = 1;
long int const count = 1000000000;

float result1 = init;
for( int i = 0; i < count; ++i ) result1 += step;

float result2 = init;
result2 += step * count;

cout << (result1 - result2);

демо:http://ideone.com/7RhfP


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

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


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


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

в то же время некоторые компиляторы могут отказаться от этого, потому что замена повторяющегося сложения умножением может изменить поведение переполнения кода. Для unsigned интегральные типы это не должно иметь значения, так как их поведение переполнения полностью определяется языком. Но для подписанных он может (вероятно, не на 2-х дополните платформу, хотя). Верно, что подписанное переполнение фактически приводит к неопределенному поведению в C, что означает, что это должно быть совершенно нормально игнорировать эту семантику переполнения вообще, но не все компиляторы достаточно смелы, чтобы это сделать. Он часто привлекает много критики из толпы" C-это просто язык ассемблера более высокого уровня". (Помните, что произошло, когда GCC представила оптимизацию, основанную на семантике строгого сглаживания?)

исторически GCC показал себя как компилятор это то, что требуется для принятия таких радикальных шагов, но другие компиляторы могут предпочесть придерживаться воспринимаемого "пользовательского" поведения, даже если оно не определено языком.


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