Funzioni Friend, Ottimizzazione (-O3) e Libreria Eigen
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:
Questo ci permetteva di scrivere in modo elegantissimo: Vettore2D v2 = v1 * 5.0;
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!
}error: no match for 'operator*' in '5.0 * v1'
Cosa è andato storto?
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.
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!
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?
}5.0 * v1 ! Il lato sinistro è lhs, il destro è rhs.
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);
}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.
friendIl 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!
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);
};Mettiamo tutto assieme. Abbiamo creato l’overloading simmetrico perfetto.
Dentro la classe (Metodo) Per v1 * 5.0
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_
);
}rhs.x_ funziona tranquillamente pur essendo privato, grazie all’amicizia.
Applichiamo subito la direttiva friend!
Complex.hpp e Complex.cpp per rappresentare un numero complesso (con due attributi privati double real_ e double imag_).std::cout << numero_complesso;..hpp dichiara questa funzione friend globale: friend std::ostream& operator<<(std::ostream& os, const Complex& rhs);.cpp implementa la funzione globale affinché stampi il numero nel formato (real + i*imag).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);
};cout per i propri oggetti!
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;
}friend, rhs.real_ avrebbe causato un errore fatale del compilatore.
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à:
Oggi impareremo esattamente questi strumenti.
-ODi 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”.
-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).
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:
Tip
Ricordate: -O3 aumenta notevolmente il tempo di compilazione. Usatelo solo quando avete finito di fare le modifiche al codice sorgente!
<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.
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.
-IDobbiamo “indicare la via” al compilatore tramite il flag -I (Include path).
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.
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.
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.
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;
}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
}
// ...
}argc è minore di 3 (./train_network è 1, 100 è 2, 0.01 è 3).
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.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;
}Prendi dimestichezza con la linea di comando!
config.cpp.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!)."Training sul dataset: NOME. Batch: X, LR: Y".(Prova a compilarlo ed eseguirlo passando volutamente argomenti mancanti!)