Не так-то просто обнуляти масиви в VC + + 2015

У чому різниця між цими двома визначеннями ініціалізований локальних змінних С/С++?

char buffer[32] = { 0 };
char buffer[32] = {};

Одна відмінність полягає в тому, що перше допустимо в мовах С та С++, а друге — тільки в З++.

Що ж, тоді давайте зосередимося на С++. Що означають ці два визначення?

Перше свідчить: компілятор повинен встановити значення першого елемента масиву в нуль і потім (грубо кажучи) ініціалізувати нулями залишилися елементи масиву. Друге означає, що компілятор повинен ініціалізувати нулями весь масив.

Ці визначення дещо різняться, але за фактом результат один — весь масив повинен бути ініціалізований нулями. Тому згідно з правилом «as-if» в С++ вони однакові. Тобто будь-досить сучасний оптимізатор повинен генерувати ідентичний код для кожного з цих фрагментів. Вірно?

Але іноді відмінності в цих визначеннях мають значення. Якщо (гіпотетично) компілятор прийме ці визначення у вищій ступеня буквально, то для першого випадку буде згенерований такий код:

алгоритм 1: buffer[0] = 0;
memset(buffer + 1, 0, 31);

у той час як для другого випадку код буде таким:

алгоритм 2: memset(buffer, 0, 32);

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

Якщо компілятор буквально реалізував алгоритм 1, то нульове значення буде присвоєно першого байту даних, потім (якщо процесор 64-розрядний) будуть виконані три операції запису по 8 байтів. Для заповнення решти семи байтів можуть знадобитися ще 3 операції запису: спочатку запис 4 байтів, потім 2 і потім ще 1 байта.

Ну, це гіпотетично. Саме так і працює VC++. Для 64-розрядних збірок типовий код, згенерований для «= {0}», виглядає так:

xor eax, eax
mov BYTE PTR buffer$[rsp+0], 0
mov QWORD PTR buffer$[rsp+1], rax
mov QWORD PTR buffer$[rsp+9], rax
mov QWORD PTR buffer$[rsp+17], rax
mov DWORD PTR buffer$[rsp+25], eax
mov WORD PTR buffer$[rsp+29], ax
mov BYTE PTR buffer$[rsp+31], al

Графічно це виглядає так (майже всі операції запису не вирівняні):

image

Але якщо опустити нуль, VC++ згенерує наступний код:

xor eax, eax
mov QWORD PTR buffer$[rsp], rax
mov QWORD PTR buffer$[rsp+8], rax
mov QWORD PTR buffer$[rsp+16], rax
mov QWORD PTR buffer$[rsp+24], rax

Що виглядає приблизно так:

image

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

Це взагалі-то не важливо, ймовірно, це навіть не матиме скільки-небудь помітного впливу на розмір реальних програм. Але особисто я вважаю код, згенерований для «= { 0 };», досить кумедним. Рівносильним постійного вживання «еее» при публічному виступі.

Вперше я помітив таку поведінку і повідомив про нього шість років тому, а недавно виявив, що ця проблема все ще присутній в VC + + 2015 Update 3. Мені стало цікаво, і я написав невеликий скрипт на Python, щоб скомпілювати код, зазначений нижче, використовуючи різні розміри масиву і різні варіанти оптимізації для платформ x86 і х64:

