Jak pisać programy w języku asembler pod Linuksem?

Część 6 - SIMD, czyli jak działa MMX

- A cóż to takiego to SIMD ?! - zapytacie.
Już odpowiadam.
SIMD = Single Instruction, Multiple Data = jedna instrukcja, wiele danych.
Jest to technologia umożliwiająca jednoczesne wykonywanie tej samej instrukcji na kilku wartościach. Na pewno znany jest wam co najmniej jeden przykład zastosowania technologii SIMD. Jest to MultiMedia Extensions, w skrócie MMX u Intela, a 3DNow! u AMD. Innym mniej znanym zastosowaniem jest SSE, które omówię później.
Zaczniemy od omówienia, jak właściwie działa to całe MMX.



MMX / 3DNow!

Technologia MMX operuje na 8 rejestrach danych, po 64 bity każdy, nazwanych mm0 ... mm7. Niestety, rejestry te nie są prawdziwymi (oddzielnymi) rejestrami - są częściami rejestrów koprocesora (które, jak pamiętamy, mają po 80 bitów każdy). Pamiętajcie więc, że nie można naraz wykonywać operacji na FPU i MMX/3DNow!.

Rejestry 64-bitowe służą do umieszczania w nich danych spakowanych. Na czym to polega? Zamiast mieć na przykład 32 bity w jednym rejestrze, można mieć dwa razy po 32. Tak więc rejestry mieszczą 2 podwójne słowa (dword, 32 bity) lub 4 słowa (word, 16 bitów) lub aż 8 spakowanych bajtów.

Zajmijmy się omówieniem instrukcji operujących na tych rejestrach.
Instrukcje MMX można podzielić na kilka grup (nie wszystkie instrukcje będą tu wymienione):

Rzadko która z tych instrukcji traktuje rejestr jako całość, częściej operują one na poszczególnych wartościach osobno, równolegle.

Spróbuję teraz podać kilka przykładów zastosowania MMX.


Przykład 1. Dodawanie dwóch tablic bajtów w pamięci. Bez MMX mogłoby to wyglądać mniej więcej tak:


(przeskocz dodawanie tablic)
; EDX - adres pierwszej tablicy bajtów
; ESI - adres drugiej tablicy bajtów
; EDI - adres docelowej tablicy bajtów
; ECX - liczba bajtów w tablicach. Przyjmiemy, że różna od zera...

	petla:
		mov al, [edx]	; pobierz bajt z pierwszej
		add al, [esi]	; dodaj bajt z drugiej
		mov [edi], al	; zapisz bajt w docelowej
		inc edx		; zwiększ o 1 indeksy tablic
		inc esi
		inc edi
		loop petla	; działaj, dopóki ECX różne od 0.

A z MMX:


(przeskocz dodawanie tablic z MMX)
	mov ebx, ecx	; EBX = liczba bajtów
	and ebx, 7	; będziemy brać po 8 bajtów - obliczamy
			; więc resztę z dzielenia przez 8

	shr ecx, 3	; dzielimy ECX przez 8
petla:
	movq mm0, [edx]	; pobierz 8 bajtów z pierwszej tablicy
	paddb mm0, [esi]; dodaj 8 spakowanych bajtów z drugiej
	movq [edi], mm0	; zapisz 8 bajtów w tablicy docelowej
	add edx, 8	; zwiększ indeksy do tablic o 8
	add esi, 8
	add edi, 8
	loop petla	; działaj, dopóki ECX różne od 0.

	test ebx, ebx	; czy EBX = 0?
	jz koniec	; jeśli tak, to już skończyliśmy

	mov ecx, ebx	; ECX = resztka, co najwyżej 7 bajtów.
			; te kopiujemy tradycyjnie
petla2:
	mov al, [edx]	; pobierz bajt z pierwszej
	add al, [esi]	; dodaj bajt z drugiej
	mov [edi], al	; zapisz bajt w docelowej
	inc edx		; zwiększ o 1 indeksy do tablic
	inc esi
	inc edi
	loop petla2	; działaj, dopóki ECX różne od 0
koniec:

	emms		; wyczyść rejestry MMX, by FPU mogło z nich korzystać

Podobnie będą przebiegać operacje PAND, POR, PXOR, PANDN.

Przy dużych ilościach danych, sposób drugi będzie wykonywał około 8 razy mniej instrukcji niż pierwszych, bo dodaje na raz 8 bajtów. I o to właśnie chodziło.


Przykład 2. Kopiowanie pamięci.
Bez MMX:


