Sono nato nel 1982, anno che mi ha permesso di vedere in fila Commodore-64, Amiga-600 e il mio primo pc in assoluto, un Olivetti-286 comprato di seconda o addirittura terza mano da un amico di famiglia. Siamo alla fine degli anni ‘80, massimo primi anni ‘90 e ricordo tre cose di quel periodo:

  • l’informatica era un’esperienza di condivisione “analogica”. Si stava tutti insieme ora a casa mia ora a casa tua e cercavamo di capire come funzionano sti cosi.
  • un libro sul linguaggio C, “Guida al Turbo C”, o qualcosa del genere con la parola Turbo. Ricordo di averlo acquistato in edicola. Lo comprai perché c’erano i floppy-disk e in copertina c’erano scritte le parole “Turbo” e “Programmazione”. Ma soprattutto “Turbo”.
  • dopo che la TV diede una delle prime notizie sui “virus dei computer”, mia nonna divenne molto contrariata sul fatto di lasciarmi davanti ad uno di questi apparecchi, con il rischio di prendere qualche strana malattia.

Posso dire di non aver mai abbandonato il C in trenta e passa anni, anche quando scrivo in C++ resta sempre tutto molto “C-oso”. Se scrivo in Python continuo a pensare alla reazione a catena dal codice all’interprete e non posso fare a meno di chiedermi quale scelta nel codice sia più vantaggiosa.

Nel frattempo il mio modo di programmare si è andato costruendo negli anni, scandito da determinati eventi. Dagli esordi in cui si schiaffeggiava la tastiera cercando sostanzialmente di fare giochi, fino a programmi sempre più complicati.

La prima evoluzione è avvenuta leggendo per la prima volta “C interfaces and implementations” (David Hanson). Scrivo “per la prima volta” perché nel tempo quel libro l’ho “divorato” al punto che le pagine ormai son in gran parte staccate e il libro è diventato più un raccoglitore.

Tra quelle pagine ho imparato cosa significa modellare un’idea sul codice. Il concetto di creare “architetture” come somma di concetti atomici.

La seconda grande evoluzione, la ebbi quando cominciai a studiare seriamente Algebra.

Gli assiomi di Peano vengono proposti in tante forme, ma con il passare del tempo e delle letture, la mia esposizione preferita è diventata quella tratta dal libro “Aritmetica e Algebra” (Dikran Dikranjan, Maria Silvia Lucido). La ripropongo così com’è:

Esiste un insieme $N$ e un’applicazione $s: N \rightarrow N$, detta successore, tale che:

  1. $a \in N$
  2. se $n \in N$ allora anche $s(n) \in N$
  3. se $n \in N$, allora $s(n) \neq a$
  4. $s$ è iniettiva
  5. Se l’insieme $E$ contiene $a$ ed ha la proprietà che assieme ad ogni $n \in E$ anche $s(n) \in E$, allora $N \subseteq E$.

Di questa esposizione ciò che mi piace, è la costruzione degli assiomi senza nominare esplicitamente i numeri naturali. Ogni insieme con queste caratteristiche è di fatto equivalente ad $\mathbb{N}$.

L’ultima proposizione (la 5) non è altro che il principio di induzione. Un modo intuitivo per renderlo “visibile” è immaginare il gioco del domino a cascata, la pratica di allineare tessere geometriche in sequenza per poi innescarle creando una reazione a catena. In che modo posso dire che le tessere dalla prima all’ultima cadranno senza errori? Grazie al fatto che le tessere sono tutte della stessa dimensione e che il successore in questo caso è definito da una tessera posta ad una distanza inferiore alla sua altezza.

Possiamo quindi affermare che l’insieme delle tessere di domino possiede le stesse proprietà dell’insieme $N$ e che la definizione di successore data resta valida per un domino a cascata composto da infiniti pezzi.

Il punto di svolta vero e proprio avvenne quando lessi “Introduzione agli algoritmi e strutture dati” (Cormen, Leiserson et alii). Oggi più che mai questo per me è e resta “Il Libro”. Affronta la materia in maniera formale e in pseudo codice, è un’avventura meravigliosa nei meandri della progettazione del codice.

Nelle prime pagine riporta ciò che ha cambiato per sempre il mio approccio alla programmazione: l’invariante di ciclo. La definizione formale di ciò che prima era per me la semplice istruzione for.

