• Nie Znaleziono Wyników

Testowanie opó´znienia i przepustowo´sci przetwarzania rozkazów

1.2 Architektury procesorów

1.2.8 Testowanie opó´znienia i przepustowo´sci przetwarzania rozkazów

Testem mierz ˛acym opó´znienie wykonania wybranych rozkazów mo˙ze by´c wykonanie przez procesor sekwencji takich rozkazów (tak˙ze np. w p˛etli), gdzie ka˙zdy nast˛epny rozkaz jako dan ˛a wej´sciow ˛a wy-korzystuje dan ˛a wyj´sciow ˛a rozkazu poprzedzaj ˛acego (jest to relatywnie łatwe do uzyskania dla operacji arytmetycznych). W przypadku tak jawnie realizowanej zale˙zno´sci danych, a tak˙ze w ogólnym przy-padku, gdy procesor nie mo˙ze korzysta´c ze współbie˙zno´sci przetwarzania, opó´znienie staje si˛e czynni-kiem decyduj ˛acym o wydajno´sci (w praktyce sytuacja taka zdarza si˛e rzadko – kompilatory optyma-lizuj ˛ace staraj ˛a si˛e usun ˛a´c zale˙zno´sci, a same procesory korzystaj ˛a np. z przekazywania argumentów pomi˛edzy rozkazami bezpo´srednio w ramach potoków wykonania (operand forwarding) oraz innych technik optymalizacji wydajno´sci omawianych w dalszej cz˛e´sci rozdziału).

Do badania opó´znienia przetwarzania rozkazów wykorzystany b˛edzie fragment kodu, zawieraj ˛acy istotne z punktu widzenia oblicze´n technicznych operacje zmiennoprzecinkowe dodawania i mno˙zenia podwójnej precyzji:

for(i=0;i<1000000;i++) a = 1.000001*a+0.000001;

}

Kompilacja z wł ˛aczon ˛a opcj ˛a optymalizacji -O3 (szerzej o optymalizuj ˛acych kompilatorach w p.?????) daje w wyniku nast˛epuj ˛acy kod asemblera (dla kompilatora gcc):

.L3:

mulsd %xmm0, %xmm1 subl $1, %eax addsd %xmm2, %xmm1 jne .L3

Kompilator (podobne warianty uzyskuje si˛e dla obu stosowanych w pracy kompilatorów gcc i icc) do wy-konywania operacji arytmetycznych zastosował rozkazy wektorowe mulsd i addsd, operuj ˛ace na 128-bitowych rejestrach xmm (dodatkowo zmodyfikował tak˙ze sprawdzanie warunku ko´nca p˛etli, co jednak nie wpływa na badanie przetwarzania rozkazów zmiennoprzecinkowych). Uruchomienie kodu i pomiar czasu wykonania na komputerze wyposa˙zonym w procesor Intel Core i7-4790 (z rdzeniami o architek-turze Haswell i nominaln ˛a cz˛estotliwo´sci ˛a pracy 3.60 GHz) daj ˛a wydajno´s´c ok. 1 GFlops (109 operacji zmiennoprzecinkowych na sekund˛e).

Program uruchomiony jest w postaci jednow ˛atkowego procesu na pojedynczym rdzeniu, co pozwala na osi ˛agni˛ecie zwi˛ekszonej cz˛estotliwo´sci pracy, ok. 4 GHz. Wyniki wskazuj ˛a na wydajno´s´c ´sredni ˛a CPI=4 (4 takty na pojedyncz ˛a operacj˛e – bardziej szczegółowe badania wskazuj ˛a na opó´znienie 3 takty dla addsd i 5 taktów dla mulsd). Co powoduje tak nisk ˛a wydajno´s´c przetwarzania?

Kod napisany jest w taki sposób (co dobrze wida´c analizuj ˛ac posta´c asemblera), ˙ze w ka˙zdej operacji zmiennoprzecinkowej wykorzystywany jest ten sam rejestr zawieraj ˛acy modyfikowan ˛a warto´s´c zmien-nej a (w konkretnym przypadku u˙zytych kompilatorów jest to rejestr xmm1). Oznacza to, ˙ze kolejny rozkaz zmiennoprzecinkowy w kodzie nie mo˙ze zosta´c wykonany, dopóki nie zostanie zako´nczony roz-kaz poprzedzaj ˛acy. Procesor/rdze´n nie jest w stanie w pełni ujawni´c swoich mo˙zliwo´sci przetwarzania zwi ˛azanych z technikami ukrywania opó´znienia (latency hiding): przetwarzaniem potokowym i wieloma potokami przetwarzania rozkazów.

W celu wykrycia maksymalnej wydajno´sci (przepustowo´sci) przetwarzania pojedynczego rdzenia, testowy kod jest modyfikowany:

for(i=0;i<1000000;i++)

