Effizienz

 

 

Seminar: Softwareentwicklung (Programmierstil), WS2002/03

Friedrich Priewasser

9956846 / 880

 

 

1. Einleitung

2. Überschlagsrechnungen

2.1 Welchen Zweck haben Überschlagsrechnungen?

2.2 Kosten von Operationen

3. Profiling

3.1 Was ist ein Profiler?

3.2 Profiling eines Beispielprogramms

4. Code Tuning

4.1 Was bringt Code Tuning?

4.2 Methoden zur Geschwindigkeitssteigerung

4.2.1 Überdenken des Programm Designs

4.2.2 Überarbeiten des Modul- und Methodendesign

4.2.3 Reduzieren der Zugriffe auf das Betriebssystem

4.2.4 Wahl guter Compiler

4.2.5 Austausch der Hardware

4.2.6 Code Tuning

4.3 Vorgehensweise beim Code-Tuning

4.4 Beispiel: 2er-Logarithmus-Berechnung für Integer

4.5 Code Tuning Techniken

4.5.1 Ersetzen komplizierter Operationen

4.5.2 Inline Coding

4.5.3 Loop-Unrolling

4.5.4 Speichern für Wiederverwendung

4.5.5 Programmteile in Assembler schreiben

4.5.6 Compiler Optimierungen

4.5.7 Weitere Techniken

4.6 Zusammenfassung der Schlüsselpunkte

5. Speichereffizienz

5.1 Ein Illustratives Problem (Geographische Datenbank)

5.2 Zusammenfassung und Übersicht

6. Literatur

 

1. Einleitung

Vor einiger Zeit, als Computer noch langsam waren und wenig Speicher hatten (im Vergleich zu heutigen Computern), wurde noch viel Zeit in die Optimierung von Programmlaufzeiten und Speicherverbrauch investiert. Heute wird diesen Bereichen, auf Grund der Weiterentwicklungen im PC-Sektor, im Vergleich zu anderen Bereichen im Softwareentwicklungsprozess nur mehr ein geringer Zeitanteil zugestanden. Dennoch muss auch heute in manchen Fällen auf (teils antiquiert erscheinende) Techniken dieses Bereichs zurückgegriffen werden, um einem Programm die nötige Performance zu verleihen. Diese Arbeit soll erklären wenn ein „Tuning“ Sinn macht, wie dabei allgemein vorzugehen ist und welche Techniken es gibt.

2. Überschlagsrechnungen

2.1 Welchen Zweck haben Überschlagsrechnungen?

Die Aufgabe von Überschlagsrechnungen ist im Grunde in allen technischen Bereichen die selbe: Man möchte mit wenig Aufwand einen ungefähren Überblick erhalten. Dies kann dazu dienen um die Korrektheit einer Lösung zu überprüfen (oder eigentlich um zu zeigen dass, die Lösung richtig sein könnte) oder um den Aufwand für eine bestimmte Aufgabe grob abzuschätzen.

Im Bereich der Softwareerstellung können Überschlagsrechnungen verwendet werden um einerseits vor der Implementierung eines geplanten Programms die Laufzeit, bei bestimmten Eingangsdaten, abschätzen zu können und andererseits den Speicherbedarf (RAM, Festplatte) zu ermitteln. Überschlagsrechnungen machen es also möglich zu überprüfen ob ein Programm nach dessen Implementierung gewünschten Laufzeitanforderungen entspricht oder nicht.

2.2 Kosten von Operationen

Um Überschlagsrechnungen durchführen zu können ist es natürlich notwendig den Code zu kennen für den die Überschlagsrechnung gemacht werden soll. Dies sind jene Prozeduren bei denen der größte Zeitverbrauch zu erwarten ist. (Bei Programmen die zeitkritisch sind handelt es sich meist sowieso nur einen von Anfang an bekannter Punkt.)

Des weiteren muss man für jeden Befehl der in diesem Codeteil ausgeführt wird wissen wie lange dessen Abarbeitung dauert. Da diese Zeiten je nach Computer, Betriebssystem, Programmiersprache und Compiler verschieden sind, ist es meist notwendig diese Werte selbst zu ermitteln. Hierbei wird im einfachsten Fall folgende Methode verwendet:

Zunächst wird für eine einfache Schleife die Ausführungsdauer bei mehreren Millionen Durchläufen gemessen:

n=...;

for (int i=0;i<n;i++);

 

Zur Messung kann man, wenn man keine besonders hohe Genauigkeit benötigt, eine von der Programmiersprache zur Verfügung gestellte Methode verwenden (z.B. in Java: System.currentTimeMillis()). Es ist jedoch darauf zu achten, dass hierbei Hintergrundprozesse (fast) keine CPU-Ressourcen in Anspruch nehmen.

Anschließend wird der Reihe nach eine Operation (von der man die Ausführungsdauer wissen möchte) nach der anderen in die Schleife eingesetzt und die Messung erneut durchgeführt:

n=...;

for (int i=0;i<n;i++)

   i1=i2+i3;

 

Durch Subtrahieren der Laufzeit der leeren Schleife und dividieren durch n erhält man nun die Ausführungsdauer eines Befehls.

Probleme bei dieser Methode:

·       Compileroptimierungen: Da die Schleife sehr kurz ist und immer wieder die selbe Operation ausgeführt wird kann es passieren, dass der Compiler einen, im Vergleich zu einem normalen Programm, überproportional hohen Effizienzgewinn durch Optimierungen erreicht. Um dies zu vermeiden sollten Compileroptimierungen bei solchen Messungen ausgeschaltet werden. (In Programmiersprachen wie C# kann durch den Just-In-Time-Compiler dieser Effekt weiterhin auftreten).

·       Verwendeter Speicher: Da immer wieder auf die selben Daten zugegriffen wird, muss wesentlich seltener auf Daten vom Hauptspeicher (oder gar von der Auslagerungsdatei) zugegriffen werden, stattdessen werden Daten aus den Registern oder dem Cache verwendet. Eine gewisse Verbesserung ergibt sich wenn mehrere gleiche Operationen auf verschiedene Variablen in der Schleife ausgeführt werden.

·       Prozessorabhängigkeit: Die so ermittelten Werte sind nur auf ähnlichen Systemen einsetzbar. Da sich Programmiersprachen, Betriebssysteme und vor allem Prozessoren schnell weiterentwickeln und ein Programm häufig auf verschiedenen Systemen eingesetzt werden soll, müssen solche Messungen sehr häufig durchgeführt werden.

Auf Grund dieser Probleme ist es sinnvoll die Ergebnisse abschließend zu kontrollieren. Dies geschieht indem man die erhaltenen Werte in ein sinnvolles Beispiel (z.B. ein fertig gestelltes Programm) einsetzt und die Berechnung mit dem gemessenen Wert vergleicht.

Des weiteren sollten bei der Abschätzung der Laufzeit eines geplanten Programms Sicherheitsfaktoren verwendet werden um eventuelle Messfehler auszugleichen.

3. Profiling

3.1 Was ist ein Profiler?

Ein Profiler ist, wie der Name schon sagt, ein Werkzeug mit dem ein Profils eines Programms erstellt werden kann. Ein solches Profil enthält zumindest Angaben über die Häufigkeit mit der ein Programmteil (ein einzelner Befehl oder auch eine Methode) ausgeführt wird. Kommerzielle Produkte zeigen im allgemeinen zusätzlich auch die Ausführungsdauer von Programmteilen an und ermöglichen so eine genaue Laufzeitanalyse.

3.2 Profiling eines Beispielprogramms

Um zu zeigen wie ein Profiler bei der Effizienzsteigerung verwendet werden kann hier ein kleines Beispiel:

Es sollen alle Primzahlen bis 1000 ermittelt werden.

Hierfür wird für alle Zahlen von 2 bis 1000 die Methode prime(int) aufgerufen, Diese überprüft für jede Zahl kleiner der zu überprüfenden Zahl (n) ob sie Teiler von der zu überprüfenden Zahl ist. Falls sie Teiler ist wird die Überprüfung mit dem Rückgabewert 0 abgebrochen, falls dies nie der Fall ist wird eine Eins zurückgegeben (die Zahl ist also eine Primzahl).

 

void main(){

   int i,n;

   n=1000;                           1

   for (i=2;i<=n;i++){               1

      if (prime(i))                999

         printf("%d\n");           168

   }

}

 

int prime(int n){

   int i;

   for (i=2; i<n; i++){            999

      if (n%i==0)                78022

         return 0;                831

   }

   return 1;                       168

}

 

Die Werte rechts neben dem Programmcode wurden von einem einfachen Profiler erzeugt und geben Auskunft darüber wie oft ein Befehl ausgeführt wurde. Aus den Werten können folgende Schlüsse gezogen werden:

·       999 Zahlen wurden überprüft

·       168 Zahlen kleiner/gleich 1000 sind prim

·       831 Zahlen kleiner/gleich 1000 sind nicht prim (=999-168)

·       78022 mal wurde überprüft ob eine Zahl Teiler von n ist (im Durchschnitt also ca. 78 mal für jede Zahl von 1 bis 1000)

 

Die Vermutung liegt nahe, dass durch Reduzieren der Tests auf Teilbarkeit das Programm beschleunigt werden kann. Außerdem ist auch ersichtlich, dass die Geschwindigkeit des printf-Befehls oder auch die Dauer die für den Methodenaufruf benötigt wird, wahrscheinlich nicht ausschlaggebend für die Laufzeit ist. Als erste Abhilfe bietet sich an nur bis zur Wurzel von n den Test auf Teilbarkeit durchzuführen:

void main(){

   int i,n;

   n=1000;                             1

   for (i=2;i<=n;i++){                 1

      if (prime(i))                  999

         printf("%d\n");             168

   }

}

 

int prime(int n){

   int i;

   for (i=2; i<root(n); i++){        999

      if (n%i==0)                   5288

         return 0;                   831

   }

   return 1;                         168

}

 

int root(int n){

   return (int) sqrt((float) n);    5456

}

 

 

Es zeigt sich das nun nur mehr 5288 Tests (ca. 7% der Tests des Originalprogramms) durchgeführt werden und immer noch gleich viele Zahlen überprüft bzw. als prim erkannt werden. Dies ist ein Anzeichen dafür, dass das Programm weiterhin korrekt arbeitet. Entgegen einer möglichen ersten Vermutung sinkt die Laufzeit allerdings nicht. Beim Suchen der Ursache dieses Phänomens ist der bisherige Profiler nicht mehr sonderlich hilfreich, deshalb wird an dieser Stelle zu einem Profiler mit Zeitmessung gewechselt:

%Zeit Name

 82.7  sqrt

  4.5  prime

  4.3  root

  2.6  frexp

  ...  ...

                    

(Dies ist nur ein kleiner Ausschnitt der Ausgabe des Profilers, in dem nur die Methodennamen und die prozentuelle Zeit, die das Programm in der Methode verbringt, dargestellt wird.)

Es zeigt sich, dass über 4/5 der Zeit zur Berechnung der Wurzeln benötigt wird. Bei diesem einfachen Programm hätte man auch ohne das Ergebnis des Profilers vermuten können, dass die Komplexität der Wurzelberechnung an der nun noch höheren Laufzeit des Programms schuld ist. In vielen anderen Fällen ist es aber nicht so einfach den Grund für eine schlechte Performance zu finden und ein Profiler mit Zeitmessung ist die einzige (effiziente) Möglichkeit die Schwachstelle des Programms zu finden.

Da nun bekannt ist weshalb das Programm so langsam ist können weitere Verbesserungen durchgeführt werden. Zwei Möglichkeiten scheinen hier offensichtlich:

Bei der ersten wird die Wuzelberechnung aus der Schleife herausgezogen:

int prime(int n){

   int i, bound;

   bound=root(n);

   for (i=2; i<bound; i++){

      if (n%i==0)         

         return 0;        

   }

   return 1;              

}

 

Bei der zweiten wird die Wurzelberechnung durch Quadrieren des Wertes auf der linken Seite des Vergleichs ersetzt:

int prime(int n){

   int i;

   for (i=2; i*i<n; i++){

      if (n%i==0)         

         return 0;         

   }

   return 1;              

}

 

Welche dieser beiden Methoden nun effizienter ist kann nicht generell gesagt werden, da einerseits die Wurzelberechnung im Vergleich zur Multiplikation je nach System unterschiedlich lang dauert und andererseits die Effizienz der beiden Methoden auch von n abhängig ist. Für kleine n ist zu erwarten, dass die zweite Methode schneller ist, da Multiplikationen wesentlich schneller ausgeführt werden als Wurzelberechnungen. Für große n ist zu erwarten, dass die erste Methode schneller ist da „bound“ nur ein mal je Methodenaufruf berechnet werden muss, „i*i“ hingegen Wurzel (n) mal.

Es wäre natürlich möglich das Programm weiter zu verbessern, jedoch ist ein Programm mir einer der beiden Methoden in den meisten Fällen ausreichend schnell und da es so auch immer noch leicht verständlich ist, wird man meist von einer weiteren Optimierung absehen.

4. Code Tuning

4.1 Was bringt Code Tuning?

Code Tuning ist kein Allheilmittel der Softwareentwicklung. Man sollte, so lange man keinen guten Grund für Code-Tuning hat, Code Tuning nicht einmal einsetzen. Der wesentliche Grund der gegen den Einsatz spricht ist, dass auf Grund der verwendeten „Tricks“ (wie Loop-Unrolling)  der Programmcode schwieriger zu programmieren, zu lesen und zu überarbeiten wird. Dies hat zur Folge, dass bei der Implementierung und vor allem beim Überarbeiten sehr leicht Fehler gemacht werden. Beim Loop-Unrolling sind dies beispielsweise Laufvariablen die über einen falschen Bereich laufen. Neben der Fehleranfälligkeit steigt natürlich auch die Implementierungsdauer auf Grund der höheren Komplexität an.

Verwendet man Code Tuning ohne genaue vorherige Analyse kann auch eine höhere Gesamtlaufzeit die Folge sein. Der Grund dafür sind vor allem falsche Vermutungen über die Abarbeitungszeit einzelner Befehle oder Methoden. Dies ist aber noch nicht der schlimmste Fall, es kann nämlich durch verfrühten Einsatz von Code Tuning auch dazu kommen das ein Projekt abgebrochen werden muss, da einerseits der Zeitbedarf übermäßig stark ansteigt und andererseits wesentliche Punkte wie Modularisierung und sauberer Programmierstil vernachlässigt werden.

Aus diesen Gründen sollte der Programmcode nicht von Anfang an optimiert werden sondern, falls überhaupt, erst nach Ende der eigentlichen Implementierung.

4.2 Methoden zur Geschwindigkeitssteigerung

Falls ein Programm nicht die gewünschte Performance hat sollte man nicht gleich auf Code Tuning zurückgreifen, da dies wie bereits erwähnt auch gewisse Gefahren mit sich bringt. Deshalb werden hier weiterer Möglichkeiten aufgezählt um die Performance zu verbessert.

4.2.1 Überdenken des Programm Designs

Hierbei handelt es sich nicht direkt um eine Methode der Geschwindigkeitssteigerung, dennoch sollte es hier erwähnt werden.

Ein schlecht modularisiertes Design macht es schwierig, wenn nicht gar unmöglich, ein schnelles Programm zu schreiben und vor allem ein bereits vorhandenes Programm schneller zu machen. Wenn man weiß das die Geschwindigkeit eines Programms eine wesentliche Rolle spielt, sollte auch eine Architektur gewählt werden die dem Rechnung trägt. Anschließend ist es möglich für die einzelnen Module Laufzeitvorgaben zu machen. Hierdurch ist es möglich Geschwindigkeits-“Bottelnecks“ schon zu finden und zu beseitigen bevor die Implementierung abgeschlossen ist und dennoch die Nachteile die beim Code Tuning entstehen zu vermeiden. Des weiteren ermöglicht ein gut gewähltes Design ein späteres Austauschen von langsamen Modulen durch schnellere.

4.2.2 Überarbeiten des Modul- und Methodendesign

Die Wahl eines geeigneten Algorithmus ist wohl der wichtigste Punkt, wenn es darum geht effiziente Programme zu erstellen. Dies ist ganz einfach dadurch zu erklären, dass ein richtig entworfener oder gewählter Algorithmus eine geringere asymptotische Laufzeitkomplexität hat wie ein schlecht gewählter Algorithmus. Beispielsweise ist ein schlecht (langsam) implementierter Sortieralgorithmus mit O(n*log n) bei groß genugem n immer schneller wie ein gut (schnell) implementierter Sortieralgorithmus mit O(n²). Es kann also ein noch so gut „getuntes“ Programm niemals die Nachteile eines schlechten Algorithmus wieder gut machen. (Höchstens noch wenn ein bestimmtes n nicht überschritten wird)

4.2.3 Reduzieren der Zugriffe auf das Betriebssystem

Zugriffe auf, vom Betriebssystem zur Verfügung gestellte, Ressourcen wie Dateizugriffe oder Ausgabe auf den Bildschirm sind im Vergleich zu gewöhnlichen Befehlen sehr langsam. Ein Zugriff auf die Festplatte dauert etwa 100 bis 1000 mal länger als ein Zugriffe auf den Hauptspeicher, ein einfaches Mittel die Performance im Falle von häufigen Dateizugriffen zu verbessern stellt etwa die Verwendung von Puffern dar. (Diese Möglichkeit wird von modernen Programmiersprachen angeboten und sollte auf jeden Fall genutzt werden.)

4.2.4 Wahl guter Compiler

Gute Compiler übersetzten sauber programmierten Code in optimierten Maschinencode. Wählt man die richtige Sprache und einen gut optimierenden Compiler, muss man oftmals nicht weiter über die Steigerung der Geschwindigkeit eines Programms nachdenken.

4.2.5 Austausch der Hardware

Oftmals ist es die einfachste und billigste Möglichkeit die Performance zu verbessern vorhandene Hardware durch neue Hardware zu ersetzen. Dies gilt zwar nicht für Produkte die für den freien Markt entwickelt werden, sehr wohl jedoch wenn die entwickelte Software nur auf wenigen oder gar nur auf einem Computer eingesetzt wird. Auf diese Weise erspart man sich eventuell aufwendiges und teures Code Tuning, der Code bleibt sauber strukturiert und alle anderen Programme die auf dem Computer laufen erfahren ebenfalls eine Performancesteigerung.

4.2.6 Code Tuning

Code Tuning ist das Ändern von korrektem Code um ihn effizienter zu machen, ohne dabei das Design zu ändern. Hierauf wird im Rest des Kapitels 4 genauer eingegangen.

4.3 Vorgehensweise beim Code-Tuning

1. Geschwindigkeit des Programms messen

Bei nahezu allen Programmen wird ein großer Anteil der Laufzeit in wenigen Prozent des Quellcodes verbracht. Üblicherweise kann man davon ausgehen, dass weniger als 5% des Codes über 50% der Laufzeit benötigen. (Bei interaktiven Programmen sollte die Zeit in der auf die Eingabe gewartet wird nicht berücksichtigt werden.) Deshalb ist es unbedingt notwendig durch Messungen diese „Hot Spots“ zu eruieren. Hierfür wird meist ein Profiler verwendet, da ein solcher wesentlich genauere Ergebnisse liefert als von Programmiersprachen zur Verfügung gestellte Mittel wie „System.currentTimeMillis()“.

2. „Hot Spot“ überarbeiten

Nachdem man die Hot Spots gefunden hat, wählt man einen (meist den mit höchster Laufzeit) aus und versucht den Code schneller zu machen.

3. Erfolg der „Optimierung“ überprüfen

Hat man einen Hot Spot überarbeitet, ist zu überprüfen ob die vermeidliche Optimierung wirklich eine Optimierung war oder ob auf Grund eventueller falscher Vermutungen das Programm in Wirklichkeit langsamer geworden ist. Des weiteren ist auch zu Überprüfen ob es weiterhin fehlerfrei ist. Ist einer dieser Punkte nicht erfüllt muss der Code weiter überarbeitet werden. Wenn auch dies nicht zum Erfolg führt sind die Änderungen dieses Optimierungsdurchlaufs rückgängig zu machen.

4. Sinn weiterer Optimierungen überdenken

Hat man das „tunen“ eines Codeteils beendet muss man zunächst überprüfen ob die Performance nun gut genug ist. Hat man die gewünschte Performance erreicht wird man das Code-Tuning meist beenden um die Nachteile, die hierdurch entstehen, so gering wie möglich zu halten, andernfalls ist ein weiterer Optimierungsdurchlauf zu machen.

Sind durch weitere kleine Änderungen große Geschwindigkeitssteigerungen  zu erwarten, oder handelt es sich um Code der wahrscheinlich wiederverwendet wird (Klassenbibliothek, ...) kann auch bei erreichtem Performanceziel ein weiteres Optimieren sinnvoll sein.

4.4 Beispiel: 2er-Logarithmus-Berechnung für Integer

Da die meisten Programmiersprachen, bzw. deren Klassenbibliotheken weder die Logarithmus-Berechnung für Integer (also ohne Typecasts) noch die Berechnung von 2er-Logarithmen (ohne Umweg über die Division durch die Basis) unterstützen bietet sich zunächst folgende Lösung (C# Implementierung) an:

static uint Log2(uint x){

   return (uint) (System.Math.Log(x)/System.Math.Log(2));

}

 

Eine einzige Logarithmus Berechnung hat auf meinem Computer (AMD Athlon 500) ca. 700 ns benötigt.

Da der Logarithmus von 2 immer gleich ist, bietet sich an, den Funktionsaufruf mit Konstantem Parameter, durch das Ergebnis zu ersetzen:

static uint Log2(uint x){

   return (uint) (System.Math.Log(x)/0.6931471805599453094);

}

 

Die Berechnung dauerte nun nur mehr 450 ns, immerhin eine Verbesserung von 36%. Natürlich könnte man hier statt dem numerischen Wert auch einen Konstantennamen einsetzen.

Bis jetzt wird zum Ermitteln des Ergebnisses der Logarithmus-Berechnung zunächst ein Integer-Wert auf Double „gecastet“ (implizit), die eigentliche Logarithmus-Berechnug durchgeführt und schließlich dieses Ergebnis wieder auf Integer „gecastet“ (explizit). Wie man sich leicht denken kann ist dies nicht die schnellste Methode der Berechnung, da bei Double-Werten selbst einfache Operationen schon wesentlich länger dauern als bei Integer-Werten. Eine einfache Überlegung beseitigt diesen aufwendigen Umweg: Da uint ein 32-Bit Typ ist kann es auch nur 32 ganzzahlige Ergebnisse einer Zweier-Logarithmus-Berechnung geben. Nachdem es hier vor allem um Geschwindigkeit geht bietet sich somit sofort an, die Berechnung durch Überprüfen in welchem Intervall sich die Zahl befindet zu ersetzen:

static uint Log2(uint x){

   if(x<0x2)          return 0;       if(x<0x4)          return 1;

   if(x<0x8)          return 2;       if(x<0x10)         return 3;  

   if(x<0x20)         return 4;       if(x<0x40)         return 5;

   if(x<0x80)         return 6;       if(x<0x100)        return 7;  

   if(x<0x200)        return 8;       if(x<0x400)        return 9;

   if(x<0x800)        return 10;      if(x<0x1000)       return 11;        

   if(x<0x2000)       return 12;      if(x<0x4000)       return 13;

   if(x<0x8000)       return 14;      if(x<0x10000)      return 15;        

   if(x<0x20000)      return 16;      if(x<0x40000)      return 17;

   if(x<0x80000)      return 18;      if(x<0x100000)     return 19;                 

   if(x<0x200000)     return 20;      if(x<0x400000)     return 21;

   if(x<0x800000)     return 22;      if(x<0x1000000)    return 23;

   if(x<0x2000000)    return 24;      if(x<0x4000000)    return 25;

   if(x<0x8000000)    return 26;      if(x<0x10000000)   return 27;         

   if(x<0x20000000)   return 28;      if(x<0x40000000)   return 29;

   if(x<0x80000000)   return 30;                   

   return 31;

}

 

Nun dauerte die Berechnung nur mehr ca. 120 ns (Durchschnitt der Zeiten für folgende Werte: 1; 3; 8; 52; 125; 1456; 6223; 563213; 6483216; 145684539).

Der Nachteil dieser Methode sind die vielen if-Abfragen die durchgeführt werden bis das Ergebnis bekannt ist (außer bei sehr kleinen x). Um diese Anzahl zu Verringern kann man die bisherige Suche durch eine Binärsuche ersetzen:

static uint Log2(uint x){

   if(x<0x10000){

      if(x<0x100){

        if(x<0x10){

           if(x<0x4){

              if(x<0x2)        return 0;

              else             return 1;

           } else {

              if(x<0x8)        return 2;

              else             return 3;

           }

        } else {

           if(x<0x40){

              if(x<0x20)       return 4;

              else             return 5;

           } else {

              if(x<0x80)       return 6;

              else             return 7;

           }

        }

      } else {

        if(x<0x1000){

           if(x<0x400){

              if(x<0x200)      return 8;

              else             return 9;

           } else {

              if(x<0x800)      return 10;

              else             return 11;

           }

        } else {

           if(x<0x4000){

              if(x<0x2000)     return 12;

              else             return 13;

           } else {

              if(x<0x8000)     return 14;

              else             return 15;

           }

        }

      }

   } else {

      if(x<0x1000000){

        if(x<0x100000){

           if(x<0x40000){

              if(x<0x20000)    return 16;

              else             return 17;

           } else {

              if(x<0x80000)    return 18;

              else             return 19;

           }

        } else {

           if(x<0x400000){

              if(x<0x200000)   return 20;

              else             return 21;

           } else {

              if(x<0x800000)   return 22;

              else             return 23;

           }

        }

      } else {

        if(x<0x10000000){

           if(x<0x4000000){

              if(x<0x2000000)  return 24;

              else             return 25;

           } else {

              if(x<0x8000000)  return 26;

              else             return 27;

           }

         } else {

           if(x<0x40000000){

              if(x<0x20000000) return 28;

              else             return 29;

           } else {

              if(x<0x80000000) return 30;

              else             return 31;

           }

         }

      }

   }

}

 

Durch diese Veränderung kann der Logarithmus mit genau 5 if-Abfragen ermittelt werden (da 2^5=32). Durch diese Verbesserung sank die Ausführungsdauer auf 40 ns.

Vergleich:

Orginalversion

700 ns

 

 

mit Konstante

450 ns

-36%

 

ohne Math.Log

120 ns

-83%

 

mit Binärsuche

40 ns

-94%

 

4.5 Code Tuning Techniken

4.5.1 Ersetzen komplizierter Operationen

Die Methode der „Strength Reduction“, also dem Ersetzen komplizierter, langsamer Operationen bzw. Befehle durch einfachere, schnellere, wurde bereits beim Verbessern der Primzahlenberechnung gezeigt. Bei diesem Beispiel wurde die Wurzelberechnung auf der rechten Seite des Vergleichs durch das Quadrieren des Wertes der linken Seite ersetzt.

Einige mögliche Ersetzungen sind folgende:

·       Multiplikation durch Addition ersetzen

·       Potenzieren durch Multiplizieren ersetzen

·       Operationen auf Long-Werten durch Operationen auf Integer-Werten ersetzen

·       Gleitkommazahlen durch ganzzahlige Werte ersetzen

·       Division durch 2 / Multiplikation mit 2 durch Shift Operation ersetzen

Ein Beispiel zur „Strength Reduction“:

Beim erhöhen der Schreib- bzw. Leseposition in einem zyklischen Puffer könnte folgender Code verwendet werden:

pos=(pos+1)%bufferSize;

 

Da die Modulo Operation selbst bei neuen Prozessoren relativ zeitaufwendig ist und der restliche Code eines zyklischen Puffers sehr einfach ist (Zuweisungen, Arrayzugriff und Vergleiche), sollte um eine gute Performance zu erreichen diese Operation ersetzt werden. Da „pos“ immer nur um eins erhöht wird ist dies ganz einfach möglich:

pos++;

if (pos>=bufferSize) pos=0;

 

Da die if-Abfrage meist „false“ ergibt wird nun statt einer Addition, einer Modulo Operation und einer Zuweisung meist nur mehr eine Inkrement Operation und ein Vergleich durchgeführt.

Ein weiteres Beispiel:

Die Auswertung eines Polynoms  n-ter Ordnung kann so erfolgen:

val=0;

for (int p=0;p<=power;p++)

   val=val+coef[p]*Math.pow(x,p);

 

wobei für das Polynom 5x³+2x²+4x+7 die Variablen so initialisiert würden:

power=3;

coef=new int[] {5,2,4,7};

 

Diese Methode kann durch folgende ersetzt werden:

val=0;

powerOfX=1;

for (int p=0;p<=power;p++){

   val=val+coef[p]*powerOfX;

   powerOfX*=x;

}

 

Durch Einführen der Hilfsvariable „powerOfX“ wurde die Potenzberechnung durch eine Multiplikation ersetzt.

Noch effizienter wird die Polynomauswertung durch Ändern des Algorithmus:

val=0;

for (int p=power;p>=0;p--)

   val=val*x+coef[p];

 

4.5.2 Inline Coding

Beim Inline Coding, also dem Ersetzen eines Funktionsaufrufs durch den Code der Funktion soll der Aufwand des Aufrufs der Funktion (samt der Parameterübergabe und Rückgabe des Resultats) vermieden werden. Vor allem bei kurzen Funktionen machte diese Methode bei älteren Compilern Sinn. Bei neuen Compilern erhält man durch Inline Coding meist keine Performancesteigerung, da diese beim Optimieren selbst den Aufruf sehr kurzer Funktionen durch Einsetzen des Funktions-Codes ersetzen. Händisches Inline Coding hat auch den Nachteil das die Modularität schlechter wird, da man eine Änderung im Funktions-Code nun mehrfach durchführen muss.

Eine dem Inline Coding sehr ähnliche und in C beliebte Methode ist das Verwenden von Makros:

Beispiel:

max Funktion:

int max(int a, int b){

   return a>b ? a : b;

}

 

als Makro:

#define max(a,b) ((a)>(b) ? (a) : (b))

 

Bei C Compilern kann der Code so um bis zu 50% schneller werden.

Der Nachteil von diesem Makro zeigt sich im folgendem Anwendungsbeispiel:

int arrmax(int n){

   if (n==1) return x[0];

   else return max(x[n-1],arrmax(n-1));

}

 

Diese Funktion soll das Maximum eines (der Einfachheit halber globalen) Arrays rekursiv ermitteln. Ein rekursiver Ansatz scheint zwar nicht unbedingt die beste Lösung zu sein, andererseits würde man dennoch eine einigermaßen akzeptable Laufzeit erwarten wenn man nicht weiß das max durch das oben gegebene Makro definiert ist (oder sich nicht überlegt hat wie die Auswertung nun ausschaut).

Falls das Array x nun die Werte {5,2,1,3} hat und max=arrmax(4) aufgerufen wird, wird max wie folgt ermittelt:

(Es wird immer nur der Ausdruck nach der return Anweisung  steht dargestellt, im if-Zweig wird x[0] durch dessen Wert ersetzt, im else-Zweig wird in das Makro eingesetzt  und x[n-1] bzw. n-1 werden durch deren Werte ersetzt.)

Wie zu erkennen ist wird in diesem Beispiel „arrmax(1)“ 16 mal (2^5 = 2^Länge(x)) berechnet. Die asymptotische Laufzeitkomplexität steigt also durch das Ersetzen des Funktionsaufrufs von O(n) auf O(2^n), somit ergibt sich selbst bei kleinen Arrays bereits eine inakzeptable Laufzeit.

Dies zeigt einmal mehr welche Vorsicht beim Einsatz von Code-Tuning-Methoden geboten ist.

4.5.3 Loop-Unrolling

Durch das “Entrollen” von Schleifen muss der Schleifenzähler weniger oft erhöht werden, vor allem aber muss auch die Laufbedingung seltener  überprüft werden. Dies hat den Effekt, dass vor allem bei neueren Prozessoren (alle heute verwendeten) Hemmnisse, die durch bedingte Sprünge entstehen, verringert werden können.

Das einfachste Beispiel für Loop-Unrolling ist wohl das vollständige Unrolling, bei dem die Schleifenstruktur vollständig wegfällt:

for (int i=0;i<5;i++) a[i]=i;

 

wird ersetzt durch:

a[0]=0; a[1]=1; a[2]=2; a[3]=3; a[4]=4;

 

In Java implementiert (und mehrere tausend mal ausgeführt) war bei mir die „entrollte“ Version 6.5 mal schneller als die Originalversion, dies entspricht einer Zeitersparnis von immerhin 85%.

Leider ist es meist nicht so einfach Loop-Unroling durchzuführen da die obere Grenze bis zu der die Laufvariable laufen soll, im allgemeinen nicht konstant ist. Meist ist diese Grenze nicht einmal das vielfache einer Konstanten n, sollte dies doch der Fall sein kann die Schleife relativ einfach n-fach entrollt werden. Im allgemeinsten Fall muss man Loop-Unrolling jedoch folgendermaßen machen:

i=1;

while (i<=Num){

   a[i]=i;

   i++;

}

 

kann entrollt werden zu:

i=1;

upper=Num-N+1;

while (i<=upper){

   a[i]=i;

   a[i+1]=i+1;

   ...

   a[i+N-1]=i+N-1;

   i+=N;

}

while (i<=Num){

   a[i]=i;

   i++;

}

 

Beim Unrolling wurde die originale Schleife in eine N-fach „entrollte“ und eine nicht „entrollte“ Schleife geteilt, wobei die erste Schleife Num/N (ganzzahlig), die zweite Schleife Num modulo N mal durchlaufen wird (eine Division bzw. eine Modulo-Operation ist hierfür nicht notwendig). Die Gefahr hierbei besteht darin, dass die Bereiche in denen sich die Laufvariablen bewegen falsch gewählt werden, der Index falsch erhöht wird (Copy and Paste Fehler!) oder Änderungen am Code nur in einer der beiden Schleife vorgenommen werden. Dennoch sollte der Einsatz dieser Methode versucht werden, falls eine einfache Schleife (wenige, einfache Anweisungen im Schleifenkörper) ein Bottleneck darstellt.

4.5.4 Speichern für Wiederverwendung

Häufig werden in einem Programm komplexe Funktionen mit den selben Parametern mehrmals aufgerufen. Dieser (unnötigen) Aufwand durch mehrmaliges Ausführen der selben Berechnung kann vermieden werden, in dem man das Ergebnis ein mal berechnet und anschließend speichert.

Dies kann geschehen

·       zur Implementierungszeit: Wird vor allem angewandt wenn es sich um sehr komplexe Funktionen handelt bei denen selbst die einmalige Berechnung während der Programmausführung zu aufwändig wäre. Die Funktion wird hierbei für alle möglichen Parameter ausgeführt und das Ergebnis wird in eine Datei gespeichert (oder in den Quellcode geschrieben), aus der die Ergebnisse bei der Programmausführung gelesen werden.

·       zur Initialisierungszeit: Es werden alle möglichen Ergebnisse beim Programmstart berechnet. Eine Abwandlung dieser Methode besteht darin, die Werte nicht sofort beim Start zu berechnen, sondern erst wenn das Programm/die CPU nicht ausgelastet ist (allerdings müssen alle Werte vor dem ersten „Funktionsaufruf“ berechnet sein).

·       beim  ersten Aufruf mit einem bestimmten Parameter: Beim Aufruf der Funktion wird, falls die Funktion mit diesen Parametern noch nicht aufgerufen wurde das Ergebnis berechnet und dann gespeichert, andernfalls muss das Ergebnis nur mehr gelesen werden. Da nicht alle möglichen Ergebnisse berechnet werden müssen ist diese Methode meist schneller wie wenn alle Werte zur Initialisierungszeit berechnet werden.

Meistens ist es allerdings nicht möglich wirklich alle möglichen Ergebnisse einer Funktion zu berechnen (Gleitkomma-Parameter, zu große Wertebereiche, ...), deshalb werden die Parameter für die Funktionsberechnung häufig in bestimmten Schrittweiten gewählt.

Die Anwendung sei an Hand der Sinusberechnung gezeigt, wobei es ausreichend ist die Sinuswerte mit 0.1 Grad Genauigkeit zu berechnen:

Berechnung der Sinuswerte bei der Initialisierung:

void InitTab(){

   for (int x=0;x<=900;x++)

      sinTab[x]=Math.sin(x);

}

 

Berechnung der Sinuswerte beim ersten Aufruf:

double SinTab(int n){

   if(sinTab[n]<-1)       //sinTab muss beim Start geeignet initialisiert werden

      sinTab[n]=Math.sin(n/10);

   return sinTab[n];

}

 

Der eigentliche Zugriff auf die Sinusfunktion erfolgt über eine Funktion die beispielsweise so aussehen könnte:

double sin(double x){

   int n;

   if(x>=0) n=(int) (x*10+0.5);

   else n=(int) (x*10-0.5);

 

   if(n<0) {

      n=-n+1800;

      if(n>3600) n-=3600;

   }

   if(n>3600) n=n%3600;

 

   if(n<=900) return sinTab(n);

   if(n<=1800) return sinTab(1800-n);

   if(n<=2700) return -sinTab(n-1800);

   return -sinTab(3600-n);

}

 

Möchte man ein genaueres Ergebnis, ohne eine größere Tabelle anzulegen, könnte man noch zusätzlich zwischen den beiden nächstgelegenen Werten interpolieren.

Schließlich sei noch kurz erwähnt, dass es manchmal auch sinnvoll sein kann nicht alle Werte zu speichern, sondern einen Cache zu verwenden in dem nur häufig berechnete Werte, bzw. die letzten berechneten Werte gespeichert werden.

4.5.5 Programmteile in Assembler schreiben

Manchmal sind alle Tuning Methoden bei denen Code umgeschrieben wird nicht ausreichend um die gewünschte Performance zu erreichen. In diesem Fall bietet sich an, auf eine maschinennähere Programmiersprache zu wechseln. Den größten Performancegewinn erhält man natürlich wenn man Programmteile in Assembler schreibt, aber auch schon das wechseln von Java auf C kann möglicherweise die gewünschte Geschwindigkeit bringen. Entschließt man sich Teile in Assembler zu schreiben ist folgendes Vorgehen zu empfehlen:

1.     Programm vollständig in Hochsprache schreiben

2.     Testen des Programms und feststellen ob das Programm allen Anforderungen entspricht

3.     Feststellen welche Teile des Codes nicht schnell genug sind (Profiler)

4.     Vom Compiler erzeugten Assembler-Code optimieren. Verwendet man Sprachen wie Java oder C# müssen die zu optimierenden Teile erst in C(++) implementiert werden um editierbaren Assembler-Code zu erhalten.

5.     Korrektheit und Geschwindigkeitssteigerung überprüfen bzw. messen

Der größte Nachteil dieser Methode ist, dass die Portierbarkeit sinkt, da für jede Plattform der Assembler-Code neu erzeugt und getunt werden muss.

4.5.6 Compiler Optimierungen

Wie schon weiter oben erwähnt kann auch durch einen guten Compiler ein Programm schneller werden. Die Wahl des Compilers gehört zwar nicht zu den eigentlichen Techniken des Code Tunings, dennoch sei es hier noch einmal kurz erwähnt.

Der Vorteil hierbei ist, dass es (bis auf eventuelle Anschaffungskosten für den Compiler) beinahe nichts kostet ein Programm mit verschiedenen Compilern zu übersetzen. Dabei sollte allerdings beachtet werden das ein Compiler ein Programm gut optimieren kann, bei einem anderen aber im Vergleich zu einem anderen Compiler versagen kann. Unter Umständen kann es auch passieren, dass (erfolgreiches) händisches Tuning dazu führt, dass das Programm nach der Compileroptimierungen langsamer ist wie ein nicht getuntes Programm. Dies liegt daran das händisch optimierter Code oft nur schwierig vom Compiler zu analysieren und optimieren ist.

Der  erzielbare Leistungsgewinn hängt ab von

·       Programmcode

·       Programmiersprache

·       Compiler

und bewegt sich im Bereich zwischen 0 und 50 Prozent, wobei ein Wert von ein paar Prozent bis zehn Prozent üblich ist.

4.5.7 Weitere Techniken

Zusammenfassen von Schleifen

Zwei (oder mehr) Schleifen können zu einer zusammengefasst werden wenn sie über einen gleich großen Bereich (üblicherweise den selben Bereich) laufen. Hierdurch wird der Schleifen-Overhead von zwei Schleifen auf den von einer reduziert.

Beispiel:

for (int i=0;i<num;i++) employeeName[i]="";

for (int i=0;i<num;i++) employeeEarnings[i]=0;

 

zusammenfassen zu:

for (int i=0;i<num;i++) {

   employeeName[i]="";

   employeeEarnings[i]=0;

}

 

Probleme ergeben sich hierbei, wenn sich bei einer der ursprünglichen Schleifen der Bereich in dem die Schleife läuft ändert. Ebenso muss man (bei zwei Schleifen die nicht direkt hintereinander stehen) beachten, dass das Ändern der Position an der die  Schleifen stehen auf den Programmablauf Einfluss haben kann.

Arbeit innerhalb von Schleifen minimieren

Um schnelle Schleifen zu erhalten, muss die Arbeit innerhalb der Schleifen möglichst gering gehalten werden. Meist geschieht dies beim Programmieren von vorn herein, merkt man allerdings, dass eine Schleife zu langsam ist sollte man noch einmal überprüfen ob wirklich alle Berechnungen die schon außerhalb der Schleife gemacht werden können dort auch gemacht werden. Auch sollten Referenzen, wenn möglich, schon außerhalb der Schleife aufgelöst werden.

Tests so bald wie möglich beenden

Die meisten Programmiersprachen verwenden inzwischen Kurzschlussauswertung, um beispielsweise die Überprüfung der Bedingung eines if-Statements zu beenden wenn das Ergebnis bekannt ist. Ähnlich sollte man auch vorgehen wenn man innerhalb einer Schleife eine Bedingung überprüfen möchte. Man sollte also die Schleifenabarbeitung abbrechen sobald das Endergebnis bekannt ist.

Test stoppt nicht wenn die Antwort bekannt ist:

boolean negativeFound;

for (int i=0;i<Num;i++){

   if (input[i]<0) negativeFound=true;

}

 

Test stoppt wenn die Antwort bekannt ist:

boolean negativeFound;

for (int i=0;i<Num;i++){

   if (input[i]<0) {

      negativeFound=true;

      break;

   }

}

 

Verwenden von „Sentinels“

Sucht man in einem Array einen bestimmten Wert (Suche wird abgebrochen sobald der Wert gefunden ist), muss man üblicherweise bei jedem Schleifendurchlauf überprüfen ob das Arrayende erreicht wurde. Dies lässt sich vermeiden in dem man sicherstellt, dass der gesuchte Wert immer gefunden wird. Hierfür verwendet man Sentinels. Der Sentinel ist hierbei der gesuchte Wert der an das Ende des Arrays geschrieben wird. Zusätzlich muss nun überprüft werden ob der alte Wert am Ende des Arrays der gesuchte Wert ist und kontrollieren ob der gefundene Wert ein „gewöhnlicher“ Wert ist oder der Sentinel. Wird das Array später noch verwendet muss am Ende auch der letzte Arrayeintrag wieder korrigiert werden.

found=false;

i=0;

while (!found && i<nrOfEmements)

   if(item[i]==testValue) found=true;

   i++;

}

if (found) { .... }

 

mit Sentinel:

lastItem=item[noOfElements-1];

item[noOfElements-1]=testValue;

i=0;

while (item[i]!=testValue) i++;

item[noOfElements-1]=lastItem;

if (i<noOfElements-1 || lastItem==testValue) { .... }

 

if-then-else und switch Tests der Häufigkeit nach ordnen

Tests die aus if, else if und else bestehen und switch bzw. case Tests sollten so angeordnet werden, sodass der am Häufigsten auftretende Fall an oberster Stelle steht, der zweit häufigste Fall an zweiter Stelle und so weiter. Hierdurch werden im Durchschnitt weniger Tests ausgeführt und die Performance des Programms steigt.

4.6 Zusammenfassung der Schlüsselpunkte

·       Performance alleine führt nicht zu guter Softwarequalität

·       Wenige Prozent des Codes (ca.5%) benötigen über 50 Prozent der Laufzeit

·       Messen der Geschwindigkeit (vor und nach der „Optimierung“) ist das A und O des Code Tunings

·       Nur von Anfang an sauberer Code führt zu guten Ergebnissen

5. Speichereffizienz

5.1 Ein Illustratives Problem (Geographische Datenbank)

Dieses Beispiel zeigt wie ein inakzeptabel großer Speicherbedarf auf ein akzeptables Maß reduziert wird und dennoch Laufzeitanforderungen eingehalten werden.

Bei dem Beispiel handelt es sich um ein Problem aus den 80ern. Es sollte eine geographische Datenbank erstellt werden bei der 2000 Nachbarn (Felder) auf einem 200x200 Felder großem Areal angeordnet sind.

Es ist zu jedem dieser Nachbarn die Position gespeichert an der er sich befindet, allerdings soll in einem interaktivem Programm zu einer gegebenen Position auch der Nachbar bestimmt werden können. Die trivialste Methode würde darin bestehen einfach bei allen 2000 Nachbarn nachzusehen ob er sich auf dem gegebenen Punkt befindet. Da allerdings Computer in den 80ern im Vergleich zu heutigen sehr langsam waren, hätte dieses Vorgehen eine zu hohe Antwortzeit zur Folge gehabt. Deshalb wurde zunächst an eine andere einfache Variante gedacht bei der zu jedem der 40000 Felder gespeichert würde, welcher Nachbar (wenn überhaupt einer) sich an der Position befindet.

Speicherbedarf für ein Array mit 4000 Elementen:

32-Bit Werte: 160 000 Byte

16-Bit Werte: 80 000 Byte

Selbst wenn statt der ursprünglich geplanten 32-Bit Werte nur 16-Bit Werte verwendet worden wären, wäre der Speicherbedarf damals inakzeptabel gewesen. Aus diesem Grund wurde das Array durch verkettete Listen ersetzt:

Um festzustellen welcher Nachbar sich an einer bestimmten Position befindet, müssen bei dieser Datenstruktur im Mittel 10 Elemente und im schlimmsten Fall 200 Elemente überprüft werden, dies genügt den Laufzeitanforderungen.

Speicherbedarf wenn immer 32-Bit Werte verwendet werden:

200*4 Byte + 2000*12 Byte = 24 800 Byte

Dieser Wert ist schon deutlich besser, allerdings leider unrealistisch, da die, für das Erstellen einer verketteten Liste notwendige, malloc-Anweisung für jedes Listenelement einige zusätzliche Byte belegt.

Da die Gesamtgröße der Liste immer gleich ist, kann auch eine Liste (oder auch 2 Listen) mit der Länge 2000 verwendet werden. Zusätzlich wird eine Liste (mit 201 Länge) verwendet in der gespeichert wird wo der erste Nachbarn einer Zeile in der Hauptliste zu finden ist:

Suche nach einem Element:

find(int i,int j){

   for (int k=firstincol[i];k<firstincol[i];k++){

      if (row[k]==j) return pointnumm[k];

   }

   return -1;

}

 

Der Suchaufwand ist genau so groß wie beim Verwenden verketteter Listen, der Speicherbedarf ist

bei 32-Bit Werten: 201*4 Byte + 2000*4 Byte + 2000*4 Byte=16804 Byte

bei 16-Bit Werten: 201*2 Byte + 2000*2 Byte + 2000*2 Byte=8402 Byte

Weiter reduziert werden kann der Speicherbedarf wenn man auf das row-Array verzichtet.

Suche nach einem Element:

find(int i,int j){

   for (int k=firstincol[i];k<firstincol[i];k++){

      if (point[pointnum[k]].row==j) return pointnumm[k];

   }

   return -1;

}

 

Speicherbedarf der verschiedenen Datenstrukturen:

 

32 Bit

 16 Bit

2-dim Array:

80 000 Byte

40 000 Byte

Linked-Lists:

>= 42 800 Byte

 

1-dim Arrays:

16 804 Byte

  8 804 Byte

  ohne row-Array:

  8 804 Byte

  4 402 Byte

 

 

Im Vergleich zur anfangs geplanten Implementierung benötigte die zuletzt verwendete Implementierung 94,5% weniger Speicher!

5.2 Zusammenfassung und Übersicht

Verwenden des kleinst möglichen Typs

In Datenstrukturen sollte, wenn man Speicher sparen muss, immer der kleinst mögliche Werttyp verwendet werden.

Werte neu berechnen statt speichern

Sind Werte leicht berechenbar sollten sie, falls sie später wieder benötigt werden, nicht gespeichert werden sondern später neu berechnet werden. Natürlich muss hier auch abgewogen werden wie wichtig Laufzeiteffizienz ist.

Verwenden geeigneter Datenstrukturen

Beim Auswahl der Datenstrukturen sollte darauf geachtet werden, dass möglichst kein Platz für Null-Werte (bzw. Zeiger) verschwendet wird und die Menge an Hilfsdaten (Zeiger, ...) möglichst klein gehalten wird.

Nicht übertreiben

Man sollte darauf achten, dass der gewählte Typ und die gewählte Datenstruktur auch langfristig den Anforderungen genügt und nicht einfach den/die Erstbeste(n) verwenden nur weil er sich gerade anbietet  und es momentan noch möglich ist alle erforderlichen Daten unterzubringen. Oft wächst das Problem das gelöst werden soll mit der Zeit wodurch eine Datenstruktur plötzlich eine inakzeptablen Laufzeit zur Folge hat oder ein Datentyp einen Wert nicht mehr fassen kann (siehe Jahr 2000 Problem).

6. Literatur

Jon Bentley: Programming Pearls, 2nd edition, Addison Wesley, 2000

Jon Bentley: More Programming Pearls, Addison Wesley, 1988

Steve McConnell: Code Complete, Microsoft Press, 1993

Tom Cargill: C++ Programming Style, Addison Wesley, 1992

Brian W. Kernighan, Rob Pike: The Practice of Programming, Addison Wesley, 1999