Hyper-Threading beim Intel Pentium 4 Prozessor

(Eine ausführlichere Beschreibung der Hyper-Threading-Technologie findet man in Heft 24/2002 der Fachzeitschrift c't.)

Hyper-Threading ist auch unter dem mehr technischen Begriff "Simultaneous Multithreading" (SMT) bekannt. Bei SMT arbeiten mehrere logische Prozessoren in einem Chip. Beide rechnen weitgehend unabhängig voneinander, was helfen kann, den Durchsatz zu steigern. Allerdings werden beim Pentium 4 mit Hyper-Threading nicht zwei komplette Prozessoren in einen Chip gepackt, sondern nur einige wichtige Komponenten wie zum Beispiel einen zusätzlichen Registersatz und einen eigenen Interrupt-Controller. Sowohl die Anzahl der Rechenwerke als auch die Anzahl und Größe der Caches bleiben gleich. Somit werden sie von beiden logischen Prozessoren gemeinsam benützt. Das ist also kein echtes Zwei-Prozessor-System; aus der Sicht von Anwendungssoftware und Betriebssystem sieht es allerdings so aus, als ob zwei Prozessoren vorhanden wären.

Dieses virtuelle Zwei-Prozessor-System erfordert vergleichsweise geringen Mehraufwand an zusätzlichen Transistoren und Chip-Fläche, verspricht aber dennoch einen Leistungsgewinn von rund 30 Prozent. Einfach die Taktfrequenz weiter zu erhöhen dürfte in Hinkunft schwieriger werden, weil die Signallaufzeiten zunehmend berücksichtigt werden müssen: Bei einer Frequenz von 3 GHz dauert ein Taktzyklus 0,33 ns. In dieser Zeit legt das Licht im Vakuum 10 Zentimeter zurück, die Fortpflanzungsgeschwindigkeit der Wellen in Leitungen beträgt rund zwei Drittel der Vakuumlichtgeschwindigkeit, sodass in einem Taktzyklus nur mehr rund 6 cm zurückgelegt werden. Das entspricht bereits ungefähr den Abmessungen des Chips.

Schon seit der Einführung des Pentium wird die Leistung der x86-Architektur auch durch Parallelverarbeitung gesteigert. Der Pentium kann im Gegensatz zu seinen Vorgängern "superskalar" arbeiten, was bedeutet, dass er mehrere Instruktionen pro Takt gleichzeitig ausführen kann. Das alleine nützt allerdings noch nicht viel, denn meist ist der Programmcode so strukturiert, dass sich aufeinanderfolgende Instruktionen nicht parallel ausführen lassen, weil die Befehle und Daten voneinander abhängen. Der Nachfolger des Pentium (Pentium Pro) konnte durch "Out-of-Order-Execution" die Reihenfolge der Abarbeitung von Instruktionen nach Bedarf umordnen, wodurch die vorhandene Parallelität, die im Original-Programmcode nicht optimal dargestellt wurde, ausgenützt werden konnte.

Studien haben gezeigt, dass sich in üblichem Code maximal 2,5 bis 3 Instruktionen pro Takt befinden, die gleichzeitig ausgeführt werden können. Schon dafür ist ein beträchtlicher Aufwand, wie z.B. Register-Renaming oder spekulative Ausführung von Befehlen, notwendig. Es liegt daher nahe, die Suche nach Parallelität nicht dem Prozessor zu überlassen, sondern schon im Vorfeld dem Compiler oder dem Programmierer aufzubürden. Tatsächlich beherrschen moderne Compiler diese Aufgabe erstaunlich gut. Aber auch der Programmierer kann durch Zerlegen seiner Software in logisch voneinander unabhängige Teile dazu beitragen, dass das Programm in Form mehrerer Threads gleichzeitig abläuft.

Tatsächlich bleibt der erhoffte Geschwindigkeitsvorteil durch Ausnützen von Multithreading jedoch aus, wenn nur ein Prozessor zur Verfügung steht. Dann kann das Betriebssystem jeweils nur einen Thread zur Ausführung bringen. Das ändert sich, wenn man zu Mehrprozessorsystemen mit "Symmetrischem Multi-Processing" (SMP) übergeht. Doch dafür sind spezielle, teure SMP-Boards sowie teurere SMP-taugliche Prozessoren nötig. Außerdem zeigt sich in der Praxis, dass diese Rechner oft nur zu einem geringen Teil ausgelastet sind, sodass das Preis/Leistungs-Verhältnis ungünstig ausfällt.

Um die wirkliche Auslastung der Prozessoren feststellen zu können, genügt es nicht, den Windows Task-Manager zu verwenden. Der Task-Manager bewertet die Wartezeit eines Prozessors auf den Hauptspeicher nämlich ebenfalls als Arbeit. Für den Task-Manager ist ein Prozessor nur dann im Leerlauf, wenn sich der ganze Thread in einem Wartezustand befindet. Das ist beim Warten auf Daten aus dem Hauptspeicher nicht der Fall. Bei höherem Prozessortakt bedeutet das oft, dass der Prozessor einfach nur schneller wartet. Die interne Auslastung des Prozessors ist alles andere als optimal.

Dabei hilft es, wenn mehrere Threads in Form eines feinkörnigen Multithreading nicht ganze Prozessoren, sondern die vorhandenen Rechenwerke innerhalb eines Prozessors besser auslasten. In vielen Fällen bleibt auch bei rechenintensiven Prozessen genügend Prozessorkapazität frei, um einen zweiten logischen Prozessor zu nutzen. Nur sehr wenige Programme sind so gut geschrieben, dass ein Thread die vorhandenen Prozessoreinheiten vollständig auslastet und auch dank geschicktem Cache-Management kaum Speicherwartezeiten erleidet. Hyper-Threading würde in einem solchen Fall keinen Vorteil bringen.

Die x86-Instruktionen werden nicht direkt verstanden, sondern beim Dekodieren in durchschnittlich zwei Mikro-Operationen (µops) zerlegt. Diese sind einfacher strukturiert und lassen sich besser in Hardware ausführen. Jede Mikro-Operation durchläuft beim Pentium 4 eine lange, mindestens 20-stufige Pipeline. Jede Pipeline-Stufe erledigt dabei nur einen Bruchteil der Arbeit. Dabei gelingt es fast nie, die Pipeline ohne Stocken am Laufen zu halten, weil immer wieder Wartezeiten ("pipeline stalls") auftreten. Das bedeutet, dass die Pipeline nicht vollständig gefüllt ist und Lücken enthält, die sich durch die einzelnen Stufen schieben. Je mehr Lücken vorhanden sind, desto ineffizienter arbeitet der Prozessor. Durch Hyper-Threading können solche Lücken mit Arbeit für einen zweiten Thread gefüllt werden. Dadurch führt der Prozessor den ersten Thread zwar nicht schneller aus, es steigt also nicht die absolute Performance, aber der Durchsatz erhöht sich, indem mehr Arbeit pro Zeiteinheit ausgeführt wird. Es spielt keine Rolle, ob die beiden Aufgaben als zwei Threads innerhalb des selben Prozesses oder als zwei separate Anwendungen in verschiedenen Prozessen ausgeführt werden.

Zwei Threads, die zu demselben Prozess gehören, können zumeist ihre Aufgaben nicht völlig unabhängig voneinander ausführen. Üblicherweise ist von Zeit zu Zeit eine gegenseitige Abstimmung zwischen mehreren Programmteilen, die an einer gemeinsamen Aufgabe arbeiten, erforderlich. Diese Abstimmung nennt man Synchronisation, und sie bewirkt, dass der Zugriff auf gemeinsame Datenbestände durch mehrere Threads in einer definierten Art und Weise erfolgt. In Windows ist dafür der Mechanismus der "Critical Section" die einfachste Methode, um sicherzustellen, dass nur ein Thread einen bestimmten Bereich im Programm ausführen kann. In anderen Betriebssystemen gibt es ähnliche Mechanismen (wie z.B. Semaphore, Mutexes usw.), die solche Aufgaben übernehmen können.

Code-Bereiche, die von mehreren Threads nicht gleichzeitig, sondern hintereinander durchlaufen werden sollen, können in Windows also durch eine Critical Section geschützt werden. Solche Codeabschnitte greifen meistens auf Datenbestand zu, der von mehreren Threads modifiziert werden kann und dessen Zugriff somit serialisiert werden muss. Dazu rufen die betroffenen Threads die Win32-Funktion EnterCriticalSection() auf und führen den zu schützenden Codeabschnitt aus. Sobald der Codeabschnitt durchlaufen worden ist, wird LeaveCriticalSection() aufgerufen, um zu signalisieren, dass ab sofort ein anderer Thread seinen zu schützenden Codeabschnitt durchlaufen kann.

Tritt der günstigste Fall ein, dass zum Zeitpunkt des Aufrufs von EnterCriticalSection() noch kein anderer Thread die selbe Critical Section angefordert hat, kostet das nur wenige Instruktionen im User-Modus, der Kernel wird dazu nicht bemüht. Anders sieht es aus, wenn der Bereich bereits belegt ist. Dann wird es notwendig, dass der Thread wartet, bis der konkurrierende Programmteil den Bereich wieder verlassen und die Critical Section freigegeben hat. So lange muss der blockierte Thread in einem Wartezustand verharren, mit dem Wunsch an das Betriebssystem, geweckt zu werden, wenn die Critical Section freigegeben worden ist.

Die Wartezeit kann jedoch verhältnismäßig groß werden, weil Windows nur in bestimmten Intervallen Prozesse auf die CPUs verteilt. Dieses Intervall heißt üblicherweise "Time Slice", in Windows spricht man von "Quantum". Es beträgt bei den Workstation-Versionen zwischen 20 und 60 ms, bei den auf Durchsatz optimierten Server-Versionen liegt es bei 120 ms. Das bedeutet, dass der Scheduler im ungünstigsten Fall den wartenden Thread erst rund 1/8 Sekunde später aufweckt, nachdem die Critical Section frei geworden ist. Da Critical Sections üblicherweise nur kurze Codeteile schützen, die in wesentlich kürzerer Zeit als in 1/8 Sekunde durchlaufen werden, ist der zeitliche Nachteil des Wartens auf den nächsten Context Switch gewaltig.

Abhilfe schaffte die Einführung eines "Spin Counts" ab Service Pack 3 von Windows NT4. Dabei wird eine kurze Abfrageschleife auf das Freiwerden der Critical Section in Kauf genommen. Wenn innerhalb der Zeitspanne, die die Abfrageschleife benötigt, die Critical Section noch immer nicht frei geworden ist (was relativ selten vorkommt), legt sich der Thread wie üblich schlafen.

Hyper-Threading macht die Optimierung durch diese Abfrageschleife sinnlos, weil der eine logische Prozessor durch die Schleife unnötigerweise ausgelastet wird und somit den anderen logischen Prozessor sogar ausbremsen kann. Dadurch wird die Freigabe der Critical Section noch weiter verzögert.

Die PAUSE-Instruktion dient nun dazu, dass eine solche Abfrageschleife dem Prozessor signalisiert wird und er die Schleife deutlich langsamer ausführt, um die sonstigen Prozessor-Ressourcen und den Stromverbrauch zu schonen. Die PAUSE-Instruktion existiert zwar schon seit langem in der x86-Architektur (auch bei den Nachbauten), doch erst mit der Einführung der NetBurst-Architektur erfolgt eine Verlangsamung um etwa den Faktor 20.


zurück
Inhalt
vor