a = 1.000001*a+0.000001;

b = 1.000001*b+0.000001;

c = 1.000001*c+0.000001;

// itd. dla dalszych zmiennych }

Dodanie kolejnej pojedynczej zmiennej pocz ˛atkowo zwi˛eksza wydajno´s´c przetwarzania o ok. 1 Gflops (do 5 zmiennych). Nast˛epnie przyrosty wydajno´sci s ˛a ju˙z mniejsze, osi ˛agaj ˛ac maksymaln ˛a wy-dajno´s´c ok. 7.9 GFlops dla 9 zmiennych.

Wydajno´s´c taka wci ˛a˙z jest daleka od teoretycznych mo˙zliwo´sci rdzenia. Jedn ˛a z wad dotychczaso-wego rozwi ˛azania jest u˙zycie odr˛ebnych rozkazów dodawania i mno˙zenia, podczas gdy rdze´n potrafi wykona´c poł ˛aczone dodawanie i mno˙zenie (FMA, fused multiply-add) w takiej samej liczbie taktów jak ka˙zd ˛a z pojedynczych operacji. Zamiana odr˛ebnych rozkazów na pojedynczy poł ˛aczony rozkaz nast˛e-puje po przekazaniu do kompilatora opcji jawnie wskazuj ˛acej na typ architektury rdzenia, w tym wy-padku opcji: -march=core-avx2. Po zastosowaniu opcji wydajno´s´c wzrasta do ok. 15.7 GFlops. Po uwzgl˛ednieniu liczby taktów zegara w czasie wykonania programu, uzyskuje si˛e wydajno´s´c ok. 4 opera-cji zmiennoprzecinkowych na pojedynczy takt (dokładnie 2 operaopera-cji FMA, co odpowiada 4 klasycznym operacjom arytmetycznym). Miara CPI dla operacji FMA na pojedynczym argumencie podwójnej pre-cyzji wynosi wi˛ec 0.5.

Kolejnym brakiem powstałego kodu jest działanie skalarne. Wprawdzie w asemblerze pojawiaj ˛a si˛e rejestry i instrukcje wektorowe, ale w ka˙zdym rejestrze 128-bitowym znajduje si˛e tylko jedna liczba po-dwójnej precyzji i operacja na takim rejestrze jest efektywnie tylko jedna operacj ˛a zmiennoprzecinkow ˛a.

W celu umo˙zliwienia kompilatorowi pełnego wykorzystania mo˙zliwo´sci przetwarzania wektorowego konieczne jest dokonanie dalszych modyfikacji kodu. Zamiast pojedynczych zmiennych wykorzystane zostaj ˛a małe tablice o rozmiarze 16:

for(i=0;i<1000000;i++)

Ju˙z u˙zycie pojedynczej tablicy zwi˛eksza wydajno´s´c przetwarzania do ok. 25 Gflops. Składa si˛e na to pełne wykorzystanie czterech 256-bitowych rejestrów AVX do przechowywania całej tablicy, pozwa-laj ˛ace rdzeniowi na ko´nczenie 3 skalarnych operacji FMA na pojedynczy takt. U˙zycie dwóch tablic zwi˛eksza wydajno´s´c do ok. 50 GFlops, a najwy˙zsz ˛a praktycznie uzyskiwan ˛a wydajno´s´c 63 GFlops za-pewnia wykorzystanie 3 tablic (dokładnie jak w przedstawionym kodzie), odpowiadaj ˛ace 16 skalarnym operacjom FMA w pojedynczym takcie.

Teoretyczna maksymalna wydajno´s´c pojedynczego rdzenia analizowanego procesora, obliczana na podstawie danych producenta, wynosi 57.6 GFlops (3.6 GHz x 16 operacji zmiennoprzecinkowych w pojedynczym takcie, jako efekt wykorzystania 2 potoków 256-bitowych rozkazów wektorowych FMA).

Zwi˛ekszenie do warto´sci uzyskanej eksperymentalnie (dla trzech tablic i przetwarzania wektorowego) zwi ˛azane jest z podwy˙zszeniem cz˛estotliwo´sci przetwarzania do ok. 4 GHz, mo˙zliwym w przypadku u˙zycia wył ˛acznie jednego rdzenia.

Uruchomienie powy˙zszego kodu w postaci wielow ˛atkowej (z wykorzystaniem biblioteki w ˛atków POSIX, pthreads) pozwala osi ˛agn ˛a´c wydajno´s´c ok. 240 GFlops (przy cz˛estotliwo´sci pracy ok. 3,8 GHz).

W obu przypadkach, jedno i wielow ˛atkowym, wydajno´s´c przetwarzania uzyskana eksperymentalnie jest zbli˙zona (z dokładno´sci ˛a do ok. 2%) do maksymalnej wydajno´sci 16 operacji zmiennoprzecinko-wych ko´nczonych w pojedynczym takcie przez pojedynczy rdze´n procesora. W ostateczno´sci obliczona

