Programmazione Scientifica++: Lezione 14

Funzioni Friend, Ottimizzazione (-O3) e Libreria Eigen

Docente

Il Problema dell’Asimmetria

Passiamo ora al terzo argomento. Nella Lezione 10 abbiamo sovraccaricato l’operatore + fra due vettori. Applichiamo ora la stessa tecnica all’operatore * fra un vettore e uno scalare:

class Vettore2D {
public:
    double x_, y_;
    Vettore2D(double x, double y) { x_ = x; y_ = y; }
    
    // Operatore Vettore * scalare
    Vettore2D operator*(double rhs) const {
        return Vettore2D(this->x_ * rhs, this->y_ * rhs);
    }
};

Questo ci permetteva di scrivere in modo elegantissimo: Vettore2D v2 = v1 * 5.0;

La Tragedia del Lato Sinistro

La matematica ci insegna che la moltiplicazione scalare è commutativa: v * 5.0 deve produrre lo stesso risultato di 5.0 * v.

Ma se proviamo a farlo in C++…

int main() {
    Vettore2D v1(1.0, 2.0);
    
    Vettore2D v2 = v1 * 5.0; // OK! Chiama v1.operator*(5.0)
    
1    Vettore2D v3 = 5.0 * v1; // ERRORE DI COMPILAZIONE FATALE!
}
1
error: no match for 'operator*' in '5.0 * v1'

Cosa è andato storto?

Perché l’Overloading interno fallisce?

Quando scriviamo A * B, il compilatore cerca il metodo operator* all’interno della classe del lato sinistro (A).

Nel caso v1 * 5.0, la classe del lato sinistro è Vettore2D. Trovato! Nel caso 5.0 * v1, la classe del lato sinistro è… un semplice double!

Per far funzionare il codice, dovremmo aggiungere un nostro metodo operator* alla libreria nativa double del linguaggio C++. Questo è letteralmente impossibile. Non possiamo toccare i tipi built-in.

La Soluzione: Funzioni Globali

Il C++ ha una via di fuga. Gli operatori non devono necessariamente essere metodi interni a una classe. Possono essere Funzioni Globali esterne a tutto!

Una funzione globale che fa da operatore prenderà DUE argomenti: 1. Il Lato Sinistro (lhs = Left Hand Side) 2. Il Lato Destro (rhs = Right Hand Side)

In questo modo, possiamo far sì che il lato sinistro sia un double!

Overloading con Funzioni Globali (Firma)

Ecco come si dichiara un operatore globale (Nota: lo scriviamo fuori dalla classe Vettore2D!):

class Vettore2D {
public:
    double x_, y_;
    Vettore2D(double x, double y) { x_=x; y_=y; }
};

// FUNZIONE GLOBALE (Fuori dalla classe!)
1Vettore2D operator*(double lhs, const Vettore2D& rhs) {
    // Come si implementa?
}
1
Questa funzione catturerà automaticamente qualsiasi calcolo nella forma 5.0 * v1 ! Il lato sinistro è lhs, il destro è rhs.

Il Problema dell’Incapsulamento

Cerchiamo di implementare questa funzione globale. Sorgerà subito un problema drammatico di accessi!

class Vettore2D {
private: // INCAPSULAMENTO RIGOROSO
    double x_, y_; 
public:
    Vettore2D(double x, double y) { x_=x; y_=y; }
};

Vettore2D operator*(double lhs, const Vettore2D& rhs) {
1    // ERRORE DI COMPILAZIONE! x_ e y_ sono privati!
    double nuovo_x = lhs * rhs.x_; 
    double nuovo_y = lhs * rhs.y_;
    return Vettore2D(nuovo_x, nuovo_y);
}
1
Essendo una funzione esterna, la classe Vettore la vede come un’estranea. Non ha il permesso di accedere alle variabili in cassaforte!

Getter o Niente?

L’istinto suggerirebbe di aggiungere metodi pubblici getX() e getY() alla classe e chiamare quelli dalla funzione globale.

Sarebbe corretto, ma: 1. In calcoli matematici ad altissima intensità, chiamare metodi aggiunge un overhead (micro-ritardo) rispetto all’accesso diretto alla memoria. 2. Magari in alcuni casi non vogliamo che ci siano dei getter pubblici per un dato attributo!

