Використання цього покажчика викликає дивну деоптимізацію в гарячому циклі


122

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

Розглянемо цю функцію для ефективного розпакування масивів 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завжди повинен бути перезавантажений перед магазином, навіть якщо таке навантаження можна просто підняти за межі петлі. Мені доведеться переписати багато коду, щоб позбутися цих додаткових магазинів, головним чином кешуючи сам покажчик в локальну змінну, яка оголошена над гарячим кодом. Але я завжди думав, що зіткнення з такими деталями, як кешування покажчика в локальній змінній, безумовно, спричинить передчасну оптимізацію в ці дні, коли компілятори стали такими розумними. Але здається, я тут помиляюся . Керування покажчиком члена в гарячому циклі, здається, є необхідною технікою ручної оптимізації.


5
Не впевнений, чому це призвело до голосування - цікаве питання. FWIW Я бачив подібні проблеми оптимізації із змінними, що не вказують на членів, де рішення було подібним, тобто кешувати змінну члена в локальній змінній протягом життя методу. Я здогадуюсь, що це щось пов’язане з правилами споріднення?
Пол Р

1
Схоже, компілятор не оптимізує, оскільки він не може забезпечити доступ до учасника через якийсь "зовнішній" код. Отже, якщо член може бути змінений зовні, то його слід перезавантажувати кожного разу, коли доступ до нього. Здається, це вважається своєрідною мінливою ...
Жан-Батист Юньєс

Ніякі не вживання this->- це просто синтаксичний цукор. Проблема пов'язана з природою змінних (локальний vs член) та речами, які компілятор виводить з цього факту.
Жан-Батист Юньєс

Щось робити з псевдонімами вказівника?
Ів Дауст

3
Що стосується більш семантичного питання, то "передчасна оптимізація" застосовується лише до оптимізації, яка є передчасною, тобто до того, як профілювання визнало, що це є проблемою. У цьому випадку ви старанно профілювали та декомпілювали та знайшли джерело проблеми та сформулювали та профілювали рішення. Застосовувати це рішення абсолютно не передчасно.
raptortech97

Відповіді:


107

Здавання вказівників, здається, є проблемою, як не дивно, між thisі this->target. Компілятор враховує досить нецензурну можливість, яку ви ініціалізували:

this->target = &this

У цьому випадку написання до this->target[0]змінює зміст this(і, таким чином, this->target).

Проблема з виділенням пам’яті не обмежується вищезазначеним. В принципі, будь-яке використання this->target[XX]заданого (не) відповідного значення XXможе вказувати на this.

Я краще розбираюся в C, де це можна усунути, оголосивши змінні вказівника за допомогою __restrict__ключового слова.


18
Я можу це підтвердити! Зміна targetвід uint8_tдо uint16_t(так , що строгі правила накладення спектрів загнутися) змінили його. З uint16_t, навантаження завжди оптимізовано.
гексицид


3
Зміна вмісту thisне є тим, що ви маєте на увазі (це не змінна); ви маєте на увазі зміну вмісту *this.
Марк ван Левен

@gexicide розум уточнює, наскільки суворий псевдонім починається і вирішує проблему?
HCSF

33

Строгі правила псевдоніму дозволяють 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кешування в локальній змінній псевдонім більше не можливий з локальною змінною.


1
Отже, чи можемо ми сказати як загальне правило: Кожен раз, коли у вас є структура char*чи void*структура, обов'язково кешуйте її в локальній змінній, перш ніж писати в неї?
гексицид

5
Насправді це коли ви використовуєте char*, не обов'язково як член.
Jarod42

24

Проблема тут - це суворий псевдонім, який говорить про те, що нам дозволено псевдонім через char * і таким чином, що перешкоджає оптимізації компілятора у вашому випадку. Нам заборонено створювати псевдонім через вказівник іншого типу, який би був невизначеним поведінкою, як правило, на SO ми бачимо цю проблему, яку користувачі намагаються отримати псевдонімом через несумісні типи вказівників .

Здавалося б , розумно застосувати uint8_t як непідписані символ , і якщо ми подивимося на cstdint на Coliru включає stdint.h , які визначення типів uint8_t наступним чином :

typedef unsigned char       uint8_t;

якщо ви використовували інший нехарактерний тип, компілятор повинен мати можливість оптимізувати.

Це висвітлено у проекті стандартного розділу C ++ 3.10 Значення та рейтинги, який говорить:

Якщо програма намагається отримати доступ до збереженого значення об'єкта через glvalue іншого, ніж одного з наведених нижче типів, поведінка не визначена

і включає в себе наступну кулю:

  • тип char або неподписаний тип char.

Зауважте, я розмістив коментар щодо можливих проблем, пов'язаних із роботою, у запитанні, коли запитується, коли uint8_t ≠ непідписаний графік? і рекомендація:

Однак тривіальним способом є використання ключового слова обмеження або скопіювання вказівника на локальну змінну, адреса якої ніколи не приймається, щоб компілятору не потрібно турбуватися про те, чи можуть об'єкти uint8_t називати його псевдонімом.

Оскільки C ++ не підтримує обмежене ключове слово, ви повинні розраховувати на розширення компілятора, наприклад, gcc використовує __restrict__, тому це не є повністю портативним, але інша пропозиція повинна бути.


Це приклад місця, де Стандарт є гіршим для оптимізаторів, ніж правило, що дозволить компілятору припустити, що між двома доступами до об'єкта типу T, або таким доступом, і початком або кінцем циклу / функції де це відбувається, усі звернення до сховища будуть використовувати один і той же об'єкт, якщо втручається операція не використовує цей об'єкт (або вказівник / посилання на нього) для отримання вказівника або посилання на якийсь інший об'єкт . Таке правило позбавило б необхідності "виключення типу символів", яке може знищити продуктивність коду, який працює з послідовностями байтів.
supercat
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.