´srednia miara CPI dla jednego rdzenia zwi ˛azana z pojedynczym rozkazem FMA na 256-bitowym reje-strze wektorowym osi ˛aga optymaln ˛a warto´s´c teoretyczn ˛a 0.5 (IPC równe 2).

Warto jeszcze zwróci´c uwag˛e na płyn ˛acy z powy˙zszego przykładu wniosek o relatywnym znaczeniu współczynnika CPI, dotycz ˛acy nie tylko ró˙znicy mi˛edzy rozkazami skalarnymi i wektorowymi. Obser-wacja, ˙ze ko´nczenie w ka˙zdym takcie jednej operacji skalarnej (CPI=1) oznacza kilkukrotnie ni˙zsz ˛a wydajno´s´c ni˙z ko´nczenie jednej pełnowarto´sciowej operacji wektorowej (CPI tak˙ze równe jeden), jest wnioskiem słusznym i oczywistym. Mniej oczywisty jest fakt, ˙ze kompilator mo˙ze u˙zy´c operacji (po-toków) przetwarzania wektorowego w przypadku nie w pełni wykorzystanych rejestrów wektorowych.

Wtedy warto´s´c współczynnika CPI (nawet uwzgl˛edniaj ˛ac, ˙ze dotyczy rozkazów wektorowych) ponow-nie ponow-nie odpowiada rzeczywistej wydajno´sci programu – istotnym staje si˛e obsadzeponow-nie rejestrów u˙zy-tecznymi danymi programu i efektywna liczba u˙zytecznych operacji kodu ´zródłowego realizowanych w pojedynczym rozkazie wektorowym.

”Ci´snienie na rejestry” i rozdzielanie p˛etli

Ciekawym faktem zwi ˛azanym z przedstawionym w poprzednim punkcie kodem jest gwałtowne zmniejszenie wydajno´sci przetwarzania w przypadku u˙zycia czterech tablic.

for(i=0;i<1000000;i++)

Kod asemblera dla trzech tablic wygl ˛ada nast˛epuj ˛aco (tym razem dla kompilatora icc):

..B1.6:

Podczas gdy dla czterech tablic otrzymuje si˛e:

..B1.7:

Kompilatorowi brakuje rejestrów, ˙zeby w nich umie´sci´c wszystkie zmienne wyst˛epuj ˛ace w pojedynczej iteracji, i w efekcie, zamiast efektywnego przetwarzania z wykorzystaniem wył ˛acznie rejestrów, procesor realizuje w ka˙zdej iteracji 8 dost˛epów do pami˛eci (za pomoc ˛a wektorowych wariantów rozkazu mov), co nawet w przypadku wył ˛acznego korzystania z pami˛eci podr˛ecznej najbli˙zszej rdzeniowi, znacznie spowalnia wykonanie programu.

Powy˙zsze negatywne zjawisko, u˙zycia wewn ˛atrz p˛etli zbyt wielu zmiennych, uniemo˙zliwiaj ˛ace efek-tywne korzystanie z rejestrów, zwane jest ”ci´snieniem na rejestry” (register pressure). Unikanie ”ci´snie-nia na rejestry” jest jednym z wskaza´n przy tworzeniu wysoko wydajnego kodu.

Dla konkretnego rozwa˙zanego przykładu, technik ˛a optymalizacji pozwalaj ˛ac ˛a na likwidacj˛e ”ci´snie-nia na rejestry” i osi ˛agni˛ecie wy˙zszej wydajno´sci jest rozdzielenie p˛etli (loop fission). Przeprowadzenie rozdzielenia p˛etli z indeksem i na dwie odr˛ebne p˛etle:

