Im folgenden soll die MMX-Erweiterung an einem konkreten Beispiel eingesetzt werden. Dabei soll hier das Aufhellen einer Bitmap, wie bereits im Abschnitt 1 kurz angesprochen wurde, betrachtet werden. Die Aufgabe, die das Beispielprogramm zu erfüllen hat, läßt sich dann z.B. so formulieren:
Die Realisierung durch eine MMX-Funktion soll dabei mit verschiedenen Funktionen (Assembler und C, ohne MMX) bezüglich der Laufzeit verglichen werden. So kann festgestellt werden, ob die MMX-Routine tatsächlich effektiver arbeitet als konventionelle Funktionen.
Um das Laufzeitverhalten der einzelnen Routinen vergleichen zu können, wird eine Methode zur Zeitmessung benötigt. Dabei fiel hier die Wahl auf den sogenannten Time Stamp Counter der PENTIUM-CPU. Dieser Counter ist 64-Bit breit und zählt die Taktzyklen (!) die seit dem letzten Reset vergangen sind.
Auf diese Weise ist eine genaue Messung des Zeitverhaltens möglich. Die in der Datei timing.h und timing.c bereitgestellten Funktionen dienen dem Zugriff auf diesen Counter. Im einzelnen stehen die folgenden Funktionen bereit:
void tstart (void);
Liest den aktuellen Inhalt des TSC aus (Assemblerbefehl RDTSC, übergibt niederwertigen 32-Bit Anteil im Register EAX und höherwertigen 32-Bit Anteil im Register EDX) und speichert die so ermittelten Werte in globalen Variablen. Zusätzlich werden über den Assemblerbefehl CLI die Interrupts verboten, um noch genauere Meßwerte zu erhalten.
void tstop (void);
Liest wiederum den aktuellen Inhalt des TSC aus und ermittelt die Differenz zur ersten Meßung (tstart). Die Interrupts werden durch diese Funktion wieder zugelassen.
unsigned int tgetlow (void);
Diese Funktion ermittelt die niederwertigen 32-Bit der durch tstop berechneten TSC-Differenz.
unsigned int tgethigh (void);
Diese Funktion ermittelt die höherwertigen 32-Bit der durch tstop berechneten TSC-Differenz.
Das Beispielprogramm test1.c stellt insgesamt 6 Funktionen bereit, die alle die gleiche Funktionalität besitzen (Addition eines Wertes auf einen bestimmten Speicherbereich). Die einzelnen Funktionen wurden jedoch unterschiedlich realisiert.
Die "Bitmap" entspricht im Beispielprogramm test1.c einfach einem allokierten Speicherbereich. Das Programm test1.c ruft jetzt nacheinander die einzelnen Funktionen auf und ermittelt über die obengenannten Funktionen aus timing.c die Laufzeiten der Funktionen.
Den in test1.c definierten Funktionen werden dabei die folgenden Parameter übergeben:
char* buffer - Zeiger auf den zu bearbeiteten Speicherbereich int xsize - Breite des Buffers int ysize - Höhe des Buffers char add - der Wert, der zu den Bufferbytes addiert werden soll
Die folgenden Funktionen sind in test1.c definiert:
// C Version Nr. 1 void c_version1 ( char *buffer, int xsize, int ysize, char add ) { int i, j; for (j=0; j<ysize; j++) { for (i=0; i<xsize; i++) { *(buffer+ j*xsize+ i)+= add; } } }
Die Funktion c_version1 addiert den übergebenen Wert auf alle Bytes des übergebenen Speicherbereichs, indem für jeden Index i und j erneut der Offset im Speicherbereich berechnet wird und anschließend der zu addierende Wert addiert wird. Die ständige Neuberechnung des Offsets kostet unötig Zeit und kann z.B. durch Zeigerarithmetik verhindert werden.
Dies wird in der zweiten C-Version verwendet.
// C Version Nr. 2 void c_version2 ( char *buffer, int xsize, int ysize, char add ) { register int i; for (i=0; i<xsize*ysize; i++) { *( buffer )+= add; buffer++; } }
Aber auch diese Funktion kann noch optimiert werden, denn auch ohne MMX kann die SIMD-Methode angewendet werden. Anstatt immer nur ein Byte zu addieren, können auch gleichzeitig 4 Byte (also 32-Bit = Integer) addiert werden.
Funktion c_version3 implementiert diese Vorgehensweise.
// C Version Nr. 3 void c_version3 ( char *buffer, int xsize, int ysize, unsigned int add ) { register int i; for (i=0; i<xsize*ysize / 4; i++) { *( (unsigned int*) buffer )+= add; buffer+= 4; } }
Dabei sollte beachtet werden, daß diese Vorgehensweise hier nur möglich ist, weil ohne Saturation gearbeitet wird.
Zum Vergleich wurden die C-Versionen 2 und 3 in Assembler umgewandelt:
// ASM Version Nr. 1 // (besitzt gleiche Funktionalität wie C Version Nr. 2) void asm_version1 ( char *buffer, int xsize, int ysize, int add ); #pragma aux asm_version1= \ " imul ecx,eax "\ " "\ "inc_loop: "\ " add byte ptr [edi],bl "\ " inc edi "\ " dec ecx "\ " jnz inc_loop "\ parm [edi] [ecx] [eax] [ebx] modify [edi ecx eax ebx];
// ASM Version Nr. 2 // (besitzt gleiche Funktionalität wie C Version Nr. 3) void asm_version2 ( char *buffer, int xsize, int ysize, int add ); #pragma aux asm_version2= \ " imul ecx,eax "\ " shr ecx,2 "\ " "\ "inc_loop: "\ " add dword ptr [edi],ebx "\ " add edi,4 "\ " dec ecx "\ " jnz inc_loop "\ parm [edi] [ecx] [eax] [ebx] modify [edi ecx eax ebx];
Im folgenden soll die eigentliche MMX-Routinen vorgestellt werden.
// MMX Version Nr. 1 (im Modul mmx1.asm definiert) MOVD MM1,ecx ; Additionswert nach MM1 MOVQ MM0,MM1 ; alle gepackten Bytes nach MM1 PSLLQ MM1,32 PADDD MM1,MM0 ; alle gepackten Bytes in MM1 ; besitzen Wert aus ecx imul eax,ebx xchg ecx,eax shr ecx,3 ; wir bearbeiten jeweils 8 Byte ! mbadd_loop: MOVQ MM0,[esi] ; 64-Bit nach MM0 PADDB MM0,MM1 ; 64-Bit gepackte Daten mit ; Unsigned Saturation addieren MOVQ [esi],MM0 ; Ergebnis zurück add esi,8 ; 64-Bit (= 8 Byte) weiter dec ecx jnz mbadd_loop
Der erste Teil der Funktion sorgt dafür, daß der in ECX übergebene Additionswert (32-Bit) auf alle gepackten Bytedaten aufgeteilt wird. Dazu wird zunächst der 32-Bit Wert in ECX nach MM1 geladen. Anschließend wird der Wert nach MM0 kopiert und 32-Bit nach links verschoben. Nach der Addition von MM1 und MM0 befindet sich der korrekte Additionswert als 8 gepackte Byte im Register MM1.
Als nächstes muß die Anzahl 8-Byte Blöcke ermittelt werden, die bearbeitet werden sollen. Dazu wird die Anzahl einzelner Bytes ermittelt, indem Breite und Höhe des zu bearbeitenden Bereiches multipliziert werden und der so ermittelte Wert durch 8 geteilt wird.
In der Hauptschleife werden jetzt jeweils 64-Bit nach MM0 geladen, mit dem Befehl PADDB 8 Byte gepackte Daten addiert und anschließend das Ergebnis in den Speicher zurückgeschrieben.
Das Programm test1.c ruft nun alle Routinen auf und gibt die ermittelten Laufzeiten auf dem Bildschirm aus. Auf einem PENTIUM 166 Mhz ergaben sich z.B. die folgenden Werte:
Werte für 921600 bearbeitete Bytes:
Funktion Counter-High Counter-Low ---------------------------------------------------- C Version 1, Taktzyklen= 0 24210951 C Version 2, Taktzyklen= 0 23084943 C Version 3, Taktzyklen= 0 8177420 ASM Version 1, Taktzyklen= 0 5440261 ASM Version 2, Taktzyklen= 0 3087126 MMX Version 1, Taktzyklen= 0 3055348
Es zeigt sich also, daß bei diesem Anwendungsfall (Addition ohne Saturation) die MMX-Routine nur unwesentlich schneller arbeitet als eine ähnliche Funktion ohne MMX !
Anmerkung: Bei Auswertung der obigen Tabelle ist zu beachten, daß die absoluten Werte von mehreren Faktoren abhängig sind (z.B. der Taktrate des Prozessors) und auf anderen Computersystemen zu anderen Ergebnissen führen können. Die relativen Unterschiede zwischen den Funktionen können jedoch (auf dem gleichen Computersystem !) zur Laufzeitanalyse herangezogen werden.
Beispielprogramm test1vis.c funktioniert nach dem gleichen Prinzip. Es lädt jedoch vor der Bearbeitung ein 24-Bit Bild im TGA-Format. Die oben beschriebenen Routinen arbeiten dann immer mit diesem Bild. Das Ergebnis der jeweiligen Routine wird dann auf dem Bildschirm angezeigt (Achtung: VESA 2.0 wird für die Anzeige vorausgesetzt). Da die Routinen ohne Saturation funktionieren, können so gut Fehler erkannt werden, die durch Überläufe der einzelnen 8-Bit breiten Farbkomponenten entstehen.
Beispielprogramm test2.c arbeitet nach dem gleichen Prinzip wie test1.c. Der einzige Unterschied besteht in der Addition, die mit unsigned Saturation durchgeführt wird und damit Fehler beim Aufhellen der Bitmap verhindert werden. Für die MMX-Version heißt das, daß jetzt PADDUSB verwendet werden muß. Die C-Versionen 1 und 2, sowie die Assembler-Version 1 müssen jetzt für jedes zu bearbeitende Byte überprüfen, ob nach der Addition das Byte den Wertebereich verlassen hat ( > 255 ). Falls ja, so muß das Byte auf den Wert 255 beschränkt werden.
Da bei der konventionellen SIMD-Technik (also ohne MMX) keine einfache Überprüfung eines solchen Überlaufs möglich ist, mußten diese Routinen (C-Version 3 und Assembler-Version 2) im Beispielprogramm test2.c entfallen.
Das Beispielprogramm test2.c liefert auf dem Testrechner die folgenden Werte:
Werte für 921600 bearbeitete Bytes: Funktion Counter-High Counter-Low ---------------------------------------------------- C Version 1, Taktzyklen= 0 40265287 C Version 2, Taktzyklen= 0 27548391 ASM Version 1, Taktzyklen= 0 7133678 MMX Version 1, Taktzyklen= 0 3055361
Dieses Beispiel zeigt die Verbesserung, die durch die MMX-Routine erreicht wurde (etwa 2.3 mal schneller als die entsprechende Assembler-Version).
Das Beispielprogramm test2vis.c zeigt die aufgehellten Ergebnisse am Beispiel einer Bitmap (Vgl. test1vis.c). Da dieses Mal mit Saturation gearbeitet wurde, entspricht das Ergebnis wohl eher einer "Aufhellung" als das Ergebnis von Beispielprogramm test1vis.c.
Alle hier vorgestellten Beispielprogramme stehen als selbstentpackendes RAR-Archiv _bitmap.exe zum Download zur Verfügung.