void ZeroArray1()
{
char buffer[BUF_SIZE] = { 0 };
printf(“Не оптимізуйте порожній буфер.%s\n", buffer);
}
void ZeroArray2()
{
char buffer[BUF_SIZE] = {};
printf(“Не оптимізуйте порожній буфер.%s\n", buffer);
}

Наведений нижче графік демонструє розмір цих двох функцій в одній конкретній конфігурації платформи — оптимізація розміру для 64-розрядної збірки — у порівнянні зі значеннями BUF_SIZE, що хитаються від 1 до 32 (коли значення BUF_SIZE перевищує 32, розміри варіантів коду однакові):

image

У випадках коли значення BUF_SIZE одно 4, 8 і 32, економія пам'яті вражаюча — розмір коду зменшується на 23,8%, 17,6% і 20,5% відповідно. Середній обсяг зекономленого пам'яті становить 5,4%, і це досить істотно, враховуючи, що всі ці функції мають загальний код епілогу, прологу і виклик printf.

Тут я хотів би порекомендувати всім програмістам С++ при ініціалізації структур і масивів віддавати перевагу «= {};» замість = «= { 0 };». На мій погляд, він краще з естетичної точки зору, і, здається, майже завжди генерує більш короткий код.

Але саме «майже». Результати, наведені вище, демонструють, що є кілька випадків, коли «= {0};» генерує більш оптимальний код. Для одно — і двобайтових форматів «= { 0 };» негайно записує нуль в масив (згідно команді), в той час як «= {};» обнуляє регістр і вже потім створює таку запис. Для 16-байтного формату «= { 0 };» використовує регістр SSE, щоб обнулити всі байти одночасно — не знаю, чому в компіляторах цей метод не використовується частіше.

Отже, перш ніж що-небудь порекомендувати, я вважав своїм обов'язком випробувати різноманітні параметри оптимізації для 32-розрядних і 64-розрядних систем. Основні результати:

32-розрядна з /O1/Oy-: Середня економія пам'яті від 1 до 32 становить 3,125 байта, 5,42%.
32-розрядна з /О2/Оу-: Середня економія пам'яті від 1 до 40 становить 2,075 байта, 3,29%.
32-розрядна з /О2: Середня економія пам'яті від 1 до 40 становить 1,150 байт, 1,79%.
64-розрядна з /О1: Середня економія пам'яті від 1 до 32 становить 3,844 байта, 5,45%
64-розрядна з /О2: Середня економія пам'яті від 1 до 32 становить 3,688 байта, 5,21%.


Проблема в тому, що результат для 32-розрядної /О2/Оу, де «= {};» в середньому на 2,075 байта , ніж при «= { 0 };». Це справедливо для значень від 32 до 40, де код «= {};» зазвичай на 22 байта більше! Причина в тому, що для обнулення масиву код «= {};» використовує команди «movaps» замість «movups». Це означає, що йому доводиться використовувати масу команд тільки для того, щоб забезпечити вирівнювання стека по 16 байтам. От невдача.

image

Висновки
Я все ж рекомендую програмістам С++ віддавати перевагу «= {};», хоч дещо суперечливі результати і показують, що надається цим варіантом перевага є незначним.
Було б непогано, якби оптимізатор VC++ генерував ідентичний код для цих двох компонентів, і було б прекрасно, якщо б цей код був ідеальним завжди. Може бути, коли-небудь так і буде?

Хотілося б мені знати, чому оптимізатор VC++ настільки непослідовний при прийнятті рішення про те, коли слід використовувати 16-байтові регістри SSE для обнулення пам'яті. В 64-бітних системах цей регістр використовується тільки для 16-байтових буферів, ініціалізований з допомогою «= { 0 };», незважаючи на те, що з SSE зазвичай генерується більш компактний код.

Думаю, ці труднощі з генерацією коду характерні і для більш серйозної проблеми, пов'язаної з тим, що суміжні инициализаторы агрегатів не об'єднуються. Однак я вже досить багато часу приділив цьому питанню, так що залишу його на рівні теорії.

Здесь я повідомив про це ба, а скрипт на Python можна знайти на тут.

Зверніть увагу, що представлений нижче код, який також повинен бути еквівалентним, в будь-якому випадку генерує навіть найгірший код, що ZeroArray1 і ZeroArray2.

char buffer[32] = "";

Хоча сам я не проводив тестування, я чув, що компілятори gcc і clang не попалися на вудку «= { 0 };».

У більш ранніх версіях VC++ 2010 проблема була серйозніше. У деяких випадках використовувався виклик memset, і «= { 0 };» за будь-яких обставин гарантував невірне вирівнювання адреси. У більш ранніх версіях VC++ 2010 CRT при невірному вирівнюванні останні 128 байт даних записувалися в чотири рази повільніше (команда stosb замість stosd). Це швидко виправили.

Переклад виконаний ABBYY LS
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.