Effiziente Programmierung moderner CPUs

 

Motivation

Moderne Prozessoren wie der DEC Alpha oder der Intel Pentium Pro unterscheiden sich in ihrer internen Struktur und in der Vorgehensweise beim Abarbeiten von Programmen teilweise sehr stark von denen, die bis Ende der 80er Jahre in PCs und Workstations hauptsächlich anzutreffen waren. In die Entwicklung dieser neueren Generation von Mikroprozessoren sind nicht nur die aktuellen Möglichkeiten modernster Halbleitertechnologie eingeflossen, sondern auch Erkenntnisse aus der Superrechner-Technologie und der Parallelverarbeitung sowie neueste Forschungsergebnisse.

Diese modernsten Prozessoren enthalten unter anderem sehr viel Logik, die die effiziente Ausführung auch solchen Codes erlauben, der nicht extra für den gegebenen Prozessor optimiert wurde. Auch der Compiler unterstützt den Entwickler bei der Implementierung effizienten Codes, indem er versucht, nur solche Befehlsfolgen zu erzeugen, die vom Prozessor problemlos verarbeitet werden können. Doch trotz des erheblichen Hardwareaufwandes für die Ausführung weniger effizienten Codes und modernster Compiler kommt der Entwickler nicht umhin, sich einige Kenntnisse über die Speicherhierarchien und die Befehlsabarbeitung des Zielsystems anzueignen, wenn er Programme entwickeln möchte, deren Abarbeitung mit einem hohen Prozentsatz der theoretisch möglichen Maximalgeschwindigkeit erfolgt.

Selbst bei der Verwendung speziell für ein bestimmtes System entwickelter aggressiver Compiler kann die tatsächliche Leistung eines Systems auf unter 20% des Maximums sinken, wenn Programm- und Datenstruktur ungeschickt gewählt werden. Bei geschickterer Auslegung sind 90% und mehr ohne weiteres möglich.

Der folgende Abschnitt geht auf einige Strukturmerkmale ein, die in den meisten modernen Prozessoren realisiert sind, und deren Kenntnis für die Entwicklung effizienten Codes wichtig sind. Natürlich unterscheiden sich die konkreten Prozessoren in vielen Details voneinander, doch in ihren wesentlichen Merkmalen sind sie sich oft erstaunlich ähnlich. Bei den Erläuterungen wurden einige Vereinfachungen vorgenommen, um den Texte nicht unnötig lang werden zu lassen.

Struktur moderner CPUs

Pipelining und superskalare Prozessoren

Die Pipeline-Technik wurde in den 70er Jahren für Superrechner entwickelt und im Laufe der 80er Jahre immer stärker auch in die Mikroprozessortechnik übernommen. Eine Pipeline ist in diesem Zusammenhang eine Einheit der CPU, die Maschineninstruktionen in sehr schneller Folge ausführen kann. Pipelines sind in mehrere Stufen aufgeteilt, die eine Instruktion durchlaufen muß, um vollständig abgearbeitet zu werden. Bei jedem Taktzyklus der CPU wird eine Stufe der Pipeline abgearbeitet. Eine Pipeline besteht typischerweise aus 4-15 Stufen.

Das ist an sich noch kein Vorteil gegenüber den ALUs der älteren Prozessoren, die ja auch mehrere Taktzyklen zur Abarbeitung einer Instruktion benötigten. Bei einer Pipeline kann sich allerdings zu einem Zeitpunkt in jeder Stufe eine Instruktion befinden, d.h. mit jedem Taktzyklus kann eine Instruktion die Pipeline betreten, während eine andere sie verläßt. Wenn eine Pipeline z.B. zehn Stufen hat, so dauert es zehn Taktzyklen, bis die erste Instruktion abgearbeitet ist, doch ab diesem Zeitpunkt wird mit jedem Taktzyklus eine weitere Instruktion abgeschlossen. Voraussetzung dafür ist, daß der Befehlsstrom auch mehr als zehn Instruktionen enthält, deren Abarbeitung nicht vom Ergebnis einer Instruktion abhängt, die sich noch in der Pipeline befindet. Vor allem bedingte Sprünge und Verzweigungen sind hier kritisch. Weiter unten wird noch auf einige Techniken eingegangen, um diese Problematik zumindest abzumildern.

