Чому компілятор не може (або ні) оптимізувати передбачуваний цикл додавання до множення?


133

Це питання, яке прийшло в голову під час читання блискучої відповіді Mysticial на запитання: чому швидше обробити відсортований масив, ніж несортований масив ?

Контекст для задіяних типів:

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

У своїй відповіді він пояснює, що Intel Compiler (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];

Сподіваємось, містичне (або хто-небудь інший) може дати не менш яскраву відповідь. Я ніколи не дізнавався про оптимізації, про які говорилося в тому іншому питанні раніше, тому я дуже вдячний за це.


14
Мабуть, це знає лише Intel. Я не знаю, в якому порядку він працює, проходить його оптимізація. І, мабуть, він не виконує цикл згортання циклу після циклу-обміну.
Містичний

7
Ця оптимізація справедлива лише в тому випадку, якщо значення, що містяться в масиві даних, незмінні. Наприклад, якщо пам'ять відображається на пристрої вводу / виводу кожного разу, коли ви читаєте дані [0], ви отримаєте інше значення ...
Thomas CG de Vilhena

2
Який тип даних це ціле число чи плаваюча точка? Повторне додавання з плаваючою комою дає дуже різні результати від множення.
Ben Voigt

6
@Thomas: Якби дані були volatile, то обмін циклом також був би недійсною оптимізацією.
Бен Войгт

3
GNAT (компілятор Ada з GCC 4.6) не перемикає цикли на O3, але якщо цикли переключені, він перетворить його на множення.
профілі

Відповіді:


105

Компілятор загалом не може трансформуватися

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];

тому що останнє може призвести до переповнення підписаних цілих чисел, де їх немає. Навіть із гарантованою поведінкою обертання для переповнення підписаних цілих чисел комплементу, це змінить результат (якщо 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.

Чому це не робиться, я не можу сказати, я думаю, це те, що Містікал сказав , "мабуть, він не виконує цикл, що руйнується циклом, після петлі-обміну".

Зауважте, що сам цикл-обмін не є дійсним (для підписаних цілих чисел), оскільки

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) використовувати конверсію з підписанням 32-біт-інт-в-подвійний у тому, 1.0/nде nбув unsigned int. Був приблизно вдвічі швидший, ніж gcc але неправильно, багато значень було більше 2^31, ой.).


4
Я пам’ятаю версію компілятора MPW, яка додала можливість дозволити кадри стека більше 32 К [попередні версії були обмежені, використовуючи @ A7 + int16 адреси для локальних змінних]. У ньому все підходить для фреймів стека нижче 32 К або понад 64 К, але для кадру стека 40 К він би використовував ADD.W A6,$A000, забуваючи, що операції зі словом з адресовими регістрами підписують слово - до 32 біт перед додаванням. Нещодавно потрібно було виправити неполадки, оскільки єдине, що робив код між цим ADDі наступним разом, коли він вискочив A6 зі стека, - це відновити регістри абонента, які він зберег у цей кадр ...
supercat

3
... і єдиним реєстром, про який викликав потурбований абонент, була адреса [константа часу завантаження] статичного масиву. Компілятор знав, що адресу масиву збережено в регістрі, щоб він міг оптимізуватись на основі цього, але налагоджувач просто знав адресу константи. Таким чином, перед твердженням MyArray[0] = 4;я міг перевірити адресу MyArrayта переглянути це місце до та після виконання заявки; це не змінилося б. Код був щось подібне, move.B @A3,#4і A3 повинен був завжди вказувати на MyArrayбудь-який час, коли ця інструкція виконується, але це не так. Весело.
supercat

Тоді чому Кланг виконує подібну оптимізацію?
Jason S

Компілятор міг виконати це перезапис у своїх внутрішніх проміжних уявленнях, оскільки в його внутрішніх проміжних представленнях допускається менш невизначена поведінка.
користувач253751

48

Ця відповідь не стосується конкретного випадку, але він стосується назви питання і може бути цікавим майбутнім читачам:

Через кінцеву точність повторне додавання з плаваючою комою не рівносильно множенню . Поміркуйте:

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);

Демо


10
Це не відповідь на поставлене запитання. Незважаючи на цікаву інформацію (і яку необхідно знати для будь-якого програвача C / C ++), це не форум, і тут не належить.
orlp

30
@nightcracker: Заявлена ​​мета StackOverflow - створити пошукову бібліотеку відповідей, корисних майбутнім користувачам. І це відповідь на поставлене запитання ... просто так трапляється, що є якась невстановлена ​​інформація, яка змушує цю відповідь не застосовуватись до оригінального плаката. Він все ще може звернутися до інших із тим самим питанням.
Бен Войгт

12
Це може бути відповіддю на питання заголовка , але питання не в цьому , ні.
orlp

