Teil 5: Erkennen von Flaschenhälsen und Problemen

.Net-Anwendungen für Multi-Core optimieren

22.12.2008 von Rami Radi
Welche Verfahren man bei der Optimierung einer Anwendung für Dual-Core, Quad-Core und Multi-Core-CPUs einsetzt und wie man gängige Flaschenhälse vermeidet, ist Thema des fünften Teils unserer Reihe „Programmieren für Multi-Core-Prozessoren“.

Die Leistung von Computersystemen steigt seit Jahren kontinuierlich an. Dies betrifft nahezu alle Komponenten der Rechner. Am deutlichsten ist der Leistungsgewinn bei den Prozessoren, dem Arbeitsspeicher und den Festplatten zu verfolgen. Insbesondere die Prozessoren haben nicht nur dank höherer Taktung sondern vor allem durch die Multi-Core-Technologie massiv an Leistungsfähigkeit zugelegt. Einzelne Anwendungen profitieren aber nur von Multi-Core, wenn sie entsprechend programmiert sind.

In dieser mehrteiligen Reihe zur Programmierung für Multi-Core-Prozessoren führen wir Sie nach und nach durch die Besonderheiten der Multi-Core-Programmierung. Angefangen bei den wichtigsten Grundlagen der verschiedenen Parallelisierungs-Verfahren in Prozessoren im ersten Teil. Der zweite Teil dreht sich um die notwendige Modularisierung in Applikationen und das .Net-Framework. Im dritten Teil geht es um Deadlocks, Race Conditions und Zugriffe auf die .Net-Thread-Implementation. Der vierte Teil behandelt eine Reihe von Problemen, die beim Parallelzugriff auf gemeinsame Daten auftauchen können, und welche Methoden Sie anwenden können, um konsistente Daten zu gewährleisten. Dieser vorerst letzte Teil befasst sich mit der Optimierung von .Net-Applikationen für Multi-Core-Architekturen - insbesondere darum, wie man Flaschenhälse und Probleme erkennt und behebt.

Optimierung von Multithreaded .NET-Applikationen

Nach all den vorangegangenen Erläuterungen zur Aufteilung des Codes in mehrere Threads und deren Synchronisation bleibt schließlich die Frage, was das das optimale Vorgehen dabei ist. Sicherlich kann dies nicht in einer Flut an Threads liegen, die dann dem ThreadPool übergeben werden. Außerdem wird der Code nicht alleine dadurch effizienter, wenn er wahllos in mehrere Threads zerlegt wird, die dann mehr oder weniger parallel laufen.

Für den Entwickler bleibt daher die Anforderung, einen gut abgestimmten und auch optimierten Source-Code zu erstellen. Dabei gilt es, all jene Code-Passagen ausfindig zu machen, die sich für eine parallele und asynchrone Verarbeitung am besten eignen und folglich auf separate CPU-Cores ausgelagert werden können.

Gleichzeitig sollen aber auch nicht übermäßig viele Threads erzeugt werden. Denn Threads benötigen CPU-Laufzeiten und die Verwaltung durch das Betriebssystem. Um eine optimale Thread-Aufteilung zu erreichen, kommt daher der Fehlersuche und Performanzanalyse eine entscheidende Rolle zu.

Wie viele Threads?

Die Suche nach der richtigen Anzahl von Threads ist keine einfache Aufgabe. So sollen sicherlich nicht mehr Threads erzeugt werden, als das System auch vernünftig verwalten kann. Ein theoretischer Ansatz zur Ermittlung der richtigen Anzahl ist der folgende:

NumThreads = NumCPUs / (1 – BP)

BP steht dabei für den prozentualen Zeitanteil, in dem Tasks geblockt sind. Laut dieser Formel soll die Anzahl der Threads gleich sein mit dem Verhältnis der CPUs zum Anteil der Tasks, die tatsächlich parallel ausgeführt werden. Wenngleich das nur ein theoretischer Wert ist, so mag es als Annäherung dennoch hilfreich sein.

Schlechte Thread Performance

Trotz einer Aufsplittung des Codes in mehrere Threads ist das natürlich kein Garant für eine optimal funktionierende Applikation. Im schlimmsten Fall kann es sogar passieren, dass die Applikation durch die Verteilung auf mehrere Threads langsamer wird, als wenn sie als single-threaded Applikation auf nur einem CPU-Kern ausgeführt würde.

Eine der Ursachen für nicht optimal austarierten Code sind ungünstige Locking-Verfahren. Wenn die Lock-Klammern um den Code zu eng gesetzt sind, zieht dies einen massiven Verwaltungsaufwand nach sich, denn die Locks müssen angefordert und wieder frei gegeben werden. Sind die Lock-Abschnitte dagegen zu groß gewählt, so werden andere Threads unnötig blockiert und die Parallelverarbeitung damit gesenkt.

