Использование этого указателя вызывает странную деоптимизацию в горячем цикле
Недавно я столкнулся с странной деоптимизацией (или, скорее, упущенной возможностью оптимизации).
Рассмотрим эту функцию для эффективной распаковки массивов трехбитовых целых чисел в 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
всегда должен быть перезагружен перед хранилищем, даже если такую нагрузку можно просто вытащить за пределы цикла. Мне придется переписать много кода, чтобы избавиться от этих дополнительных хранилищ, главным образом, путем кэширования указателя в локальную переменную, объявленную выше горячего кода. Но я всегда думал, что с такими деталями, как кеширование указателя в локальной переменной, наверняка будет претендовать на преждевременную оптимизацию в эти дни, когда компиляторы стали настолько умными. Но, похоже, я здесь не так.. Кэширование указателя элемента в горячем контуре кажется необходимым методом ручной оптимизации.
Ответы
Ответ 1
Сглаживание указателя кажется проблемой, по иронии судьбы между this
и this->target
. Компилятор учитывает довольно неприличную возможность, которую вы инициализировали:
this->target = &this
В этом случае запись в this->target[0]
изменит содержимое this
(и, следовательно, this- > target).
Проблема с псевдонимом памяти не ограничивается приведенным выше. В принципе, любое использование this->target[XX]
с учетом (in) соответствующего значения XX
может указывать на this
.
Я лучше разбираюсь в C, где это можно устранить, объявив переменные указателя с помощью ключевого слова __restrict__.
Ответ 2
Строгие правила сглаживания позволяют char*
выполнять псевдоним любого другого указателя. Таким образом, this->target
может иметь псевдоним this
, а в вашем методе кода - первую часть кода,
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
на самом деле
this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;
как this
может быть изменен при изменении содержимого this->target
.
Как только this->target
кэшируется в локальную переменную, псевдоним больше невозможен с локальной переменной.
Ответ 3
Проблема заключается в строгом псевдониме, в котором говорится, что нам разрешен псевдоним через char *, и поэтому это предотвращает оптимизацию компилятора в ваш случай. Нам не разрешают псевдоним с помощью указателя другого типа, который будет undefined поведения, обычно на SO, мы видим эту проблему, которая является попыткой пользователя с помощью несовместимых типов указателей.
Казалось бы разумным реализовать uint8_t как unsigned char, и если мы посмотрим на cstdint на Coliru, он включает stdint.h, который typedefs uint8_t выглядит следующим образом:
typedef unsigned char uint8_t;
если вы использовали другой тип char, тогда компилятор должен иметь возможность оптимизировать.
Это описано в стандартном разделе проекта С++ 3.10
Lvalues и rvalues, в котором говорится:
Если программа пытается получить доступ к сохраненному значению объекта через значение gl, отличное от одного из следующие типы: undefined
и включает следующую марку:
- a char или неподписанный char тип.
Заметьте, я разместил комментарий о возможной работе вокруг в вопросе, который спрашивает, когда isuint8_t ≠ unsigned char? и рекомендация:
Тривиальное обходное решение, однако, заключается в использовании ключевого слова ограничения или скопируйте указатель на локальную переменную, адрес которой никогда не берется так что компилятору не нужно беспокоиться о том, будет ли uint8_t объекты могут использовать его.
Так как С++ не поддерживает ключевое слово ограничения, вы должны полагаться на расширение компилятора, например gcc использует __restrict__, поэтому это не полностью переносимо но другое предложение должно быть.