Newsgroup: it.comp.lang.c++
Titolo: Overhead del C++ rispetto al C

Cari amici, tempo fa si è discusso sull'eventuale overhead introdotto in
un programma C++ attraverso l'utilizzo del polimorfismo (metodi
virtuali) a causa della doppia indirezione da esso causata (puntatore
alla vtable -> puntatore al metodo) e mi sembra di ricordare che si era
giunti alla conclusione che, in effetti, rispetto alla codifica manuale,
c'era una efficienza minore. Anche Paolo Marotta, che sembrava convinto
del contrario, alla fine si è arreso.
Oggi però leggo sul tutorial di Alex Martelli:
"...sia in C++ (con la comodità di considerarla una "classe astratta",
da utilizzarsi con i comuni idiomi C++, e senza alcun aggravio
computazionale rispetto all'uso in C)."
Quindi mi viene il dubbio che le nostre precedenti conclusioni fossero
errate. Alex, puoi chiarire il dilemma?
Ciao a tutti.
EMG

 errate. Alex, puoi chiarire il dilemma?
Qui, in particolare, sto parlando dell'uso di una interfaccia COM.
Le operazioni "di macchina" da fare per accedere ad una
interfaccia COM sono esattamente le stesse in C, e in C++.
In C (e in C++ se si e` usata la #define CINTERFACE) la
interfaccia COM e` dichiarata come struct di puntatori a
funzione; in pratica, al netto delle macro e typedef (e
usando una struct anonima):
    struct IUnknown {
        struct {
        unsigned long (*QueryInterface)(IUnknown*, void*, void**);
        long (*AddRef)(IUnknown*);
        long (*Release)(IUnknown*);
        }* lpVtbl;
    };
e avendo un "IUnknown* punk", una tipica chiamata e`:
    punk->lpVtbl->AddRef(punk);
Il compilatore C (o quello C++ quando usiamo la macro
CINTERFACE e quindi lo "riduciamo" a questo livello:-)
non ha pero` una informazione cruciale: che lpVtbl e`
in realta` costante, immutabile per un dato "punk", e
cosi` pure lo sono i vari puntatori-a-funzione ai quali
lpVtbl punta.  Nota che qui non servirebbe dichiarare
const gli uni o gli altri -- la semantica di const e` (salvo
casi particolari) "io mi impegno a non cambiarlo", e non
c'e` nel linguaggio modo di asserire "e inoltre non ha
alcun alias per cui puoi fidarti che non lo cambiera` mai
nessuno" (vabbe`, i casi particolari sono importanti, e
in effetti ne abbiamo appena discusso nel thread "i
misteri del C++", comunque, qui, non si applicano).
Quindi, se io chiamo (sarebbe un po' futile, ma...:-)
    punk->lpVtbl->AddRef(punk);
    punk->lpVtbl->Release(punk);
il povero compilatore non puo` non generare codice
del tipo, e.g.:
    load BX, punk                    // punk nel registro BX
    load BX, dword ptr [BX]      // punk->lpVtbl " " "
    // punto [1]
    add BX, 4                          // offset di AddRef
    push punk                         // punk in stack
    call [BX]                            // call indiretta
    load BX, punk                    // punk nel registro BX
    load BX, dword ptr [BX]      // punk->lpVtbl " " "
    // punto [2]
    add BX, 8                          // offset di Release
    push punk                         // punk in stack
    call [BX]                            // call indiretta
Il compilatore C++, invece, _sa_ che, al punto qui
marcato come "punto [2]", avra` in BX esattamente
lo stesso valore che aveva in "punto [1]".  Quindi,
se la convenzione di chiamata garantisce che il
registro BX non viene cambiato dalla routine qui
chiamata, il compilatore puo` invece generare:
    load BX, punk                    // punk nel registro BX
    load BX, dword ptr [BX]      // punk->lpVtbl " " "
    add BX, 4                          // offset di AddRef
    push punk                         // punk in stack
    call [BX]                            // call indiretta
    add BX, 4                          // offset di Release-AddRef
    push punk                         // punk in stack
    call [BX]                            // call indiretta
risparmiando due istruzioni di caricamento da
memoria a registro, che puo` essere una parte
non irrilevante dell'overhead (se anche BX fosse
soggetto a "smucinamento" da parte della
routine chiamata, e` comunque probabile che
salvare BX "da qualche parte" e poi recuperarlo
possa avere un piccolo vantaggio in termini di
prestazioni rispetto ai due caricamenti, purche`
la convenzione di chiamata preveda UN qualche
registro che resta intonso, oppure l'hardware sia
tale che push/pop da stack sono piu` veloci di
due load da memoria, eccetera eccetera).
Similmente, se vi sono a breve distanza l'una
dall'altra molteplici chiamate allo stesso metodo,
il compilatore C++ puo` essere in grado di
ottimizzare il processo "come trovare mai
l'indirizzo di questo metodo".
Potrei parzialmente simulare questo a livello C
definendo variabili locali da usare per tenere
gli "indirizzi intermedi" punk->lpVtbl, ovvero
punk->lpVtbl->AddRef, ecc, ma e` difficile
fare questo in un modo che garantisca vantaggi
di prestazioni (o al peggio "chiusura in pari") su
tutte le macchine.  L'uso di incrementi e
decrementi per passare dall'uno all'altro
di diversi indirizzi-di-metodo (magari con
signature diverse) sembra particolarmente
ostico, e non riesco a pensare che un programma
C potrebbe voler scendere "cosi` in basso".
L'ottimizzatore/generatore di codice di un
compilatore C++ con l'ottimizzazione posta al
massimo dovrebbe _ovviamente_ "scendere
cosi` in basso", visto che e` appunto il suo
lavoro, e ha buone informazioni sul grado
esatto di "flessibilita`" delle strutture di tipo
virtual table.  Quindi, _in teoria_, puo` di
certo generare codice piu` performante, a
parita` di "quanta flessibilita` mi tengo",
rispetto a quello "sensatamente" ottenibile
da un programma C che vuole fare le
stesse operazioni (ad esempio accedere a
una interfaccia COM).
Alex

 una interfaccia COM).
Qualsiasi compilatore degno di questo nome prevede algoritmi standard per
ottimizzare tutto ciň che č const (sia esso un immediato o una variabile,
cambia ben poco).
Il problema č quali sono i compilatori degni di questo nome :)
Posso assicurarti che il Visual C++ fa l'ottimizzazione da te proposta, su
gli altri non garantisco...
Bye
-----
Giovanni Bajo
Lead Programmer
Prograph Research S.r.l.
http://www.prograph.it

 gli altri non garantisco...