Eine weitere Technik, um die Leistung eines Prozessors zu erhöhen, ist der Einsatz mehrerer paralleler Pipelines. Der Prozessor wird dadurch zu einer Art Parallelrechner; man bezeichnet ihn dann als "superskalar". (Man unterscheidet "skalare CPUs" von den sogenannten "Vektorrechnern", bei denen in einer Pipeline immer die gleiche Instruktion auf eine ganze Reihe von Eingabedaten - den Vektor - ausgeführt wird. Vektorrechner haben lange Zeit den Markt der Supercomputer dominiert, werden aber mehr und mehr von massiv parallelen Systemen mit superskalaren CPUs verdrängt.)

Im einfachsten Fall gibt es je eine Pipeline für Ganzzahl- und eine für Fließkommaoperationen, doch viele Prozessoren haben heute weit mehr Pipelines, die oft auch auf bestimmte Operationen spezialisiert sind. In solchen Prozessoren sorgt für gewöhnlich eine sehr aufwendige Logik für eine möglichst effiziente Aufteilung des Instruktionsstromes auf die verschiedenen Pipelines. Es ist an dieser Stelle sehr wichtig, daß dieser "Dispatcher" eine Aufteilung findet, die zu keinen "Pipeline Stalls" führt. Dieses Problem tritt auf, wenn die Abarbeitung einer Instruktion vom Ergebnis einer anderen Instruktion abhängt, die sich gerade noch in einer anderen Pipeline befindet oder noch gar nicht bearbeitet wurde. In diesem Fall muß die Pipeline so lange angehalten werden, bis das gewünschte Ergebnis vorliegt. Wenn solche "Pipeline Stalls" und "Pipeline Interlocks" vermieden werden können, so kann eine superskalare CPU theoretisch mit jedem Taktzyklus so viele Instruktionen abarbeiten, wie sie Pipelines hat.

Speziell für einen bestimmten Prozessor entwickelte Compiler geben normalerweise darauf acht, möglichst nur Befehlsfolgen zu erzeugen, die vom Pipeline-Dispatcher gut "verdaut" werden können und die zu keinem der genannten Probleme führen.

An dieser Stelle sollten noch kurz die Very-Long-Instruction-Word-Prozessoren (VLIW) erwähnt werden: Bei ihnen existiert keine oder nur wenig Logik für das Pipeline-Dispatching und für das Auflösen von Pipeline-Konflikten. Es ist Sache des Compilers, die Pipelines optimal zu füllen und Konflikte zu vermeiden.

Der gesparte (erhebliche) Hardwareaufwand kann beispielsweise für noch mehr Pipelines oder für größere Caches genutzt werden.

Branch Prediction

Wie im vorangegangenen Abschnitt beschrieben wurde, ist die Leistung moderner CPUs stark davon abhängig, daß die vorhandenen Pipelines immer gefüllt sind und keine in einer Pipeline befindliche Instruktion vom Ergebnis einer anderen Instruktion abhängt, die sich gerade noch in der gleichen oder einer anderen Pipeline befindet.

Dieser Fall tritt unter anderem immer dann auf, wenn ein bedingter Sprung ausgeführt werden muß, d.h. wenn zum Beispiel ein if-Statement auftritt oder die Abbruchbedingung einer Schleife überprüft wird. Normalerweise müssen alle Pipelines dann so lange angehalten werden, bis die den bedingten Sprung abarbeitende Pipeline so weit gekommen ist, daß die Zieladresse des Sprunges feststeht. Dann müssen erst Instruktionen von der Zieladresse des Sprunges in den Instruktionscache der CPU geladen werden, bevor sie endlich wieder den Pipelines zugeführt werden können. Der Effekt ist um so unangenehmer, je mehr Stufen die vorhandenen Pipelines haben. Aus anderen Gründen ist man aber gewöhnlich bestrebt, möglichst "tiefe" Pipelines zu bauen.

Wie man sieht, hat also jeder bedingte Sprung geradezu katastrophale Auswirkungen auf die Leistung einer superskalaren CPU. Man kann (und sollte) dieses Wissen nutzen, um möglichst lange Instruktionsfolgen ohne bedingte Sprünge zu erzeugen (Vermeidung von if-Statements in Schleifen, "Loop Unrolling", etc.), doch ganz vermeiden lassen sie sich natürlich nicht.

