Umstieg auf Multicore-Systeme Migrationshelfer: Multicore erfordert Feintuning

Früher oder später steht er für fast jeden an: der Umstieg auf Multicore-Systeme. Manager und Kunden erwarten selbstverständlich Performance-Steigerungen - doch wie nutzt man Multicore richtig gut aus? Tatsächlich steigt die Leistung meist nur unter bestimmten Voraussetzungen wesentlich; die meisten Verbesserungen erfordern Feintuning.

Statt höherer Taktfrequenzen bekommt man heute zwei oder mehr CPU-Cores, egal ob x86-Prozessor, ARMs »Cortex A9« oder Freescales »QorIQ«. Wer das Maximum herausholen will, sieht sich mit zwei Herausforderungen konfrontiert:

  • Parallelisierung: Wenn die Applikationsfunktionen mit mehreren Prozessen beziehungsweise Threads implementiert wurde, ergibt sich in der Regel ein Performancevorteil auf Multicore. Doch gibt es nur einen einzigen Thread, der alles tut, dann kann dieser auch nur einen Core nutzen, und die anderen liegen brach.
  • Synchronisation: Oft wurde existierender Code nicht mit dem Gedanken an Multicore-CPUs entwickelt, sondern für einen einzelnen Rechenkern. Mit Multicore tauchen gegebenenfalls plötzlich Race-Conditions oder Zugriffskollisionen auf, und je nach Komplexität ist das Ganze schwer zu debuggen.

Das Thema Parallelisierung ist enorm wichtig, um Multicore-CPUs effizient zu nutzen. Als erster Gedanke können Virtualisierungslösungen eine Möglichkeit sein: Eine spezielle Software trennt hierbei die Cores voneinander und simuliert mittels Zuordnung der Hardwarekomponenten mehr oder weniger komplette Einzelrechner, auf denen dann separate Betriebssysteme laufen. Vorteilhaft ist dies, wenn Software von mehreren Rechnerknoten auf einen konsolidiert werden soll: Nichts muss portiert werden, sondern kann auf dem existierenden OS verbleiben. Nachteil ist jedoch, dass mit dieser zusätzlichen Softwarekomponente (dem Virtualisierer) gegebenenfalls Kosten und Komplexität steigen.

An Performance ist oft auch noch nichts gewonnen, denn parallel arbeitet hier natürlich nur die Software, die vorher bereits auf zwei unterschiedlichen Hardwareplattformen parallel lief. Im schlimmsten Fall ist ein Core also zu 100% ausgelastet, während der andere »idle« ist. Besser ist oft, gleich auf ein Multicore-fähiges OS zu setzen. Dabei lohnt es sich aber, genau hinzusehen, denn für einige OS-Hersteller ist dieses Thema immer noch relativ neu, während andere schon viel Erfahrung haben. Das Embedded-Betriebssystem »Neutrino« von QNX bietet beispielsweise bereits seit 1998 Unterstützung für SMP (Symmetrisches Multi-Processing) und basiert auf einem der Sicherheit und Stabilität dienenden Microkernel-Konzept.

Der Hersteller hat auch fertig zertifizierte Kernel mit Multicore-Unterstützung im Programm (IEC 61508 SIL3, Common Criteria EAL4+). Damit eine Portierung existierender Software möglichst einfach ist, setzt QNX auf Standards wie etwa POSIX. Mit dieser API kann jeder sofort arbeiten, der schon einmal mit einem Unix-artigen OS zu tun hatte. Für grafische Anwendungen sind beispielsweise QT, OpenGL ES oder Adobes Flash/AIR möglich. Da unter Neutrino auch Grafiklösungen gekapselte Prozesse mit einstellbaren Prioritäten sind, bleiben Determinismus und Echtzeit voll erhalten - die früher oft notwendige Trennung von Visualisierung und Echtzeitteil entfällt damit.

Freuden der Parallelisierung

Liegt Code vor, der in einem »großen« Thread sequenziell diverse Aufgaben erledigt, kann dieser nicht von den mehreren Cores profitieren. Hier versucht Software zur (halb-)automatischen Parallelisierung mit Compiler-Direktiven oder speziellen Bibliotheken einen Performancezuwachs zu erreichen. Beispielsweise kann OpenMP mittels »pragma«-Direktive im Quellcode den Compiler anweisen, Schleifen - in der Regel Berechnungsalgorithmen - zu parallelisieren.

Intels »Thread Building Blocks«-Bibliothek (TBB) hingegen fügt C++ einige Templates wie »parallel_for« hinzu. In jedem Fall wird natürlich mit mehreren Threads parallelisiert, wobei es sich auch mehrmals um denselben Code handeln kann, jedoch mit anderen Parametern mehrmals (parallel) ausgeführt. Für jemanden, der noch nie mit Threads gearbeitet hat, stellen solche Lösungen eine gewisse Versuchung dar. Doch erfahrungsgemäß dauert die Einarbeitung in diese mindestens genauso lange wie das Erlernen der Thread-Programmierung.