Vogliamo mantenere il dato privato al 100%, ma fare un’eccezione solo per questa specifica funzione globale.

La Direttiva friend

Il C++ introduce il concetto di Amicizia. Una classe può dichiarare che un’entità esterna (una funzione globale o un’intera altra classe) è sua friend (amica).

I friend bypassano del tutto l’incapsulamento! Hanno libero accesso a tutti i membri private e protected della classe che gli ha concesso l’amicizia!

Note

L’amicizia si concede, non si prende. È la classe Vettore2D che deve autorizzare esplicitamente la funzione globale a leggere i suoi dati, non viceversa!

Come dichiarare un’Amicizia

Dentro al file Header (Vettore2D.hpp), posizioniamo la “tessera VIP” della funzione globale in qualsiasi punto della classe, facendola precedere dalla keyword friend:

class Vettore2D {
private:
    double x_, y_;
    
public:
    Vettore2D(double x, double y) { x_=x; y_=y; }
    
    // Concedo a QUESTA SPECIFICA funzione globale l'accesso ai miei dati
1    friend Vettore2D operator*(double lhs, const Vettore2D& rhs);
};
1
Avendo concesso l’amicizia qui dentro, ora l’implementazione della funzione globale compilerà perfettamente.

L’Overloading Perfetto

Mettiamo tutto assieme. Abbiamo creato l’overloading simmetrico perfetto.

Dentro la classe (Metodo) Per v1 * 5.0

// Vettore2D.cpp
Vettore2D Vettore2D::operator*(double rhs) const {
    return Vettore2D(
        this->x_ * rhs, 
        this->y_ * rhs
    );
}

Fuori dalla classe (Globale Friend) Per 5.0 * v1

// Vettore2D.cpp
Vettore2D operator*(double lhs, const Vettore2D& rhs) {
    return Vettore2D(
1        lhs * rhs.x_,
        lhs * rhs.y_
    );
}
1
rhs.x_ funziona tranquillamente pur essendo privato, grazie all’amicizia.

Esercizio in Classe (20 min)

Applichiamo subito la direttiva friend!

  1. Crea due file Complex.hpp e Complex.cpp per rappresentare un numero complesso (con due attributi privati double real_ e double imag_).
  2. Crea un costruttore che inizializzi i due attributi.
  3. Vogliamo poter stampare a schermo l’oggetto direttamente con std::cout << numero_complesso;.
  4. Nel .hpp dichiara questa funzione friend globale: friend std::ostream& operator<<(std::ostream& os, const Complex& rhs);
  5. Nel .cpp implementa la funzione globale affinché stampi il numero nel formato (real + i*imag).
  6. Testa nel main!

Esercizio: Soluzione .hpp

Complex.hpp

#pragma once
#include <iostream>

class Complex {
private:
    double real_;
    double imag_;
    
public:
    Complex(double r, double i) { real_ = r; imag_ = i; }
    
    // Concedo l'accesso alla funzione di output (std::cout)
1    friend std::ostream& operator<<(std::ostream& os, const Complex& rhs);
};
1
È lo standard C++ per sovraccaricare cout per i propri oggetti!

Esercizio: Soluzione .cpp

Complex.cpp

#include "Complex.hpp"

// Implementazione Globale (nessun "Complex::" davanti!)
1std::ostream& operator<<(std::ostream& os, const Complex& rhs) {
    
    // Stampo usando i dati PRIVATI dell'oggetto rhs
2    os << "(" << rhs.real_ << " + i*" << rhs.imag_ << ")";
    
    // Restituisco lo stream per poter concatenare (cout << a << b;)
    return os; 
}
1
Non dimenticate di includere gli Header corretti.
2
Senza friend, rhs.real_ avrebbe causato un errore fatale del compilatore.

Prepararsi al Machine Learning

Nelle prossime lezioni praticheremo scrivendo un vero e proprio Framework per Reti Neurali. Non scriveremo piccoli script da 10 righe, ma un’infrastruttura completa che dovrà:

  1. Fare calcoli matematici ad altissima velocità.
  2. Usare una libreria di algebra lineare esterna (Eigen).
  3. Ricevere gli iperparametri (Epoche, Learning Rate) dal Terminale.
  4. Leggere milioni di numeri da un file CSV (Il Dataset MNIST).
  5. (In futuro) Produrre grafici analitici dell’addestramento.