(przeskocz kopiowanie pamięci)
; DS:ESI - źródło
; ES:EDI - cel
; ECX - liczba bajtów
	mov ebx, ecx	; EBX = liczba bajtów
	and ebx, 3	; EBX = reszta z dzielenia liczby bajtów przez 4
	shr ecx, 2	; ECX = liczba bajtów dzielona przez 4

	cld		; kierunek: do przodu
	rep movsd	; dword z DS:ESI idzie pod ES:EDI, EDI:=EDI+4,
			; ESI:=ESI+4, dopóki ECX jest różny od 0
	mov ecx, ebx	; ECX = liczba pozostałych bajtów
	rep movsb	; resztkę kopiujemy po bajcie

Z MMX:


(przeskocz kopiowanie pamięci z MMX)
		mov ebx, ecx	; EBX = liczba bajtów
		and ebx, 7	; EBX = reszta z dzielenia liczby bajtów
				; przez 8

		shr ecx, 3	; ECX = liczba bajtów dzielona przez 8
	petla:
		movq mm0, [esi]	; MM0 = 8 bajtów z tablicy pierwszej
		movq [edi], mm0	; kopiujemy zawartość MM0 pod [EDI]
		add esi, 8	; zwiększamy indeksy tablic o 8
		add edi, 8
		loop petla	; działaj, dopóki ECX różne od 0

		mov ecx, ebx	; ECX = liczba pozostałych bajtów
		cld		; kierunek: do przodu
		rep movsb	; resztkę kopiujemy po bajcie

		emms		; wyczyść rejestry MMX

lub, dla solidniejszych porcji danych:


(przeskocz kolejne kopiowanie pamięci)
		mov ebx, ecx	; EBX = liczba bajtów
		and ebx, 63	; EBX = reszta z dzielenia liczby bajtów
				; przez 64
		shr ecx, 6 	; ECX = liczba bajtów dzielona przez 64
	petla:
		; kopiuj 64 bajty spod [ESI] do rejestrów MM0, ... MM7
		movq mm0, [esi]
		movq mm1, [esi+8]
		movq mm2, [esi+16]
		movq mm3, [esi+24]
		movq mm4, [esi+32]
		movq mm5, [esi+40]
		movq mm6, [esi+48]
		movq mm7, [esi+56]

		; kopiuj 64 bajty z rejestrów MM0, ... MM7 do [EDI]
		movq [edi   ], mm0
		movq [edi+8 ], mm1
		movq [edi+16], mm2
		movq [edi+24], mm3
		movq [edi+32], mm4
		movq [edi+40], mm5
		movq [edi+48], mm6
		movq [edi+56], mm7

		add esi, 64	; zwiększ indeksy do tablic o 64
		add edi, 64
		loop petla	; działaj, dopóki ECX różne od 0

		mov ecx, ebx	; ECX = liczba pozostałych bajtów
		cld		; kierunek: do przodu
		rep movsb	; resztkę kopiujemy po bajcie

		emms		; wyczyść rejestry MMX


Przykład 3. Rozmnożenie jednego bajtu na cały rejestr MMX.


(przeskocz rozmnażanie bajtu)
; format ELF executable		; tylko dla FASMa
; entry _start

; FASM: segment readable executable
section .text

global _start			; FASM: usunąć tę linijkę

_start:

	movq mm0, [wart1]	; mm0 = 00 00 00 00 00 00 00 33
				;  (33h = kod ASCII cyfry 3)

	punpcklbw mm0, mm0	; do najmłodszego słowa włóż najmłodszy bajt
				; mm0 i najmłodszy bajt mm0 (czyli ten sam)
				; mm0 = 00 00 00 00 00 00 33 33

	punpcklwd mm0, mm0	; do najmłodszego dworda włóż dwa razy
				; najmłodsze słowo mm0
				; mm0 = 00 00 00 00 33 33 33 33

	punpckldq mm0, mm0	; do najmłodszego (i jedynego) qworda włóż 2x
				; najmłodszy dword mm0 obok siebie
				; mm0 = 33 33 33 33 33 33 33 33

	movq [wart2], mm0

	emms			; wyczyść rejestry MMX

	mov	eax, 4
	mov	ebx, 1
	mov	ecx, wart2
	mov	edx, 9		; wartość2 + znak nowej linii
	int	80h		; wyświetl

	mov	eax, 1
	xor	ebx, ebx
	int	80h

; FASM: segment readable writeable
section .data

wart1:	db	"3"
	times 7 db 0			; trójka i 7 bajtów zerowych

wart2:	times	8	db	2	; 8 bajtów o wartości 2 != 33h

nowa_linia	db	0ah

Kompilujemy, uruchamiamy i ... rzeczywiście na ekranie pojawia się upragnione osiem trójek!

Technologia MMX może być używana w wielu celach, ale jej najbardziej korzystną cechą jest właśnie równoległość wykonywanych czynności, dzięki czemu można oszczędzić czas procesora.




