Twoje PC  
Zarejestruj się na Twoje PC
TwojePC.pl | PC | Komputery, nowe technologie, recenzje, testy miejsce na Twojš reklamę
M E N U
  0
 » Nowości
0
 » Archiwum
0
 » Recenzje / Testy
0
 » Board
0
 » Rejestracja
0
0
 
Szukaj @ TwojePC
 

w Newsach i na Boardzie
 
TwojePC.pl © 2001 - 2024
RECENZJE | Technika: x86 i rozszerzenia...
    

 

Technika: x86 i rozszerzenia...


 Autor: shadow | Data: 10/08/04

Technika: x86 i rozszerzenia...Porównując opisy współczesnych procesorów x86 natkniemy się na całą masę tajemniczych skrótów - wśród nich znajdziemy też takie jak MMX, 3DNow! czy SSE. Co kryje się za tymi nazwami? W kilku słowach - zestawy dodatkowych instrukcji obsługiwanych przez jednostkę centralną. Swego rodzaju dodatkowe słownictwo języka, którym operuje procesor. Dzięki temu - jeśli wierzyć marketingowemu zgiełkowi producentów - nasz komputer nie dość, że zmiecie przeciwników, to jeszcze pozostanie rześki na długie lata. Brzmi pięknie, ale czy stwierdzenie takie jest chociaż bliskie prawdy? Czy na tej podstawie warto dokonywać wyboru - przecież każdy producent oferuje w swoim rozwiązaniu coś, czego nie znajdziemy u konkurencji. Czym różnią się od siebie najpopularniejsze rozszerzenia? Na czym polega ich działanie, jak wygląda praktyczne ich użycie? Spróbujemy znaleźć odpowiedzi na te pytania. To nie wszystko - by mieć solidne podstawy, drogę do nowoczesnego x86 rozpoczniemy od pozbawionego dodatków procesora 386.




Praprzodek, czyli wycieczka ku 16 bitom

Podróż przez rozszerzenia architektury procesorów x86 rozpoczniemy od skoku w przeszłość. Pierwszy powszechnie znany komputer osobisty firmy IBM - PC model 5150 - zbudowany został w oparciu o kość 8088. Był to funkcjonalny odpowiednik 16 bitowego procesora 8086, lecz z przyciętą do 8 bitów szyną danych - sama konstrukcja komputera stawała się przez to trochę prostsza, a przy okazji można było zastosować tańsze, 8 bitowe elementy. Z punktu widzenia oprogramowania procesory te różniły się jednak głównie prędkością jego wykonywania. Możliwe były operacje tak na liczbach 8, jak i 16 bitowych - zwanych słowami.


Praprzodek: pierwszy CPU komputerów IBM PC (źródło: Red Hill CPU Guide)

Kolejny przedstawiciel rodziny - 80186 - nie zmienił zasadniczo modelu programowania. Układ ten integrował rdzeń procesora z kilkunastoma układami pomocniczymi (m.in. zegarem, kontrolerami DMA i przerwań), wprowadził też kilka nowych instrukcji, powszechnie przypisywanych następcy - prawdopodobnie ze względu na nikłą obecność 186 w komputerach PC. Rewolucyjne zmiany poczynił za to 80286 - wprowadzono nowy sposób pracy - tryb chroniony. Niestety, wciąż 16 bitowy. Wymagania użytkowników wciąż rosną - tak było i za tamtych, jakże odległych już czasów. Programy stawały się coraz większe. Podział pamięci na bloki o rozmiarze do 64 KB (216 bajtów), zwane segmentami, stawał się największą kulą u nogi 286 - co z tego, że pamięci mogło być do 16 MB, jeśli operowanie większymi strukturami było co najmniej niewygodne. Rozwiązanie? Więcej bitów.


32 bity w rodzinie: 386


Intel 80386 - x86 goni konkurencję

Wielkie nadzieje wiązano z kolejnym układem Intela - 80386. Przy zachowaniu wstecznej zgodności z 16 bitowymi poprzednikami, była to konstrukcja w pełni 32 bitowa. Oznaczało to dostęp - przynajmniej teoretycznie - aż do 4 GB ciągłej przestrzeni adresowej. Nowy, 32 bitowy typ danych ochrzczono niezbyt fortunnym mianem podwójnego słowa. Intel nie chciał wprowadzać zamieszania, i słowo pozostało liczbą 16 bitową, wbrew potocznemu rozumienia tego terminu - natywnego typu danych procesora.

386 przyniósł ze sobą znacznie więcej - mechanizm stronicowania (podziału pamięci na dosyć małe bloki - 4 KB) we współpracy z wbudowaną jednostką MMU (ang. Memory Management Unit) umożliwiał realizację wymiatanej na dysk pamięci wirtualnej. Także i instrukcje operujące na bitach okazały się całkiem przydatne. Wprowadzono nowe tryby adresowania pamięci, upraszczające pracę programisty. Dzięki temu linia x86 pierwszy raz stała się faktycznie konkurencyjna wobec propozycji innych producentów.

Wszystko to działo się w połowie lat 80 ubiegłego wieku - spodziewać się więc można dalszej ewolucji podstawowej architektury x86. Rzeczywistość trochę jednak zaskrzeczała - szersze przyjęcie 32 bitowego oprogramowania nadeszło dopiero ładnych parę lat później. Przez dłuższy czas kolejne procesory - 486, ale też i Pentium - wykorzystywano po prostu jak turbodoładowane odpowiedniki 80286. Stąd kolejna znacząca zmiana - 64 bitowe rozszerzenia AMD64 - rozpoczęła się dopiero w roku 2003, wraz z wprowadzeniem Opteronów.


A gdyby tak coś policzyć?

Właśnie - mowa tu o ewolucji procesorów, typach danych, etc. Ale jak w praktyce wygląda styk program-procesor? Jak wyglądają instrukcje, które wykonuje CPU? Przełamując strach przed asemblerem, postaramy się przebrnąć przez obliczanie prostego wyrażenia arytmetycznego. Słyszę już głosy - po co? Spokojnie - zobaczymy, jak z liczeniem poradzi sobie procesor klasy 386, a później - gdy dojdziemy do mających w końcu być bohaterami tekstu rozszerzeń - zobaczymy, co wnoszą w kwestii wykonania omawianego kodu. Zajmijmy się zatem jednym z etapów obliczania pierwiastków równania:

ax2 + bx + c

W pewnym momencie obliczeń potrzebny będzie tzw. wyróżnik trójmianu - oznaczany literą delta:

Δ = b2 - 4 ac

Wyrażenie to zapisane w języku wysokiego poziomu przyjmie postać:

b * b - 4 * a * c

Zobaczymy teraz, jaki kod zostanie wygenerowany przez kompilator.

 89 da                   mov    edx, ebx
 0f af d3                imul   edx, ebx
 0f af c1                imul   eax, ecx
 c1 e0 02                shl    eax, 0x2
 29 c2                   sub    edx, eax
 89 d0                   mov    eax, edx

Wygląda groźnie? Spokojnie - to wbrew pozorom proste. Spróbujemy policzyć deltę dla równania 5*x^2 + 10*x + 4 (powinniśmy otrzymać w wyniku 20). Zacznijmy od pierwszej instrukcji:

mov edx, ebx

Rozkaz ten (MOVe) służy kopiowaniu wartości między pamięcią bądź rejestrami. Tutaj będziemy mieli do czynienia wyłącznie z rejestrami ogólnego przeznaczenia. Te zaś w przypadku procesorów zgodnych z 386 noszą nazwy eax, ebx, ecx oraz edx. Jaki więc będzie efekt wykonania pierwszej instrukcji? Wydawać się może, że zawartość rejestru edx skopiowana zostanie do ebx. Jest inaczej - w składni firmy Intel, którą tutaj się posługujemy - pierwszy argument to cel, a dopiero drugi - źródło. Instrukcja ma więc postać:

mov dokąd, co

Pierwszy rozkaz przeniesie zatem zawartość rejestru ebx do edx. Dlaczego? Co takiego kryje się w ebx? No tak, trzeba było od tego zacząć - wartości zmiennych a, b oraz c umieściłem - odpowiednio - w rejestrach eax, ebx, ecx. Zawartość rejestrów wygląda więc po tym kroku tak:

EAX = 5EBX = 10ECX = 4EDX = 10

Kolejna instrukcja:

imul edx, ebx

dokonuje mnożenia rejestrów (Integer MULtiply) edx oraz ebx - podobnie jak w poprzedniej, tak i tu wynik zostanie zapisany w rejestrze wymienionym jako pierwszy. 10 razy 10 daje 100, tak więc tej liczby spodziewać się możemy wewnątrz edx:

EAX = 5EBX = 10ECX = 4EDX = 100

Zauważmy, że tak naprawdę policzyliśmy kwadrat liczby b. Nie powinien więc dziwić następny rozkaz:

imul eax, ecx

Wykonuje tu się kolejny krok obliczeń - liczba a zostanie przemnożona przez c, wynik zapisany w eax:

EAX = 20EBX = 10ECX = 4EDX = 100

We wzorze na deltę a * c wzięte jest cztery razy - stąd kolejna instrukcja pomnoży zapewne zawartość rejestru eax. Sprawdźmy to:

shl eax, 2

Czyżby nie? A jednak - rozkaz shl to przesunięcie argumentu w lewo (SHift Left). To jakby dopisanie zer (tutaj dwóch) na koniec liczby. W systemie dziesiętnym odpowiada to mnożeniu razy 10^2, lecz komputer działa w systemie dwójkowym - tutaj mnożymy razy 2^2. Tak, tak - to właśnie oczekiwane przez nas działanie, zapisane w szybszej postaci. Kompilatory stosują tą formę przy mnożeniu przez potęgi dwójki. Jest jeszcze jeden powód, dla którego nie wykorzystaliśmy ecx - to, że jest tam czwórka, jest zasługą danych wejściowych - nasze c ma taką akurat wartość. Spójrzmy, co mamy w rejestrach:

EAX = 80EBX = 10ECX = 4EDX = 100

Hmm, pozostaje odjąć 4*a*c (teraz w eax) od b*b (aktualnie w edx). Rozkaz odejmowania nosi nazwę sub (SUBtract), nie powinna więc dziwić następująca konstrukcja:

sub edx, eax

EAX = 80EBX = 10ECX = 4EDX = 20

Mamy wynik - 20! Ze względu na zastosowaną przez kompilatory konwencje, wynik należy zwrócić przez rejestr eax:

mov eax, edx

EAX = 20EBX = 10ECX = 4EDX = 20

Brawo - udało się dobrnąć końca analizy. Kod działa, ale jest z nim kilka problemów. Pierwszy, którym się zajmiemy, to rodzaj przetwarzanych danych. Cały czas mówimy o liczbach całkowitych - procesory x86, począwszy od 8088, a na 386 kończąc - nie potrafiły przetwarzać liczb zmiennoprzecinkowych. Do tego celu przeznaczone zostały osobne układy - koprocesory numeryczne x87, którymi zajmiemy się w następnym paragrafie. Problem drugi, innej natury - załóżmy, że mamy policzyć dużą ilość delt. Jak to zrobić? Niestety, trzeba tyle razy wywołać omówiony kod, ile jest danych do policzenia. Może da się jakoś inaczej? Da się - ale to dopiero za chwilę.


Towarzystwo dla 8086 - koprocesor numeryczny 8087 (źródło: cpu-museum.com)


Latający przecinek


486 zintegrował CPU z FPU w ramach jednej struktury krzemowej

Nie ukrywajmy - liczby całkowite to nie koniec marzeń. Dosyć często przychodzi nam zmierzyć się z problemem, w którym potrzebne będzie trochę więcej precyzji. Możemy "podzielić" rejestr na część całkowitą i ułamkową (ang. fixed point), nie daje to jednak takich możliwości, jak użycie formatów zmiennoprzecinkowych (ang. floating point). Stosowane powszechnie formaty zgodne z normą IEEE-754 działają na innej zasadzie. Na początek zastanówmy się nad wykładniczym zapisem liczby 324. Możemy zapisać ją w kilku postaciach, przesuwając przecinek:

0,324 * 10^3
3,24 * 10^2
32,4 * 10^1
324 * 10^0

Zwykle stosuje się postać z jedną cyfrą przed przecinkiem, zwany notacją naukową. Wystarczy zapamiętać tzw. mantysę (pole znaczące) - 3,24, oraz wykładnik - 2, by w danym systemie liczbowym odtworzyć konkretną liczbę. Dla systemu binarnego otrzymamy wzór:

(-1)znak · mantysa · 2wykładnik

O dokładności zapisu liczby decyduje ilość bitów poświęconych mantysie. Zmiana wykładnika powoduje jedynie przesunięcie przecinka - ot, zmienny przecinek.

Liczba zmiennoprzecinkowa podzielona jest więc na trzy pola: bit znaku, mantysę oraz wykładnik. Koprocesory x87 operują na trzech formatach liczb:

typ liczby ilość bitów bity mantysy bity wykładnika
single

32

23

8

double

64

52

11

extended

80

64

15

Formaty single oraz double, dzięki sprytnemu wykorzystaniu specyfiki systemu binarnego, efektywnie dysponują dodatkowym bitem mantysy - można uznać, że ma ona 24 lub 53 bity.

Gdzie koprocesor przechowuje liczby? Do tego celu wydzielono osiem 80 bitowych (zdolnych pomieścić liczby typu extended) rejestrów. Niestety, zdecydowano się na utworzenie stosu. Nie ma więc możliwości bezpośredniego wyboru danego rejestru - mamy do nich dostęp jedynie względem wierzchołka, oznaczanego symbolicznie ST lub ST(0). Wyższe numery oznaczają kolejne elementy stosu - od ST(1) aż do ST(7). Naturalnie, wierzchołek przemieszcza się w czasie obliczeń - jeśli w danym kroku obliczeń liczba była osiągalna przez ST(2), to w następnym być może trzeba będzie użyć ST(3). Zresztą, zobaczymy to na przykładzie. Naturalnie - delta.

 dd 45 f0                fld    [b]
 dc 4d f0                fmul   [b]
 dd 45 f8                fld    [a]
 dd 05 50 85 04 08       fld    [4]
 de c9                   fmulp  st(1)
 dc 4d e8                fmul   [c]
 de e9                   fsubp st(1)

Dla uproszczenia - zamiast adresów pamięci - w nawiasach kwadratowych podałem nazwy liczb, do których odwołują się instrukcje. Rozpoczynamy od policzenia b*b - ładujemy b na stos (fld - Floating poing LoaD):

fld [b]

Stos wygląda więc następująco:

ST(0):10

Podnosimy liczbę do kwadratu, mnożąc wierzchołek stosu przez b (fmul - Fp MULtiply):

fmul [b]

Wynik trafia znów na wierzchołek stosu:

ST(0):100

Ładujemy teraz a (równe 5), oraz stałą 4:

fld [a]
fld [4]

Stos będzie miał postać:


ST(0):4
ST(1):5
ST(2):100

By otrzymać liczbę 100, musimy teraz odwołać się do trzeciego elementu stosu - ST(2) - a jeszcze przed chwilą był to wierzchołek (ST)! Następny fragment:

fmulp st(1)

to mnożenie ze zdjęciem ze stosu (Fp MULtiply and Pop). Wykonane jest mnożenie ST(1)*ST(0), wynik zostaje zapisany w ST(1), a ST(0) zdjęte ze stosu:

krok pierwszy (multiply):
ST(0):4
ST(1):20
ST(2):100

krok drugi (pop):
ST(0):20
ST(1):100

fmul [c]

Kolejny rozkaz to zwykle mnożenie ST(0) * c, z wynikiem umieszczonym na wierzchołku stosu:

ST(0):80
ST(1):100

Wreszcie ostatnia instrukcja odejmuje pozostałe liczby:

fsubp st(1)

Wynik odejmowania zapisany zostaje w ST(1), ale ponieważ chcemy też zdjęcia wierzchołka stosu (forma Fp SUBtract and Pop), otrzymamy:

ST(0):20

Pięknie - kolejny raz poprawny wynik. Zdążyliśmy zauważyć, że stos potrafi zaciemnić obraz sytuacji. Co gorsza, większość instrukcji za niejawny argument przyjmuje ST(0). Poważnie utrudnia to proces optymalizacji oprogramowania - przynajmniej w porównaniu do maszyn o bezpośrednim dostępie do rejestrów FPU. Co można zrobić, gdy chcemy dodać ST(1) do ST(5)? Jest pewne rozwiązanie. Koprocesor oferuje instrukcję fxch (Fp eXCHange), która zamienia zawartość wierzchołka z danym elementem stosu. Można zatem zamienić ST(1) z wierzchołkiem, po czym wykonać operację dodawania z elementem ST(5), by za pomocą kolejnego fxch przywrócić kolejność danych na stosie. Otrzymujemy zatem funkcjonalność odpowiadającą praktycznie bezpośredniemu dostępowi do rejestrów. Powstaje pytanie - jakim kosztem? Jak długo wykonuje się fxch? Do procesorów 486 włącznie trwało to dłuższą chwilę. Ale począwszy od Pentium fxch stało się praktycznie "bezpłatne" - równolegle można było wykonywać inną operację FPU. Do czasu - w Pentium 4, ze względu na poważne cięcia w obszarze koprocesora, znowu trzeba się liczyć z kosztami wykonania fxch. Pech.

Tak oto dochodzimy do końca możliwości oferowanych przez klasyczną architekturę x86. Podsumujmy - mamy możliwość wykonywania operacji na liczbach całkowitych, a dzięki koprocesorowi - także i w formacie zmiennoprzecinkowym. Póki co to tyle - przenieśmy się teraz do roku 1997 - roku premiery procesora Pentium z rozszerzeniami MMX.

Trzy magiczne litery - MMX


Pentium MMX: nadeszła era SIMD