Oggi impareremo esattamente questi strumenti.

Ottimizzazione: Il flag -O

Di default, quando usate g++ main.cpp, il compilatore traduce il vostro C++ in linguaggio macchina privilegiando una cosa sola: il tempo di compilazione.

Non si preoccupa di far girare il programma velocemente, ma si preoccupa di compilarlo in una frazione di secondo e lasciare tutte le variabili intatte in memoria per facilitare il Debugging.

Ma nell’addestramento di una Rete Neurale, le moltiplicazioni matriciali pesano. Il flag -O (Optimization) dice al compilatore: “Prenditi tutto il tempo che ti serve per analizzare la matematica del mio codice e applica dei trucchi per sveltirlo”.

Quale livello di ottimizzazione scegliere?

-O0 (Zero) È il default. Nessuna ottimizzazione. Variabili non eliminate, loop eseguiti esattamente come scritti. (Da usare mentre state programmando e debuggando).

-O2 (Due) Ottimizzazione standard. Il compilatore inizia a srotolare i piccoli loop e a eliminare variabili inutili. (Da usare se il programma è stabile ma non critico).

-O3 (Tre) L’estremo. Il compilatore applica la “Vettorizzazione”, cerca pattern hardware predittivi, e violenta la struttura del codice per estrarre la massima potenza dalla CPU. (Da usare per il RUN FINALE della Rete Neurale).

Sbloccare le performance estreme

La differenza di velocità per codice intensivo (es. cicli for annidati) tra -O0 e -O3 può essere di un fattore 10 o superiore. Un training che dura 3 ore potrebbe concludersi in 20 minuti.

Da oggi in poi, quando sarete certi che il vostro codice è privo di bug logici e volete lanciarlo, usate sempre:

$ g++ -Wall -O3 -o train_network main.cpp Network.cpp Layer.cpp

Tip

Ricordate: -O3 aumenta notevolmente il tempo di compilazione. Usatelo solo quando avete finito di fare le modifiche al codice sorgente!

Misurare il Tempo: <chrono>

Non si ottimizza quello che non si misura: <chrono> è il cronometro standard.

#include <chrono>

using cl = std::chrono::high_resolution_clock;

auto start = cl::now();
for (int i = 0; i < 10000; ++i)   // ripetere: una singola forward è troppo veloce
    rete.forward(X);
auto end = cl::now();

auto us = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
std::cout << "Tempo: " << us / 1000.0 << " ms\n";

duration_cast converte nell’unità che volete (microseconds, seconds, …).

Note

Una parola su auto. Il tipo di cl::now() si scriverebbe std::chrono::time_point<std::chrono::high_resolution_clock>: auto dice al compilatore di dedurlo da sé — il tipo resta statico e controllato, solo non lo scriviamo noi. Per esteso nella Lezione 18.

Lo useremo in laboratorio per il flag -O3, e lo ritroverete nel Trainer.

Librerie Esterne per la Rete Neurale

Il C++ nudo e crudo non conosce il concetto di Matrice o di Vettore Matematico.

Per fare backpropagation e feedforward, la nostra Rete Neurale si affiderà a una delle librerie open-source più famose e veloci al mondo: Eigen.

Ma se scriviamo #include <Eigen/Dense> nel nostro codice, il compilatore andrà in errore! Non sa dove si trovino i file scaricati di Eigen nel vostro computer.

Dove sono i file Header? Il flag -I

Dobbiamo “indicare la via” al compilatore tramite il flag -I (Include path).

# Esempio: Su un mac/linux standard Eigen si installa in /usr/include/eigen3
$ g++ -Wall -O3 -I/usr/include/eigen3 -o train_network main.cpp

Con questo flag, quando il compilatore incontrerà l’istruzione #include <Eigen/Dense>, andrà prima a cercare nella cartella di default del sistema, e poi nella cartella /usr/include/eigen3/Eigen/Dense.

Come funziona Eigen? (Header-only)