Ein zu großzügiger Gebrauch von „shared Data“ sorgt dafür, dass viel Aufwand zur Synchronisation und der Abstimmung des Zugriffs getrieben werden muss.

Die generelle Verteilung der Last auf die unterschiedlichen Cores ist ebenso von entscheidendem Einfluss. Ist die Rechenlast nicht gleichmäßig auf alle CPU-Cores verteilt, so wird ein Core überlastet, ein anderer jedoch ungenutzt sein. Eine sorgfältige Analyse der Lastsituation ist daher in jedem Fall anzuraten. Die Analyse des Lastverhaltens muss durch die Unterstützung weiterer Werkzeuge erfolgen, wie etwa dem Intel VTune Performance Analyzer.

Um die bestmögliche Skalierbarkeit von Applikationen zu erzielen, sollten Sie darauf achten, dass keine zu großen seriellen Code-Abschnitte entstehen, die eine parallele Codeausführung per se verhindern. Hierbei gilt es, den wichtigsten und am meisten verwendeten Code-Abschnitt ausfindig zu machen und zu parallelisieren. Aber auch völlig andere Ursachen, wie etwa ungenügender Durchsatz bei den Speicheroperationen können sich negativ auf die Gesamtperformanz auswirken.

Um Probleme oder Engpässe auf Mehrprozessorsystemen zu ergründen, ist eine Analyse des Laufzeitverhaltens mit Testreihen über die tatsächlichen Auswirkungen unerlässlich. Auch an dieser Stelle leisten Tools wie der Intel VTune Performance Analyzer wertvolle Hilfe.

Intel VTune Performance Analyzer

VTune ist ein Analysewerkzeug (Profiler), das dabei hilft, Applikationen in Hinsicht auf die Leistung und den Durchsatz zu untersuchen. Durch die Ermittlung (Sampling) von Messwerten lassen sich Rückschlüsse auf das Verhalten der Applikation ableiten. Beim Sampling klinkt sich VTune in den Prozessablauf ein und ermittelt die jeweils aktuellen Gegebenheiten.

VTune: Die Analyse überwacht im ersten Schritt alle laufenden Prozesse.

Mittels integrierter Performance Counter werden Leistungsinformationen gewonnen, wie beispielsweise zu Cache Misses, Interrupts oder zur Verweildauer von Prozessen in den Prozeduren. Aus den gewonnenen Leistungswerten bildet VTune graphische Übersichten wie beispielsweise die folgende Übersicht zu den Prozessen. Das Beispiel zeigt die benötigte Laufzeit (Verweildauer) von Prozessen, Modulen oder Funktionen. Durch Drill-Down lassen sich diese Werte weiter untersuchen.

Drill-Down: Die Perfomance-Analyse ist bis auf einzelne Code-Zeilen Detailtiefe möglich.

Sampling over Time

Beim “Sampling over Time” im folgenden Bild lassen sich die unterschiedlichen Threads der Applikation im Vergleich zueinander untersuchen. Diese Analyse liefert auch Aussagen darüber, ob die Verteilung der Aufgaben auf die Threads zu einer gleichmäßigen Auslastung der Threads führt und wie groß der Synchronisationsaufwand zwischen ihnen ist.

Die Applikation im folgenden Bild weist große Unterschiede in den Threads auf und kann daher kaum als gut verteilt betrachtet werden. Einige der Threads haben eine Menge Arbeit zu bewältigen. Dies wird in roter Farbe dargestellt. Andere wiederum - in hellem Grün unterlegt - sind eher unterlastet.

Thread-Analyse: Die grünen Threads langweilen sich, der rot ist überlastet.

Call Graph-Modus

Ein weiterer Analysemodus von VTune ist der “Call Graph”-Modus. Hierbei wird in einer Baumstruktur die Aufrufsequenz der Funktionen aufgezeigt. Die Anzeige liefert auch die Informationen darüber, welche Funktion welche andere Funktion aufruft und wie sich die Prozessdauer der Codeausführung verhält.

Angezeigt wird außerdem der kritische Pfad der Applikation. Dies ist jener Pfad, der die längste Ausführungszeit besitzt. Diese Informationen sind besonders für verwaltete Laufzeitumgebungen (managed runtime environment) hilfreich, um abzuschätzen, welche Runtime-Funktionen von wem und wie oft aufgerufen werden.

Baumstruktur: VTune zeigt die Aufrufsequenz und die Laufzeit in den einzelnen Modulen.