MMX był pierwszym rozszerzeniem architektury x86, które miało na celu poprawę własności "multimedialnych" tychże procesorów. Intel wyczuł dokonującą się rewolucję w sposobie korzystania z komputerów, rozpoczynający się boom na intensywne medialnie aplikacje. Stąd pomysł na zestaw MMX. By zająć się jego działaniem, musimy poznać kolejny skrót - SIMD. Akronim ten rozwija się do angielskiego określenia Single Instruction - Multiple Data. Oznacza to pracę nie z jedną daną, jak do tej pory, ale z ich zestawem - jedna instrukcja (SI) obrabia na raz wiele danych (MD). Dotychczasowy model pracy określić można jako SISD. Weźmy dla przykładu dodawanie dwóch wektorów liczb - A oraz B, obu długości czterech elementów. Klasyczna architektura wymagać będzie tylu właśnie rozkazów, po jednym na każde pole wektora:

add A[1], B[1]
add A[2], B[2]
add A[3], B[3]
add A[4], B[4]

Dla odmiany w modelu SIMD dysponujemy zazwyczaj rozkazami wykonującymi operacje równoległe (parallel). Równoległe dodawanie nazwijmy padd:

padd A, B

Ta jedna instrukcja wykona taką samą pracę, jak cztery poprzednie. Na tym właśnie polega idea przetwarzania SIMD - jeśli procesor dysponuje odpowiednimi zasobami wykonawczymi, dodawanie trwać będzie do czterech razy krócej! Pomysł ten też przyświecał konstruktorom Pentium MMX.

Zauważmy ciekawy fakt - by wykonać operację na całym wektorze, potrzebujemy rejestrów, w których przechowamy całe A, a także pełną zawartość B. Procesory rodziny 386 (486, ale również Pentium Classic i Pentium Pro) nie przewidują odpowiednio szerokich rejestrów - 32 bity to trochę za mało by osiągnąć satysfakcjonujący poziom zrównoleglenia przetwarzania. Powstała więc konieczność dodania nowych rejestrów, a wraz z nimi nowych typów danych, na których pracować będzie jednostka MMX. Przyjrzyjmy się im.


AMD K6 - MMX stalo sie standardem

Pentium MMX wprowadza 8 prawie-że-nowych rejestrów 64 bitowych. Skąd takie określenie? Otóż okazuje się, że rejestry te pokrywają się z rejestrami FPU. Od początku więc wiadomo, że konieczne będzie przełączanie między kodem FPU, a kodem MMX - po każdym segmencie trzeba poinformować procesor o konieczności wyczyszczenia stanu rejestrów. No cóż, od razu chociaż wiemy, że będą jakieś dodatkowe koszty - na usta ciśnie się pytanie - dlaczego? Odpowiedź nie jest skomplikowana - wielozadaniowe systemy operacyjne, przełączając zadania, zmieniają kontekst pracy procesora. Przy każdej takiej operacji trzeba zapamiętać zawartość wszystkich rejestrów procesora, także i stosu FPU. Gdyby wprowadzić zupełnie nowe rejestry, musielibyśmy i je zapamiętywać przy przełączaniu - a do tego konieczna jest modyfikacja odpowiednich części OS. Dzięki wykorzystaniu stosu x87 unikamy tego typu zmian. Na krótką metę jest to więc w miarę rozsądne rozwiązanie.

Rejestry nazwano mm0 - mm7. Dostęp do nich jest bezpośredni - nie ma problemów związanych ze stosem czy inną, bardziej skomplikowaną organizacją. Jakie typy danych mogą one przechowywać?

  • pakowane bajty - osiem liczb 8 bitowych
  • pakowane słowa - cztery liczby 16 bitowych
  • pakowane słowa podwójne - dwie liczby 32 bitowe
  • słowo poczwórne - liczba 64 bitowa

Typy pakowane to po prostu kilka liczb danego typu upakowanych jedna przy drugiej w rejestrze.

słowo 1słowo 2słowo 3słowo 4

Rzut okiem na oferowane przez MMX typy ujawnia podstawową wadę tego rozszerzenia: operuje ono wyłącznie na liczbach całkowitych. Musimy brać to pod uwagę, gdy będziemy rozważać użycie MMX - nie oznacza to jednak, że od razu trzeba je skreślić. W końcu bardzo wiele algorytmów operuje na liczbach całkowitych, bądź też daje się w taki sposób zapisać. Głowa do góry, nie będzie tak źle. Nawet delty skorzystają ;)

Jedną z głównych zalet intelowskiego rozszerzenia jest ciekawa odmiana arytmetyki - arytmetyka z nasyceniem (saturacją). Zwykłe obliczenia, w przypadku nadmiaru, powodują "zawinięcie" (ang. wrap-around) rejestru:

200 + 100 = 300 mod 256 = 44
(występuje "przebicie" licznika)

Arytmetyka z nasyceniem przyjmuje w takim przypadku maksymalną wartość oferowaną w ramach danej szerokości rejestru:

200 + 100 = 255
(maksymalna wartość zapisana na 8 bitach)

Tego typu obliczenia są więcej niż przydatne przy zadaniach dotyczących dźwięku i obrazu - przepełnienia mogą powodować trzaski, a w przypadku grafiki zasadnicze zmiany kolorów pikseli. MMX jest tu bardzo pomocny - nie dość, że można w jednym kroku działać na ośmiu 8 bitowych pikselach, to jeszcze nie trzeba sprawdzać przepełnień i dokonywać korekt, gdyż zajmie się tym sam procesor.

Zobaczmy, jakie możliwości daje zestaw rozkazów MMX:

  • przede wszystkim operacje arytmetyczne: dodawanie, odejmowanie, mnożenie, mnożenie z dodawaniem wyników
  • wersje z saturacją - odmiany z uwzględnieniem znaku bądź bez
  • operacje logiczne
  • porównywanie liczb
  • konwersja typów - np. pakowania typów 16 bitowych do postaci 8 bitowej
  • przesunięcia logiczne i arytmetyczne
  • transfer do/z rejestrów

Operacje wykonywane są oczywiście na całej zawartości rejestrów - mamy więc i odpowiednik przykładowej instrukcji padd - rodzinę paddb, paddw, paddd (bajty, słowa, słowa podwójne). Co z paddq? Niestety - arytmetyka na tym formacie danych nie jest możliwa, pozostają działania logiczne i przesunięcia.

