[english]
[index]
[back]
[mail]
Copyright © 2001 h-peter Recktenwald, Berlin
Unter Beachtung des allgemein geltenden Ausschlußvermerks frei zu
beliebiger Verwendung bei nur kostenloser Weitergabe.
Grundlegendes zur Code-Optimierung bei K6/Pentium Processoren
im "protected" Modus, bei durchgängiger 32-bit-Adressierung.
Quellen
- "AMD K6-2 processor data sheet", Nr. 21850H/0,
und "AMD-K6 Processor Code Optimization", Nr. 21924D/0.
-
Zu den vergleichbaren Typen Intel Pentium (bis P-II) gibt es
Entsprechendes, da aber dort im Text kein Hinweis auf die Dokumenten-Spezifikation gegeben wird
(und ich die Quelle vergessen habe), hilft ggf. nur Suchen...
- "intel 'pentium' type cpu optimization",
pentopt.html, von
-
Beispiele in "nasm"/intel-Syntax.
Eigene Versuche nur mit K6/2 von AMD, hier kurz "K6", die dafür und als Beispielsammlung
herangezogene "lib4th" (Forth DLL in asm) ist verfügbar unter
Im wesentlichen zählen folgende Faktoren:
Erstmal:
Viel Glück, insofern, als die gerade herangezogene Dokumentation hält, was ihre Autoren
versprechen; selbst in o.g. authentischen Unterlagen fanden sich Fehler! noch dazu in einer Form,
die nicht gerade auf gehobene Sorgfalt bei Erstellung jener Texte schließen läßt
(z.B. AMD 21924D/0). Secundaer-Quellen sind immer mit Vorsicht zu genießen, selbst wenn sie
noch so sorgfältig zusammengestellt wurden, da sich dort auch Interpretationsfehler finden
können - wovon ich trotz aller Bemühungen diesen Text keineswegs ausschließe.
Aus eigener (subjektiver!) Erfahrung sei vor allem auch vor dem Autorennamen "Floegel", den Verlagen
"Elektor" und "Hofacker" gewarnt (wenn's die denn noch gibt), und vor den "Motorola"-Unterlagen.
- -> Sorgfalt, Sicherheit, profunde Erfahrung und grenzenlose Geduld
- -> "cache"-Treffer, Localität
- -> Anordnung von Daten & Code - "alignment"
- -> Einsprungs-Verzögerung - "first time penalty"
- -> Sprung-Vorhersage - "branch prediction"
- -> Befehls-Decodierung - "instruction fetch ..."
- -> Register-Verriegelung - "register stall"
- -> Abhängige Ketten - "dependency chains"
- -> Besondere Empfehlungen
- -> in beiden cpu-pipes ausfürbare Operationen
Im einzelnen:
-
- Aufmerksamkeit und Pedanterie
Die Code-Optimierung hat nichts Spektakuläres, sie ist in erster Linie
mühsames und stumpfsinniges Pedantenwerk, das erst in der Summe vieler
einzelner Maßnahmen zum u.U. recht eindrucksvollen Erfolg führt.
- Gewissenhaftigkeit
auch wenn sowas nicht "angesagt" scheint - doch wer Assembly programmiert,
wird sich ohnehin kaum nach irgendwelchen lächerlichen Conferenciers richten.
Würden etwa Kraftfahrzeuge mit der jämmerlichen Berufsauffassung
des gewöhnlichen Erzeugers von Weichware gebaut, hätte die unversehrt
davongekommene Bevölkerung Orte wie Redroit oder Düsselsheim vermutlich
längst dem Erdboden gleichgemacht... Will sagen, wenn auch "der gemeine
Programmanwender" von kaum zu übertreffendem Leidenswillen geprägt
scheint, ist ohne sichere Funktion jede "Optimierung" von Programmen reine Wahnvorstellung.
- Bereits während des Programmierens das Codierte richtig
und den #Zweck# betreffend ausführlich beschreiben. Viele
Fehler fallen sofort in's Auge, wenn man sein Tun zugleich
konkret in Worte faßt. Nebenbei ergibt sich ziemlich
mühelos auch noch eine brauchbare Dokumentation.
- "Selbstmodifizierender Code" zerstört jeden Nutzeffekt
der betr. Processor-Caches.
- Subroutinen ordentlich abschließen, CALL und RET paarweise,
ohne vorher im Return-Stack herumzufummeln.
- Widereintrittstauglicher Code ist auf der sicheren Seite,
"hart" gegenüber Interrupts und im Multitasking.
- Positionsunabhängiger Code hat u.a. den Vorteil beliebiger
Wiederverwendbarkeit in neuen Programmen; eine Sammlung
sicherer, geprüfter Programmteile hilft ungemein.
- Verifikation: Nichts, rein garnichts muß richtig sein, nur
weil irgendein "Experte" das abgesondert hat! Abgeschriebene
Dokumente (wie dieses!) sind Hinweise, nicht "Wahrheit", selbst
Herstellerunterlagen sind mitunter grauenvoll zusammengeschlampt.
- Den "primär-rechner" nicht vernachlässigen: "Denken" ist eine höchst effiziente Optimierungsmethode!
Sorgfältige Programm-Planung erleichtert die anschließende Optimierung
ungemein, und selbst raffinierteste "Optimierung" kann Fehler darin kaum ausgleichen.
Dabei zählen nicht nur geistvolle Algorithmen, sondern vor allem auch
Programmablauf und -Anordnung, bis hin zur Registerzuweisung. Sind die nicht halbwegs
"optimal", gerät die mühsame Code-Optimierung von Hand bestenfalls zur
Neutralisierung des ungeschickten Ansatzes ("C"!).
-
gehen zusammen, da die Arbeit der Processor-Cache eng mit
der Lage von Code und Daten - je getrennt - zusammenhängt.
- Code
Einsprung möglichst auf 16-Byte-Grenze.
Wahrung des möglichst engen Code-Zusammenhanges steht dem bei
größeren Programmen u.U. entgegen. Die Ergebnisse hängen
stark vom jeweiligen Programmaufbau ab. Meist genügt es offenbar
schon, wenn etwa der Datenteil eines "call near" an einer 4-byte-Grenze
beginnt und evtl. zusammen mit dem Op-Code-Byte innerhalb eines
32-Byte-Blocks: Mit K6 und "lib4th" ergaben größere Posten
keinerlei Nutzen (s.u.), was jedoch vom jeweiligen Processor abhängt
- die betr. Handbücher geben entspr. Auskunft.
Da alle (mir bekannten) Assembler in dieser Hinsicht hoffnungslos
veraltet sind und keine Möglichkeit "vorausschauender"
Justierung bieten, hilft ggf. nur geduldige "Handarbeit".
- Localität
ist ein allzuoft völlig vernachlässigter Faktor. Generell
spielt der enge Zusammenhang aller Daten und Codeteile (je für
sich) innerhalb des Speichers eine große Rolle. Er kann die Wirkung
der Processor-"cache(s)" entscheidend fördern. Optimierung auf
möglichst kurze Sprungcodierung wird dies unterstützen, ist
jedoch gegenüber anderen Einflüssen eher von untergeordneter
Bedeutung - wobei gezieltes Codieren der Länge einer Instruktion
auch "kostenloses" Hilfsmittel zum Alignment ist.
Beispiel "lib4th": Mit Alignment 16
statt 4 für (nahezu) alle Einsprungstellen wächst der
Codebereich um ca 10%; dabei wird zugleich die Programmausführung
entgegen allen langläufigen Prognosen deutlich langsamer!
- Daten
- nicht direkt hinter auszuführendem Code anordnen (5.1).
- Für die Adressenjustierung wählt man das nächste
auf eine Zweier-Potenz aufgerundete Vielfache der Postengröße,
etwa "fword" (6 bytes) auf Alignment 8 gelegen. - Werte über 8
ohne zusätzlichen Vorteil.
-
CALL NEAR wird sehr schnell abgearbeitet (ein Takt) und
mag Verwendung vieler kurzer Subroutinen nahelegen. Dabei
ist abzuwägen, ob die beim Einsprung in u.U. "neuen" Code
erfolgende Auffrischung der Processor-Cache (L1) das trotz der
dadurch bedingten Verzögerung sinnvoll erscheinen läßt.
-
- Man lege hinter Sprüngen - wozu auch CALL und RET zählen
- nur ausführbaren Code ab: Beim "op-code prefetching" nimmt
der Processor sonst unbrauchbare Daten auf, deren Auswertung sinnlos
die Decodierung durchläuft und den Ablauf entsprechend hemmt.
- SET benutzen, wenn dadurch eine Verzweigung vermieden
werden kann (K6: SET erwies sich als sehr ungünstig!).
- CMOVE wie .2 (K6: nicht anwendbar).
- Sprung auf unmittelbar vorangehende Operation vermeiden.
- Sprünge überlegt anordnen:
Zuerst gilt die "statische" Bewertung, d.h. der Zustand, in dem der
Sprung noch nicht in der speziellen "branch prediction cache" verzeichnet
ist. Ab Pentium II(?) stehen dort die jüngsten 16 Sprünge.
- Statisch werden Sprünge richtig vorhergesagt
- als genommen, wenn sie unbedingt sind: CALL, JMP, RET
- wenn ein bedingter VORwärtsprung nicht genommen wird.
- wenn ein bedingter RÜCKwärtsprung genommen wird.
- Dynamische Vorhersage ist richtig, wenn
- die statischen Richtlinien zutreffen
- der Ablauf der vier jüngsten Sprungbefehle einem regelmäßigen Muster folgt.
- besondere Muster eingehalten werden, s. Quelle 2.
Diese Einflüsse sind nicht zu unterschätzen, auch hierzu gibt es in
der "lib4th" ein überaus deutliches Beispiel:
Die bitweise Division einer 128-Bit-Zahl durch eine 64-Bit-Zahl (Aufruf "uqd/mod")
wurde ohne Veränderung im eigentlichen Code allein durch die günstigere
Anordnung nur zweier bedingter Sprung-Paare um ca 10% schneller!
- CALL/RET
beim K6 und ähnlichen Modellen ist der Rücksprung aus <CALL subroutine>
selbst nach Stackmanipulationen mit RET deutlich schneller als mit JMP REG:
| POP ECX |
==> | MOV ECX,[ESP] |
| POP EDX |
| MOV EDX,[ESP+4] |
| JMP ECX |
| MOV [ESP+4],ECX |
| |
| LEA ESP,[ESP+4] |
| |
| RET |
-
- Vorzugsweise Ein-Byte-Operationen verwenden, oder solche,
die innerhalb eines Mikro-Zyklus verarbeitet werden
(K6: "Risc86", Intel hat entsprechende Hinweise im Handbuch)
Etwa INC und DEC statt ADD, SUB.
- Auf paarweise Ausführbarkeit, "op-code pairing", in beiden
Recheneinheiten achten - Was auf die "Risc86" und (nahezu)
alle sonstigen Ein-Byte-Operationen zutrifft. Ausnahmen s.u.
Geeignete Anordnung (Reihenfolge) ist insbes bei solchen
Operationen bedeutsam, die nur in einer der beiden Einheiten
ausgeführt werden können. Welche das sind, ist den jeweiligen
cpu-Handbüchern zu entnehmen (Processor-spezifisch).
- Möglichst keine Operationen einsetzen, die mehr als vier
Mikro-Zyklen oder 7 Bytes Speicherplatz benötigen.
- LEA läßt sich oft außerordentlich effizient für einfache
arithmetische Operationen verwenden - aber s. auch 8.4!
- Praefix hex 0F hat als einziges keine schädliche Wirkung.
Und auch das nur, wenn die Befehlscodierung unter 8 Bytes bleibt.
Bei K6 sind außerdem noch 66h und 67h unschädlich.
- Lesen/schreiben im selben Speicherblock (Vielfache von 4K)
unmittelbar nacheinander nur in gleichgroßen Posten.
| d.h. | MOV EAX,[MEM] | |
| | MOV EAX,[MEM+4] | etc. |
Kontrolle z.B. durch
( abs ((mem1 & -32 ) - (mem2 & -32 )) mod 4096
soll für volle L1-cache-Tauglichkeit Werte =/= 0 liefern,
ist das Ergebnis Null, gilt obige Bedingung.
- Speicheradressen für die Daten-Ablage möglichst frühzeitig
ermitteln, und auch beim Lesen nicht in der Operation selbst.
-
- Zusammen mit Verwendung eines Teil-Registers Zugriff auf ein
größeres Format desselben Registers vermeiden
| z.B. | XOR EAX,EAX | ersetzen durch | MOVZX EAX,BL |
| | MOV AL,BL | | |
| | | | |
- Ein Zielregister sollte unmittelbar vorher nicht als
Basisregister einer Operation verändert worden sein,
-
Ein Beispiel
| | MOV EAX,[MEM1] | |
| | ADD EAX,[MEM2] | |
| | ADD EAX,[MEM3] | |
| | ADD EAX,[MEM4] | |
| | MOV [MEM5],EAX | |
wo das zweite ADD erst ausgeführt werden kann, wenn das erste
Ergebnis vorliegt. Bei ADD entsteht nur ein Takt Wartezeit,
wogegen z.b. (I)MUL und (I)DIV weit größere "Strafzeiten" zur
Folge haben können. Geeignete Anordnung vermeidet dergleichen.
Im obigen Falle etwa Einfügen anderer einfacher MOV-Befehle,
die später und hiervon unabhängig ggf. ohnehin benutzt
würden. Oder Aufspaltung in mehrere Operations-Zweige:
| | MOV EAX,[MEM1] | ; erster zweig |
| | MOV EBX,[MEM2] | ; zweiter zweig, anderer accu |
| | IMUL EAX,[MEM3] | |
| | IMUL EBX,[MEM4] | |
| | IMUL EAX,EBX | ; zum schluß zweige verbinden |
| | MOV [MEM5],EAX | |
-
- EAX ist (historisch bedingt, als "das" Accumulator-Register) in
vielen Fällen vom Processor-Entwurf her begünstigt, d.h. einige
erfahrungsgemäß besonders häufige Operationen erfahren bereits
im Processor selbst eine gewisse Optimierung, die gerade darum
sehr wirkungsvoll ausfallen kann, XOR oder TEST als Beispiele.
- Einige Operationen, die nicht oder nur selten paarweise mit
anderen ausgeführt werden können. Sonderfälle
nicht berücksichtigt, ggf. s. Handbuch der betr. cpu:
-
| Operation(en) |
ersetzen durch | Operation(en) |
-
| CDQ |
=> | MOV EDX,EAX |
| |
| SAR EDX,31 |
wobei der Ersatz für sich genommen dieselbe Zeit braucht, aber
im Unterschied zu CDQ die gleichzeitige Ausführung mit andern
OP-s ermöglicht.
-
| CMP src,0 |
=> | TEST src,-1 |
|
| oder | TEST reg,reg |
insbes. kann TEST eax,reg/imm paarig ausgeführt werden.
-
nur verwenden, wo unvermeidich (hohe "Strafzeit", rsp. ineffizient)
-
ist anfällig auf "address generation interlock" oder "AGI stall", Verriegelung durch
Warten auf das Ende der Adressenermittlung, und sollte weder auf Register angewandt werden,
die unmittelbar zuvor verändert wurden, noch unmittelbar vor Operationen stehen, die das
Ergebnis verarbeiten.
So ist auch ein einzelnes STOSD keineswegs immer so "ineffizient" wie allgemein behauptet (K6, lib4th):
| STOSD | | LEA EDI,[EDI+4] |
| MOVZX AL,[EDI] | | MOVZX AL,[EDI] |
STOSD & MOVZX wird hier in einem Takt ausgeführt rsp. abhängig vom vorangehenden Code
auch ganz ohne Verzögerung, die Kombination mit LEA benötigt dagegen mindestens drei Takte.
-
s.o, EAX-Optimierung im Processor - evtl. cpu-abhängig
K6: erste Form schneller, "immediate"-Werte sind stets sofort verfügbar!
-
möglichst vermeiden (nicht K6).
-
vorzugsweise verwenden, wenn dadurch eine Gruppe anderer
Operationen ersetzt werden kann.
-
| MOV reg,[ESP+disp] |
=> | POP |
| ... |
| |
| ADD ESP,imed |
| |
sofern nicht mehr als 8 Bytes vom Stack geholt werden.
-
Intel: generell vermeiden
AMD : PUSH mem spart ggf. eine Registeroperation ein, wird "short"
ausgeführt und ist dem Umweg über ein Register vorzuziehen, hier
ist POP mem der ungüstigere Fall, "vector"-codiert.
-
existiert eher als Compatibilitäts-Fossil, außerordentlich ineffizient.
- K6:
| MOV [ESI],EAX |
=> | MOV [ESI+0],EAX |
Ziel-Adressierung "[ESI]" vermeiden.
Wenn der betr. Assembler die Null weg"optimiert", hilft evtl. Hinschreiben in der Form
- (K6)
| XCHG src,reg |
=> | ADD src,reg |
oder | XCHG reg,src |
| SUB reg,src |
| |
| ADD src,reg |
| |
| NEG reg |
Die Alternative wird in einem Sechstel der Zeit abgearbeitet (5 statt 33 Zyklen),
wenn "src" im Speicher liegt; Zeit für reg,reg gleich (stets 2 Zyklen), dann aber
mit anderen Operationen paarig ausführbar und ebenfalls ohne besondere Wartezeit:
| XCHG reg1,reg2 |
=> | LEA reg1,[reg1+reg2] |
oder | XCHG reg2,reg1 |
| SUB reg2,reg1 |
| |
| LEA reg1,[reg1+reg2] |
| |
| NEG reg2 |
Ersatz immer zweckmäßig, wo nur ein Processor existiert rsp. wenn die synchronisierende
Eigenschaft des XCHG nicht erforderlich ist - und der Speicherbedarf nicht die absolut
übergeordnete Rolle spielt.
- K6:
Schiebeoperationen nur "alux" rsp. "vector"-codiert,
ADD arbeitet in beiden alu, paarig ausführbar (per byte-Register nur "alux").
-
| TEST reg1,reg1 |
=> | TEST reg1,reg1 |
| MOV reg2,reg3
| | Jcc label |
| Jcc label
| | ... |
| ...
| | label: |
| label:
| | MOV reg2,reg3 |
Flag-Auswertung unmittelbar nach TEST spart einen Taktzyklus.
-
| CMP reg,[mem] |
=> | CMP [mem],reg |
Die Alternative wird oft scheller ausgeführt. Auswertung anlog, mit complementären
Flags. Allerdings, dies gilt keineswegs immer, ihr Einsatz sollte darum stets
überprüft werden. Ansich wäre die erste Form optimal, da die damit verbundene
"read-modify-write"-Operation dann ohne Speicher-Zugriff auskommt - soweit die Theorie...
- K6:
| STC |
=> | OR reg, byte 1 |
| |
| NEG reg |
CLC,CMC,STC kosten 'Strafzeit' von 1 rsp. 2(CMC) Takten, die Folge OR/NEG dagegen ist 'umsonst':
OR stellt reg =/= 0 sicher, NEG erzeugt dann stets gesetztes Carry. Oder:
Ist irgendein Register mit Wert =/= 0 sicher voraussetzbar, kann das C-Flag mit doppelter Negation
gesetzt werden und der Registerwert bleibt unverändert.
Für die anderen Konstellationen der Arithmetik-Flags lassen sich oft ähnliche Ersatzoperationen
finden, die sämtlich schneller sind, als die direkte Änderung.
setzt NC und zugleich auch NO(verflow).
Ohne Effekt war der CMC- oder STC-Ersatz nur bei derartigen Sequenzen:
| |
| LEA reg,[mem] |
| |
| stosd |
| |
| NEG reg |
| |
| NEG reg |
...was vermuten läßt, daß STC u.dgl. die 'AGI stall'-Lücke nach LEA füllen.
- -> in beiden cpu-pipes ausführbare Operationen
- ADD
- AND
- CMP
- DEC
- INC
- LEA
- MOV - nicht mit ctrl/debug/segment-Registern
- NOP
- OR
- POP - reg
- PUSH - reg und imed
- SUB
- TEST - reg,reg; mem,reg; EAX,immediate
- XOR