Una struttura iterativa rende corretto un algoritmo se:

  • è vera alla prima iterazione del ciclo (inizializzazione)
  • se è vera prima di un’iterazione di ciclo, allora resta vera prima della successiva (conservazione)
  • quando il ciclo termina, l’invariante definisce la correttezza dell’algoritmo

È il testo stesso che dichiara l’analogia con l’induzione matematica, mentre la rivoluzione che ha innescato nel mio approccio è una spinta continua verso la ricerca di un’“algebrizzazione” della programmazione. Ormai cominciavo a pensare ad un programma come un sistema in cui costruire operazioni ben definite sui dati.

Quando già la programmazione compiva un passo verso l’astrazione e si affermava il paradigma ad oggetti, io decisi ad un certo punto di intraprendere un viaggio in direzione totalmente opposta. Cominciai a studiare l’hardware (il caro Tanenbaum, altro libro che reputo fondamentale) e l’assembly. I computer si stavano evolvendo e io volevo ragionare in termini di strutture algebriche non di classi.

Anni dopo ritrovai parte del mio modo di pensare nelle idee definite nel paradigma architetturale Data-Oriented Design (DOD) (per chi volesse approfondire rimando al quasi manifesto di uno dei suoi cantori Richard Fabian https://www.dataorienteddesign.com/dodbook/).

Nel Data-Oriented Design non si parte dal singolo elemento e da ciò che sa fare, ma dall’insieme degli elementi che subiscono la stessa trasformazione. Si progettano strutture dati e algoritmi per trattarli come un blocco unico. È qui che ritrovavo il modo di ragionare della teoria dei gruppi. In un gruppo un elemento esiste solo attraverso l’operazione che lo lega agli altri, e ciò che si studia è la coppia insieme più operazione, chiusa su se stessa.

Chiarisco, è un’analogia metodologica, non strutturale. Non esiste una formalizzazione matematica, sto parlando di ciò che ha costruito il mio approccio alla programmazione.

L’ultima evoluzione è stata determinata dall’avvento degli assistenti AI alla programmazione. Il mio rapporto con questi strumenti (di questo stiamo parlando, dovremmo ricordarcene) si è stabilizzato nel tempo ed ha avuto un punto di svolta nel momento in cui ho realizzato che le mie più grandi avversarie di questi anni sono state sempre loro: le mie mani. Per quanto mi sforzassi le mie mani non riuscivano a tenere il passo alle idee.

E allora a questo è seguito il domandarsi: programmare e scrivere codice si possono separare?

Che siano due cose distinte è quasi ovvio. La programmazione per me è sempre stata un atto intellettuale, è cultura, arte se vogliamo. La scrittura del codice è un atto fisico, esecutivo. Meno ovvio è che si possano staccare, perché siamo portati a ritenere che scrivendo si scopra quanto un’idea stia o non stia in piedi, e in questo senso la scrittura non è solo esecuzione, è anche verifica.

La mia risposta è che ciò che si delega non è la programmazione né la scrittura, è la battitura. Il giudizio su quello che viene scritto resta dov’era.

L’adozione di questi nuovi strumenti per me ha comportato più di un cambiamento e portato un’inaspettata varietà di approcci. Non ho smesso di scrivere codice, anzi, ho più tempo per studiare nuove soluzioni e nuovi linguaggi. Inoltre accelerare l’analisi del codice in cerca di criticità ha portato alla messa in discussione immediata delle mie idee.

Questi sono punti non da poco, l’iter ricerca $\rightarrow$ sperimentazione $\rightarrow$ implementazione $\rightarrow$ fallimento $\rightarrow$ ricerca $\rightarrow$ implementazione $\rightarrow$ … comportava tanto tempo prima di vedere risultati.

E non è vero che l’intero processo rappresentava comunque una forma di apprendimento, perché le parti con un reale valore aggiunto venivano diluite in tante altre pratiche a corredo spesso ripetitive e meccaniche.

Mentre per quel che riguarda la domanda: lascio andare l’assistente AI in libertà o intervengo? Dipende da tanti aspetti, il concetto di delega non è mai banale.

Facciamo un esempio. Per esigenze narrative riproporrò solo i blocchi essenziali di codice generati dall’AI senza il contorno.

Bisogna simulare il moto di un milione di particelle.

Una soluzione senza particolari indicazioni (eccetto la scelta di C++ come linguaggio) ha portato il modello a delineare una soluzione decisamente Object Oriented.

class Particle {
public:
    Particle(double x, double y, double z,
             double vx, double vy, double vz)
        : x(x), y(y), z(z), vx(vx), vy(vy), vz(vz) {}

    void update(double dt, double t) {
        x += vx * dt;
        y += vy * dt;
        z += vz * dt;
    }

private:
    double x, y, z;
    double vx, vy, vz;
    double temperature = 0.0;
};

Di seguito la parte computazionale.

...
Particle* particelle = new Particle[N];
...
for (int i = 0; i < N; i++) 
    particelle[i].update(dt, t);

Ha senso, ma cosa dovrà fare l’hardware per eseguire questo codice?

La classe Particle occupa 56 byte in memoria (sei double per posizione e velocità, uno per la temperatura). Nella memoria allocata dinamicamente Particle particelle[N], la particella i inizia all’offset i × 56. Il problema è che 56 non è un divisore di 64 (la dimensione di una linea di cache). Il resto (i × 56) mod 64 cambia a ogni particella e si riallinea solo dopo 8 particelle, perché il minimo comune multiplo tra 56 e 64 è 448 byte, cioè esattamente 7 linee di cache.

Calcolando gli offset delle prime otto:

$$ \begin{aligned} \text{particella 0: offset 000 } &\rightarrow \text{ occupa una linea di cache} \\ \text{particella 1: offset 056 } &\rightarrow \text{ a cavallo tra due linee}\\ \text{particella 2: offset 112 } &\rightarrow \text{ a cavallo}\\ \text{ particella 3: offset 168 } &\rightarrow \text{ a cavallo}\\ \text{ particella 4: offset 224 } &\rightarrow \text{ a cavallo}\\ \text{ particella 5: offset 280 } &\rightarrow \text{ a cavallo}\\ \text{ particella 6: offset 336 } &\rightarrow \text{ a cavallo}\\ \text{ particella 7: offset 392 } &\rightarrow \text{ sta tutta in una linea}\\ \end{aligned} $$

Sei particelle su otto hanno i propri campi spezzati su due linee di cache diverse. Ne segue che update() deve prelevare dalla memoria due linee da 64 byte invece di una sola. Per tutte le rappresentazioni inoltre vale il fatto che 8 byte su 56 non vengono usati nel calcolo (temperature).

Il ragionamento precedente è già ottimistico, perché assume che l’array inizi esattamente all’inizio di una linea di cache. In pratica, però, questa ipotesi non è garantita. L’istruzione new Particle[N] alloca la memoria tramite operator new[], che assicura soltanto l’allineamento predefinito della piattaforma, tipicamente pari a 16 byte sui sistemi a 64 bit. Non garantisce invece un allineamento a 64 byte, cioè alla dimensione di una linea di cache.

Una semplice verifica sperimentale conferma questo comportamento. In una delle esecuzioni, l’indirizzo restituito era effettivamente allineato a 16 byte, ma il suo resto rispetto a 64 era pari a 16 (indirizzo mod 64 = 16). Questo valore dipende dall’allocatore e dalla piattaforma, non dalla singola esecuzione, e in ogni caso non è controllabile dal programma.

Di conseguenza, gli offset calcolati in precedenza (0, 56, 112, …) non sono riferiti all’inizio di una linea di cache, ma all’indirizzo iniziale effettivamente restituito dall’allocatore. La conclusione, tuttavia, non cambia: 6 particelle su 8 continuano a occupare due linee di cache. Ciò che cambia è soltanto la posizione esatta in cui questo schema ha inizio, che dipende dall’allineamento assegnato all’array.

Un fattore che contribuisce al rallentamento del ciclo è rappresentato dalle cache miss. Quando i dati richiesti dal processore non sono presenti nella cache (L1, L2 o L3), la CPU è costretta a recuperarli dalla memoria principale (RAM), che ha tempi di accesso di gran lunga superiori. Durante questa attesa il processore rimane in gran parte inattivo, introducendo numerosi cicli di stallo (stall cycles) che riducono significativamente le prestazioni complessive. Se il ciclo accede ai dati con scarsa località spaziale o temporale, il numero di cache miss aumenta e il tempo di esecuzione può crescere sensibilmente, anche quando il numero di operazioni computazionali rimane invariato.

E quindi dobbiamo spiegare ad un modello come la classe deve essere necessariamente decomposta in qualcosa di completamente diverso rispetto al concetto di “incapsulamento”.

struct alignas(32) vec3 {
    double x, y, z;
    double _pad;   
};

struct scalar{
  double t;  
};

Dove alignas(32) serve a indicare al compilatore che ogni vec3 deve iniziare su un indirizzo di memoria multiplo di 32. I 32 byte esatti li ottengo già da qui, perché la dimensione di un tipo è sempre un multiplo del suo allineamento. L’attributo _pad è dichiarato per rendere esplicito quel riempimento, che altrimenti il compilatore aggiungerebbe comunque ma senza nome.

Questo piccolo trucco ci permetterà di avere insiemi caratterizzati da operazioni ben definite e ben proiettate sull’hardware.

Sia dal punto di vista dell’allocazione di memoria:

vec3* position  = static_cast<vec3*>(std::aligned_alloc(32, static_cast<size_t>(N) * sizeof(vec3)));

vec3* velocity = static_cast<vec3*>(std::aligned_alloc(32, N * sizeof(vec3)));

scalar* temperature = static_cast<scalar*>(std::malloc(N * sizeof(scalar)));

C’è però un dettaglio da non trascurare. aligned_alloc restituisce memoria non inizializzata, e se in fase di inizializzazione scrivo solo x, y e z, il campo _pad conserva i bit che si trovavano in quella zona di memoria. Nel ciclo scalare non accade nulla, perché le tre componenti vengono aggiornate una per una. Nella versione vettoriale invece il caricamento legge quattro double in una volta sola e l’operazione li aggiorna tutti insieme, quindi anche quei bit.

I risultati su x, y e z restano corretti, perché i quattro valori vengono elaborati in modo indipendente l’uno dall’altro. Il costo è nascosto altrove: se quei bit casuali corrispondono a valori denormali, l’unità in virgola mobile paga una penalità che su molte microarchitetture è pesante, e la paga su un milione di particelle a ogni passo. È anche il caso peggiore da diagnosticare, perché non si presenta sempre: le pagine appena consegnate dal sistema operativo sono azzerate, quindi alla prima allocazione _pad vale zero e va tutto bene. I bit residui compaiono solo quando l’allocatore restituisce memoria già usata e liberata, cioè in esecuzioni lunghe e non nei test brevi. Un codice che dà i numeri giusti e ogni tanto va molto più lento di quanto dovrebbe.

Basta azzerare la memoria subito dopo averla allocata.

std::memset(position, 0, static_cast<size_t>(N) * sizeof(vec3));
std::memset(velocity, 0, static_cast<size_t>(N) * sizeof(vec3));

Questa impostazione mi porta un ulteriore vantaggio nel poter riutilizzare lo stesso codice sfruttando le operazioni SIMD del processore.

Le SIMD (Single Instruction, Multiple Data) sono un paradigma di elaborazione che permette al processore di eseguire la stessa operazione su più dati contemporaneamente. Nelle architetture x86 moderne, le istruzioni AVX (Advanced Vector Extensions) operano su registri vettoriali larghi 256 bit, sufficienti a contenere quattro valori double (4 × 64 bit) in un’unica variabile.

Nel codice, la variabile utilizzata per questo scopo è di tipo __m256d, un tipo definito in <immintrin.h> che rappresenta un registro AVX contenente quattro numeri in doppia precisione. I dati vengono caricati dal vettore position o velocity tramite l’istruzione _mm256_load_pd, elaborati simultaneamente e successivamente salvati in memoria con _mm256_store_pd. Proprio perché _mm256_load_pd richiede che l’indirizzo di memoria sia allineato a 32 byte, gli array corrispondenti vengono allocati con aligned_alloc(32, ...).

temperature non ha lo stesso vincolo, dal momento che non è mai letta a blocchi di 4 con un caricamento vettoriale in questo ciclo, un semplice malloc basta.

Ora posso passare da questo:

void update(vec3* position, vec3* velocity, int n, double dt) {
    for (int i = 0; i < n; i++) {
        position[i].x += velocity[i].x * dt;
        position[i].y += velocity[i].y * dt;
        position[i].z += velocity[i].z * dt;
    }
}

A questo:

void update_simd(vec3* position, vec3* velocity, int n, double dt) {
    const __m256d vdt = _mm256_set1_pd(dt);
    for (int i = 0; i < n; i++) {
        __m256d v = _mm256_load_pd(reinterpret_cast<double*>(&velocity[i])); 
        __m256d p = _mm256_load_pd(reinterpret_cast<double*>(&position[i]));
         p = _mm256_fmadd_pd(v, vdt, p);     
        _mm256_store_pd(reinterpret_cast<double*>(&position[i]), p);
    }
}

Oppure posso anche decidere di passare al calcolo su GPU, e siccome voglio provare qualcosa di alternativo a CUDA, provo OpenCL.

OpenCL (Open Computing Language) è uno standard aperto, alternativo a CUDA di Nvidia, e come quest’ultimo, progettato per eseguire calcoli paralleli sfruttando diversi tipi di dispositivi hardware, come GPU, CPU e acceleratori dedicati. L’idea alla base di OpenCL è separare il programma in due parti: il kernel, cioè il codice che viene eseguito in parallelo sul dispositivo di calcolo, e il codice host, che viene eseguito sulla CPU e si occupa di preparare i dati, trasferirli alla GPU e avviare l’esecuzione.

Nel modello di esecuzione OpenCL, il lavoro viene suddiviso in tante unità indipendenti chiamate work-item. Nell’esempio, ogni work-item si occuperebbe quindi dell’aggiornamento di una singola particella e quindi di leggere un blocco di memoria di 32 byte consecutivi corrispondente all’intero vettore della particella (double4). Questo permette alla GPU di accedere ai dati in modo efficiente, perché ogni elemento contiene già tutte le informazioni necessarie in un’unica struttura.

Va detto che questa non è l’unica strada, e nemmeno la più ortodossa. Il Data-Oriented Design allo stesso problema risponde di norma con tre array separati, uno per x, uno per y e uno per z, dove il riempimento sparisce del tutto mentre in vec3 restano 8 byte su 32. Se ho scelto comunque vec3 è perché quei 32 byte contigui mi servono due volte, come allineamento richiesto da _mm256_load_pd e come double4 letto da un work-item OpenCL. Un solo layout che riesco a riutilizzare su entrambe le strade senza ripensare il pattern di accesso. Non la soluzione migliore in assoluto, il compromesso più adatto a questo caso.

Qui si inserisce esattamente quanto dicevo all’inizio sul mio riconsiderare la programmazione ad oggetti. L’approccio DoD si è inserito perfettamente nel modo in cui avevo già maturato il mio approccio alla programmazione. Al di là del fatto che un vettore 3D rappresenti già di per sé una struttura algebrica, in questo esempio ha una doppia natura nel modo in cui lo intendo in qualità di oggetto proiettato sull’hardware: un insieme di blocchi di byte definiti da una caratteristica e operazioni ben definite che operano su di essi.

Questo è un esempio di quanto oggi cerco di trasferire ad un tool in grado di accelerare la mia idea di “programmazione”. A volte mi capita di scrivere specifiche dettagliate, a volte mi capita di “discutere” passo passo partendo da un’idea scritta in pseudo-codice o prototipata.

Resta il fatto che, se in una prima fase cercavo costantemente di rivedere, intervenire, adesso ho realizzato di trovarmi davanti ad una sorta di Specchio di Galadriel.

Nel “Signore degli Anelli”, questo artefatto elfico agisce come una sorta di amplificatore psichico che risponde direttamente alla natura, ai desideri e al destino di chi vi si affaccia. Non proietta immagini fisse, ma si sintonizza con l’anima dell’osservatore, mostrando visioni uniche basate sulla sua sensibilità e sul suo ruolo nella Terra di Mezzo.

Ora, non voglio dire che un LLM si sintonizzi con l’anima di chi lo usa, molto più semplicemente reagisce direttamente ai token che abbiamo deciso di passargli.

Quindi alla fine, quando un coding assistant lavora male, credo valga sempre il grande classico del Machine Learning: garbage in, garbage out.