Przyjrzyjmy się, jak można wykorzystać MMX do przyspieszenia liczenia kilku delt na raz - tym razem 4:

 0f 6f 45 f0             movq   mm0, [b1, b2, b3, b4]
 0f d5 45 f0             pmullw mm0, [b1, b2, b3, b4]
 0f 6f 4d d8             movq   mm1, [4,  4, 4,  4 ]
 0f d5 4d f8             pmullw mm1, [a1, a2, a3, a4]
 0f d5 4d e8             pmullw mm1, [c1, c2, c3, c4]
 0f f9 c1                psubw  mm0, mm1

Jak zwykle, rozpoczynamy od b*b. Ładujemy (MOVe Quadword) rejestr mm0 wektorem czterech współczynników b - są to liczby 16 bitowe. Mnożenia liczb tego typu dokonujemy przy pomocy polecenia pmullw (Packed MULtiply Low, Words). Działanie takie daje 32 bitowy wynik (16b * 16b), zakładamy jednak, że zmieści się on w dolnych 16 bitach - stąd słowo low w opisie instrukcji. Jeśli interesuje nas wyższe 16 bitów, skorzystać możemy z wariantu high - pmulhw. Kolejnym krokiem jest załadowanie rejestru mm1 wektorem składającym się z czwórek. Następnie należy pomnożyć je przez liczby a oraz c. Odpowiedzialne są za to dwie występujące po sobie instrukcje pmullw. Zróbmy mały przystanek - zobaczmy, co w tej chwili zawierają rejestry mm0 oraz mm1.

mm0:
b1 * b1b2 * b2b3 * b3b4 * b4
mm1:
4 * a1 * c14 * a2 * c24 * a3 * c34 * a4 * c4

Jak widać, by otrzymać w rejestrze mm0 wyniki, wystarczy odjąć od jego elementów odpowiednie słowa mm1 - to właśnie zadanie wykonuje ostatnia instrukcja - psubw (Packed SUBtract, Words)

Wynik:

b1*b1 - 4*a1*c1b2*b2 - 4*a2*c2b3*b3 - 4*a3*c3b4*b4 - 4*a4*c4

Wspaniale - dzięki MMX, przy praktycznie takim samym nakładzie pracy, możemy wykonać kilka razy więcej obliczeń. Pójdźmy jednak krok dalej - gdyby teraz z delty wyciągnąć pierwiastek... No tak. Arytmetyka całkowitoliczbowa. Jednak pozostaje pewien niedosyt... Cóż, musimy szukać dalej. Może teraz u konkurencji?

3DWhen? 3DNow!


Pierwszy przedstawiciel jednostek 3DNow! (źródło: AMD)

Właśnie - konkurencja. Błędem by było zakładać, że takie firmy jak AMD czy Cyrix siedziały z założonymi rękoma, obserwując poczynania giganta. Nie, wszędzie trwały prace - i to nie tylko nad integracją MMX z własnymi produktami, ale również polepszeniem własności rozszerzenia. Cyrix rozbudował implementację MMX w swoich procesorach 6x86MX/M-II, dodając kilka ciekawych rozwiązań - przykładowo część instrukcji potrafiła korzystać z trzech operandów. Projektanci planowali też wprowadzenie czegoś bardzo ciekawego - wariantu MMX operującego na liczbach w formacie zmiennoprzecinkowym. MMXfp - tak miało się to rozwiązanie nazywać - został jednak porzucony na rzecz propozycji AMD. Pojawił się nowy standard - pierwsze w świecie x86 rozszerzenie SIMD FP - 3DNow! Stało się ono jednym z mocniejszych argumentów procesorów serii K6-2.




Zmiany poczynione pomiędzy K6 a K6-2 nie są wielkie (sandpile.org)

Nazwa wskazuje na pole potencjalnego zastosowania - rozrywkowych aplikacji 3D. Dlaczego rozrywkowych? Przyjrzyjmy się, co nowego wprowadza 3DNow! Po pierwsze - zachowany zostaje model rejestrów MMX. Jest ich tyle samo, i są tej samej wielkości. Co oznacza to w praktyce? By do jednego 64 bitowego rejestru zmieścić więcej niż jedną liczbę zmiennoprzecinkową, musimy ograniczyć precyzję do 32 bitowego typu single:

32 bity - liczba single32 bity - liczba single

To właśnie stanowi o mniejszej przydatności 3DNow! do szerszej gamy zastosowań - często pożądana bywa podwójna precyzja. Zaś liczby pojedynczej precyzji są wystarczające, jeśli chodzi o przekształcenia 3D używane często w grach. Tak więc oprogramowanie rozrywkowe stało się głównym celem AMD. Był to spory krok do przodu - MMX od początku powinien był zawierać wsparcie dla operacji na liczbach zmiennoprzecinkowych, tak jednak się nie stało. To o tyle ciekawe, że "budżet krzemowy" 3DNow! jest raczej skromny - procesor K6 od K6-2 różni pół miliona tranzystorów.

Przyjrzyjmy się operacjom oferowanym przez 3DNow!

  • zmiennoprzecinkowe dodawanie, odejmowanie, mnożenie
  • wsparcie dla operacji dzielenia i wyciągania pierwiastka kwadratowego
  • konwersja typów
  • dodawanie "poziome"
  • wstępne pobieranie danych do obróbki (prefetch)

MMX nie umożliwiał wykonywania dzielenia - 3DNow! daje taką możliwość, choć nie w jednym kroku. Zależnie od wymaganej precyzji, dzielenie koduje się 4 bądź 7 instrukcjami. Podobnie jest zresztą z pierwiastkowaniem - tu również możemy poświęcić precyzję kosztem czasu wykonania. Dzięki omawianym operacjom ograniczyć można użycie koprocesora x87, a więc związane z tym przełączanie stanu FPU - chociaż dzięki nowej instrukcji (femms) może ono być znacznie mniej kosztowne.

Czym jest dodawanie "poziome"? Jest to operacja dodawania wykonana na liczbach zawartych w jednym rejestrze - okazuje się ona być całkiem przydatna w wielu sytuacjach.

