Objekt v.s. Funktional
Keine Angst, ich will hier keinen Glaubenskrieg auslösen. Mich interessieren mögliche Performance Nachteile und wie sie entstehen.
Das Beispiel zeigt einen vereinfachten Codeausschnitt aus der Praxis, bei dem jede Variable als Funktionsargument übergeben wird. Meine Frage ist nun, ob dies Performancetechnich schlechter ist, als alle Funktionen als Memberfunktionen auszulegen und alle Variablen als Membervariablen.
Fazit:
Für das gewählte Beispiel mit drei Funktionsargumenten entsteht kein nennenswerter Performance Unterschied. Die Lesbarkeit des Code, und damit die Fehlerfreiheit und Wartbarkeit des Codes, leidet allerdings stark wenn jede benötigte Variable als Funktionsargument übergeben wird. Die Methode mit Memberfunktionen löst nicht alle Probleme. Die beste Kombination der Paradigmen von C++ habe ich noch nicht gefunden.
Hier der Kern des Programms ohne Memberfunktionen.
extern double calcT(const double Tmax, const vector<Vec3>& U, const M& m, bool& finish); extern void calcF(const vector<Vec3>& U, const M&m, vector<Vec3>& F); extern void calcE(const vector<Vec3>& U, int nE, const vector<E_t>& E, const M& m); extern void calcQ(const vector<Vec3>& U, int nE, const vector<E_t>& E, const M& m, const vector<Vec2>& iN, const vector<int>& NCO, const vector<int>& CO, vector<Vec3>& Q); extern void calcI(vector<Vec3>& U, double T, const vector<Vec3>& F, const M& m, const vector<Vec3>& Q); extern void calcC(const vector<Vec3>& U, vector<Vec2>& C); auto exec(double Tmax, const M& m, const vector<int>& NCO, const vector<int>& CO, const int nE, const vector<E_t>& E, const vector<Vec2>& iN, vector<Vec3>& U, vector<Vec2>& C, vector<Vec3>& F, vector<Vec3>& Q) { double Tinc = 0; while(Tinc < Tmax) { double T = calcT(Tmax, U, m); Tinc += T; calcF(U, m, F); calcE(U, nE, E, m); calcQ(U, nE, E, m, iN, NCO, CO, Q); calcI(U, T, F, m, Q); calcC(U, C); } } auto func() { double Tmax; M m; vector<Vec3> U; vector<Vec3> F; int nE; vector<E_t> E; vector<Vec2> iN; vector<Vec3> Q; vector<Vec2> C; vector<int> NCO; vector<int> CO; double T; init(...); exec(Tmax, m, NCO, CO, nE, E, iN, U, C, F, Q); }
Es gibt eine Reihe von Funktionen die nacheinander mit unterschiedlichen Variablen aufgerufen werden und teilweise die übergebenen Argumente verändern. Die Funktions- und Variablennamen sind schon stark gekürzt, einerseits um das Beispiel einfach zu halten, andererseits ist die Lesbarkeit durch die Übergabe jeder einzelnen Variable schon beeinträchtigt. Die Funktionen selbst sind als "extern" ausgelegt, da zu diesem Zeitpunkt nicht die genaue Implementierung interessiert. Weiterhin sei angenommen, dass die Funktionen selbst alle groß und komplex sind, so dass der Compiler den Funktionsaufruf nicht durch inline eliminiert.
Es gibt eine ganze Reihe von Nachteilen. Ich will sie nicht im Detail erläutern, nur kurz aufzählen:
- Erstes natürlich die redselige Form des Codes. Es wird oft an vielen Stellen immer der selbe Code wiederholt.
- Mit aussagekräftigen Variablen Namen wird der Code nur noch schlechter lesbar. Das ist ein Widerspruch. Der Grund hierfür ist sind die Funktionsdeklaration, die nun sehr lang werden, oder sich über mehrere Zeilen erstreckt.
- Es ist zwar ersichtlich, welche Funktion von welcher Variablen abhängt, aber nicht welche Variablen geändert werden. Natürlich kann man eine Konvention eingehen und nicht konstanten Argumente an das Ende der Argumente legen, aber das ist keine Garantie.
- Die Sache dass eine Funktion ihr Ergebnis als Rückgabewert liefert ist auch nicht so einfach. Die Ergebnisse sind große Arrays die auf keinen Fall zur Rechenzeit neu allokiert werden dürfen. Wie kann man das garantieren?
- Auch können die Argumente leicht verwechselt werden. Die Variablen F und Q haben den selben Typ. Natürlich lässt sich das Problem durch explizite Typen wie 'F_t' und 'Q_t' lösen, aber das erhöht wieder den Codebedarf.
Die Variante mit Memberfunktionen löst einige Probleme. Hier zum Vergleich der selbe Code etwas umgeschrieben:
struct Methode1 { const double Tmax; const M m; vector<Vec3> U; vector<Vec3> F; const int nE; vector<E_t> E; const vector<Vec2> iN; vector<Vec3> Q; vector<Vec2> C; const vector<int> NCO; const vector<int> CO; void init() { ... }; double calcT(bool& finish); void calcF(); void calcE(); void calcQ(); void calcI(); void calcC(); auto exec() { double Tinc = 0, while(Tinc < Tmax) { double T = calcT(); Tinc += T; calcF(); calcE(); calcQ(); calcI(); calcC(); } } };
Viel redundanter Code fällt weg. Allerdings ist auch nicht mehr ersichtlich welche Funktion von welcher Variablen abhängt oder sie ändert. Darauf gehen ich im nächsten Post ein. Momentan interessiert die Performance. Dazu schauen wir uns am besten den Assemblercode an. Für alle die kein Assembler lesen können, habe ich den Code etwas dokumentiert.
Hier ein Ausschnitt der Funktion "exec" vom ersten Code Beispiel:
.L2: # Begin Schleife movq %rbp, %rsi # copy address of variable m to rsi movq %rbx, %rdi # copy address of variable U to rdi movsd 16(%rsp), %xmm0 # copy Tmax to xmm0 call calcT(Tmax, U, m) movsd 8(%rsp), %xmm1 # copy Tinc, 8(rsp) to xmm1 movsd %xmm0, 24(%rsp) # copy result of calcT to 24(rsp) for later use addsd %xmm0, %xmm1 # Tinc = Tinc + T movsd %xmm1, 8(%rsp) # copy Tinc back to 8(rsp) movq %r13, %rdx # F movq %rbp, %rsi # m movq %rbx, %rdi # U call calcF(U, m, F) movq %rbp, %rcx # m movq %r14, %rdx # E movl %r15d, %esi # nE movq %rbx, %rdi # U call calcE(U, nE, E, m) pushq %r12 # Q is passed per stack pushq 48(%rsp) # CO is passed per stack movq 48(%rsp), %r9 # NCO movq 128(%rsp), %r8 # iN movq %rbp, %rcx # m movq %r14, %rdx # E movl %r15d, %esi # nE movq %rbx, %rdi # U call calcQ(U, nE, E, m, iN, NCO, CO, Q) addq $16, %rsp # add 16 to rsp to adjust stack pointer movq %r12, %rcx # Q movq %rbp, %rdx # m movq %r13, %rsi # F movsd 24(%rsp), %xmm0 # T movq %rbx, %rdi # U call calcI(U, T, F, m, Q) movq 128(%rsp), %rsi # C movq %rbx, %rdi # U call calcC(U, C) movsd 16(%rsp), %xmm2 # note: rsp has changed. so the offset to T and Tinc is different comisd 8(%rsp), %xmm2 # Tinc < Tmax ? ja .L4 # jump to loop head
Vor jedem Funktionsaufruf werden die Adressen bzw. Werte der Argumente in ein Register kopiert. Das sind auf der Aufrufer Seite mindestens so viele Instruktionen wie Funktionsargumente. In der aufgerufenen Funktion selbst gibt es wohl auch noch zusätzliche Instruktionen. Genau um diesen Mehraufwand geht es mir. Ich vermute nun, dass dieser Aufwand eingespart werden kann, ohne die Funktion zu inlinen.
Dazu müssen wir allerdings genauer in den Code rein schauen.
Beispielsweise die einfache Funktion calcT:
double calcT(const double Tmax, const vector<Vec3>& U, const M& m) { double T_min = Tmax; for(int IP=0; IP < m.MNP; ++IP) { double T = m.A[IP]/(U[IP][1]/U[IP][0]+U[IP][2]/U[IP][0]); if(T < T_min) { T_min = T; } } return T_min; }
Im Prinzip wird hier nur ein Minimum gesucht. Wie genau sich das Berechnet ist egal. Zur Erinnerung: Die vier Argumente 'Tmax', 'U' und 'm' der Funktion wurden per Register xmm0, rdi, rsi übergeben. Das Ergebnis per Register xmm0 zurück gegeben.
Auf den Assemblercode will ich nicht im Detail eingehen. Den kann jeder für sich selbst einmal durch kauen. Wichtig ist der Unterschied zur selben Funktion nur als Member Funktion implementiert.
calcT(Tmax, U, m): movl (%rsi), %ecx testl %ecx, %ecx jle .L1 movq 8(%rsi), %rdx movq (%rdi), %rax leal -1(%rcx), %ecx leaq 8(%rdx,%rcx,8), %rcx .L5: movsd (%rax), %xmm3 movsd 8(%rax), %xmm1 divsd %xmm3, %xmm1 movsd 16(%rax), %xmm2 divsd %xmm3, %xmm2 addsd %xmm2, %xmm1 movsd (%rdx), %xmm2 divsd %xmm1, %xmm2 movapd %xmm2, %xmm1 minsd %xmm0, %xmm1 movapd %xmm1, %xmm0 addq $8, %rdx addq $24, %rax cmpq %rcx, %rdx jne .L5 .L1: ret exec(): .L12: # Loop head movq %rbp, %rsi movq %rbx, %rdi movsd 16(%rsp), %xmm0 # copy Tmax to xmm0 call calcT(Tmax, U, m) # result T is stored in xmm0 movsd 8(%rsp), %xmm1 # copy T to xmm1 for later use movsd %xmm0, 24(%rsp) # copy T to 24(rsp) addsd %xmm0, %xmm1 # Tinc += T movsd %xmm1, 8(%rsp) # copy Tinc back to 8(rsp) ... movsd 16(%rsp), %xmm2 # copy Tmax to xmm2 comisd 8(%rsp), %xmm2 # Tinc < Tmax ? ja .L12 # jump to loop head |
Methode1::calcT(): movsd (%rdi), %xmm0 # copy Tmax to xmm0 movl 8(%rdi), %ecx testl %ecx, %ecx jle .L22 movq 16(%rdi), %rdx movq 40(%rdi), %rax leal -1(%rcx), %ecx leaq 8(%rdx,%rcx,8), %rcx .L26: movsd (%rax), %xmm3 movsd 8(%rax), %xmm1 divsd %xmm3, %xmm1 movsd 16(%rax), %xmm2 divsd %xmm3, %xmm2 addsd %xmm2, %xmm1 movsd (%rdx), %xmm2 divsd %xmm1, %xmm2 movapd %xmm2, %xmm1 minsd %xmm0, %xmm1 movapd %xmm1, %xmm0 addq $8, %rdx addq $24, %rax cmpq %rcx, %rdx jne .L26 .L22: ret Methode1::exec(): .L42: # Loop head movq %rbx, %rdi # copy address of this objekt to rdi. Its the same address of Tmax call Methode1::calcT() # result T is stored in xmm0 movsd (%rsp), %xmm1 # copy Tinc to xmm1 movsd %xmm0, 8(%rsp) # copy T to 8(rsp) for later use addsd %xmm0, %xmm1 # Tinc += T movsd %xmm1, (%rsp) # copy Tinc to rsp ... movsd (%rbx), %xmm0 # Copy Tmax to xmm0 comisd (%rsp), %xmm0 # Tinc < Tmax ja .L42 # Jump to loop head |
Nun, es gibt für dieses Beispiel praktisch keinen Unterschied. Die Variante mit Member Funktionen ist eine Zeile kürzer. Woran liegt das? Statt drei Funktionsargumente zu übergeben muss nur eines übergeben werden, die Adresse von 'this'. Da die Funktion ja nicht geinlint wurde, ist das notwendig. Und zufällig ist bei diesem Algorithmus bei der ersten Variante das erste Funktionsargument gleichzeitig der Rückgabewert, wenn die Schleife nicht läuft. Dieser Schritt mit dennoch bei der zweiten Variante ausgeführt werden. So bleibt nur eine Zeile übrig die eingespart wird.