Optymalizacja dostępu do pamięci
Czasy dostępu do pamięci

Czasy dosteępu do pamięci to w przybliżeniu

Wykorzystanie pamięci podręcznej

Zmiana kolejności indeksowania może spowodować, że kod działa nawet 10 razy szybciej z uwagi na lepsze wykorzystanie pamięci podręcznej

// zapis kolumnami 
int array[256][256];
for( i=0; i<256; ++i)
  for( j=0; j<256; ++j)
    array[j][i] = i*j;

// zapis wierszami - zgodnie z ułożeniem w pamięci
int array[256][256];
for( i=0; i<256; ++i)
  for( j=0; j<256; ++j)
    array[i][j] = i*j;

Używaj krótkich instrukcji

Wybieraj instrukcję która ma mniejszy opcode i mniej operandów. Wtedy cache'owanie instrukcji i dekodowanie jest wydajniejsze.

Używaj zmiennych lokalnych zamiast globalnych

Wyrównaj dane

Wyrównanie przyspiesza dostęp do pamięci.
Szczególnie ważne:

Wyrównaj stos

Stos powinien być wyrównany do granicy 16 bajtów przed wywołaniem funkcji w większości systemów operacyjnych.
Dobrze jest tak ułożyć lokalne zmienne aby były wyrównane do odpowiednich granic.

Bezpośrednie wyrównanie stosu

funkcja:   ; jeden argument na stosie (32-bit)
push   ebp
mov    ebp, esp
sub    esp, zmienneLokalne    
and    esp, 0FFFFFFF0H        ; wyrównanie do 16b
mov    eax, [ebp+8]           ; kopiowanie 
movdqu xmm0,[eax]             ; z niewyrównanej tablic
movdqa [esp],xmm0             ; do wyrównanej zmiennej lokalnej
call   SomeOtherFunction      
...
mov     esp, ebp
pop     ebp
ret

Możemy tak dobrać rozmiar danych lokalnych aby stos pozostał wyrównany

funkcja:   ; (32-bit)
sub    esp, 28
mov    eax, [esp+32]
movdqu xmm0,[eax]
movdqa [esp],xmm0
call   SomeOtherFunction 
...
add esp, 28
ret
Przed wywołaniem funkcji stos powinien być wyrównany, potem został odłożony adres powrotu (4 bajty) dlatego alokując 28 bajtów nadaj pozostanie wyrównany.

Jeżeli odkładamy wcześniej jakieś rejestry na stos to musimy je też wliczyć.

W kodzie 64 bitowym adresy są 8 bajtowe.

funkcja:   ; (64-bit)
sub    rsp, 24
mov    rax, [rsp+32]
movdqu xmm0,[rax]
movdqa [rsp],xmm0
call   SomeOtherFunction 
...
add rsp, 24
ret

Optymalizuj Stos

Okazuje się, że dzieki superskalarności zamiast

push eax
push ebx
push ecx 
lepiej jest zrobic
sub esp, 12
mov eax, [esp+8]
mov ebx, [esp+4]
mov ecx, [esp]

Trzymaj często używane wartości w rejestrach
sub esp, 12
mov eax, [esp+8]
mov ebx, [esp+4]
mov ecx, [esp]
Cache'uj dane z wyprzedneniem

Jeżeli dane są wykorzystywane wielokrotnie albo wykorzystujemy wielu danych z ciągłęgo obszaru dobrze jest wczesniej pobrać je do odpowiedniego poziomu pamięci podręcznej. Służą do tego instrukcje

PREFETCH0 mem ;  załaduj do wszystkich poziomów cache 
PREFETCH1 mem ; załaduj do wszystich poziomów poza L1
PREFETCH2 mem ; ... poza L1 i L2

Używaj PREFETCH wewnątrz pętli. Rozwiń częściowo pętlę tak aby w jednej iteracji "zużyć" cały wiersz cache'u.

mov rbx, tab
 mov rcx, [n]
 .loop:
 PREFETCH [rbx + 16] 
 add eax, [rbx]
 add ebx, [rbx+4]
 add dcx, [rbx+8]
 add esi, [rbx+12]
 add rbx, 16
 sub rcx, 4
 jg .loop
 

We współczesnych procesorach mechanizmy przewidywania rozgałęzień i wstępnego pobierania danych i kodu działają tak dobrze, że dają często lepsze rezultaty niż ręczna optymalizacja.

Kontroluj cache'owanie

Nietrafienia w cache są szczególnie kosztowne przy zapisach. Cały wiersz musi zostać wczytany, zmodyfikowany i zapisany do pamięci. Jeżeli zapisujemy dane, które z dużym prawdopodobieństwem nie będą ponownie wczytywane można użyć instrukcji które pomijają cache (non-temporal write) MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPD, MOVNTPS.

Instrukcje te są jednak dość kosztowne i dają zysk jeżeli zapisuje się blok pamięci większy niż połowa rozmiaru największego cache'a.


Tomasz Kapela, Kraków 2011-2020