Mit statischer Code-Analyse Performance-Flaschenhälse finden
Viele Performance-Probleme können bereits in einer sehr frühen Phase des Software Development Lifecycles gefunden werden. Je früher ein Flaschenhals erkannt wird, desto einfacher und billiger kann er auch beseitigt werden. Dazu eignet sich ein Tool, das eigentlich in jedem Entwicklungsteam vorhanden sein sollte: Die statische Code-Analyse.
Anbieter zum Thema

Innerhalb der Software-Entwicklung spielt die Qualitätssicherung eine immer größere Rolle. Zurecht, denn mit der steigenden Kritikalität von Software – nicht zuletzt im Embedded-Bereich – müssen die funktionalen und nicht-funktionalen Anforderungen sowie die Sicherheit der Systeme auf hohem Niveau gewährleistet werden. Neben dem Testing hat sich die statische Code-Analyse als Methode etabliert, Fehler oder Abweichungen von Programmierstandards oder -richtlinien aufzuspüren. Was oft übersehen wird: Die statische Analyse kann nicht nur Buffer Overruns oder Null Pointer Dereferences erkennen. Sie kann auch dabei helfen, potenzielle Performance-Einschränkungen frühzeitig innerhalb des Software Development Lifecycles (SDLC) sichtbar zu machen.
Das Funktionsprinzip der statischen Analyse basiert darauf, dass der zu untersuchende Code in ein Modell überführt wird. Anhand dieses Modells werden vom Analyse-Tool alle Steuerungs- und Datenströme durchlaufen und mit definierten Prinzipien verglichen. So können unterschiedliche potenzielle Probleme erkannt werden; der Entwickler bekommt aussagekräftige Hinweise, wie er den Code verbessern kann. Im Gegensatz zum Testing benötigt die Analyse keinen lauffähigen Code, diese Methode kann also schon sehr früh im SDLC eingesetzt werden. Zudem lässt sie sich in weiten Teilen automatisieren, wenn entsprechende Tools genutzt werden.
Beispiel: Cache als Flaschenhals
Dieses Verfahren kann sich die Software-Entwicklung zunutze machen, besonders bei Embedded-Systemen. Gerade in der Embedded-Entwicklung ist die verfügbare Hardware oft limitiert, die Software muss mit geringen Ressourcen performant funktionieren. Dabei sind es oft vermeintliche Kleinigkeiten, die die Performance negativ beeinflussen – so zum Beispiel die Nutzung des Cache.
Der Cache ist ein besonders schneller Speicher, um häufig benötigte Daten temporär zu speichern. Ein in SRAM ausgeführter Speicher ist bis zu 100-mal schneller als der üblicherweise für den Hauptspeicher genutzte DRAM – aber auch entsprechend teurer. Auch liegt der Energieverbrauch des SRAM deutlich über dem des DRAM. Um die Geschwindigkeitsvorteile bei wirtschaftlich sinnvollen Kosten zu realisieren, kommen verhältnismäßig kleine SRAM-Caches zum Einsatz, die in die CPU integriert sind. Dabei gilt die Regel: Je größer die Speicherkapazität, desto langsamer der Zugriff. Deshalb werden heute oft bis zu drei Cache-Stufen genutzt:
Zudem ist der Cache so organisiert, dass größere Datenblöcke am Stück adressiert und ausgelesen werden. Der Zugriff auf diese so genannten Cache Lines vermeidet die vom Systembus bekannten Bandbreitenprobleme. Bei Intel-Prozessoren etwa sind die Cache Lines 64 Bytes groß.
Mit jeder Cache-Stufe steigt die Zugriffszeit
Der Ablauf ist streng hierarchisch: Die CPU sucht ein benötigtes Datum zunächst im First Level Cache (L1). Steht es dort nicht zur Verfügung, spricht man von einem Misfetch. Auf einen L1-Misfetch folgt die Suche im Second Level Cache (L2), anschließend im Third Level Cache (L3). Mit jedem Cache-Level steigt dabei die mittlere Zugriffszeit. Kann auch der L3 das benötigte Datum nicht liefern, muss auf den Hauptspeicher zugegriffen werden, was erhebliche Einbußen bei der Performance nach sich zieht.
Ein typisches Code-Beispiel, das zu Performance-Problemen führen wird, kann wie folgt aussehen:
struct Test_Struct
{
char a;
int b;
char c;
float d;
char e;
} ;
struct Test_Struct *precord;
…
for (i = 0; i < 1024 * 1024 * 10; i++)
{
precord[i].a++;
precord[i].b++;
precord[i].c++;
precord[i].d++;
precord[i].e++;
}
Das Beispiel verfügt über eine Struktur mit fünf Variablen, bei denen unterschiedliche Datentypen gemischt sind. Stellt man sich diese Struktur als ein großes Array vor, bei dem häufig auf alle Variablen zugegriffen werden muss, würde sich das in der Cache Line niederschlagen (siehe Bild 2).
Die Cache Line wird entsprechend der im Code definierten Variablen befüllt. Da die Cache Line wie auch der Hauptspeicher in der Regel in Datenwörtern mit einer Wortbreite von vier Byte organisiert ist (Alignment), bleiben weite Teile ungenutzt: Die erste Variable a vom Typ char belegt ein Byte. Variable b vom Typ int benötigt vier Byte, kann also nicht mehr ins erste Wort gespeichert werden. Die gleiche Situation tritt auch bei den weiteren Variablen auf. Das Ergebnis: Die Cache Line kann nur zu 55 Prozent genutzt werden, die restliche Kapazität wird vergeudet und verursacht vermeidbare Geschwindigkeitsverluste. Die nötige Anzahl an Cache-Zugriffen steigt um 45 Prozent. Damit steigt auch die Wahrscheinlichkeit, dass benötigte Daten nicht in diesem Cache liegen und somit aus langsameren Speichern wie etwa dem Hauptspeicher geladen werden müssen.
Abhilfe in diesem einfachen Beispiel schafft das Umsortieren der Variablen im Code:
struct Test_Struct
{
char a;
char c;
char e;
int b;
float d;
} ;
Indem die fünf Variablen entsprechend ihres Platzbedarfs im Cache angeordnet sind, kann die Cache Line fast optimal ausgenutzt werden (siehe auch Bild 3).
Neben der besseren Performance durch den gut genutzten Cache sinkt damit auch der Platzbedarf der Anwendung im Hauptspeicher, der so genannte Memory Footprint. Zudem bedingt jede Veränderung einzelner Bits der Cache Line, dass die komplette Cache Line via Systembus in den Hauptspeicher zurückgeschrieben werden muss. Das gilt selbstverständlich auch für leere Bytes. Durch die Optimierung wird also ein deutlich geringerer Anteil der limitierten Bus-Bandbreite durch die Applikation beansprucht.
Effizienter Datenzugriff
Doch nicht nur die Art, wie die Variablen im Code sortiert sind, kann zu spürbaren Performance-Schwächen führen. Auch ein ineffizienter Datenzugriff macht die Anwendung langsamer als nötig.
struct Test_Struct
{
int a;
int b;
int c;
int d;
int e;
} ;
struct Test_Struct *precord;
…
for (i = 0; i < 1024 * 1024 * 50; i++)
{
precord[i].b++;
}
Das Ergebnis ist wieder eine sehr ungünstige Nutzung der Cache Line, wie die Verteilung der Strukturvariablen in Bild 4 zeigt.
Obwohl kein Speicherplatz im Cache vergeudet wird, ist die Ausnutzung nicht optimal. Denn innerhalb jeder Strukturinstanz wird nur auf ein Element zugegriffen. Hier kann eine deutliche Beschleunigung erzielt werden, indem das Array in zwei Arrays aufgeteilt oder indem ein zweites, temporäres Array erzeugt wird.
struct Test_Struct br> {
int a;
int c;
int d;
int e;
} ;
struct Test_Struct *precord;
int *b;
…
for (i = 0; i < 1024 * 1024 * 50; i++)
{
b[i]++;
}
Das Ergebnis dieser Optimierung ist vollständige Nutzung der Cache Line, die nun ausschließlich die benötigten Daten vorhält. In der Praxis kann diese Form der Optimierung, je nach Größe des Arrays, erhebliche Auswirkung auf die Performance einer Anwendung haben.
Einsatz der Code-Analyse
Tools zur statischen Analyse können in der Regel mit individuellen Checkern erweitert werden, die relativ einfach zu schreiben und zu implementieren sind. Um Performance-Probleme wie ungenutzter Speicherplatz in der Cache Line zu finden, muss der Checker erkennen, ob die Struktur- und Member-Variablen möglichst ideal angeordnet sind. Dazu gehört auch eine Erkennung, ob eine Neuordnung der Variablen sich nur positiv auf den Memory-Footprint der Anwendung auswirken wird oder ob zudem signifikante Performance-Verbesserungen zu erwarten sind. Diese Frage hängt unter anderem davon ab, ob auf diese Daten häufig zugegriffen werden muss. Das ist etwa in Schleifen der Fall.
Das letztgenannte Beispiel, die lediglich partiell genutzte Struktur, erfordert zur Optimierung die Aufspaltung in Arrays. Dieser Ansatz zieht in der Regel umfassende Änderungen am Code nach sich, er kann also nur in einer recht frühen Phase der Entwicklung wirtschaftlich sinnvoll umgesetzt werden. Durch den Einsatz eines Tools zu statischen Analyse lässt sich das Problem frühzeitig erkennen und so mit vertretbarem Aufwand lösen. Mögliche Performance-Verbesserungen von erheblichem Ausmaß rechtfertigen dieses Vorgehen. Um die Aufwände möglichst gering zu halten, können Analyse-Tools den Entwicklern einiges an Hilfestellung bieten. Zum Beispiel im Analyse-Tool CodeSonar des Herstellers GrammaTech: Ein einfacher, als Plugin realisierter Checker warnt nicht nur vor potenziellen Performance-Problemen, sondern gibt auch gleich eine Empfehlung aus, wie diese behoben werden können.
Die statische Analyse ist bei der Performance-Optimierung nicht auf das Caching beschränkt. Ebenso ist es denkbar, damit auch Schleifen zu identifizieren, die zusammengeführt werden können oder auf Funktionen hinzuweisen, die sich zu einer einzigen Funktion zusammenfassen lassen. Hier sind zahlreiche Checks möglich. Doch muss klar sein: Die Performance-Optimierung mit Hilfe der statischen Analyse kann und will ein systematisches, methodisches Software Performance Engineering nicht ersetzen. Der Ansatz ist aber gut geeignet, potenzielle Flaschenhälse bereits während der Entwicklung zu eliminieren. Grundsätzlich gilt: Je früher im SDLC ein Problem erkannt wird, desto weniger Aufwand und Kosten verursacht die Behebung. Nicht-funktionale Anforderungen erst im Testing zu überprüfen, kann oft verheerend sein. Große Änderungen an der Software sind dann kaum mehr sinnvoll möglich. Jede kleine und große Schwäche im Code, die bis dahin bereits bereinigt ist, ist also ein Gewinn – für das Entwicklungsbudget, die Code-Qualität und nicht zuletzt für die Time to Market.
:quality(80)/images.vogel.de/vogelonline/bdb/1390900/1390949/original.jpg)
Sicherheitslücke im Linux-Tool beep als Einfallstor ins System
:quality(80)/images.vogel.de/vogelonline/bdb/1400800/1400856/original.jpg)
Absichtlich platzierte Schwachstellen
Risiko Insider-Angriffe – Code-Manipulationen erkennen
* * Royd Lüdtke ist Direktor für Statische Analysetools bei Verifysoft Technology GmbH
(ID:45363705)