Нещодавно я натрапив на дивну деоптимізацію (а точніше, пропустив можливість оптимізації).
Розглянемо цю функцію для ефективного розпакування масивів 3-бітових цілих чисел до 8-бітних цілих чисел. Він розпаковує 16 входів у кожній ітерації циклу:
void unpack3bit(uint8_t* target, char* source, int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
target+=16;
}
}
Ось створена збірка для частин коду:
...
367: 48 89 c1 mov rcx,rax
36a: 48 c1 e9 09 shr rcx,0x9
36e: 83 e1 07 and ecx,0x7
371: 48 89 4f 18 mov QWORD PTR [rdi+0x18],rcx
375: 48 89 c1 mov rcx,rax
378: 48 c1 e9 0c shr rcx,0xc
37c: 83 e1 07 and ecx,0x7
37f: 48 89 4f 20 mov QWORD PTR [rdi+0x20],rcx
383: 48 89 c1 mov rcx,rax
386: 48 c1 e9 0f shr rcx,0xf
38a: 83 e1 07 and ecx,0x7
38d: 48 89 4f 28 mov QWORD PTR [rdi+0x28],rcx
391: 48 89 c1 mov rcx,rax
394: 48 c1 e9 12 shr rcx,0x12
398: 83 e1 07 and ecx,0x7
39b: 48 89 4f 30 mov QWORD PTR [rdi+0x30],rcx
...
Це виглядає досить ефективно. Просто з shift right
подальшою and
, а потім store
в target
буфер. Але тепер подивіться, що станеться, коли я зміню функцію на метод у структурі:
struct T{
uint8_t* target;
char* source;
void unpack3bit( int size);
};
void T::unpack3bit(int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
target+=16;
}
}
Я вважав, що сформована збірка повинна бути абсолютно однаковою, але це не так. Ось його частина:
...
2b3: 48 c1 e9 15 shr rcx,0x15
2b7: 83 e1 07 and ecx,0x7
2ba: 88 4a 07 mov BYTE PTR [rdx+0x7],cl
2bd: 48 89 c1 mov rcx,rax
2c0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
2c3: 48 c1 e9 18 shr rcx,0x18
2c7: 83 e1 07 and ecx,0x7
2ca: 88 4a 08 mov BYTE PTR [rdx+0x8],cl
2cd: 48 89 c1 mov rcx,rax
2d0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
2d3: 48 c1 e9 1b shr rcx,0x1b
2d7: 83 e1 07 and ecx,0x7
2da: 88 4a 09 mov BYTE PTR [rdx+0x9],cl
2dd: 48 89 c1 mov rcx,rax
2e0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
2e3: 48 c1 e9 1e shr rcx,0x1e
2e7: 83 e1 07 and ecx,0x7
2ea: 88 4a 0a mov BYTE PTR [rdx+0xa],cl
2ed: 48 89 c1 mov rcx,rax
2f0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
...
Як бачите, load
перед кожним зсувом ми ввели додатковий запас пам’яті ( mov rdx,QWORD PTR [rdi]
). Схоже, що target
вказівник (який зараз є членом замість локальної змінної) повинен завжди перезавантажуватися перед зберіганням у ньому. Це значно уповільнює код (приблизно 15% у моїх вимірах).
По-перше, я подумав, що, можливо, модель пам'яті C ++ примушує те, що покажчик члена може не зберігатися в реєстрі, але повинен бути перезавантажений, але це виглядало як незручний вибір, оскільки це зробило б неможливим багато життєздатних оптимізацій. Тож я був дуже здивований, що компілятор тут не зберігався target
в реєстрі.
Я спробував кешувати вказівник члена в локальну змінну:
void T::unpack3bit(int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
uint8_t* target = this->target; // << ptr cached in local variable
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
this->target+=16;
}
}
Цей код також дає "хороший" асемблер без додаткових магазинів. Тому я здогадуюсь: компілятору не дозволяється піднімати навантаження вказівника члена структури, тому такий "гарячий покажчик" завжди повинен зберігатися в локальній змінній.
- Отже, чому компілятор не в змозі оптимізувати ці навантаження?
- Чи забороняє це модель пам'яті C ++? Або це просто недолік мого укладача?
- Моя здогадка правильна чи яка точна причина, чому оптимізація не може бути виконана?
Використовуваний компілятор був g++ 4.8.2-19ubuntu1
з -O3
оптимізацією. Я також спробував clang++ 3.4-1ubuntu3
з подібними результатами: Clang навіть може векторизувати метод за допомогою локального target
вказівника. Однак використання this->target
вказівника дає той самий результат: додаткове навантаження вказівника перед кожним сховищем.
Я перевірив асемблер якихось подібних методів, і результат той самий: Схоже, що член this
завжди повинен бути перезавантажений перед магазином, навіть якщо таке навантаження можна просто підняти за межі петлі. Мені доведеться переписати багато коду, щоб позбутися цих додаткових магазинів, головним чином кешуючи сам покажчик в локальну змінну, яка оголошена над гарячим кодом. Але я завжди думав, що зіткнення з такими деталями, як кешування покажчика в локальній змінній, безумовно, спричинить передчасну оптимізацію в ці дні, коли компілятори стали такими розумними. Але здається, я тут помиляюся . Керування покажчиком члена в гарячому циклі, здається, є необхідною технікою ручної оптимізації.
this->
- це просто синтаксичний цукор. Проблема пов'язана з природою змінних (локальний vs член) та речами, які компілятор виводить з цього факту.