Um diese größte Performancebremse superskalarer CPUs zumindest abzuschwächen, wurde die "Branch Prediction", also die Vorhersage von Sprungzielen entwickelt. Dabei legt die sogenannte "Branch Prediction Unit" (BPU) an Hand bestimmter Regeln fest, an welcher der beidem möglichen Adressen das Programm wahrscheinlicher fortgesetzt werden wird. Neuere BPUs verwenden dafür auch die Ergebnisse vorangegangener Sprünge der gleichen Instruktion, was besonders bei der Überprüfung der Abbruchbedingung von oft durchlaufenen Schleifen zu hohen Trefferquoten führt.

Das Ergebnis der BPU wird genutzt, um von der vermuteten Folgeadresse schon einmal Instruktionen zu laden und auszuführen. Wenn die Vermutung der BPU richtig war, so hat der Sprung keinerlei negative Auswirkungen auf die Performance. War sie falsch, so müssen die Pipelines angehalten und die bisher "spekulativ" ermittelten Ergebnisse verworfen werden. Dann müssen Instruktionen von der anderen Adresse geladen und schließlich ausgeführt werden. Dieser Fall ist allerdings auch nicht schlimmer als der oben beschriebene Ablauf ohne BPU, so daß die BPU also auf jeden Fall einen Gewinn bringt. Wie hoch der Gewinn tatsächlich ist, hängt von der Qualität der Vermutungen der BPU und natürlich vom gegebenen Programm ab. Für die meisten modernen CPUs mit BPU gibt es optimierende Compiler, die notfalls auch if-else- oder switch-(case-)Statements so umstellen, daß die BPU korrekte Vorhersagen treffen kann. Für so optimierten Code erreichen die BPUs Trefferquoten von über 80%. Man sollte sich allerdings auch selbst nach den Strategien der BPU des Zielsystems erkundigen, um kritische Codefragmente für spezielle CPUs auf Trefferquoten von über 90% optimieren zu können.

Instruction Reordering, Speculative Execution und Register Renaming

Diese Punkte sollen nur kurz angesprochen werden, da man sie als Programmierer kaum beeinflussen kann, doch sind sie technisch sehr interessant und repräsentieren neueste Entwicklungsergebnisse.

Wie aus den vorangegangenen Abschnitten bereits indirekt hervorging, hat der "Program Counter" heute nur noch einen ungefähren Charakter, da potentiell etliche Instruktionen eines eigentlich streng seriellen Befehlsstromes parallel ausgeführt werden. Die neuesten Prozessoren gehen sogar noch einen Schritt weiter und laden Instruktionen zunächst in eine Art "Pool", aus dem die Pipeline-Dispatcher sich die Instruktionen heraussuchen, die sie gerade optimal verarbeiten können. Auf die ursprüngliche Reihenfolge wird dabei nur bedingt Rücksicht genommen. Wenn ein Datum fehlt, das zur Abarbeitung einer Instruktion notwendig ist, so wird das Laden des Datums aus dem Speicher angestoßen und die betreffende Instruktion sowie davon abhängige Instruktionen erst einmal übergangen und andere Befehle vorgezogen. Die auf diese Weise ermittelten Ergebnisse landen ebenfalls in einer Art Pool. Die BPU, der "Instruction Decoder" und der Cache sorgen dafür, daß immer genügend unabhängige Instruktionen im "Pool" vorhanden sind. Erweist sich diese spekulative Instruktionsabarbeitung doch einmal als falsch, so werden die Ergebnisse im Ergebnispool gelöscht. Sind schließlich genügend Ergebnisse im Pool, um eine ursprüngliche Befehlsfolge wieder zu rekonstruieren, so sorgt eine weitere Einheit der CPU (beim Pentium Pro "Retire Unit") dafür, daß die Effekte der Instruktionen auf die Daten wieder in die richtige Reihenfolge gebracht werden und der Program Counter entsprechend aktualisiert wird.

