C++Guns – RoboBlog

21.07.2018

C+ Guns: Praxisbeispiel: Objekt v.s. Funktional

Filed under: Allgemein — Tags: — Thomas @ 15:07

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.

No Comments

No comments yet.

RSS feed for comments on this post.

Sorry, the comment form is closed at this time.

Powered by WordPress