Wreszcie kilka słów wypada powiedzieć na temat wstępnego pobierania danych - data prefetching. Jeśli wiemy, że wkrótce wykonywać będziemy operacje na bloku danych, dobrze jest wcześniej poinformować o tym procesor. Umożliwiają to instrukcje prefetch/prefetchw zawarte w zestawie 3DNow! W ten sposób liczyć możemy na to, że dane znajdą się w pamięci podręcznej - pobieranie ich z pamięci głównej może odbywać się "w tle", podczas przetwarzania poprzedniej iteracji pętli. Ukrywa się w ten sposób czas sprowadzenia danych, efektywnie zwiększając prędkość działania programu.

Rzućmy okiem na nieśmiertelne już delty - tym razem dwie za jednym zamachem:

 0f 6f 45 f0             movq   mm0, [b1, b2]
 0f 0f 45 f0 b4          pfmul  mm0, [b1, b2]
 0f 6f 4d d8             movq   mm1, [4.0, 4.0]
 0f 0f 4d f8 b4          pfmul  mm1, [a1, a2]
 0f 0f 4d e8 b4          pfmul  mm1, [c1, c2]
 0f 0f c1 9a             pfsub  mm0, mm1

Nie ma potrzeby dokładniej omawiać kodu - sytuacja odpowiada praktycznie tej z przykładu dla MMX, tyle że tu przetwarzamy dwie liczby naraz. Do tego są to liczby zmiennoprzecinkowe - stąd literka f w nazwie instrukcji.

No tak - delta deltą, ale właściwie dlaczego operacje równoległe mogą być przydatne w przetwarzaniu grafiki 3D? Otóż bierze to się ze sposobu, w jaki zwykle opisuje się przekształcenia w przestrzeni trójwymiarowej. Współrzędne punktu przechowuje się jako wektor o rozmiarze 4x1, "przepisy" na operacje takie jak obroty, skalowanie czy przesunięcia zapisać można w postaci macierzy 4x4. Weźmy - przykładowo - macierz R wymiaru 4x4 opisującą obrót, oraz wektor v, na którym chcemy dokonać przekształcenia. Wynikowy wektor v' otrzymamy w wyniku mnożenia:

By obliczyć pierwszą współrzędną przekształconego wektora v'1, czeka nas trochę pracy:

v'1 = r11 * v1 + r12 * v2 + r13 * v3 + r14 * v4

Podobnie liczymy dalsze współrzędne:

v'2 = r21 * v1 + r22 * v2 + r23 * v3 + r24 * v4
v'3 = r31 * v1 + r32 * v2 + r33 * v3 + r34 * v4
v'4 = r41 * v1 + r42 * v2 + r43 * v3 + r44 * v4

Jest to trochę roboty. Szesnaście mnożeń, dwanaście dodawań. Spróbujmy teraz wykorzystać możliwości 3DNow! Policzymy v'1...

  • rejestr mieści dwie liczby - załadujmy więc r11 i r12 do jednego z nich
  • drugi niech zawiera dwa pierwsze elementy wektora v - tj. v1 i v2
  • mnożymy oba rejestry przez siebie - otrzymujemy r11 * v1 oraz r12 * v2
  • w drugiej parze rejestrów w analogiczny sposób dochodzimy do r13 * v3 i r14 * v4
  • dodajemy wyniki:

    r11 * v1 + r13 * v3

    r12 * v2 + r14 * v4


  • musimy teraz dodać obie połówki rejestrów - potrzebujemy tutaj "poziomego" dodawania:

    r11 * v1 + r13 * v3 + r12 * v2 + r14 * v4

    r11 * v1 + r13 * v3 + r12 * v2 + r14 * v4


Gdy przyjrzymy się wynikom, widać, że otrzymaliśmy v'1! Wykonaliśmy dwa mnożenia, zwykłe i poziome dodawanie. Całość trzeba oczywiście cztery razy powtórzyć - by poznać pozostałe elementy v'. W sumie - osiem mnożeń i osiem dodawań, a to tylko najprostsza możliwa implementacja. Nieźle, prawda? Troszkę uwiera mały rozmiar rejestru - przydałoby się trochę więcej...

A gdyby mieć więcej miejsca?


Pentium III: witamy Streaming SIMD Extensions!

Jak wyglądałoby przekształcenie, gdybyśmy dysponowali dłuższym rejestrem - powiedzmy - 128 bitowym? Wystarczy do przechowania 4 liczb pojedynczej precyzji - akurat wiersz czy kolumna macierzy. Dla odmiany załóżmy, że tym razem nie dysponujemy dodawaniem poziomym. Co więc można zrobić? Zauważmy, że pierwszym krokiem we wszystkich wzorach jest wymnożenie r_1 przez v1. Podobnie - mnożymy elementy drugiej kolumny przez v2, etc. Jaka z tego korzyść? Zbierzmy elementy pierwszej kolumny w wektorze, drugi wypełnijmy wartościami v1:

[ r11 r21 r31 r41 ], [ v1 v1 v1 v1 ]

Pozostaje pomnożyć odpowiednie elementy obu wektorów, i... jedna czwarta pracy za nami - dzięki zastosowaniu podejścia SIMD, jednym ruchem wykonujemy cztery mnożenia odpowiadające pierwszym mnożeniom rozpisanych wcześniej wierszy!

[ r12 r22 r32 r42 ], [ v2 v2 v2 v2 ]
[ r13 r23 r33 r43 ], [ v3 v3 v3 v3 ]
[ r14 r24 r34 r44 ], [ v4 v4 v4 v4 ]

Po kolejnych trzech operacjach mamy składowe wyniku - wystarczy je tylko dodać do siebie. Użyliśmy czterech mnożeń SIMD, oraz trzech dodawań. Musieliśmy za to podać macierz w postaci kolumn, i wypełniać drugi wektor powielanymi elementami.