Prinzipiell sollten alle CPUs gleichmäßig ausgelastet sein. Wenn sich im Applikationslauf allerdings eine geringe CPU-Auslastung zeigt, so deutet diese auf einen der beiden folgenden Umstände hin: entweder werden die Prozessoren durch die Tasks nicht hinreichend genutzt oder die Threads sind blockiert oder blockieren sich gegenseitig.

Die Tests sollten auf mehreren Hardware-Systemen mit unterschiedlichen CPU-Leistungsdaten ausgeführt werden, denn häufig zeigen sich hierbei unterschiedliche Leistungsergebnisse.

Paint.NET – eine multithreaded Applikation

Eine der derzeit interessantesten multithreaded .NET-Applikationen ist Paint.NET. Dieses Programm ist als 64 Bit multithreaded C#-Anwendung als Open Source verfügbar. Paint.NET wurde ursprünglich als Studienprojekt der Studenten an der Washington State University entworfen. Es ist ein Programm zur Bearbeitung von Fotos oder Bildern. Paint.NET hat sich mittlerweile zu einem leistungsfähigen Programm der Bildbearbeitung weiterentwickelt und unterstützt dabei einen umfangreichen Bildeditor mit einer umfassenden Funktionsvielfalt.

Studienobjekt: Paint.NET ist Open Source und mit aktueller Programmiertechnik entworfen.

Rick Brewster, der verantwortliche Entwickler für Paint.NET, erstellte einen Benchmark für das Programm. Dieser umfasst eine Reihe von Prüfungen, die mehrere rechenintensive Operationen mit unterschiedlichen Objektgrößen, sowohl auf 32-Bit- aber auch 64-Bit-Systemen analysieren.

Seit der Version 2.5 von Paint.NET zeigt diese Analyse viele hilfreiche Hinweise und Tipps für den Einsatz von multithreading in der .NET-Umgebung und auch den Einsatz von 64-Bit-System in diesem Kontext.

Testreihen auf einem 2-Wege Quad-Core Intel Xeon X5355-System zeigten, dass sich die Leistung des Systems nahezu linear mit der Anzahl der CPU-Cores erhöhte. Eine Verdopplung der Cores brachte auch eine Verdopplung der Leistung des Gesamtsystems mit sich. Die Ergebnisse sind der nachfolgenden Grafik zu entnehmen.

Skalierung: Die Leistung von Paint.NET korreliert sehr schön mit der Anzahl der Threads.

Bei der Ausführung der Tests auf unterschiedlichen Systemen wiesen 64-Bit-Systeme im Vergleich zu 32-Bit-Systemen durchschnittlichen einen Leistungszuwachs von 35 Prozent auf. Paint.NET wird beständig weiterentwickelt. Daher ist davon auszugehen, dass die Leistungen des Systems auch in Zukunft noch erweitert werden.

Zusammenfassung und Ausblick

Moore's Law gilt auch heute noch. Die Prozessorhersteller werden in Zukunft die Anzahl der Prozessoren auf einem Chip im Zeitraum von etwa 24 Monaten verdoppeln.

Geändert hat sich aber der Leistungsdurchsatz von single-threaded Applikationen, den die Taktung der einzelnen Kerne wird nicht großartig steigen. Daher können in den kommenden Jahren single-threaded Applikationen nicht den Leistungszuwachs erzielen, wie er sich eigentlich in der Hardware wieder spiegelt.

Diese Einschränkung wird allerdings durch multithreaded programmierte Anwendungen mehr als wettgemacht. Sie können die Applikationen beschleunigen und weiterhin einen Leistungsgewinn für sich verbuchen. Die Compiler der Zukunft werden Parallelverarbeitung deutlich besser unterstützen als zurzeit. .NET-Code und die verteilte Verarbeitung wird sich dann über mehrere CPUs und Kerne erstrecken

Bis dahin allerdings ist Multithreading der einzige Weg, um die erweiterten Leistungsmöglichkeiten der Plattformen bestmöglich nutzen zu können. Dies sollte auch die Motivation sein, sich mit den Konzepten der .NET-Programmierung im Allgemeinen und dem CLR-Threadpool, dem BackgroundWorker, ReaderWriterLockSlim, Interlocked Klassen, asynchronen Delegaten, Timern, sowie allen weiteren hier behandelten Themen näher auseinanderzusetzen.

Aber auch die Herausforderungen, wie etwa die Aspekte der Skalierung wollten wir durchleuchten. Zur Analyse der Leistungsdaten schließlich sollte man sich auch mit den Werkzeuge und Hilfen wie etwa VTune beschäftigen. (mha)

Referenzen