Da während einer solchen spekulativen Instruktionsverarbeitung weit jenseits des aktuellen Wertes des Program Counters einzelne Prozessorregister potentiell mehrfach gelesen und geschrieben werden, verwaltet eine solche CPU gewöhnlich weit mehr Register als für den Benutzer sichtbar sind und ordnet diese je nach Bedarf den sichtbaren Registern zu ("Register Renaming"). So kann es dazu kommen, daß am Ende einer spekulativen Ausführung mehrere Kopien eines Registers mit unterschiedlichem Inhalt existieren. Es ist dann Aufgabe der "Retire Unit" den endgültigen Inhalt zu ermitteln und die Kopien wieder freizugeben.

Diese Verfahren findet bei einigen CPUs der neuesten Generation Anwendung. Andere CPUs verwenden weniger aggressive Varianten des "Instruction Reordering". Allen gemeinsam ist die Bestrebung, die Pipelines ständig gefüllt und am Laufen zu halten. Die Entwicklung ist in diesem Bereich noch längst nicht abgeschlossen.

Caches und Speicherhierarchien

Moderne Prozessoren können Instruktionen und Daten mit einer Geschwindigkeit verarbeiten, die bezahlbare RAM-Bausteine nicht einmal annähernd erreichen. Beließe man es bei der Hierarchie "CPU-Speicher", so würden die CPUs den weitaus größten Teil ihrer Zeit mit "Wait States" verbringen, während denen die auf die Erfüllung von Anfragen an den Hauptspeicher warten. Superrechner lösten dieses Problem früher durch die Verwendung sehr teurer schneller Speicher und aufwendiger Speicherzugriffstechniken, doch hat sich dies auf Grund der extrem hohen Kosten nicht durchsetzen können.

Stattdessen erweitert man die Speicherhierarchie um einen Cache, der relativ klein ist und deshalb mit noch vertretbaren Kosten aus sehr schnellen RAMs aufgebaut werden kann. Der Cache kann nun die CPU ausreichend schnell mit Instruktionen und Daten versorgen, falls die geforderten Daten sich gerade in ihm befinden. Um das mit hoher Wahrscheinlichkeit zu erreichen, sind Caches in "Cache-Lines" organisiert. Das sind kleine Speicherblöcke (z.B. 256 Bytes), die jeweils komplett aus dem Hauptspeicher geladen werden, wenn ein Teil von ihnen von der CPU angefordert wurde. Das erhöht die Wahrscheinlichkeit beträchtlich, daß die folgenden Anfragen der CPU aus dem Cache bedient werden können. Ist das nicht der Fall ("Cache Miss"), so muß die CPU entweder warten, bis der Cache-Controller die entsprechende Cache-Line mit Daten aus dem Hauptspeicher gefüllt hat, oder sie kann versuchen, mit spekulativer Ausführung so weit wie möglich fortfahren. Allerdings kann selbst die sehr aggressive "Dynamic Execution" eines Pentium Pro nur eine sehr begrenzte Zahl von Cache Misses verkraften, bevor auch hier die Leistung steil nach unten abknickt.

In der Regel sind Caches selbst noch einmal in Schichten aufgeteilt, wobei sich der schnellste und kleinste "Level-1-Cache" normalerweise auf der CPU selbst befindet und der "Level-2-Cache" auf einem anderen Chip oder in externen RAM-Bausteinen untergebracht ist. Einige Systeme verfügen sogar noch über einen "Level-3-Cache", doch bringt dieser meist kaum noch spürbare Leistungsgewinne. Bei einigen CPUs ist der Level-1-Cache noch in Instruktions- und Daten-Cache unterteilt, wobei der Instruktions-Cache durch die CPU nur lesbar und damit einfacher zu realisieren ist.

Da Caches naturgemäß sehr viel kleiner sind als der Hauptspeicher, kann bei größeren Programmen mit vielen Daten zu einem Zeitpunkt nur ein kleiner Teil des insgesamt benötigten Daten im Cache gehalten werden. Es ist im wesentlichen Sache des Programmierers, dafür zu sorgen, daß die CPU möglichst lange auf einer oder wenigen einmal geladenen Cache-Lines arbeiten kann und so die Zahl der Cache-Misses zu minimieren. Das bedeutet in der Praxis, daß ein Programm möglichst lange auf einem kleinen Speicherbereich arbeiten muß ("Lokalität der Daten"), bevor es an den nächsten geht, da dies mit großer Wahrscheinlichkeit zu Cache-Misses führt. Die Kenntnis der Größe einer Cache-Line kann bei der Optimierung für ein spezielles Zielsystem von Nutzen sein.

