Недавно я столкнулся со странной деоптимизацией (точнее, упущенной возможностью оптимизации).
Рассмотрим эту функцию для эффективной распаковки массивов 3-битных целых чисел в 8-битные целые числа. На каждой итерации цикла он распаковывает 16 int:
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из памяти перед каждым shift ( 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->- это просто синтаксический сахар. Проблема связана с природой переменных (локальные и члены) и с тем, что компилятор выводит из этого факта.