Użycie tego wskaźnika powoduje dziwną deoptymizację w hot loop

Ostatnio natknąłem się na dziwną deoptymizację (a raczej przegapiłem możliwość optymalizacji).

Rozważmy tę funkcję do efektywnego rozpakowywania tablic z 3-bitowych liczb całkowitych do 8-bitowych liczb całkowitych. Rozpakowuje 16 Int w każdej iteracji pętli:

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;
   }
}

Oto wygenerowany zestaw dla części kodu:

 ...
 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
 ...
Wygląda całkiem sprawnie. Po prostu shift right, a następnie and, a następnie store do bufora target. Ale teraz, zobacz co się dzieje, gdy zmienię funkcja do metody w strukturze:
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;
        }
}

Myślałem, że wygenerowany assembly powinien być taki sam, ale tak nie jest. Oto jego część:

...
 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!
 ...

Jak widzicie, przed każdą zmianą wprowadziliśmy dodatkowy redundantny load z pamięci (mov rdx,QWORD PTR [rdi]). Wygląda na to, że wskaźnik target (który jest teraz członkiem zamiast zmiennej lokalnej) musi być zawsze przeładowany przed zapisaniem do niego. to znacznie spowalnia kod (OKOŁO 15% W moim pomiary).

Najpierw pomyślałem, że może Model pamięci C++ wymusza, że wskaźnik członka może nie być przechowywany w rejestrze, ale musi być przeładowany, ale wydawało się to niezręcznym wyborem, ponieważ uniemożliwiłoby to wiele opłacalnych optymalizacji. Byłem więc bardzo zaskoczony, że kompilator nie przechowuje target w rejestrze tutaj.

Sam próbowałem buforować wskaźnik member do zmiennej lokalnej:

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;
    }
}

Ten kod daje również" dobry " asembler bez dodatkowe sklepy. Domyślam się, że kompilator nie może podnosić obciążenia wskaźnika elementu struktury, więc taki "gorący wskaźnik" powinien być zawsze przechowywany w zmiennej lokalnej.

  • dlaczego więc kompilator nie jest w stanie zoptymalizować tych obciążeń?
  • czy to model pamięci C++ tego zabrania? Czy jest to po prostu wada mojego kompilatora?
  • Czy moje przypuszczenie jest poprawne lub jaki jest dokładny powód, dla którego optymalizacja nie może być wystąpili

Używany kompilator był g++ 4.8.2-19ubuntu1 z -O3 optymalizacją. Próbowałem również clang++ 3.4-1ubuntu3 z podobnymi wynikami: Clang jest nawet w stanie wektoryzować metodę za pomocą lokalnego wskaźnika target. Jednak użycie wskaźnika this->target daje ten sam wynik: dodatkowe obciążenie wskaźnika przed każdym sklepem.

Sprawdziłem w asemblerze podobne metody i wynik jest taki sam: wygląda na to, że element this zawsze musi być przeładowany przed sklepem, nawet jeśli takie obciążenie można go po prostu podnieść poza pętlę. Będę musiał przepisać dużo kodu, aby pozbyć się tych dodatkowych sklepów, głównie przez buforowanie wskaźnika do lokalnej zmiennej, która jest zadeklarowana powyżej gorącego kodu. ale zawsze myślałem, że manipulowanie takimi szczegółami jak buforowanie wskaźnika w lokalnej zmiennej z pewnością kwalifikuje się do przedwczesnej optymalizacji w tych dniach, gdzie Kompilatory stały się tak sprytne. Ale wygląda na to, że się mylę . Buforowanie wskaźnika członka w gorącej pętli wydaje się być niezbędna ręczna technika optymalizacji.

Author: Peter Mortensen, 2014-10-10

3 answers

Pointer aliasing wydaje się być problemem, jak na ironię między this a this->target. Kompilator bierze pod uwagę raczej nieprzyzwoitą możliwość, którą zainicjowałeś:

this->target = &this

W takim przypadku zapisanie do this->target[0] zmieniłoby zawartość this (a więc ten - > target).

Problem aliasingu pamięci nie ogranicza się do powyższego. W zasadzie każde użycie this->target[XX] z odpowiednią wartością XX może wskazywać na this.

Jestem lepiej zorientowany w C, gdzie można temu zaradzić, deklarując zmienne wskaźnika za pomocą słowa kluczowego__ restrict__.

 93
Author: Peter Boncz,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-10-10 16:35:36

Ścisłe reguły aliasingu pozwalają char* aliasować dowolny inny wskaźnik. Więc this->target może być aliasem z this, a w metodzie kodu pierwsza część kodu,

target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;

Jest w rzeczywistości

this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;

Jako {[4] } mogą być modyfikowane przy modyfikowaniu this->target treści.

Po zapisaniu this->target w pamięci podręcznej do zmiennej lokalnej, alias nie jest już możliwy dla zmiennej lokalnej.

 28
Author: Jarod42,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-10-10 16:36:27

Problemem jest tutaj ścisłe aliasowanie , które mówi, że możemy aliasować przez znak * , co zapobiega optymalizacji kompilatora w Twoim przypadku. Nie wolno nam aliasować za pomocą wskaźnika innego typu, który byłby niezdefiniowanym zachowaniem, Zwykle włączonym, więc widzimy ten problem, który polega na tym, że użytkownicy próbują aliasu za pomocą niekompatybilnych typów wskaźników .

Wydaje się rozsądne zaimplementowanie uint8_t jako unsigned char i jeśli spójrz na cstdint na Coliru zawiera stdint.h który wpisał uint8_t następująco:

typedef unsigned char       uint8_t;

Jeśli użyłeś innego typu non-char, kompilator powinien być w stanie zoptymalizować.

Jest to omówione w sekcji draft C++ standard3.10 Lvalues i rvalues , który mówi:

Jeśli program próbuje uzyskać dostęp do zapisanej wartości obiektu za pomocą wartości glvalue innej niż jedna z następujące typy zachowania to undefined

I zawiera następujący punkt:

  • Typ char lub unsigned char.

Uwaga, zamieściłem komentarz na temat możliwej pracy wokółw pytaniu, które pyta Kiedy jest uint8_t ≠ unsigned char? A zalecenie brzmiało:

Banalnym obejściem jest jednak użycie słowa kluczowego ogranicz lub skopiuj wskaźnik do zmiennej lokalnej, której adres nigdy nie jest tak że kompilator nie musisz się martwić, czy uint8_t obiekty mogą go nazywać aliasem.

Ponieważ C++ nie obsługuje słowa kluczowego restrict , musisz polegać na rozszerzeniu kompilatora, na przykład gcc używa _ _ restrict _ _ więc nie jest to całkowicie przenośne, ale inna sugestia powinna być.

 23
Author: Shafik Yaghmour,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-05-23 12:01:28