7
Як я вже казав, це цікава інформація. Однак мені все ще здається неправильним, що зараз ніхто з найвищої відповіді на питання не відповідає на запитання, як воно є . Це просто не причина, чому Intel Compiler вирішив не оптимізувати, basta.
orlp

4
@nightcracker: Мені здається неправильним і те, що це найкраща відповідь. Я сподіваюсь, що хтось опублікує дійсно гарну відповідь на цілий випадок, який перевершує цей за шкалою. На жаль, я не думаю, що існує відповідь "не можна" для цілого випадку, тому що перетворення було б законним, тому ми залишаємося з "чому це не відбувається", що насправді потрапляє під " занадто локалізована "близька причина", оскільки вона властива певній версії компілятора. Питання, на яке я відповів, є більш важливим, ІМО.
Бен Войгт

6

Компілятор містить різні пропуски, що робить оптимізацію. Зазвичай в кожному проході робиться або оптимізація операторів, або циклічна оптимізація. В даний час не існує моделі, яка б оптимізувала тіло циклу на основі заголовків циклу. Це важко виявити і менш поширене.

Оптимізацією, яку було зроблено, було циклічне інваріантне рух коду. Це можна зробити за допомогою набору прийомів.


4

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

У той же час деякі компілятори можуть відмовитися це робити, оскільки заміна повторюваного додавання на множення може змінити поведінку переповнення коду. Для цілих цілей без підпису це не повинно змінювати значення, оскільки їх поведінка переповнення повністю визначена мовою. Але для підписаних, можливо, це (мабуть, не на платформі доповнення 2). Це правда, що підписане переповнення насправді призводить до невизначеної поведінки в С, це означає, що слід цілком нормально ігнорувати цю семантику переповнення взагалі, але не всі компілятори досить сміливі для цього. Він часто викликає багато критики з боку натовпу "С - це просто мова монтажу вищого рівня". (Пам'ятаєте, що сталося, коли GCC запровадив оптимізацію, засновану на суворій семантиці?)

Історично GCC показав себе як компілятор, який вимагає зробити такі різкі кроки, але інші компілятори, можливо, вважають за краще дотримуватися сприйнятої "призначеної для користувача" поведінки, навіть якщо вона не визначена мовою.


Я вважаю за краще знати, чи я випадково залежу від невизначеної поведінки, але я думаю, у компілятора немає способу дізнатися, як переповнення буде проблемою, що
склалася

2
@jhabbott: якщо відбувається переповнення, то є невизначена поведінка. Чи визначається поведінка, поки невідомо (поки припустимо, що цифри вводяться під час виконання): P.
orlp

3

Це зараз - принаймні, кланг робить :

long long add_100k_signed(int *data, int arraySize)
{
    long long sum = 0;

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

компілює з -O1 до

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        movsxd  rdx, dword ptr [rdi + 4*rsi]
        imul    rcx, rdx, 100000
        cmp     rdx, 127
        cmovle  rcx, r8
        add     rax, rcx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

Переповнення цілого числа не має нічого спільного з цим; якщо є переповнення цілочисельних чисел, що спричиняє невизначене поведінку, це може статися в будь-якому випадку Ось такий самий вид функції, який використовується intзамістьlong :

int add_100k_signed(int *data, int arraySize)
{
    int sum = 0;

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

компілює з -O1 до

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        mov     edx, dword ptr [rdi + 4*rsi]
        imul    ecx, edx, 100000
        cmp     edx, 127
        cmovle  ecx, r8d
        add     eax, ecx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

2

Існує концептуальна перешкода на шляху такого оптимізації. Автори-компілятори витрачають багато сил на зменшення сили - наприклад, замінюючи множення на додавання та зрушення. Вони звикають думати, що розмножуватися - це погано. Тож випадок, коли треба йти іншим шляхом, є дивовижним та контрсуєтним. Тож ніхто не думає це здійснити.


3
Заміна циклу розрахунком із закритою формою - це також зменшення міцності, чи не так?
Бен Войгт

Формально, так, я думаю, але я ніколи не чув, щоб хтось так говорив про це. (Хоча я трохи застаріла в літературі.)
zwol

1

Люди, які розробляють та підтримують компілятори, мають обмежену кількість часу та енергії, щоб витратити їх на роботу, тому вони, як правило, хочуть зосередитись на тому, що їх найбільше хвилює користувачів: перетворення добре написаного коду на швидкий код. Вони не хочуть витрачати свій час на пошук способів перетворити нерозумний код у швидкий код - саме для цього використовується огляд коду. У мові високого рівня може бути "нерозумний" код, який виражає важливу ідею, тому варто витратити час розробникам на це швидко - наприклад, скорочення вирубки лісів та злиття потоків дозволяють програмам Haskell, структурованим на певних видах лінь створені структури даних для компіляції в тісні петлі, які не виділяють пам'ять. Але такий стимул просто не поширюється на перетворення певного додавання в множення. Якщо ви хочете, щоб це було швидко,

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.