Di quale parli, fra le varie ottimizzazioni che discutevo?
Se porti un brandello di codice forse e` piu` chiaro.
Alex

 Se porti un brandello di codice forse e` piu` chiaro.
Intendevo l'ottimizzazione sulla vftable, cioé quella secondo la quale si
può risparmiare delle operazioni macchina sapendo che la vftable è costante.
Purtroppo un esempio proprio di questo è abbastanza complesso da portare,
visto che quando si lavora con queste cose è difficile evitare che il
compilatore possa assumere che un registro non venga modificato durante
l'esecuzione di un metodo virtuale, e quindi sarà quasi sempre costretto a
ricaricare il punatore alla vfptr table.
Ciao


 ricaricare il punatore alla vfptr table.
Su chip intel, notoriamente poveri di registri.  Prova su qualsiasi
RISC, e vedrai:-).
Alex

del contrario, alla fine si è arreso.
Rispondo solo adesso con notevole ritardo perche` e` da poco che sono
rientrato...
La discussione cui accenni riguardava il costo del polimorfismo in C++
e la conclusione fu che esso non era minimale, nel senso che una
indirezione poteva essere evitata, ma cio` avrebbe avuto un forte
costo in termini di uso di memoria (la soluzione era quella di
replicare la vtable in ciascuna istanza).
In sostanza la soluzione del C++ e` ottimale solo come compromesso tra
impiego di memoria e costo in termini di tempo.
Per il resto la risposta ti e` stata data dallo stesso Alex Martelli.
Saluti,
Paolo
-----------------------------------------------
  Paolo Marotta
  Computer Science, University of Pisa
  home:   
http://www.cli.di.unipi.it/~marotta
-----------------------------------------------

 impiego di memoria e costo in termini di tempo.
Curioso.  In pratica, per sistemi di dimensione "verace" (non giocattoli), e
con le tipiche tecnologie d'implementazione (memoria virtuale), spendere
piu` memoria significa anche spendere piu` tempo, quindi il fatto che
ci sia un compromesso puo` essere un'illusione ottica.
Programmare a livello piu` basso ha l'unico potenziale vantaggio che
si possono manualmente implementare 'sfumature' di tradeoff.
Programmare a livello piu` alto ha, per contro, il potenziale vantaggio
(spesso sottostimato) che diventa possibile provare architetture anche
molto diverse, con sforzo relativamente modesto, il che, a volte, puo`
dare dei grandiosi *breakthrough* di prestazioni.
Alex

ci sia un compromesso puo` essere un'illusione ottica.
In effetti non si era tenuto conto di cio`, per cui eliminare una delle
due indirezioni non significa necessariamente prestazioni migliori,
anzi e` probabile un maggior numero di page fault della cache
con conseguente peggioramento delle prestazioni.
Oltretutto valutazioni di questo tipo ritengo siano troppo legate
all'architettura hw per poter dare stime generali abbastanza valide.
Alla fine e` mia convinzione che l'attuale implementazione del 
polimorfismo non sia semplicemente un compromesso tra
velocita` e occupazione di memoria, ma ancor meglio la soluzione
ottimale (salvo implementazioni a me sconosciute o hw ad hoc).
Unica cosa un poco interessante (ma non piu` di tanto), legato
all'avere la vtable incorporata direttamente nell'oggetto, e` la
possibilita` di avere un polimorfismo legato non tanto alla
gerarchia cui l'oggetto appartiene, quanto alla sua storia.
Ritengo pero` che le complicazioni alla fine sarebbero enormemente
superiori ad ogni eventuale vantaggio (penso alla verifica del principio
di Liskov per esempio, e piu` in generale alla manutenibilita`
del software). Tanto piu` che la cosa e` ottenibile anche allo stato
attuale e senza troppi sforzi.
paolo


 ottimale (salvo implementazioni a me sconosciute o hw ad hoc).
Incidentalmente, in vari casi i compilatori gia` possono generare
codice migliore di quello possibile in C; ad esempio usando, non
un puntatore a funzione, bensi` un thunk linguaggio macchina di
e.g. 8 byte.  Questo thunk puo` contenere l'istruzione macchina
'JMP <indirizzo>' (e` piu` veloce saltare ad un JMP, che non
recuperare l'indirizzo ad un registro con indirettezza poi saltare
attraverso il registro -- o almeno, puo` esserlo, e l'ottimizzatore
e` appunto 'tuned' per la macchina, dovrebbe saperlo); per
funzioni virtuali particolarmente semplici, tipo 'return this->pip;',
il thunk potrebbe anche contenere l'intera funzione virtuale (se
gli 8 byte di cui parlo bastano per cose del genere sulla macchina;
se no, magari, se ne potrebbero usare 12, o 16 -- vtbl un poco
piu` grandi, ma spazio risparmiato altrove...).  Questi thunk sono
anche spesso usati per ottimizzare i casi di eredita` multipla (che
spesso richiedono un aggiustamento in + o - al registro usato
per tenere il puntatore 'this').
Alex