W poprzednim artykule przedstawiłem sposoby lepszego wykorzystania cache'u oraz parzystości instrukcji procesora. Tym razem chciałbym przedstawić jak można poprawić wydajność tych części kodu, które intensywnie korzystają z koprocesora (FPU).
Koprocesor jest jednostką przeznaczoną do wykonywania operacji na liczbach zmiennoprzecinkowych. Wcześniejszy generacje procesorów albo nie posiadały tej jednostki lub była ona montowana oddzielnie od procesora na płycie głównej. Sytuacja ta zmieniła się w nowszych generacjach procesorów 486. W przypadku Pentium koprocesor jest montowany razem z procesorem tworząc tym samym jedną kość. Koprocesor Pentium ma podobnie jak procesor dwa układy pipeline, które umożliwiają jednoczesne wykonywanie dwóch instrukcji w szczególnym przypadku. Ogólnie układy te umożliwiają wykonywanie instrukcji w ten sposób, że ich wykonanie nakłada się w czasie. Oznacza to, że dwie instrukcje będą wykonywane krócej niż suma ich wykonania i dłużej niż najdłuższa z nich. Obydwie sytuacje w artykule będę nazywać parzystością (można znaleźć pewną analogię między nakładaniem się czasu wykonania instrukcji koprocesora do parzystości niedoskonałej procesora). Pentium jest pierwszym procesorem z serii x86, którego jednostka zmiennoprzecinkowa posiada pipeline. Aby wykorzystać nowe możliwości Pentium potrzebna jest odpowiednia optymalizacja kodu.
W przypadku procesora zaleca się jak najmniejsze odwoływanie się do pamięci, a większe wykorzystanie działań na rejestrach. W przypadku koprocesora działania na pamięci zamiast na rejestrach stosu nie powoduje żadnych dodatkowych cykli związanych z odczytem lub zapisem do pamięci.
Pseudo-parzystość kodu koprocesora. Instrukcje koprocesora nie mogą być wykonywane jednocześnie jak instrukcje procesora. Niekiedy wykonywanie tych instrukcji nakłada się na siebie (overlapping). Oczywiście ten sposób wykonywania instrukcji znacznie przyśpiesza kod, jednak taka sytuacja jest obarczona kilkoma warunkami, które muszą zostać spełnione. Przypadek, gdy instrukcje wykonywane są jednocześnie występuje gdy: - pierwsza instrukcja (wykonywana w U-pipe) to: FLD, FADD, FSUB, FMUL, FDIV, FCOM, FUCOM, FTST, FBAS, FCHS lub FABS - druga instrukcja (V-pipe) to FXCH - następną instrukcją po FXCH jest instrukcja koprocesora; w innym przypadku wykonanie FXCH zajmuje dodatkowy cykl - instrukcja FXCH jest pobierana z cache'u - podobnie jak w przypadku procesora koprocesor nie zna długości instrukcji dopóki ich nie wykona i nie zaznaczy tych wielkości w cache'u, stąd wynika, że instrukcja ta może być używana do dostępu do głębszych rejestrów koprocesora zamiast wczytywanie i zapisywania ich w pamięci Jeżeli chodzi o nakładanie czasu wykonania instrukcji to sytuacja taka nie może nastąpić gdy następna instrukcja wymaga wyniku poprzedniej instrukcji np.:
FADD ST(0), [a] ; cykl 1-3
FADD ST(1), [b] ; cykl 2-4
FADD ST(3), [c] ; cykl 3-5
FADD ST(3), [d] ; cykl 6-8
Opóźnienie wynika stąd, że ta operacja dodawania wymaga wyniku poprzedniej operacji i musi czekać na jej wykonanie Ponieważ większość instrukcji działa tylko na stosie koprocesora - ST(0) następna instrukcja musi czekać na zakończenie pierwszej. Instrukcja FXCH umożliwia zmianą takiego stanu rzeczy. Jak wspomniałem w pewnych okolicznościach może być ona wykonywana parzyście z poprzednią instrukcją. Instrukcja FXCH w rzeczywistości nie przenosi fizycznie zawartości rejestrów, tylko zamienia ich nazwy. Zamiana nazw rejestrów nigdy nie powoduje dodatkowych opóźnień Wręcz przeciwnie - możliwa jest zamiana rejestrów więcej niż raz w ciągu jednego cyklu. np. Poniższy program realizuje następujące instrukcje (pseudo-kod): a1+a2+a3+a4 b1+b2+b3+b4 c1+c2+c3+c4
Jak widać powyższe instrukcje można zrealizować następująco:
FLD [c1] ; cykl 1
FADD [c2] ; cykl 2-4
FADD [c3] ; cykl 5-7
FADD [c4] ; cykl 8-10
FLD [b1] ; cykl 9
FADD [b2] ; cykl 10-12
FADD [b3] ; cykl 13-15
FADD [b4] ; cykl 16-18
FLD [a1] ; cykl 17
FADD [a2] ; cykl 18-20
FADD [a3] ; cykl 21-23
FADD [a4] ; cykl 24-26
w wyniku czego otrzymujemy:
ST(0)= a1+a2+a3+a4
ST(1)= b1+b2+b3+b4
ST(2)= c1+c2+c3+c4
w 26 cykli. Jakby nie patrzeć jest to wynik dość kiepski. Powyższy kod można jednak zapisać nieco inaczej, w ten sposób aby działał szybciej:
FLD [a1] ; cykl 1
FADD [a2] ; cykl 2-4
FLD [b1] ; cykl 3
FADD [b2] ; cykl 4-6
FLD [c1] ; cykl 5
FADD [c2] ; cykl 6-8
FXCH ST(2) ; cykl 6
FADD [a3] ; cykl 7-9
FXCH ST(1) ; cykl 7
FADD [b3] ; cykl 8-10
FXCH ST(2) ; cykl 8
FADD [c3] ; cykl 9-11
FXCH ST(1) ; cykl 9
FADD [a4] ; cykl 10-12
FXCH ST(2) ; cykl 10
FADD [b4] ; cykl 11-13
FXCH ST(1) ; cykl 11
FADD [c4] ; cykl 12-14
FXCH ST(2) ; cykl 12
W powyższym przykładzie otrzymujemy to samo co wcześniej ale tylko w 14 cyklach. Daje to - z grubsza licząc - 50% przyśpieszenia.
Powyższy przykład tworzą trzy oddzielne wątki a, b, c. W celu najszybszego ich wykonania używana jest instrukcja FXCH. Używając jej mamy dostęp do wierzchołka stosu ST(0) i ominięcia jakichkolwiek opóźnień wynikających z oczekiwania na zakończenie instrukcji z niego korzystającej (patrz opóźnienia w pierwszym przykładzie). Układanie takiego kodu wymaga "ręcznego" wykonania kodu tak jak by to zrobił procesor i w każdym kroku trzeba badać rejestry koprocesora po wykonaniu każdej instrukcji. Jako wynik otrzymujemy zwykle kod działający znacznie szybciej od jego pierwowzoru. Przy tworzeniu kodu należy wykorzystać następujące informacje: - wszystkie wersje instrukcji FADD, FSUB, FMUL i FILD zajmują 3 cykle i istnieje możliwość nałożenie czasu ich wykonania - wykonanie następnej instrukcji FADD, FSUB, FILD, po poprzedzającej ją podobnej instrukcji, rozpoczyna się w następnym cyklu - instrukcja FMUL rozpoczyna się dopiero w drugim cyklu po rozpoczęciu poprzedniej instrukcji FMUL lub pary FMUL/FXCH np.:
a3=a1*a2
b3=b1*b2
c3=c1*c2
FLD [a1] ; cykl 1
FLD [b1] ; cykl 2
FLD [c1] ; cykl 3
FXCH ST(2) ; cykl 3
FMUL [a2] ; cykl 4-6
FXCH ; cykl 4
FMUL [b2] ; cykl 5-7 (opóźnienie)
FXCH ST(2) ; cykl 5
FMUL [c2] ; cykl 7-9 (opóźnienie)
FXCH ; cykl 7
FSTP [a3] ; cykl 8-9
FXCH ; cykl 10 (nieparzysta z FSTP [a3] - FSTP nie jest parzysta z żadną instrukcją w czasie zapisu do pamięci zamiana rejestrów nie jest możliwa)
FSTP [b3] ; cykl 11-12
FSTP [c3] ; cykl 13-14
W tym przypadku mamy dwa opóźnienia: przed FMUL [b2] i FMUL [c2]. W obu przypadkach poprzednie mnożenie rozpoczyna się w poprzednim cyklu co powoduje owo właśnie opóźnienie. Taki kod można poprawić wstawiając, w miarę możliwości inne instrukcje (FLD, FADD, FSUB), między dwie instrukcje FMUL. Rozdzielanie takie może być również wykonane za pomocą instrukcji procesora (o tym dalej).
FLD [a1] ; cykl 1
FMUL [a2] ; cykl 2-4
FLD [b1] ; cykl 3
FMUL [b2] ; cykl 4-6
FLD [c1] ; cykl 5
FMUL [c2] ; cykl 6-8
FXCH ST(2) ; cykl 6
FSTP [a3] ; cykl 7-8
FSTP [b3] ; cykl 9-10
FSTP [c3] ; cykl 11-12
Oczywiście sytuacja gdy mamy do wykonania trzy różne wątki zdarza się rzadko. Zwykle mamy do czynienia z jednym, dużym wyrażeniem. W takim wypadku należy podzielić wyrażenie na kilka mniejszych wątków (sześć dodawań rozkładamy na trzy wątki po 2 dodawania). Nie wszystkie instrukcje koprocesora mogą być parzyste. Czasami wykonanie instrukcji koprocesora może przysłonić wykonanie instrukcji procesora np. Wykonanie FDIV zajmuje 39 cykli i następna instrukcja koprocesora może być wykonana dopiero w dwóch ostatnich cyklach, natomiast w czasie wykonywania FDIV, oprócz pierwszego cyklu, procesor może wykonywać swoje instrukcje:
FDIV ; cykl 1-39
FXCH ; cykl 1-2 (nieparzyste z instrukcją procesora)
CMC ; cykl 3-4
RCR EAX, 1 ; cykl 5
INC EBX ; cykl 5
FADD [x] ; cykl 38-40
FXCH ; cykl 38
FMUL ; cykl 40-42 (instrukcja czeka na wykonanie dzielenia)
Jeżeli nie mamy żadnej instrukcji procesora, która może być przysłonięta przez FDIV lub FSQRT, to można wstawić dowolny odczyt spod adresu, do którego odwołuje się następna instrukcja koprocesora. np.
FDIV QWORD PTR [EBX]
CMP [ESI], EAX
FMUL QWORD PTR [ESI]
W tym przypadku instrukcja CMP [ESI], EAX wymusza załadowanie do cache'u danych spod adresu [ESI]. W ten sposób FMUL nie powoduje powstania opóźnień związanych z odczytem danych.
Zaleca się unikania używania instrukcji FIADD, FISUB itp. zamiast tego należy użyć FILD i odpowiedniej operacji zmiennoprzecinkowej. np.:
użycie jednej instrukcji na liczbach całkowitych:
FIADD [a] ; cykl 1-4
użycie kilku instrukcji zmiennoprzecinkowych:
FILD [a] ; cykl 1
FADD ST(1) ; cykl 2-4
Ale cykle 3,4 mogą być użyte przez inne instrukcje. Natomiast FIADD nie pozwala na to.
Jeżeli instrukcja zapisuje wynik operacji, która zakończyła działanie w poprzednim cyklu to wykonanie FST(P) zajmuje dodatkowy 1 cykl. Dodatkowo w czasie wykonywania tej instrukcji żadna inna instrukcja koprocesora lub procesora nie może być wykonywana równolegle.
Dzielenie i mnożenie zmiennoprzecinkowe a stałoprzecinkowe
Procesor wykorzystuje jednostkę zmiennoprzecinkową do wykonywania mnożenia liczb całkowitych - instrukcje FMUL i (i)mul nie mogą być wykonywane równolegle. Inaczej wygląda sytuacja w przypadku instrukcji FDIV i (i)div. Obie instrukcje mają swoje oddzielne układy i tym samym mamy możliwość wykonania dwóch dzieleń w czasie jednej operacji. Pozwala to znaczne przyśpieszenie tych fragmentów kodu, które intensywnie korzystają z dzielenia a czas wykonania powinien być jak najkrótszy (wyznaczanie delt w przypadku teksturowania, cieniowania itp.). np.
FILD [b1]
FILD [b2]
MOV EAX, [a1]
MOV EBX, [a2]
XOR EDX, EDX ;jeżeli chcemy wykonać instrukcję IDIV to zamiast tej linii należy wpisać: MOV EDX, EAX;
SAR EDX, 31
FDIV
DIV EBX
FISTP [b]
MOV [a], EAX
W tym przykładzie instrukcja DIV wykonywana jest w czasie równolegle z FDIV. Opóźnienie wnosi tu powolna instrukcja FISTP ale i tak całość jest szybsza niż dwie oddzielne instrukcje dzielenie stałoprzecinkowego.
[1] "Optimizations for Intel's 32-Bit Processors" [2] "How to optimize for the Pentium processor" Agner Fog
Stronę opracował Michał Szulowski