Tym samym zobaczyliśmy, w jaki sposób działa kolejne rozszerzenie - Streaming SIMD Extensions - SSE. Znane było początkowo jako Katmai New Instructions - KNI - a to ze względu na premierę wraz z pierwszymi procesorami Pentium III, o kodowej nazwie Katmai. Od tego czasu obecne jest w kolejnych produktach Intela (w Celeronach SSE obsługiwane jest począwszy od modeli opartych o jądro Coppermine). Po stronie AMD nowy zestaw instrukcji ochrzczony został mianem 3DNow! Professional - i pojawił się w pierwszych Athlonach XP - Palomino. Częściowo zgodne z SSE są też starsze procesory Athlon - niestety, nie dotyczy to operacji zmiennoprzecinkowych.

SSE wprowadza spore zmiany - przede wszystkim nowe, szerokie rejestry. Zrywa w ten sposób ze zgodnością ze starszymi systemami operacyjnymi - przykład zachowawczej konstrukcji MMX i tego konsekwencji pokazuje, że był to dobry wybór. Nowych rejestrów jest osiem, mają po 128 bitów - dokładnie tyle, ile potrzebne jest do przechowania 4 liczb single. Rejestry nazwano xmm0 - xmm7. Podobnie jak w przypadku rejestrów MMX, mamy do nich bezpośredni dostęp. Jedyny wprowadzony typ danych to właśnie liczby pojedynczej precyzji.

SSE wykonuje obliczenia w dwóch trybach:

  • packed - działania przeprowadzane są na wszystkich 4 polach rejestru (np. addps)
  • scalar - działania przeprowadzane są na jednej liczbie (addss)

Jaki ma sens wprowadzenie drugiej kategorii instrukcji? Nie zawsze chcemy działań równoległych, a szkoda marnować zasoby na niepotrzebne obliczenia. Szkoda też odwoływać się do rozkazów x87, po to tylko, by wykonać kilka operacji.

Jakie możliwości daje SSE? Podzielmy je na kategorie:

  • operacje arytmetyczne - nie tylko dodawanie, odejmowanie i mnożenie, ale też dzielenie, wyciąganie pierwiastka, szukanie minimum i maksimum
  • odwracanie liczb i ich pierwiastków
  • wykonywanie porównań
  • operacje logiczne
  • konwersję formatów
  • zamianę pól wewnątrz rejestru
  • transfer z/do rejestrów 128 bitowych
  • wstępne pobieranie danych, zapis z pominięciem cache
  • rozszerzenia całkowitoliczbowego MMX - makisma i minima, etc. - zasadniczo zgodne z rozszerzeniami AMD

SSE to pokaźny zestaw instrukcji - podobnie jak 3DNow!, zawiera też dodatkowi wspomagające przetwarzanie dużych bloków danych. Poza odczytem, można również wymusić zapis danych z ominięciem pamięci podręcznej. Stąd zresztą słowo Streaming w nazwie rozszerzenia - chodzi o wskazanie przydatności SSE w przetwarzaniu mediów strumieniowych.

Wydaje się, że w starciu z SSE zestaw 3DNow! nie ma żadnych szans. Dysponuje przecież większymi rejestrami, potrzebuje więc mniej operacji... Owszem, lecz nikt nie daje nam gwarancji wykonywania działań w jednym cyklu - tak naprawdę rozkazy dzielone są na dwie 64 bitowe połowy. Teoretyczna przepustowość obu rozwiązań jest więc podobna. Niewątpliwie kod SSE może być bardziej zwarty. Szkoda tylko, że nie ma odpowiednika instrukcji poziomego dodawania.

Nadszedł więc chyba czas na... delty, a jak. Znów liczby zmiennoprzecinkowe, cztery za jednym zamachem:

 0f 28 45 28             movaps xmm0, [b1, b2, b3, b4]
 0f 59 45 28             mulps  xmm0, [b1, b2, b3, b4]
 0f 28 4d d8             movaps xmm1, [4.0, 4.0, 4.0, 4.0]
 0f 59 4d 18             mulps  xmm1, [a1, a2, a3, a4]
 0f 59 4d 38             mulps  xmm1, [c1, c2, c3, c4]
 0f 5c c1                subps  xmm0, xmm1

Także i ten fragment nie wymaga zbyt obszernego komentarza - widzieliśmy już tyle wersji liczenia delty, że bez problemu rozróżniamy poszczególne operacje. Zwróćmy uwagę na wykorzystanie wariantów packed - zakończonych ps. Rejestry ładowane są przy pomocy movaps - zakłada to wyrównanie danych w pamięci do granicy 16 bajtów (MOVe Aligned).

Czego brakuje? Precyzji, precyzji...

Wszystkie omówione do tej pory dodatkowe zestawy instrukcji dobrze spełniają swoje zadanie. Szkoda tylko, że działają wyłącznie na danych pojedynczej precyzji. Dobrze by było, gdyby rozszerzyć SSE o działania na liczbach typu double. Stało się to podstawą konstrukcji kolejnej ewolucji rozszerzenia - SSE-2. Na rynek trafiło ono wraz z procesorem Pentium 4. Można odnieść wrażenie, że celowo "przycięto" jednostkę x87 celem wypromowania SSE-2. Trzeba przyznać, że faktycznie wraz z szerszym przyjęciem SSE-2 rozpoczął się proces odchodzenia od używania klasycznego koprocesora. Bezpośredni dostęp do rejestrów, operacje skalarne, a teraz jeszcze podwójna precyzja w SSE-2 - to wystarczające argumenty. Wciąż jednak pozostają operacje, których nie wykonamy przy użyciu jednostek SSE/SSE-2 (np. funkcje sinus czy cosinus).


SSE-2 pozwala uratować honor Pentium 4

Co nowego wnosi SSE-2 do modelu rejestrów? W wykonaniu firmy Intel - niewiele. Wprowadzony został nowy typ danych - dwie spakowane, 64 bitowe liczby double. Ilość rejestrów pozostała bez zmian. Dopiero implementacja SSE-2 będąca częścią AMD64 (EM64T) dodaje osiem kolejnych - xmm8 do xmm15.

Nie będzie prawdą stwierdzenie, jakoby SSE-2 ograniczać się miało do wprowadzenia operacji na nowym typie danych. SSE-2 to znacznie więcej. Przede wszystkim - instrukcje MMX mogą teraz operować na rejestrach xmm - SSE-2 to jakby MMX-128. Stąd pojawiające się co jakiś czas wskazówki, by odchodzić od zestawu MMX na rzecz SSE-2 - osiąga się podobną funkcjonalność, ale operuje na 128 bitach na raz. Wraz z SSE-2 maleje więc znaczenie pierwszego z rozszerzeń SIMD x86.

