Недавно я столкнулся с странной деоптимизацией (или, скорее, упущенной возможностью оптимизации).
Рассмотрим эту функцию для эффективной распаковки массивов трехбитовых целых чисел в 8-битные целые числа. Он распаковывает 16 ints в каждой итерации цикла:
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
...
Он выглядит довольно эффективно. Просто a shift right
, за которым следует and
, а затем a 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% в моих измерениях).
Сначала я подумал, что, возможно, модель памяти С++ обеспечивает, чтобы указатель-член не мог быть сохранен в регистре, но его нужно перезагрузить, но это казалось неудобным выбором, поскольку это сделало бы много жизнеспособных оптимизаций невозможным. Поэтому я был очень удивлен, что компилятор не сохранил 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;
}
}
Этот код также дает "хороший" ассемблер без дополнительных магазинов. Поэтому я предполагаю следующее: компилятору не разрешается поднимать нагрузку указателя на элемент структуры, поэтому такой "горячий указатель" всегда должен храниться в локальной переменной.
- Итак, почему компилятор не может оптимизировать эти нагрузки?
- Не запрещает ли это модель памяти С++? Или это просто недостаток моего компилятора?
- Является ли мое предположение правильным или какая именно причина невозможна для оптимизации?
Используемый компилятор был g++ 4.8.2-19ubuntu1
с оптимизацией -O3
. Я также пробовал clang++ 3.4-1ubuntu3
с аналогичными результатами: Clang даже способен векторизовать метод с помощью локального указателя target
. Однако использование указателя this->target
дает тот же результат: дополнительная нагрузка указателя перед каждым хранилищем.
Я проверил ассемблер некоторых подобных методов, и результат тот же: Кажется, что элемент this
всегда должен быть перезагружен перед хранилищем, даже если такую нагрузку можно просто вытащить за пределы цикла. Мне придется переписать много кода, чтобы избавиться от этих дополнительных хранилищ, главным образом, путем кэширования указателя в локальную переменную, объявленную выше горячего кода. Но я всегда думал, что с такими деталями, как кеширование указателя в локальной переменной, наверняка будет претендовать на преждевременную оптимизацию в эти дни, когда компиляторы стали настолько умными. Но, похоже, я здесь не так.. Кэширование указателя элемента в горячем контуре кажется необходимым методом ручной оптимизации.