Effizient auf Multicore migrieren

Effizient auf Multicore migrieren

Früher oder später steht er für fast jeden an: Der Umstieg auf Multicore. Manager und Kunden erwarten selbstverständlich Performance-Steigerungen – doch wie nutzt man Multicore richtig gut aus?
Statt höherer Taktfrequenzen bekommt man heute zwei oder mehr CPU-Cores, egal ob x86-Prozessor, ARM Cortex A9 oder Freescale QorIQ. Wer das Maximum herausholen will, sieht sich mit zwei Herausforderungen konfrontiert:

1. Parallelisierung:

Wenn die Applikationsfunktionalität mit mehreren Prozessen bzw. 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.

2. Synchronisation:

Oft wurde existierender Code nicht mit dem Gedanken an Multicore-CPUs entwickelt, sondern für eine einzelne CPU. Mit Multicore tauchen ggf. plötzlich Race Conditions oder Zugriffskollisionen auf, und je nach Komplexität ist das ganze schwer zu debuggen.

Herausforderung 1: Parallelisierung – aber wie?

Das Thema Parallelisierung ist enorm wichtig, um Multicore-CPUs effizient zu nutzen. Als erster Gedanke können Virtualisierungs-Lösungen eine Möglichkeit sein: Eine spezielle Software trennt hierbei die Cores voneinander und simuliert mittels Zuordnung der Hardware-Komponenten 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 Software-Komponente (dem Virtualisierer) ggf. 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 Hardware-Plattformen parallel lief. Im schlimmsten Fall ist ein Core also zu 100% ausgelastet, während der andere ‚idle‘ ist. Besser ist es oft, von vornherein auf ein Multicore-fähiges OS zu setzen. Dabei lohnt es sich aber, genau hinzusehen, denn für einige OS-Hersteller ist dieses Thema noch relativ neu, während andere schon viel Erfahrung haben. Das Embedded-Betriebssystem QNX Neutrino bietet z.B. bereits seit 1998 Unterstützung für SMP (Symmetrisches Multi-Processing). Es wird gern in den Bereichen Automotive, Medizintechnik und Industrie-Automatisierung eingesetzt, weil es auf einem der Sicherheit und Stabilität sehr dienlichen Microkernel-Konzept basiert. QNX ist heute außerdem der einzige Anbieter, der fertig zertifizierte Kernel mit Multicore-Unterstützung im Programm hat (IEC61508 SIL3, Common Criteria EAL4+). Damit eine Portierung existierender Software möglichst einfach ist, setzt QNX viel auf Standards wie z.B. POSIX. Mit dieser API kann jeder sofort arbeiten, der schon einmal mit einem Unix-artigen OS zu tun hatte. Für grafische Anwendungen sind unter QNX beispielsweise QT, OpenGL ES oder HTML5 möglich. Da unter QNX 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 Echtzeit-Teil entfällt damit. Liegt Code vor, der in einem ‚großen‘ Thread sequenziell diverse Aufgaben erledigt, kann dieser nicht von mehreren Cores profitieren. Hier versucht Software zur (halb-) automatischen Parallelisierung mit Compiler-Direktiven oder speziellen Bibliotheken einen Performance-Zuwachs zu erreichen. Beispielsweise kann mit OpenMP mittels ‚pragma‘-Direktive im Source-Code der Compiler angewiesen werden, Schleifen – in der Regel Berechnungsalgorithmen – zu parallelisieren. Die Intel Thread Building Blocks (TBB) Bibliothek 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 den selben 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 bzw. abgenommen werden, kann bereits vorher abgenommener Code nicht einfach ‚durchgewinkt‘ 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. QNX Neutrino bietet hier beispielsweise POSIX-Threads sowie Thread-Pools für Client-Server-Ansätze. Damit behält der Entwickler die volle Kontrolle und erspart sich unnötige Komplexität. Bei Code-Generatoren oder Threading-Libraries hingegen wird ein zusätzlicher Abstraktionslevel hinzugefügt, der das Debuggen erschweren kann.

Herausforderung 2: Synchronisation

Liegt bereits Multi-Threaded Code vor, kann beim Umstieg auf Multicore plötzlich Fehlverhalten auftreten, 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, in dem man ihm eine Priorität kleiner X zuweist. Der erste Thread kann so z.B. 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 kommen, während unser Thread auf Priorität X noch aktiv ist, auch niederpriore Threads ans laufen – auf dem/den anderen Core(s). Unser 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 ggf. auf einem anderen Core läuft. Was sich paradox anhört – ‚Interrupt‘ heißt schließlich ‚unterbrechen‘ – ist auf Multicore-Systemen ganz normal. Zur Lösung solcher Probleme nutzt man einen der zahlreichen Synchronisationsmechanismen des OS: Mutex (am besten mit Prioritätsvererbung), Condition Variable, Semaphore, Thread Barrier, Read­er/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 werden.

Wichtig: gute Entwicklungswerkzeuge

Auch die Entwicklungswerkzeuge spielen eine große Rolle. So bietet z.B. die Tool Suite QNX Momentics 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 Threads an bestimmte Cores lohnen. Über den hochauflösenden CPU-Auslastungsgraphen ist zu sehen, wie sich die Systemleistung verändert und welche Verteilung der Threads Sinn macht. Damit können Entwickler ihr System effizient gestalten – und damit auch dem Wettbewerb voraus sein.

Lucy Turpin Communications GmbH
www.aspentech.com

Das könnte Sie auch Interessieren