Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

domenica 4 giugno 2017

Il buono, il brutto, il VLA
come usare i Variable Length Arrays in C - pt.2

Dunque, dove eravamo rimasti? Ah si, nell'ultimo post (che avete appena riletto, vero?) avevamo approvato (con riserva) i VLAs, che sono facili da usare, utili e con ottime prestazioni, ma allora... perché ho detto che sono adatti al ruolo del cattivo nel mitico "Il buono, il brutto, il cattivo"?

non fidatevi del VLA, parola del buono!
Presto detto: oltre ai (notevoli) pro ci sono anche alcuni (pesanti) contro. Prima di seguire ricordiamoci sempre che un VLA si alloca dinamicamente nello stack come una variabile automatica con scope limitato al blocco di codice dove avviene l'allocazione: dopodiché i (principali) possibili problemi sono:
  1. la gestione degli errori è problematica, perché non c'è maniera di sapere se il VLA è stato allocato bene o ha provocato un stack overflow (in questo caso provoca effetti simili a quelli di un errore per ricorsione infinita).
  2. il size del VLA si decide a run-time, quindi il compilatore deve fare dei giochi un po' strani: in base all'implementazione è possibile che una parte (anche importante) dello stack di una funzione venga riservato per un VLA, limitando molto la memoria locale disponibile. Quindi lo stack overflow è sempre dietro l'angolo.
  3. la portabilità del codice va un po' a farsi benedire: il codice diventa molto compiler-dependent e, soprattutto, visto che una buona fetta di programmatori C scrivono anche codice per sistemi embedded (dove lo stack è, spesso, limitato) risulta complicato il porting di funzioni da applicazioni normali a applicazioni embedded. Funzioni che, magari, smetterebbero di funzionare per motivi misteriosi (beh, neanche tanto misteriosi).
  4. dulcis in fundo: forse per i motivi appena elencati (o per altri ancora) da C11 in avanti i VLAs sono opzionali e subordinati a una variabile del compilatore __STDC_NO_VLA__: brutto segno.
Che fare allora? Meglio non usarli o usarli con le precauzioni del caso, anche perché le alternative non mancano. Cattivo trovato!

E adesso ci tocca cercare qualcuno che sia adatto ai ruoli del buono e del brutto. Ecco, per il buono non c'è problema, il candidato ideale è la cara, buona, vecchia malloc() che è sempre una garanzia ed è uscita molto bene dal test. Sulla malloc() è inutile dilungarci, è un punto fermo del C e ne abbiamo già parlato abbondantemente qui.

E il brutto? Beh, per cercare uno adatto dovremo, ahimè, addentrarci nel lato oscuro della forza, e cioè in territorio C++...