Technologia SSE

Streaming SIMD Extensions (SSE), Pentium III lub lepszy oraz najnowsze procesory AMD
Streaming SIMD Extensions 2 (SSE 2), Pentium 4 lub lepszy oraz AMD64
Streaming SIMD Extensions 3 (SSE 3), Xeon lub lepszy oraz AMD64

Krótko mówiąc, SSE jest dla MMX tym, czym FPU jest dla CPU. To znaczy, SSE przeprowadza równoległe operacje na liczbach ułamkowych.
SSE operuje już na całkowicie osobnych rejestrach nazwanych xmm0, ..., xmm7 po 128 bitów każdy. W trybie 64-bitowym dostępne jest dodatkowych 8 rejestrów: xmm8, ..., xmm15.
Prawie każda operacja związana z danymi w pamięci musi mieć te dane ustawione na 16-bajtowej granicy, czyli jej adres musi się dzielić przez 16. Inaczej generowane jest przerwanie (wyjątek).

SSE 2 różni się od SSE kilkoma nowymi instrukcjami konwersji ułamek-liczba całkowita oraz tym, że może operować na liczbach ułamkowych rozszerzonej precyzji (64 bity).

U AMD częściowo 3DNow! operuje na ułamkach, ale co najwyżej na dwóch gdyż są to rejestry odpowiadające MMX, a więc 64-bitowe. 3DNow! Pro jest odpowiednikiem SSE w procesorach AMD. Odpowiedniki SSE2 i SSE3 pojawiły się w AMD64.

Instrukcje SSE (nie wszystkie będą wymienione):

W większości przypadków instrukcje dodane w SSE 2 różnią się od powyższych ostatnią literą, którą jest D, co oznacza double precision, na przykład MOVAPD.


No i krótki przykładzik. Inna wersja procedury do kopiowania pamięci. Tym razem z SSE.


(przeskocz kopiowanie pamięci z SSE)
; Tylko jeśli ESI i EDI dzieli się przez 16! Inaczej używać MOVUPS.
		mov ebx, ecx	; EBX = liczba bajtów
		and ebx, 127	; EBX = reszta z dzielenia liczby bajtów
				; przez 128
		shr ecx, 7	; ECX = liczba bajtów dzielona przez 128
	petla:
		; kopiuj 128 bajtów spod [ESI] do rejestrów XMM0, ... XMM7
		movaps xmm0, [esi]
		movaps xmm1, [esi+16]
		movaps xmm2, [esi+32]
		movaps xmm3, [esi+48]
		movaps xmm4, [esi+64]
		movaps xmm5, [esi+80]
		movaps xmm6, [esi+96]
		movaps xmm7, [esi+112]

		; kopiuj 128 bajtów z rejestrów XMM0, ... XMM7 do [EDI]
		movaps [edi    ], xmm0
		movaps [edi+16 ], xmm1
		movaps [edi+32 ], xmm2
		movaps [edi+48 ], xmm3
		movaps [edi+64 ], xmm4
		movaps [edi+80 ], xmm5
		movaps [edi+96 ], xmm6
		movaps [edi+112], xmm7

		add esi, 128	; zwiększ indeksy do tablic o 128
		add edi, 128
		loop petla	; działaj, dopóki ECX różne od 0

		mov ecx, ebx	; ECX = liczba pozostałych bajtów
		cld		; kierunek: do przodu
		rep movsb	; resztkę kopiujemy po bajcie

Nie jest to ideał, przyznaję. Można było na przykład użyć instrukcji wspierających pobieranie danych z pamięci: PREFETCH.


A teraz coś innego: rozdzielanie danych. Przypuśćmy, że z jakiegoś urządzenia (lub pliku) czytamy bajty w postaci XYXYXYXYXY..., a my chcemy je rozdzielić na dwie tablice, zawierające tylko XXX... i YYY... (oczywiście bajty mogą mieć różne wartości, ale idea jest taka, że co drugi chcemy mieć w drugiej tablicy). Oto, jak można tego dokonać z użyciem SSE2. To jest tylko fragment programu.


(przeskocz rozdzielanie bajtów)
	mov	eax, 4			; funkcja zapisu do pliku
	mov	ebx, 1			; na stdout (ekran)
	mov	ecx, dane_pocz
	mov	edx, dane_pocz_dl
	int	80h

	mov	eax, 4			; funkcja zapisu do pliku
	mov	ebx, 1			; na stdout (ekran)
	mov	ecx, dane
	mov	edx, dane_dl
	int	80h			; wypisz dane początkowe


