Laboratorio 07

Funzioni di Attivazione e Performance (-O3)

Docente

Soluzione del Laboratorio 06

La Regola del Tre su Matrix — il punto delicato è l’ordine dei passi nell’operatore di assegnazione:

Matrix& Matrix::operator=(const Matrix& other) {
    if (this == &other) return *this;   // a. auto-assegnazione PRIMA di tutto
    delete[] data;                      // b. libera
    rows = other.rows; cols = other.cols;
    data = new double[rows * cols];     // c. rialloca e clona
    for (int i = 0; i < rows * cols; ++i) data[i] = other.data[i];
    return *this;                       // d.
}

E la deep copy polimorfica della rete, che funziona solo grazie a clone():

SequentialNetwork::SequentialNetwork(const SequentialNetwork& other)
    : capacity(other.capacity), size(other.size) {
    layers = new ILayer*[capacity];
    for (int i = 0; i < size; ++i)
        layers[i] = other.layers[i]->clone();   // ogni layer sa clonare sé stesso
}

Chi ha commentato il copy constructor ha visto il Double Free: due reti che fanno delete sugli stessi puntatori.

Obiettivo di Oggi

La rete che abbiamo costruito è inutile: due DenseLayer in fila sono una singola trasformazione lineare (Lezione 2). Serve la non-linearità.

Oggi:

  1. Aggiungiamo SigmoidLayer, il primo layer senza pesi.
  2. Archiviamo Matrix: da qui in poi solo Eigen.
  3. Misuriamo le performance e scopriamo cosa fa davvero il flag -O3.

Task 1: Addio a Matrix

Rimuovete matrix.hpp e matrix.cpp dal progetto e dal CMakeLists.txt.

Non è tempo perso: scrivere quella classe serviva a capire cosa c’è dentro un Eigen::MatrixXd — un blocco contiguo row-major, con la Regola del Tre già implementata da altri e ottimizzata meglio di quanto potremmo fare noi.

Note

È il ciclo normale dell’ingegneria del software: si costruisce a mano per capire, poi si adotta la libreria matura. Quello che avete imparato serve a leggere gli errori di Eigen, non a sostituirla.

Task 2: Il layer SigmoidLayer

A differenza del DenseLayer, un layer di attivazione non ha pesi: applica una funzione elemento per elemento.

Crea activation_layer.hpp:

#ifndef ACTIVATION_LAYER_HPP
#define ACTIVATION_LAYER_HPP

#include "ilayer.hpp"

class SigmoidLayer : public ILayer {
public:
    SigmoidLayer() = default;

    Eigen::MatrixXd forward(const Eigen::MatrixXd& input) override;
    ILayer* clone() const override;
};

#endif

Task 3: L’implementazione elemento per elemento

In activation_layer.cpp:

#include "activation_layer.hpp"
#include <cmath>

Eigen::MatrixXd SigmoidLayer::forward(const Eigen::MatrixXd& input) {
    return input.array()
                .unaryExpr([](double x) { return 1.0 / (1.0 + std::exp(-x)); })
                .matrix();
}

ILayer* SigmoidLayer::clone() const {
    return new SigmoidLayer(*this);
}

Tre passaggi in una riga: .array() passa alla semantica elemento per elemento, .unaryExpr(...) applica una lambda a ogni elemento, .matrix() torna al tipo matrice.

Warning

Senza .array(), l’operatore * di Eigen è il prodotto matriciale. È l’errore più insidioso del corso: compila, gira, e dà numeri sbagliati.

Task 4: La rete non-lineare

Nel main.cpp, alterniamo strati densi e attivazioni:

SequentialNetwork net(4);
net.add(new DenseLayer(784, 128));
net.add(new SigmoidLayer());
net.add(new DenseLayer(128, 10));
net.add(new SigmoidLayer());

Eigen::MatrixXd X = Eigen::MatrixXd::Random(784, 1);
std::cout << "Output:\n" << net.forward(X).transpose() << std::endl;

Criterio di verifica: l’output è 10 × 1 e ogni valore è strettamente compreso tra 0 e 1 — è la sigmoide finale che lo garantisce. Se vedete valori fuori da quell’intervallo, l’ultimo layer non è stato applicato.

Task 5: Cronometrare (<chrono>)

Quanto costa un forward pass? Un singolo passaggio è troppo veloce per essere misurato: lo ripetiamo 10.000 volte.

#include <chrono>

int N_ITER = 10000;
auto start = std::chrono::high_resolution_clock::now();

for (int i = 0; i < N_ITER; ++i)
    net.forward(X);

auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

std::cout << "Tempo trascorso: " << ms << " ms" << std::endl;

La parola chiave auto fa dedurre al compilatore quei tipi verbosi delle librerie (l’abbiamo vista oggi a lezione; le sue regole complete arrivano nella Lezione 18).

Annotate il numero: fra un minuto lo confronteremo.

Task 6: Scatenare il compilatore (-O3)

Per default CMake compila senza ottimizzazioni (-O0): privilegia la velocità di compilazione, non quella del programma.

Aggiungete al CMakeLists.txt:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3")

Ricompilate da zero e rilanciate il benchmark.

Il tempo deve crollare, tipicamente di un fattore fra 5 e 20. Il motivo è che Eigen è scritto per essere ottimizzato: con -O3 il compilatore fonde i cicli (loop fusion), elimina le matrici temporanee e usa le istruzioni vettoriali della CPU (SSE/AVX) che elaborano 4 o 8 double per ciclo di clock.

Con -O0 tutto questo è disattivato, e Eigen diventa più lenta di un ciclo for scritto a mano.

Task da Completare

Se Avete Finito: Tre Misure in Più

Il lab di oggi è corto apposta: il tempo va nelle compilazioni e nelle misure. Chi arriva in fondo ha tre esperimenti pronti, in ordine di difficoltà. Nessuno è obbligatorio; tutti insegnano qualcosa che userete nel Lab 15.

  1. Ordine di grandezza. Ripetete la misura con -O0, -O1, -O2, -O3. Il salto grosso è fra -O0 e -O1 o fra -O2 e -O3? Annotatevi i quattro tempi: il Lab 15 chiederà misure di questo tipo su tutto il training.
  2. Eigen contro il ciclo for. Scrivete la sigmoide con due cicli annidati su (i, j) invece di .array().unaryExpr(...), e cronometrate entrambe le versioni a -O3. Chi vince, e di quanto? Poi rifatelo a -O0: la risposta cambia, e il perché è nella slide precedente.
  3. float contro double. Sostituite Eigen::MatrixXd con Eigen::MatrixXf nel solo test di velocità. Il tempo si dimezza? Quasi mai esattamente: ragionate su cosa limita davvero il calcolo, la CPU o la memoria. Fra due laboratori il framework diventerà template sul tipo numerico proprio per poter fare questa scelta a piacere.

La soluzione di riferimento verrà discussa all’inizio della prossima lezione.