Montag, 28. Juli 2008

Die Floating Point Unit (FPU)

Immer wieder wird mir in meinem Assemblerprojekt schmerzlich spürbar, dass die FPU nicht wirklich in den Prozessor integriert ist, sondern erst nachträglich aufgenommen wurde. Dem FPU-Befehlssatz haftet gewissermassen die ganze Geschichte der Mikroprozessorentwicklung noch an. Wenn man sich aber einmal daran gewöhnt hat, kann man seine Berechnungen flüssig codieren. Zugute kommt mir dabei, dass ich an das stackbasierte Programmieren durch PostScript gewöhnt bin (ich schreibe mir den Code zur Generierung von Vektorgraphiken selbst, und zwar in PostScript).

In diesem Blog möchte ich einige Erfahrungen mit der FPU-Programmierung festhalten:


  • Keine Synchronisierung

    Lapidar erwähnen meine beiden Assemblerbücher (Roming/Rohde und Müller), dass das Kommando fwait den Coprozessor "synchronisiert". Nach ihren Darstellungen muss man annehmen, dass vor einer Weiterverarbeitung von FPU-Rechnungen im CPU-Stack unbedingt immer fwait gemacht werden muss. Als ich dies das erste Mal las, bekam ich einen Schrecken. Denn erstens hatte ich schon einige FPU-Berechnungen durchgeführt, ohne je das Kommando fwait verwendet zu haben. Zweitens graute mir vor der Verhässlichung des Codes, wenn man gewissermassen an jeder Mülltonne ein fwait hinschreiben müsste. Glücklicherweise ergaben einige Tests, dass das Kommando fwait im Normalfall völlig überflüssig ist. Auch entsprechende Studien in Newsgruppen und Internetforen bestätigten mir das. fwait wurde eingeführt, weil in Prozessoren bis zur Version 80486sx eine Parallelverarbeitung der FPU- und CPU-Befehle vorgesehen ist. In allen nachfolgenden Prozessorversionen steuern die FPU-Befehle implizit selbst das Wait, wo es nötig ist.
    Andere Befehle - wie fstsw - existieren in einer No-Wait-Variante (im Beispiel fnstsw), in der der implizite Wait unterdrückt wird.

  • Stapelüberlauf

    Obwohl meine Assemblerbücher von einem ringförmig organisierten Stapel sprechen, kommt es zu Stapelüberläufen, wenn man zuviele fld Operationen ausführt, ohne den Stapel zu bereinigen. Nicht einmal die Debug-Macros zur Ausgabe von ST0 funktionieren dann mehr. Einen Stapelüberlauf kann man manchmal daran erkennen, dass die Sequenz von FPU-Anweisungen wie erwartet funktioniert, wenn man ihr testhalber den Befehl finit voranstellt, der insbesondere den Stack bereinigt.

    Der Stapel funktioniert aber nicht nur mit einem Zähler für die Stacktiefe - den es zwar auch gibt, der aber nicht das ganze Bild bestimmt. Zwar kann man mit dem Befehl fincstp den Zähler auf das Top-Of-Stack Register um 1 erhöhen und damit die Stacktiefe um eins erniedrigen. Aber die Behauptung von Oliver Müller in seiner Assembler-Referenz [2], dies entspräche einer POP-Operation, ist so nicht richtig. Das Erhöhen des Stack Pointers ist nur ein Teil der bei POP nötigen Operationen. Dazu muss unbedingt noch das vorherige Top-Of-Stack-Register entleeren, sonst kommt man beim Weiterarbeiten mit der FPU bald in "ungültige Zustände" und damit zu unsinnigen Ergebnissen.

    Ein klares Bild ergab sich mir erst nach Studium des FPU-Tutorials "Simply FPU" von Raymond Filiatreault [1]. Der Stapel ist in der Tat ringförmig organisiert, und man kann ein beliebiges der acht Register auf den Top-Of-Stack setzen. Aber die FPU merkt sich, ob in diesem Register bereits ein Wert enthalten ist. Filiatreault vergleicht den Ring mit der Trommel eines Revolvers, die Platz für acht Patronen bietet. Durch Drehen der Trommel lässt sich ein beliebiger der acht Plätze auf "Zwölf Uhr" = Top-Of-Stack bringen. Aber wenn der Platz bereits eine Patrone enthält, kann man in diesen Platz nicht noch eine zweite Patrone stecken. Die FPU verweigert das Laden einer Gleitkommazahl in ein bereits besetztes Register und geht dann in einen speziellen Ungültig-Status. Ab da werden alle weiteren Rechenergebnisse falsch.

    Es ist daher ganz wichtig, dass jedes mit der FPU operierende Unterprogramm - wie auch die meisten Macros - den Stack bereinigt - d.h. ihn in der Tiefe zurückgibt, in der er ihm vorgelegt wurde: "Bitte verlassen Sie den Stack so, wie Sie ihn vorgefunden haben". Man kann dies sicherstellen, indem man nach jedem automatischen Test auch noch testet, dass die Stapeltiefe 0 ist. Hierzu habe ich folgende Testroutine geschrieben, die nach jedem meiner Unit Tests aufgerufen wird.

    checkFPU proc
    local lStatus:word,
    lStackLevel:dword,
    lEnv[28]:byte,
    lTagWord:word

    ; Alle Tests sorgen dafür, dass der Stacklevel hinterher 0 ist
    fstsw lStatus
    ; Statuswort beschaffen, um die Stacktiefe zu ermitteln
    xor eax, eax
    mov ax, lStatus
    shr eax, 11
    neg eax
    and eax, 7 ; Stacklevel
    mov lStackLevel,eax
    je @F
    print "# Fehler: Stacklevel ist nicht Null, sondern "
    print str$(lStackLevel)
    print offset cr_lf
    jmp exitCheckFPU
    @@:
    ; Das TagWord prüfen, es müssen alle Register leer sein
    fstenv lEnv
    xor eax,eax
    mov ax,word ptr lEnv+8
    mov lTagWord,ax
    xor ax,0FFFFh
    je exitCheckFPU
    print "# Nach Testausführung nicht alle FPU-Reg entleert: Statuswort = "
    xor eax,eax
    mov ax,lTagWord
    print hex$(eax)
    print offset cr_lf
    mov al,1 ; Fehler melden
    exitCheckFPU:
    ret
    checkFPU endp



  • Keine immediate-Operanden

    Ich vermisse die Möglichkeit, die FPU-Operanden immediately zu adressieren. Jede real8Konstante, die ich als Operand benötige (auch gängige wie z.B. 10 oder 100), muss ich im Memory ablegen, um auf sie zugreifen zu können. Zwar lässt sich die überflüssige Tipparbeit mit dem MASM-Macro FP8 reduzieren, mit dem man in schöner funktionaler Notation schreiben kann
      fld  FP8(1.526322)

    Es bleibt aber unschön, Daten nicht ab Befehlszeiger einlesen zu können. Und wie so oft, ist die Ästhetik von Code auch mit der Effizienz verknüpft, denn ein überflüssiges Kommando erzeugt auch überflüssige Bytes im Maschinencode und verbraucht überflüssige Taktzyklen.

  • Indirekte Adressierung nur via CPU-Register

    Gewöhnungsbedürftig ist auch, dass ein per Referenz übergebener Parameter (und das ist die beste Möglichkeit,Parameter zur Änderung zu übergeben und daher keineswegs selten) nur auf dem Umweg über ein CPU-Register angesprochen werden kann. Das heisst, ich muss die als dword übergeben Adresse in ein Register laden,um den Wert ansprechen zu können.

    local t:real8
    ...
    invoke get_t, addr t
    ...

    ; --- Unterprogramm get_t
    get_t proc p_t:dword

    ... ; Berechnung von t

    ; Wert von t an Aufrufer zurückgeben
    mov esi,p_t
    fstp [esi]

    ret
    get_t endp

    Merkwürdigerweiserweise akzeptiert der Assembler auch die Notation fld real8 ptr [p_t], es passiert aber nichts Sinnvolles (und jedenfalls nicht das Erwartete).

  • Umständliche bedingte Verzweigungen

    Ob eine Zahl Null oder kleiner als eine andere ist, ist nur umständlich zu ermitteln, indem nach allfälliger Ausführung des FPU-Befehls zum Testen das Steuerregister in ein Register geschrieben und von dort in das Flagregister der CPU übertragen wird. Dann erst können bedingte Sprünge ausgeführt werden. Hier ein Beispiel, bei dem ich prüfe, ob die Zahl im Top-Of-Stack vom Betrag her kleiner als eine bestimmte vorgegebene Genauigkeit gPrec ist:
      fabs
    fcomp real8 ptr gPREC
    fstsw ax
    sahf
    ja failed ; Zahl ist grösser - Test fehlgeschlagen
    ... ; OK

    Auch dies ist, wie die umständlichen Adressierungen, historisch bedingt - eine Kröte, die man schlucken muss. Glücklicherweise enthalten manche Befehle wir frndint bereits implizit eine bedingte Entscheidung, so dass sich manche einfachere Prüfungen statt algorithmisch mit Sprüngen auch in Form einer Rechnung hinschreiben lassen.

  • Arcustangens

    Dass nur der Arcustangens in der FPU implementiert ist, aber kein Arcussinus und Arcuscosinus, stört nur penible Geister. Ein Macro, das den vermissten Befehl hinzufügt, ist schnell geschrieben (es ist ja arcsin(x) = arctan(x/sqrt(1-x*x))), und der Befehl würde vermutlich nicht wesentlich schneller, wenn er direkt von der FPU ausgeführt würde. Sehr gut ist, dass der Arcustangens quadrantengerecht aufgelöst wird - auch diesen wichtigen Hinweis habe ich in den Büchern von Roming/Rohde und Müller vermisst. Erst die x86er Doku im Intel-Archiv liess dies durchblicken. Es wird ein Wert im Intervall zwischen -Pi und Pi zurückgegeben. Das ist auch der Grund, warum der Arcustangens zwei Argumente entgegennimmt, den Zähler und den Nenner. Denn die Vorzeichen von Zähler und Nenner werden verwendet, um den richtigen Quadranten zu ermitteln. In manchen Hochsprachen gibt es diesen quadrantengerechten Arcustangens unter dem Namen atan2, im Gegensatz zum einfachen atan, der nur ein Argument hat und nur Werte zwischen -Pi/2 und Pi/2 zurückgibt.

  • Sinus und Cosinus zugleich

    Der Befehl fsincos bietet Performancevorteile, die an keine Hochsprache weitergereicht werden, sondern nur den Assemblerprogrammierer erfreuen. Es ist bei trigonometrischen Berechnungen aller Art häufig, dass von einem Winkelwert nicht nur der Sinus, sondern auch der Cosinus benötigt wird. Die Potenzreihen für Sinus und Cosinus sind aber so verwandt, dass es effizient ist, mit einem Befehl gleich beide Winkelwerte zu berechnen und auf den Stapel zu legen, wie es fsincos tut, statt diese nacheinander mit fsin und fcos zu berechnen, was natürlich auch möglich wäre. Der Befehl fsincos ersetzt also die Sequenz
      fld st   ; Top-of-Stack duplizieren
    fsin
    fxch
    fcos

    ist aber dabei wesentlich effizienter.


[1] Raymond Filiatreault: Simply FPU, Teil jeder MASM32-Distribution im Ordner ./tutorial/fputute.
[2] Oliver Müller: Assembler-Referenz, Franzis Verlag, Poing 2000, S. 443.

Keine Kommentare :