Verlockend sind halbautomatisierende Ansätze deshalb primär bei existierendem Code, den man in seiner eigentlichen Struktur nicht mehr anfassen will oder kann. Doch Achtung: Leider sind die Grenzen dabei fließend, denn eingegriffen wird ja in jedem Fall. Und soll das fertige System zertifiziert beziehungsweise abgenommen werden, kann bereits vorher abgenommener Code nicht einfach »durchgewunken« werden, da die Parallelisierung nicht nur eine binäre, sondern auch eine logische Veränderung darstellt. Und wie weist man nach, dass automatisch parallelisierter Code auch sicher ist? Dann doch lieber selbst ein paar eigene Threads erzeugen. Zu diesem Zweck bietet Neutrino hier beispielsweise POSIX-Threads sowie Thread-Pools für Client-Server-Ansätze. Damit behält der Entwickler die Kontrolle und erspart sich unnötige Komplexität. Bei Codegeneratoren oder Threading-Bibliotheken hingegen wird ein zusätzlicher Abstraktionslevel hinzugefügt, der das Debuggen erschweren kann.

Synchronisation

Liegt bereits Multi-Threaded-Code vor, kann beim Umstieg auf Multicore plötzlich Fehlverhalten auf-treten, dessen Ursache oft eine implizite Annahme ist, die umgangssprachlich ausgedrückt lautet: »Dieser Code ist mit sich und der CPU allein«. Dahinter steckt die Nutzung von Prioritäten als Ausschlussmechanismus: Wenn ein Thread auf Priorität X mit einer Ressource (z.B. Tabelle) beschäftigt ist, die ein zweiter Thread ebenfalls bearbeiten will, kann man diesen (auf einer einzelnen CPU) einfach vom Zugriff abhalten, indem man ihm eine Priorität kleiner X zuweist.

Der erste Thread kann so etwa eine komplexe Operation mit der Tabelle durchführen und erst, wenn er damit fertig ist, kommt der zweite Thread an die Reihe und arbeitet mit dem Ergebnis. Doch auf einer Multicore-CPU können, während der Thread auf Priorität X noch aktiv ist, auch niederpriore Threads laufen - eben auf dem/den anderen Core(s).

Der Thread manipuliert also noch die Tabelle, während ein anderer - trotz niedrigerer Priorität - bereits versucht, mit dem Ergebnis zu arbeiten. Ähnliches gilt für einen Interrupt-Handler, der jetzt nicht mehr zwangsläufig ein zugehöriges Hauptprogramm unterbricht. Denn auf einer Multicore-CPU wird ein Interrupt-Handler auf einem der Cores abgearbeitet, während das Hauptprogramm gegebenenfalls auf einem anderen Core läuft.

Was sich paradox anhört - Interrupt heißt schließlich Unterbrechung - ist auf Multicore-Systemen ganz normal. Bei der Lösung solcher Probleme hilft einer der zahlreichen Synchronisationsmechanismen des OS: »Mutex« (am besten mit Prioritätsvererbung), »Condition Variable«, »Semaphore«, »Thread Barrier«, »Reader/Writer Locks«, usw. Bei der Thread-Barrier beispielsweise läuft ein Thread, der auf Ergebnisse anderer Threads angewiesen ist, erst los, wenn die zuarbeitenden Threads auch wirklich fertig sind (Bild 1).

Alternativ bindet man die Teile der Applikation mit dem Synchronisationsproblem an einen Core und lässt den Code selbst, wie er ist. Dieser verhält sich dann so wie auf einem Single-CPU-System, während alle anderen Komponenten dynamisch über die Cores verteilt sind.

Werkzeugkasten

Auch die Entwicklungswerkzeuge spielen eine große Rolle. So bietet beispielsweise die Toolsuite »Momentics« von QNX Werkzeuge, um in Single-Threaded-Programmen die Funktionen mit dem meisten CPU-Verbrauch herauszufinden - hier lohnt sich dann die Umstellung auf Multi-Threading. Der »System Profiler« zeigt dann das Zeitverhalten des Gesamtsystems und die Auslastung der einzelnen Cores (Bild 2).

Somit werden Performancegewinne messbar und Optimierungspotenziale sichtbar. Wichtig ist auch das Thema Core-to-Core-Migration (Bild 3): Springen Threads zu oft zwischen den Cores hin und her, muss unter Umständen der CPU-Cache neu gefüllt werden, und Performance geht verloren. Auch hier kann sich bei sehr rechenintensiven, gleichmäßig auftretenden Abläufen das Binden eines oder mehrerer Thread-s an bestimmte Cores lohnen. Über den hochauflösenden CPU-Auslastungsgraphen ist zu sehen, wie sich die Systemleistung verändert und welche Verteilung der Threads sinnvoll ist.

Über den Autor:

Malte Mundt ist Field Application Engineer bei QNX Deutschland