(...apro una parentesi: non parlo mai di argomenti che non conosco, perché penso che sia stupido farlo. Per fare un esempio: io non capisco niente di moto e, vi assicuro, nessuno ha mai avuto l'onore di sentirmi disquisire sul mondiale di MotoGP. Seguo una filosofia, che, sfortunatamente, non è seguita da molta gente, è cioè: "meglio stare zitti che parlare solo per dare aria alla bocca". Proprio in virtù di questa coerenza penso di avere i titoli per parlare del C++: lo uso in parallelo al mio amato C da quasi trenta (!) anni, e, modestia a parte, penso si saperlo usare bene. Quindi ne posso disquisire, nel bene e nel male. Chiudo la parentesi...).

Allora, ho ripreso pari pari l'esempio C del post precedente e (facendo il minimo sindacale di modifiche) l'ho trasformato in codice C++, per poter, così, aggiungere un test nuovo che usa std::vector (questo è un oggetto particolarmente caro ai C++ lovers, che lo usano anche per condire l'insalata). Per non ripetere tutto il codice dello scorso post vi riporto solo il main() e la nuova funzione di test aggiunta (il resto è, praticamente, invariato). Vai col codice!
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <vector>

#define MYSIZE  1000000

// variabile dummy per evitare lo svuotamento totale delle funzioni usando G++ -O2
int avoid_optimization;

// prototipi locali
void testVLA(int size);
void testMallocVLA(int size);
void testStackFLA(int dum);
void testHeapFLA(int dum);
void testVectorVLA(int size);
void runTest(int iterations, void (*funcptr)(int), int size, const char *name);

// funzione main()
int main(int argc, char* argv[])
{
    // test argomenti
    if (argc != 2) {
        // errore: conteggio argomenti errato
        printf("%s: wrong arguments counts\n", argv[0]);
        printf("usage: %s vla iterations [e.g.: %s 10000]\n", argv[0], argv[0]);
        return EXIT_FAILURE;
    }

    // estrae iterazioni
    int iterations = atoi(argv[1]);

    // esegue test
    runTest(iterations, &testVLA, MYSIZE, "testVLA");
    runTest(iterations, &testMallocVLA, MYSIZE, "testMallocVLA");
    runTest(iterations, &testStackFLA, 0, "testStackFLA");
    runTest(iterations, &testHeapFLA, 0, "testHeapFLA");
    runTest(iterations, &testVectorVLA, MYSIZE, "testVectorVLA");

    // esce
    return EXIT_SUCCESS;
}

// funzione testVectorVLA()
void testVectorVLA(
    int size)       // size per std::vector
{
    std::vector<int> vectorvla(size);

    // loop di test
    for (int i = 0; i < size; i++)
        vectorvla[i] = i;

    // istruzione per evitare lo svuotamento totale della funzione usando G++ -O2
    avoid_optimization = vectorvla[size / 2];
}
(...Nota: grazie alla segnalazione di Ponchietto, un lettore attento e preparato, mi sono reso conto che avevo dimenticato di aggiungere, in ogni funzione di test, una semplice istruzione che usasse l'array creato, così da evitare che l'ottimizzatore del G++ azzerasse il contenuto della funzione stessa (visto che l'array non lo usava nessuno). Ovviamente così anche i risultati dei test con ottimizzazione cambiano. Adesso il post ha codice e risultati rinnovati...)

Compilando (con/senza ottimizzazioni) ed eseguendo questo codice i risultati sono i seguenti:
aldo@ao-linux-nb:~/blogtest$ g++ vlacpp.cpp -o vlacpp
aldo@ao-linux-nb:~/blogtest$ ./vlacpp 2000
testVLA       -  Tempo trascorso: 4.274441 secondi
testMallocVLA -  Tempo trascorso: 3.641508 secondi
testStackFLA  -  Tempo trascorso: 4.340430 secondi
testHeapFLA   -  Tempo trascorso: 4.312986 secondi
testVectorVLA -  Tempo trascorso: 10.660610 secondi
aldo@ao-linux-nb:~/blogtest$ g++ -O2 vlacpp.cpp -o vlacpp
aldo@ao-linux-nb:~/blogtest$ ./vlacpp 2000
testVLA       -  Tempo trascorso: 0.768702 secondi
testMallocVLA -  Tempo trascorso: 0.694418 secondi
testStackFLA  -  Tempo trascorso: 0.682241 secondi
testHeapFLA   -  Tempo trascorso: 0.694299 secondi
testVectorVLA -  Tempo trascorso: 1.364321 secondi
Come si è comportato std::vector? direi che i numeri parlano da soli... va beh, mettiamola giù in maniera diplomatica: diciamo che abbiamo due notizie, una buona e una cattiva:
  • la buona notizia è che il C++ è efficiente come il C (e su questo non avevo dubbi), infatti il nostro programma C trasformato in C++ ottiene (nei primi quattro test) le stesse prestazioni (andate a controllare la, se non ci credete, eh!).
  • la brutta notizia è che il C++ è efficiente come il C, ma solo se lo usate come il C, quindi niente STL e ammennicoli vari.
(...apro un altra parentesi: ovviamente la brutta notizia qui sopra non deriva solo dal semplice test proposto in questo post: deriva da anni ed anni di osservazioni ed uso intensivo di entrambi i linguaggi, ci mancherebbe solo. Chiudo la parentesi...).

Senza dilungarmi troppo (magari un giorno scriverò un post specifico sull'argomento) vi espongo la mia opinione: il C++ è un grande linguaggio potente, efficiente ed espressivo (è parente stretto del C!), con cui si può scrivere del Software di alta qualità. Ma i risultati migliori (perlomeno in termini di prestazioni e fluidità del codice) si ottengono usandolo per quello che era stato concepito originalmente, e cioè come un C a oggetti. La piega che ha preso in seguito (da quando è caduto nelle mani dei committee ISO) non mi piace e non mi convince... ma, fortunatamente (e questo è importante), continua a poter essere usato nella sua essenza, quella che permette di scrivere a oggetti usando un linguaggio (quasi) identico al C (e questo si aggancia alla buona notizia qui sopra. Ovviamente, se prestazioni e fluidità del codice non si considerano qualità importanti, allora tutte queste considerazioni perdono di significato...).

Ah, una ultima precisazione per chi si è sorpreso del codice C++ (qui sopra) che include un VLA: è una gentile offerta dal nostro amato GCC (nella sua incarnazione G++). Quindi è un estensione del linguaggio fornita dal compilatore, visto che i VLAs non fanno parte del C++ standard (neanche nelle ultime versioni C++11 e C++14).

Nel prossimo post, per chiudere il cerchio, parleremo di un parente stretto dei VLAs, e cioè della funzione alloca(). Sarà un altro buono, un altro brutto o un altro cattivo?

Ciao e al prossimo post!

2 commenti:

  1. Nella versione ottimizzata il compilatore rimuove il codice inutile (riempire un vettore per poi buttarlo...). E infatti nel caso heapVLA (che accede memoria fuori dalla funzione) non rimuove il codice. Non se ne accorge nemmeno nel caso del Vector.

    I tempi del vettore piu' lunghi sono dovuti al fatto che quando lo ridimensione
    la memoria viene inizializzata col costruttore di default.
    Se sposti il vettore fuori dalla funzione (come per heapVLA) i tempi sono identici, (l'ASM e' identico).

    RispondiElimina
    Risposte
    1. Grazie per la puntualizzazione. Hai ragione sulle ottimizzazioni: mi sono dimenticato di aggiungere almeno una istruzione (che usi l'array) nelle funzioni per evitare che l'ottimizzatore azzeri completamente il contenuto della funzione, visto che l'array creato non lo usa nessuno. Comunque, i risultati della versione non ottimizzata sono più che sufficienti (e significativi) per farsi un idea di chi è più o meno veloce.

      Modificherò in parte questo post e il precedente per eliminare l'errore. Ovviamente ti citerò e ringrazierò nel testo per la segnalazione.

      Per quanto riguarda std::vector il discorso è: l'argomento del post sono i VLAs, mentre i FLAs sono stati aggiunti solo per avere un riferimento. Il std::vector fuori dalla funzione che proponi tu sarebbe un FLA molto simile (anche nelle prestazioni) al heapFLA. Ma in questo test std::vector si propone come alternativa a VLA e mallocVLA, e deve essere usato nello stesso modo, quello proposto. E le prestazioni sono sempre decisamente le peggiori del lotto (anche nella versione esterna alla funzione che dici tu, che ho provato).

      Elimina