Dochodzi do tego oczywiście zestaw rozkazów konwersji typów czy kopiowania danych.

Naturalnie, opis nie będzie kompletny bez kilku delt, prawda? ;)
Może najpierw wprowadzimy podwójną precyzję:

 66 0f 28 45 28          movapd xmm0, [b1, b2]
 66 0f 59 45 28          mulpd  xmm0, [b1, b2]
 66 0f 28 4d d8          movapd xmm1, [4.0, 4.0]
 66 0f 59 4d 18          mulpd  xmm1, [a1, a2]
 66 0f 59 4d 38          mulpd  xmm1, [c1, c2]
 66 0f 5c c1             subpd  xmm0, xmm1

Zmianą w stosunku do SSE jest pojawienie się końcówki d - od specyfikacji precyzji - double. Rejestr mieści teraz dwie liczby, poza tym kod jest praktycznie identyczny.

Mowa była o wykorzystaniu SSE-2 jako 128 bitowej odmiany MMX. Umożliwi to jednorazowe policzenie ośmiu 16 bitowych wyników:

 0f 28 45 28             movaps xmm0, [b1, b2, b3, b4, b5, b6, b7, b8]
 66 0f d5 45 28          pmullw xmm0, [b1, b2, b3, b4, b5, b6, b7, b8]
 0f 28 4d d8             movaps xmm1, [4,  4,  4,  4,  4,  4,  4,  4]
 66 0f d5 4d 18          pmullw xmm1, [a1, a2, a3, a4, a5, a6, a7, a8]
 66 0f d5 4d 38          pmullw xmm1, [c1, c2, c3, c4, c5, c6, c7, c8]
 66 0f f9 c1             psubw  xmm0, xmm1

Porównajmy kod z odpowiednikiem MMX - różnią się tylko odwołania do rejestrów (no i oczywiście sposób ich ładowania).
Można już odetchnąć z ulgą - to ostatnie pojawienie się delt w tym tekście ;)

Co kryje w sobie Prescott?

Jak Intel mógł nazwać dodatkowe instrukcje zaszyte w jądrze Prescott? Kodowe określenie - PNI - Prescott New Instructions, zmienione zostało na SSE-3. To w sumie kilkanaście nowych rozkazów, dotyczących różnych obszarów działania procesora. Podzielimy je na grupy - rozkazy FPU, rozszerzenia SSE-2, oraz dodatkowe wsparcie dla technologii Hyper Threading.

SSE-3 dodaje nową instrukcję FPU (tak, rozszerzony zostaje stary zestaw instrukcji x87). fisttp (Fp Integer Store with Truncation and Pop) przekształca liczbę zmiennoprzecinkową na jej całkowitą postać - obcinając część ułamkową. Różni się tym od starszego rozkazu fistp, który potrafi skorzystać z innych metod zaokrąglania liczb.

Instrukcje SSE-2 uzupełnione zostały kilkoma pożytecznymi dodatkami. Rodzina intelowskich rozszerzeń doczekała się operacji poziomych - dodawania (haddps, haddpd) oraz odejmowania (hsubps, hsubpd).

Dostępne są też mieszane operacje dodawania/odejmowania elementów rejestrów - addsubps, addsubpd. Instrukcje te okazują się być szczególnie przydatne przy mnożeniu liczb zespolonych. Także i w tym przypadku Intel dopiero dogania konkurencję - podobne polecenie (pfpnacc) AMD udostępniło użytkownikom wraz z rozszerzonym na potrzeby Athlona Enhanced 3DNow! Również z myślą o algorytmach liczb zespolonych dodano rozkazy przesunięć z powielaniem argumentów (movddup - MOVe Double and DUPlicate, movshdup - MOVe Single High and DUPlicate, movsldup - MOVe Single Low and DUPlicate).

Rozkaz lddqu ładuje niewyrównane do granicy 16 bajtów dane do wybranego rejestru xmm. W porównaniu z dostępnymi do tej pory instrukcjami - przy odwołaniach przechodzących przez granice linii pamięci cache, lddqu może znacząco przyspieszyć działanie programu. Intel poleca użycie tej instrukcji przy projektowaniu oprogramowania kodującego wideo - występuje w nim sporo niewyrównanych odwołań.

Pozostają dwie nowości dotyczące HT - monitor oraz mwait. monitor ustala obszar pamięci, który w specjalnym trybie pracy monitorowany będzie na wystąpienie pewnych operacji (np. zapisu). By procesor wszedł we wspomniany tryb, użyć należy drugiego rozkazu - mwait. Procesor zajmuje się wtedy monitorowaniem wybranego wcześniej obszaru - normalnie to program musiałby w pętli sprawdzać, czy operacja zapisu została już wykonana, czy może jeszcze nie. Oszczędzamy w ten sposób jednostki wykonawcze CPU, daje to też pozytywny efekt jeśli chodzi o konsumpcję energii. W przypadku Prescotta to dosyć ważne ;)

Wszystko? Gdzieżby, może większość...

Tak oto docieramy do końca podróży przez dodatki do architektury x86. Powtórzmy, gdzie się zatrzymywaliśmy. Sprawdziliśmy, jak wyglądają klasyczne operacje wykonywane przez CPU. By nie straszne nam były ułamki, wpadliśmy na moment z wizytą do sąsiedniej krainy x87. Później rozpoczęliśmy eksplorację MMX, zaraz potem 3DNow!, nie obyło się też bez kilku odmian SSE. Poznaliśmy, w jaki sposób rozszerzenia te przyspieszać mogą popularne obliczenia przekształceń 3D, przy okazji wiemy też, jak w miarę szybko policzyć kilka wyznaczników trójmianu ;) Mam nadzieję, że ciekawość Czytelników została w pewnym chociaż stopniu zaspokojona.









Polub TwojePC.pl na Facebooku

Rozdziały: Technika: x86 i rozszerzenia...
 
Wyświetl komentarze do artykułu »