Использование этого указателя вызывает странную деоптимизацию в горячем цикле

Недавно я столкнулся с странной деоптимизацией (или, скорее, упущенной возможностью оптимизации).

Рассмотрим эту функцию для эффективной распаковки массивов трехбитовых целых чисел в 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__, поэтому это не полностью переносимо но другое предложение должно быть.