for(i=0;i<1000000;i++){

nie powoduje zmiany wyniku (rozdzielane obliczenia s ˛a od siebie całkowicie niezale˙zne), a pozwala na podniesienie wydajno´sci z ok. 28 GFlops (kod z czterema tablicami w ka˙zdej iteracji wewn˛etrznej p˛etli) do ok. 50 GFlops (kod z dwoma tablicami w ka˙zdej wewn˛etrznej iteracji). W przypadku sze´sciu tablic, loop fissionpozwala na zwi˛ekszenie wydajno´sci z ok. 30 GFlops (wszystkie sze´s´c tablic w jednej p˛etli z indeksem k) do ok. 63 GFlops po rozdzieleniu na dwie podwójne p˛etle.

”Rozci ˛aganie” tablic

Powy˙zsze zadanie pozwala na zaobserwowanie jeszcze jednej charakterystycznej cechy przetwarza-nia danych w tablicach, maj ˛acej istotny wpływ na wydajno´s´c. Cech ˛a t ˛a jest zale˙zno´s´c wydajno´sci prze-twarzania od rozmiaru tablicy. Wyobra´zmy sobie sytuacj˛e, kiedy z wymaga´n aplikacji wynika, ˙ze w ka˙z-dej z tablic a_tab, b_tab i c_tab mamy do przechowania 15 egzemplarzy danych, na których mamy wykona´c operacje, takie jak w dotychczasowym kodzie. Naturalne zaprojektowanie kodu z rozmiarem tablic 15 i tak ˛a sam ˛a liczba operacji w p˛etli wewn˛etrznej powoduje zmniejszenie wydajno´sci przetwa-rzania do ok. 75 GFlops. Analiza kodu asemblera wyprodukowanego przez kompilator (icc) pokazuje,

˙ze zastosował on, podobnie jak w przypadku ci´snienia na rejestry, dost˛epy do pami˛eci, zamiast czy-stego przetwarzania z u˙zyciem rejestrów (dodatkowo zamiast wył ˛acznie rejestrów 256-bitowych, u˙zył dodatkowo rejestrów 128-bitowych obsadzonych tylko pojedynczymi liczbami podwójnej precyzji).

W celu przywrócenia przetwarzania zbli˙zonego do optymalnego mo˙zna w tym momencie zastoso-wa´c technik˛e rozci ˛agania (rozpychania?, watowania?) tablic (array padding). Polega ona na u˙zyciu tablic wi˛ekszych ni˙z wymaga tego aplikacja, tak aby umo˙zliwi´c kompilatorowi i sprz˛etowi działanie prowa-dz ˛ace do wy˙zszej wydajno´sci. Rozci ˛aganie tablic stosuje si˛e najcz˛e´sciej w celu optymalizacji dost˛epów do pami˛eci, niemniej tak˙ze w tym przypadku, optymalizacji przetwarzania wektorowego, prowadzi ona do po˙z ˛adanych efektów.

Zastosowanie rozci ˛agania tablic jest niezwykle proste. Nale˙zy zaalokowa´c wi˛eksze tablice, o ˙z ˛ ada-nym rozmiarze, a nast˛epnie dostosowa´c obliczenia. W przypadku analizowanego kodu nale˙zy zaaloko-wa´c tablice o rozmiarze 16 i zwi˛ekszy´c liczb˛e iteracji w p˛etli wewn˛etrznej tak˙ze do 16 (ostatni wyraz tablicy dobrze jest zainicjowa´c (np. warto´sci ˛a zero), tak aby unikn ˛a´c ewentualnych (cho´c mało prawdo-podobnych) problemów przetwarzania warto´sci nietypowych (np. NaN) ). Po kompilacji i uruchomieniu okazuje si˛e, ˙ze czas wykonania programu uległ znacznemu skróceniu, mimo ˙ze w całym programie wy-konywana jest wi˛eksza liczba operacji ni˙z przed optymalizacj ˛a.

Dalsze modyfikacje kodu zale˙z ˛a od aplikacji. W przypadku badanego programu nale˙zy zmodyfiko-wa´c sposób liczenia wydajno´sci. Mimo wykonywania 16 iteracji w p˛etli wewn˛etrznej, efektywna praca na potrzeby aplikacji dotyczy tylko 15 iteracji i 15 elementów ka˙zdej z tablic. Co oznacza, ˙ze praca sprz˛etu z wydajno´sci ˛a ok. 240 GFlops (uzyskana w układzie optymalnym), na potrzeby aplikacji daje tylko ok. 227 GFlops (czyli ok. 240*15/16).

Taki sposób liczenia wydajno´sci przyjmowany b˛edzie w całej ksi ˛a˙zce. Wa˙zna b˛edzie u˙zyteczna praca wykonana na potrzeby aplikacji, niezale˙znie od tego ile i jakie operacji zrealizuje sprz˛et. Jest to natu-ralne podej´scie, odpowiadaj ˛ace nie tylko zastosowanym technikom optymalizacji (które mog ˛a wprowa-dza´c nieu˙zyteczne, dodatkowe operacje, które jednak skracaj ˛a czas wykonania programu), ale tak˙ze np.

z analizy pracy procesorów (rdzeni), które wykonuj ˛a wiele operacji nie odpowiadaj ˛acych instrukcjom kodu (i rozkazom asemblera), np. przy pobieraniu z wyprzedzeniem, przewidywaniu skoków i innych typach wykonania spekulatywnego. Z przyj˛ecia zało˙zenia, ˙ze wydajno´s´c dotyczy tylko operacji efektyw-nie wykonanych na potrzeby aplikacji, wynika tak˙ze wzgl˛edna przydatno´s´c zliczania zdarze´n sprz˛eto-wych – istotne jest nie tyle ile operacji wykonał procesor (rdze´n), ale ile z tych operacji przeło˙zyło si˛e na efektywna prac˛e programu.

Powiązane dokumenty