Generalmente, quando usate una libreria esterna gigante, avete bisogno di due cose: 1. I file .hpp (che includete con #include e dite dove sono con -I). 2. I file binari .so o .a pre-compilati dagli autori (da lincare con -L e -l).

Ma Eigen è speciale. È una libreria Header-Only. Non c’è codice precompilato. Tutti i calcoli matriciali ultra-ottimizzati di Eigen sono interamente scritti nei file Header (sfruttando potentissimi Template che studieremo più avanti).

Questo vi salva da ore di mal di testa durante la compilazione! Vi basterà solo il flag -I per usarla.

Passare argomenti da terminale

Problema pratico: Volete addestrare la rete neurale 10 volte, testando Learning Rate diversi (0.01, 0.05, 0.1).

Se il Learning Rate è una variabile dentro al vostro main.cpp, dovrete modificare il codice, salvare, e ricompilare tutto ogni singola volta!

Soluzione: Passare gli “Iperparametri” dinamicamente dal terminale.

# Formato: ./programma <epoche> <learning_rate>
$ ./train_network 100 0.01

La firma estesa del main()

Per ricevere i dati dal Terminale, dobbiamo sostituire il banale int main() con la sua firma completa:

1int main(int argc, char* argv[]) {
    
    // Il nostro codice...
    
    return 0;
}
1
Spieghiamo i parametri:

Parsing: La sicurezza prima di tutto

La regola fondamentale del terminale: Non fidarti mai dell’utente.

Se vi aspettate che l’utente inserisca epoche e learning rate, dovete verificare che l’abbia fatto, altrimenti il programma andrà in Segmentation Fault quando cercherete di leggere array inesistenti.

int main(int argc, char* argv[]) {
    // Attenzione: Il NOME DEL PROGRAMMA stesso è il primo argomento (argv[0])!
    // Quindi se vogliamo 2 iperparametri, ci aspettiamo che argc sia ALMENO 3.
    
1    if (argc < 3) {
        std::cerr << "Errore: Uso scorretto del comando.\n";
        std::cerr << "Sintassi: " << argv[0] << " <epoche> <learning_rate>\n";
        return -1; // Esco con un codice di errore
    }
    
    // ...
}
1
Controlliamo se argc è minore di 3 (./train_network è 1, 100 è 2, 0.01 è 3).

Convertire Stringhe in Numeri

Ora che siamo sicuri che gli argomenti esistano, dobbiamo estrarli. argv[1] contiene “100” e argv[2] contiene “0.01”.

Ma questi non sono numeri, sono stringhe di testo! Usiamo la libreria standard <string> per convertirli.

  • std::stoi: (String-TO-Integer) converte verso int.
  • std::stod: (String-TO-Double) converte verso double.
#include <string>

// ... dentro al main ...
int epoche = std::stoi(argv[1]);        // Ora è un VERO int (100)
double lr  = std::stod(argv[2]);        // Ora è un VERO double (0.01)

Esempio Completo: Iperparametri da CLI

Mettiamo tutto insieme nel nostro main per la Rete Neurale:

#include <iostream>
#include <string>

int main(int argc, char* argv[]) {
    if(argc < 3) {
        std::cerr << "Sintassi: ./train_net <epoche> <learning_rate>\n";
        return -1; 
    }

    int epochs = std::stoi(argv[1]); 
    double learning_rate = std::stod(argv[2]); 

    std::cout << "Avvio training della Rete Neurale...\n"
              << " - Epoche impostate: " << epochs << "\n"
              << " - Learning Rate impostato: " << learning_rate << "\n";

1    // network.train(epochs, learning_rate);
    return 0;
}
1
Una riga di codice pulitissima e indipendente!

Esercizio: Leggere Iperparametri (5 min)

Prendi dimestichezza con la linea di comando!

  1. Crea un file config.cpp.
  2. Scrivi un main che accetti da terminale 3 argomenti: un Batch Size (int), un Learning Rate (double), e il Nome del Dataset (stringa normale, non va convertita!).
  3. Se l’utente digita meno argomenti del dovuto, stampa un errore chiaro.
  4. Se è tutto ok, stampa a schermo: "Training sul dataset: NOME. Batch: X, LR: Y".

(Prova a compilarlo ed eseguirlo passando volutamente argomenti mancanti!)