Der leistungssteigernde Effekt der Cache-Optimierung eines Programmes wird um so größer, je größer der Unterschied zwischen der von der CPU benötigten maximalen Latenzzeit bei Speicherzugriffen und der minimalen Latenzzeit des Hauptspeichers ist. Besonders CPUs mit sehr hoher Taktfrequenz und einem breiten Bus zu Cache und Hauptspeicher sind davon betroffen, vor allem dann, wenn die Pipelines - wie z.B. beim Pentium Pro - mit noch erheblich höherer Taktrate betrieben werden.

Programmoptimierung

Die Hinweise zur Optimierung von Programmen für moderne superskalare, cache-basierte Systeme, die bereits in den Abschnitten über Struktur und Methoden moderner CPUs angesprochen wurden, sollen hier noch einmal zusammengefaßt und ergänzt werden.

  1. Verwendung optimierender Compiler: Dies ist zweifellos die einfachste Methode zur Beschleunigung bestehender Programme. Die Hersteller von CPUs und Workstations bieten in der Regel speziell für ihre Prozessoren optimierte Compiler an, die teilweise sogar verwendete Algorithmen erkennen und durch bereits optimierte ersetzen, die wo notwendig extensives Loop-Unrolling betreiben und if-Statements eliminieren oder umstellen und Code nach Vorgaben des Pipeline-Dispatchers und der BPU verändern. Diese Compiler optimieren in kritischen Fällen für gewöhnlich besser als portable Compiler wie der GNU GCC. Man sollte dabei aber beachten, daß besonders aggressiv optimierende Compiler unter Umständen auch die Semantik eines Programmes verändern. Das Handbuch gibt darüber Auskunft, welche Optionen des Optimierers in dieser Hinsicht kritisch sind.
  2. Vermeidung bedingter Sprünge: Natürlich lassen sich if-Statements nicht generell vermeiden, doch sollte man sie an Stellen eliminieren, die besonders häufig durchlaufen werden, also besonders in Schleifen. Man sollte sich auch erkundigen, ob bei einem if-else der wahrscheinlichere Fall in den if- oder in den else-Zweig kommen sollte, um die BPU optimal zu unterstützen. Weiterhin sollte man Schleifenkörper nicht zu kurz machen, sondern mit möglichst viel verzweigungsfreier Aktivität füllen, um das Verhältnis von hintereinander ausführbaren Instruktionen und der Überprüfung von Abbruchbedingungen möglichst groß zu machen. Gute BPUs sind in diesem Fall zwar eine große Hilfe, doch kann man sich nicht auf ihr Vorhandensein und ihre Wirkung verlassen, besonders wenn man portablen Code schreibt. Eine einfache Technik ist das "Loop Unrolling", das die meisten Compiler auf Wunsch automatisch ausführen können. An kritischen Stellen sollte man es allerdings besser selbst tun.
  3. Arbeiten auf kleinen Datenbereichen: Dies bringt in der Praxis den höchsten Leistungsgewinn. Man muß die verwendeten Algorithmen so gestalten, daß die CPU möglichst lange auf wenigen relativ kleinen Datenbereichen arbeitet, bevor sie auf andere Daten zugreift. Auf diese Weise lassen sich die ärgerlichen Cache-Misses stark vermindern. Wegen der immer größer werdenden Geschwindigkeitsunterschiede zwischen CPUs und Hauptspeicher kann dieser Punkt gar nicht überbetont werden; die Wahl der Algorithmen und der Datenstrukturen muß dies unbedingt berücksichtigen. Bei ungünstiger Auswahl kann die Leistung des Systems auf deutlich unter 20% der maximal möglichen Leistung sinken, selbst wenn man ansonsten gut optimiert hat!

Nutzt man alle allgemeingültigen Optimierungsstrategien aus, so sollte es kein Problem sein, auf jedem Zielsystem zwischen 80 und 90% der maximalen Leistung zu erreichen. Durch weitere Optimierung im Hinblick auf bestimmte Prozessoren läßt sich dies auf über 90% steigern. Ca. 95% stellen normalerweise die Grenze des praktisch Machbaren dar.