; FASM: "movaps		xmm0, dqword [dane]"
	movaps		xmm0, [dane]
	movaps		xmm1, xmm0
		; XMM1=XMM0 = X1Y1 X2Y2 X3Y3 X4Y4 X5Y5 X6Y6 X7Y7 X8Y8
		; XXM* muszą zawierać tylko po jednym bajcie w każdym słowie

	psllw		xmm0, 8
		; teraz XMM0 = Y1 0 Y2 0 Y3 0 Y4 0 Y5 0 Y6 0 Y7 0 Y8 0

	psrlw		xmm0, 8
		; teraz XMM0 = 0 Y1 0 Y2 0 Y3 0 Y4 0 Y5 0 Y6 0 Y7 0 Y8

	psrlw		xmm1, 8
		; teraz XMM1 = 0 X1 0 X2 0 X3 0 X4 0 X5 0 X6 0 X7 0 X8

	packuswb	xmm0, xmm0
		; teraz XMM0 = Y1Y2 Y3Y4 Y5Y6 Y7Y8 Y1Y2 Y3Y4 Y5Y6 Y7Y8

	packuswb	xmm1, xmm1
		; teraz XMM1 = X1X2 X3X4 X5X6 X7X8 X1X2 X3X4 X5X6 X7X8


; FASM: "movq	qword [dane2], xmm0"
	movq	[dane2], xmm0	; dane2 ani dane1 już nie mają adresu
				; podzielnego przez 16,
				; więc nie można użyć MOVAPS
				; a my i tak chcemy tylko 8 bajtów
; FASM: "movq	qword [dane1], xmm1"
	movq	[dane1], xmm1

	mov	eax, 4			; funkcja zapisu do pliku
	mov	ebx, 1			; na stdout (ekran)
	mov	ecx, dane_kon
	mov	edx, dane_kon_dl
	int	80h

	mov	eax, 4			; funkcja zapisu do pliku
	mov	ebx, 1			; na stdout (ekran)
	mov	ecx, dane1
	mov	edx, dane1_dl
	int	80h			; wypisz pierwsze dane końcowe

	mov	eax, 4			; funkcja zapisu do pliku
	mov	ebx, 1			; na stdout (ekran)
	mov	ecx, dane2
	mov	edx, dane2_dl
	int	80h			; wypisz drugie dane końcowe

	mov	eax, 1
	xor	ebx, ebx
	int	80h



section .data
	; FASM: segment readable writeable

align	16			; dla SSE

dane		db	"ABCDEFGHIJKLMNOP", 10
	; FASM: "=" zamiast "equ"
dane_dl		equ	$ - dane

dane1		db	0, 0, 0, 0, 0, 0, 0, 0, 10, 9
	; FASM: "=" zamiast "equ"
dane1_dl	equ	$ - dane1

dane2		db	0, 0, 0, 0, 0, 0, 0, 0, 10
	; FASM: "=" zamiast "equ"
dane2_dl	equ	$ - dane2

dane_pocz db "Program demonstrujacy SSE. Dane na poczatku: ", 10, 9
	; FASM: "=" zamiast "equ"
dane_pocz_dl	equ	$ - dane_pocz

dane_kon	db	"Dane na koncu: ", 10, 9
	; FASM: "=" zamiast "equ"
dane_kon_dl	equ	$ - dane_kon


Po szczegółowy opis wszystkich instrukcji odsyłam, jak zwykle do Intela i AMD.

Instrukcje typu SIMD wspomagają szybkie przetwarzanie multimediów: dźwięku, obrazu. Omówienie każdej instrukcji w detalu jest niemożliwe i niepotrzebne, gdyż szczegółowe opisy są zamieszczone w książkach Intela lub AMD.

Miłej zabawy.

Poprzednia część kursu (Alt+3)
Kolejna część kursu (Alt+4)
Spis treści off-line (Alt+1)
Spis treści on-line (Alt+2)
Ułatwienia dla niepełnosprawnych (Alt+0)



Ćwiczenia

  1. Z dwóch zmiennych typu qword wczytaj do dwóch dowolnych rejestrów MMX (które najlepiej od razu skopiuj do innych), po czym wykonaj wszystkie możliwe dodawania i odejmowania. Wynik każdego zapisz w oddzielnej zmiennej typu qword.

  2. Wykonaj operacje logiczne OR, AND i XOR na 64 bitach na raz (wczytaj je do rejestru MMX, wynik zapisz do pamięci).

  3. Wczytajcie do rejestru MMX wartość szesnastkową 30 31 30 31 30 31 30 31, po czym wykonajcie różne operacje rozpakowania i pakowania, zapiszcie i wyświetlcie wynik jak każdy normalny ciąg znaków.

  4. Wczytajcie do rejestrów XMM po 4 liczby ułamkowe dword, wykonajcie dodawania i odejmowania, po czym sprawdźcie wynik koprocesorem.