Seminar aus Softwareentwicklung: Programmierstil Effizienz Friedrich Priewasser Übersicht • • • • Überschlagsrechnungen Profiling Code Tuning Effiziente Speichernutzung Überschlagsrechnungen • Ermöglichen das Abschätzen von Laufzeiten und Speicherbedarf • Schon vor Implementierung kann Machbarkeit überprüft werden • Für jede Operation wird die durchschnittliche Ausführungsdauer gemessen: n=... for (int i=0; i<n; i++); n=... for (int i=0; i<n; i++) i1=i2+i3; Kosten für eine Operation • Probleme: Compileroptimierungen verwendeter Speicher (Cache oder Hauptspeicher) Ergebnis nur für ähnliche Prozessoren einsetzbar • an „sinnvollen“ Bsp. überprüfen ob Schätzung stimmt (Größenordnung) • Sicherheitsfaktoren verwenden Profiling • Profiler: Werkzeug zum Auflisten der Häufigkeit mit der ein Programmteil ausgeführt wurde • Bsp: Primzahlen bis 1000 ermitteln: int prime(int n){ int i; for (i=2; i<n; i++) if (n % i == 0) return 0; return 1; } 999 78022 831 168 main(){ int i,n; n=1000; for (i=2; i<=n; i++) if (prime(i)) printf("%d\n", i); } 999 Zahlen werden überprüft 168 Zahlen sind prim 1 1 999 168 Reduzieren der Tests auf Teilbarkeit int root(int n){ return (int) sqrt((float) n); } 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 } 5288 statt 78022 Tests Laufzeit steigt allerdings 5456 main(){ int i,n; n=1000; for (i=2; i<=n; i++) if (prime(i)) printf("%d\n", i); } 1 1 999 168 Verwenden eines Profilers mit Zeitmessung %Zeit 82.7 4.5 4.3 2.6 ... Name sqrt prime root frexp ... Wurzelberechnung benötigt über 4/5 der Gesamtzeit • Arbeit innerhalb Schleifen minimieren int prime(int n) { int i, bound; bound = root(n); for (i=2; i<=bound; i++) if (n%i == 0) return 0; return 1; } • Komplexe Funktion durch einfache ersetzen int prime(int n) { int i; for (i=2; i*i<=n; i++) if (n%i == 0) return 0; return 1; } Code Tuning • Gründe die gegen Code Tuning sprechen: optimierter Code ist schwierig zu programmieren, zu lesen und zu überarbeiten fehleranfällig mit viel Zeitaufwand beim Erstellen verbunden im schlimmsten Fall Langsamer • Falsche Vermutungen können die Laufzeit erhöhen das Aus für das Projekt • Zu frühes Optimieren führt zu nicht korrekten, schlecht modularisierten Code • Programmcode (noch) nicht optimieren Methoden zur Geschwindigkeitssteigerung • Programm Design überdenken (Modularisierung, Grobentwurf, ...) • Modul- und Methodendesign überarbeiten (Wahl geeigneter Datenstrukturen und Algorithmen) • Zugriffe auf Betriebssystem reduzieren (Ausgabe auf Bildschirm, Festplatte, ... Einlesen von Festplatte, ...) • Geeigneten Compiler wählen (Compileroptimierungen) • Andere Hardware verwenden (Hardware ist billiger als Software) • Code Tuning Vorgehensweise beim Code-Tuning • Geschwindigkeit des Programms messen ca. 5% des Codes benötigen über 50% der Laufzeit • „Hot Spot“ im Programm überarbeiten, „tunen“ • Erfolg der Optimierung überprüfen Ist Programm wirklich schneller geworden? Läuft es weiterhin fehlerfrei? • Sinnhaftigkeit weiterer Optimierung überdenken Ein Beispiel: Zweier-Logarithmus-Berechnung für Integer static uint Log2(uint n){ return (uint) (System.Math.Log(n)/System.Math.Log(2)); } 700ns • Ersetzen von Funktionsaufrufen durch Ergebnis static uint Log2(uint n){ return (uint) (System.Math.Log(n)/0.6931471805599453094); } 450ns • Geeignete Datentypen verwenden / Algorithmus ändern static uint Log2(uint x){ if(x<0x2) return if(x<0x8) return ... if(x<0x20000000) return if(x<0x80000000) return return 31; } 0; 2; if(x<0x4) if(x<0x10) return 1; return 3; 28; 30; if(x<0x40000000) return 29; 120ns Ein Beispiel: Zweier-Logarithmus-Berechnung für Integer • Algorithmus verbessern static uint Log2(uint x){ if(x<0x10000){ if(x<0x100){ if(x<0x10){ if(x<0x4){ if(x<0x2) else } else { if(x<0x8) ... else } else { if(x<0x80000000) else } } } } } return 0; return 1; return 2; return 29; return 30; return 31; Vergleich: Originalversion mit Konstante ohne Math.Log mit Binärsuche 700ns 450ns 120ns 40ns 40ns -36% -83% -94% Komplizierte Operationen durch einfache ersetzen • Positionsbestimmung bei Zyklischer Puffer: pos=(pos+1) % n; ersetzen durch: pos++; if(pos>=n) pos=0; • Polynom-Auswertung val=0; for(int p=0;p<=power;p++) val=val+coef[p]*Math.pow(x,p); ersetzen durch: val=0; powerOfX=1; for(int p=0;p<=power;p++){ val=val+coef[p]*powerOfX; powerOfX*=powerOfX; } Weitere Verbesserung durch Ändern des Algorithmus: val=0; for(int p=power;p>=0;p--) val=val*x+coef[p]; Inline-Codierung • Vermeidet Aufwand des Funktionsaufrufs • Beliebtes Mittel in C: Makros Bsp.: max Funktion int max(int a, int b){ return a>b ? a : b; } wird ersetzt durch #define max(a,b) ((a)>(b) ? (a) : (b)) Je nach Compiler bis zu 50% schneller Beispiel zur Anwendung: int[] x={5,2,1,3}; int max=arrmax(4); 3 > arrmax(3) ? 3 : arrmax(3) 1 > arrmax(2) ? 1 : arrmax(2) 2 > arrmax(1) ? 2 : arrmax(1) 5 2<5 => arrmax(1) berechnen 5 1<5 => arrmax(2) berechnen 2 > arrmax(1) ? 2 : arrmax(1) 5 2<5 => arrmax(1) berechnen 5 3<5 => arrmax(3) berechnen int arrmax(int n){ if (n==1) return x[0]; else return max(x[n-1],arrmax(n-1)); } 3<5 => arrmax(3) berechnen 1 > arrmax(2) ? 1 : arrmax(2) 2 > arrmax(1) ? 2 : arrmax(1) 5 2<5 => arrmax(1) berechnen 5 1<5 => arrmax(2) berechnen 2 > arrmax(1) ? 2 : arrmax(1) 5 2<5 => arrmax(1) berechnen 5 Komplexität steigt durch Verwenden des Makros von O(n) auf O(2n) Loop-Unrolling for (int i=0;i<5;i++) a[i]=i; a[0]=0; a[1]=1; a[2]=2; a[3]=3; a[4]=4; 6.5 mal schneller = 85% Zeit Ersparnis Allgemein: i=1; while (i<=Num){ a[i]=i; i++; } 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<=N){ a[i]=i; i++; } Speichern für Wiederverwendung • Einmalige Berechnung von Funktionsergebnissen zur Implementierungszeit (Ergebnisse in Datei speichern) zur Initialisierungszeit bei erstem Aufruf • z.B.: Tabelle mit vorberechneten Sinuswerten 0 bis 90 Grad (Rest kann berechnet werden) 0.1 Grad Schritte bei Initialisierung: void InitTab(){ for(int x=0;x<=900;x++) sinTab[x]=Math.sin(x); } bei erstem Aufruf: double SinTab(int n){ if(sinTab[n]<-1) sinTab[n]=Math.sin(n/10); return sinTab[n]; } Schreiben von Programmteilen in Assembler • Vorgehensweise Programm vollständig in Hochsprache schreiben Testen und feststellen ob das Programm den Anforderungen entspricht Feststellen welche Teile des Codes nicht schnell genug arbeiten (Profiler) Vom Compiler erzeugten Assembler-Code optimieren Korrektheit und Geschwindigkeitsgewinn überprüfen bzw. messen • Nachteil: Portabilität geht verloren Compiler Optimierungen • Kosten nichts • Leistungsgewinn hängt ab von Programmcode Sprache Compiler Bereich: 0 bis 50 Prozent Weitere Techniken • Gleich oft durchlaufene Schleifen zusammenfassen • Arbeit innerhalb Schleifen minimieren • Tests beenden wenn Ergebnis bekannt ist Mit break Schleife beenden • „Sentinels“ verwenden beim Suchen in Arrays Letztes Element durch gesuchten Wert ersetzen Ersetzt dir Abfrage ob der Index noch gültig ist • if else und switch Statements der Häufigkeit nach ordnen Speichereffizienz • Bsp.: Geographische Datenbank: 200x200 Felder 5 538 2000 Nachbarn geg.: x und y Position ges.: Nr. des Nachbarn 965 1171 17 98 162 0 0 Einfachste Lösung: Array mit 200x200 Einträgen 40000 Elemente: bei 32-Bit Werten 160 000 Byte bei 16-Bit Werten 80 000 Byte 7 Verwenden verketteter Listen: colhead pointnum row next 0 2 17 1 1 98 5 538 126 1053 138 15 2 Suchaufwand: Max: Mittel: über 200 Punkte über 10 Punkte Speicherbedarf: 200*4 Byte + 2000*12 Byte = 24800 Byte durch malloc steigt der Bedarf auf das mehrfache Ersetzen der Listen durch eine Liste mit fixer Länge: pointnum row 17 2 538 5 1053 126 98 1 firstincol 0 0 3 1 5 2 5 3 15 138 1800 11 ... ... ... 1889 2000 199 200 437 11 832 67 find(int i,int j){ for (k=firstincol[i];k<firsincol[i];k++){ if (row[k]==j) return pointnum[k]; } return -1; } Suchaufwand: Max: Mittel: über 200 Punkte über 10 Punkte Speicherbedarf: 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 Entfernen des Row-Arrays: find(int i,int j){ for (k=firstincol[i];k<firsincol[i];k++){ if (point[poinnum[k]].row==j) return pointnum[k]; } return -1; } Speicherbedarf bei 16 Bit-Werten: 201*2 Byte + 2000*2 Byte = 4402 Byte Vergleich: 2-dim Array: Linked-Lists: 1-dim Arrays: ohne „Row“ 32 Bit 80 000 Byte >= 42 800 Byte 16 804 Byte 8 804 Byte Platzersparnis: 75.6 kB = 94.5% 16 Bit 40 000 Byte 8 804 Byte 4 402 Byte Zusammenfassung Speichereffizienz • Kleinst mögliche Wert-Typen verwenden • Werte neu berechnen statt speichern • Geeignete Datenstrukturen verwenden Keinen Platz für Null-Werte verschwenden Menge an Hilfsdaten (Zeiger, ...) reduzieren • Nicht übertreiben (Jahr 2000 Problem) Schlüsselpunkte beim Optimieren • Performance alleine führt nicht zu guter Softwarequalität • Wenige Prozent des Codes (ca. 5%) benötigen über 50% der Laufzeit • Messen der Geschwindigkeit (vor und nach der "Optimierung") ist das A und O des Code Tuning • Nur von Anfang an sauberer Code führt zu guten Ergebnissen