Mestiere

Le vittime del clean code

18 luglio 20269 min

Nell'industria dello sviluppo di giochi (e forse del software in generale) vive un'idea molto comoda, con cui in tanti giustificano strati superflui di logica e astrazione. Suona più o meno così: «Voglio che il processo stesso di creare giochi sia più semplice, più piacevole e più produttivo, e sono disposto a sacrificare il 10% di performance per questo» — Tim Sweeney.

L'argomento è buono, promette che a un piccolo prezzo di belle astrazioni, pattern e feature del linguaggio si permette al programmatore di produrre più in fretta codice "cattivo", cattivo nel senso di lento, ma che fa lo stesso lavoro utile di quello alternativo buono, ma illeggibile e difficile da capire. Per il business è più conveniente, anche se, per mia esperienza, al business importa profondamente poco di quante righe di codice ha scritto un certo Giovanni, Vanja o Vapur, della loro complessità o della presenza di astrazioni: lì contano metriche di "bontà" tutte diverse, pur a scapito della qualità del risultato finale.

Mi affretto a precisare che per "codice cattivo" non intendo ciò che il programmatore vede nel suo editor. Lì anzi può regnare la bellezza: indentazioni ordinate, nomi parlanti, astrazioni e documentazione. Parlo invece del codice reale, quello che di fatto gira sul processore o sulla scheda video del giocatore, e che si trasforma in cali di framerate, in mezzo minuto di caricamento del livello e in ventole che decollano.

L'articolo è nato dopo l'ennesimo attacco di clean-code-mania in un certo studio, quando qualcuno per l'ennesima volta ha visto e portato a tutti il notissimo libro di Martin sui pregi di questa stessa clean-code-ia. Ma "Clean Code", quello con la maiuscola e le tirature da migliaia di copie, non è affatto «codice pulito» nel senso quotidiano, perché Martin ha inventato questo brand, questa etichetta, e per diversi anni l'ha coltivata alle conferenze, legando il suo nome a questo concetto. Sarebbe più corretto chiamarlo "stile del signor Martin" o "stile di Uncle Bob", e allora metà delle dispute sull'importanza del libro cade da sola, perché non si discute più dell'astratta "pulizia", ma di insiemi concreti di tecniche di un autore concreto. Quella discussione, dal punto di vista tecnico, non ha mai dato risultati chiari, perché al terzo giro di dibattito entravano in gioco la retorica e la sostituzione dei concetti, tipo «clean» (buono, valido) che cominciava a trasformarsi in «Clean» (secondo il manualetto), e viceversa.

Non tutti gli "yogurt" fanno ugualmente bene

Qui serve una piccola postilla al testo dell'articolo e al perché non conviene precipitarsi a usare gli esempi del libro rosso nel proprio progetto. Quando Martin scriveva Clean Code (2008), da molti anni si occupava non solo di sviluppo, ma anche di consulenza ai team tramite Object Mentor, che insegnava programmazione estrema, approcci orientati agli oggetti e idee agile. Perciò il libro è cresciuto dall'esperienza di lavoro con grandi sistemi aziendali e team, dove il problema principale di solito era la difficoltà di modificare e mantenere il codice, e non il costo di ogni singolo tratto di esecuzione al microsecondo.

Bisogna capire che Clean Code non è un libro sull'ottimizzazione dell'esecuzione dei programmi, e la sua area principale riguarda la manutenibilità dei grandi sistemi, dove il costo principale spesso nasce non da una chiamata di funzione superflua o da un cache-miss, ma dalla difficoltà di modificare il codice anni dopo. Per i motori di gioco e i sistemi realtime queste idee diventano uno strato di astrazione superfluo e possono ricadere non solo sul costo di manutenzione, ma anche sul costo di ogni iterazione di sviluppo e di ogni frame.

Cominciamo dal fatto che l'interfaccia principale attraverso cui il programmatore scrive codice è molto specifica e scomoda: bisogna scrivere letterine, tante letterine, tantissime letterine. Solo per descrivere una piccola azione, come stampare HelloWorld in console. L'interfaccia attraverso cui scriviamo codice è strettamente legata alla psicologia, alla capacità di ragionare ed esprimere effetti computazionali, e non a tutti si addice, mentre gran parte delle interfacce esistenti sono ugualmente scomode per l'uomo, per esempio perché scrivere su carta, disegnare o parlare è più naturale che esprimere un pensiero attraverso un insieme limitato di lettere, segni più e ampersand.

Ma questo se guardiamo dal lato dell'uomo, perché c'è un altro lato, dove pure va male, e qualsiasi interfaccia si realizza in modo ugualmente inefficiente sull'hardware, semplicemente perché è un'interfaccia. E l'inefficienza dell'hardware rientra a ritroso nel codice.

Prendiamo qualcosa di molto terra terra, la scorsa di un array di oggetti, per esempio, e proviamo a calcolare la somma di un certo campo. La variante «pulita» sembra quasi sempre innocente: un vettore di shared_ptr e un metodo virtuale, una comoda gerarchia, o un grafo di tipi, ma il problema non comincia dove il codice diventa pulito. Il problema comincia dove l'astrazione diventa il fine, e non lo strumento.

// "comodo", ma ogni oggetto è un'allocazione separata
for (Entity* e : scene.getAllEntities())
    e->getTransform()->update(dt);   // chiamata virtuale + chiamata virtuale
                                     // + salto nell'heap

Sembra bello, si legge facilmente, si testa facilmente, ma... Ma ogni oggetto sta nella propria area di memoria, la cache fa miss, la chiamata virtuale non permette al compilatore di fare inline e nel contempo di vettorizzare il ciclo, e se nella gerarchia ci sono dieci tipi diversi, allora nemmeno la BPU (Branch Prediction Unit) aiuta granché.

La seconda variante, quella senza gerarchie e interfacce, tiene semplicemente un array di strutture (o, ancora meglio, un array di singoli campi, structure of arrays) e un ciclo piatto, che il compilatore srotola e vettorizza da solo, senza suggerimenti. Questa variante è più "sporca" nel senso di Martin, più difficile da leggere per un principiante e si incastra peggio nella bella gerarchia di classi da manuale, ma è più facile da profilare, perché non ha nulla in cui nascondersi, più facile da parallelizzare, perché i dati stanno vicini, ed è molte volte più veloce sull'hardware. Non è un sacrificio in nome della velocità, è semplicemente un altro modo di pensare i dati, non come oggetti con comportamento, ma come array di dati da far scorrere in fretta attraverso il processore, cosa che tutto questo era, finché non abbiamo provato ad abbellirlo e a "ripulirlo".

// passata densa sull'array: piacevole da leggere e comoda per la cache
for (size_t i = 0; i < count; ++i)
    positions[i] += velocities[i] * dt;   // SoA, accesso prevedibile

Ed ecco che quando arriviamo a misurare gli fps, i consumi di memoria o a profilare su hardware reale, i sostenitori seri del «Clean Code» si dimezzano, e tutte le dichiarazioni sul fatto che scambiamo cicli di CPU con la produttività del programmatore restano appunto dichiarazioni.

Ma supponiamo di trovarci davvero nella situazione in cui tocca scambiare performance con velocità di scrittura del codice. Ammettiamo pure di avere strumenti di cui si sa per certo che fanno proprio questo, ma anche in questo caso ideale per loro scegliere il comfort del programmatore è quasi sempre poco saggio. L'errore principale che manda in frantumi tutto l'argomento «velocità di scrittura a scapito della velocità di esecuzione» è la costruzione di un ecosistema sbagliato fin dall'inizio.

Io ti ho scritto, io ti eseguo

Il modello implicito su cui tutto questo si regge appare così: abbiamo un produttore (che scrive il codice) e un consumatore (che lo esegue). Il produttore soffre sulla tastiera, il consumatore si gode il risultato. Bello! Solo che nello sviluppo di giochi non è così, per niente.

In realtà il produttore, cioè il programmatore, esegue il codice per primo e più spesso di tutti: durante lo sviluppo, il debug, il profiling e i playtest, e solo dopo innumerevoli esecuzioni la build arriva al giocatore. Il ciclo di gioco dello sviluppatore appare più o meno così:

ITERAZIONE DELLO SVILUPPATORE
  modifica del codice / asset
  compilazione (cattiva architettura -> +2 minuti sul build incrementale)
  avvio dell'editor / cooking degli asset
  arrivare fino alla scena giusta
  guardare il risultato 10 secondi
  -> accorgersi che non va
  -> ripetere N volte

Ed è qui che il cattivo tempo di esecuzione colpisce la produttività dello stesso autore, e parte del «guadagno» derivato dall'aver scritto il codice più "pulito" viene subito divorata dall'attesa. Finché si compila il progetto, finché si carica il livello, finché arrivi al bug al decimo minuto di gameplay, perché in quel punto non c'è un salvataggio rapido e il caricamento richiede mezzo minuto, finché... finché... finché...

Per continuare a giustificare gli approcci che accelerano la generazione di codice cattivo, bisogna dimostrare che il peggioramento del ciclo di iterazione non supera il guadagno in velocità di scrittura. Eppure il codice si scrive una volta, ma si esegue, si debugga e si mette a punto centinaia e migliaia di volte, e ogni secondo di compilazione in più e ogni editor sceso a 20 fps si moltiplicano per quel numero. Pensate alla compilazione del vostro progetto: da qualche parte avete aggiunto <iostream>, vi ha aggiunto quasi un !secondo! alla compilazione, quel codice lo compilate 60 volte al giorno, un minuto di tempo è semplicemente sparito nel nulla, e questo è solo un file header, ma nel progetto ce ne sono centinaia, e ognuno con il suo effetto collaterale. Questo secondo è critico (lo spiego più sotto), ma nessuno lo conta, finché il tempo non comincia a sforare in minuti, decine di minuti o ore. Ore di attesa.

I rallentamenti si propagano

Gli esempi e i problemi di Clean Code riguardano in primo luogo il software aziendale applicativo, i sistemi contabili, la business logic, le applicazioni, dove la difficoltà principale sta nel cambiamento dei requisiti e nel mantenere comprensibile la codebase. Anche questi sono problemi importanti, ma lo sviluppo realtime vi aggiunge un ulteriore strato, in cui il codice non deve solo essere comprensibile all'uomo, deve rientrare in un budget rigido di tempo del processore, di memoria e di iterazioni del team.

Io mi trovo a fare un motore, e per fare bene un motore e perché il designer non si lamenti dei cinque minuti di caricamento del livello, mi servono un profiler, un debugger, un sistema di controllo versione e un editor. Il profiler me lo fa un altro programmatore, e per fare il profiler a lui serve... un motore, su cui testare che il profiler catturi effettivamente qualcosa.

Ed ecco che al posto degli astratti «produttore» e «cliente» ci sono io, che scrivo il motore, e alla mia destra siede un Giovanni-Vanja, che scrive lo strumento senza il quale non posso scrivere il motore come si deve. E di legami del genere dentro lo studio ce ne sono tantissimi ovunque: i gameplay programmer dipendono dai tool, i tool dai build engineer, questi dipendono dal renderer, che è sviluppato dai render programmer, ai quali servono i tool dei gameplay programmer per testare, e in generale tutta l'industria è seduta su lib, motori, librerie e SDK di qualcun altro.

In questo contesto, se il mio software è più lento di quanto potrebbe essere, allora rallenta non solo me. Rallenta tutti quelli che dipendono da me, riduce il numero delle loro iterazioni, e dal loro software dipendo poi io. Ho cominciato peggiorando un pochino il mio motore, e l'effetto comincia a propagarsi lungo la rete di dipendenze e, poiché il grafo è ciclico, è tornato di nuovo a me. Ho abbattuto la mia stessa produttività attraverso tre strette di mano.

I rallentamenti si accumulano

E questo è ancora il quadro ottimistico, perché il grafo reale dell'ecosistema di sviluppo di un gioco sarà un'enorme dipendenza ciclica attraverso partecipanti indipendenti: studi, middleware, motori, plugin, asset store, toolchain, SDK di piattaforma. Tutti dipendono da tutti.

La cattiva performance, introdotta in un qualsiasi nodo di questa rete, si propaga per tutta la rete, perché «dipendere dal software» nella nostra palude significa letteralmente «eseguirlo». Il tempo di lavoro della dipendenza rientra in un modo o nell'altro nel costo di avvio del livello, e attraverso di esso nel costo del lavoro. A volte direttamente nel runtime, tramite una libreria fisica lenta e un paio di millisecondi a frame, a volte nei costi dello sviluppatore, quando un editor lento si mangia un'ora al giorno a ciascuno in un team di cento persone.

Un numero da un progetto reale attivo: un artista in media fa 40 generazioni di asset al giorno, non ridisegna da capo, ma cambia qualcosa e aggiorna/guarda nel gioco; ho preso solo l'import dell'asset senza l'avvio dell'editor e la visualizzazione su un livello reale.

import asset veloce: 0.2 s   ->   lento: 8 s
                                     × 40 generazioni di asset al giorno
                                     × 40 artisti
                                     = +4 ore di attesa al giorno

Si blocca o il programma, o la persona, ma il prezzo c'è in entrambi i casi, solo che nel secondo è più difficile vederlo nel profiler, perché si nasconde nelle tazze di caffè e nelle sigarette fumate. E più lunga è la catena di dipendenze, più alta è la probabilità che l'effetto si moltiplichi, si duplichi e torni indietro, e più bassa è la probabilità che qualcuno capisca in generale dove si trovi la radice del problema. Ecco i costi reali dell'applicazione del clean code: avreste potuto assumere un altro designer o un altro artista, ma quei soldi sono andati a pagare gli interessi sul "debito tecnico" del progetto.

«Perché l'editor rallenta?», ma perché il plugin sollecita un manager che, sotto il cofano, a ogni tick alloca stringhe, perché così era «più pulito» scriverlo tre anni fa in una libreria che da allora nessuno ha più aperto, e questo, tra l'altro, spiega benissimo lo stato dei giochi e degli strumenti contemporanei.

Nonostante la mostruosa crescita della potenza dell'hardware, gran parte dei progetti si avvia, carica e itera visibilmente peggio dei propri analoghi di dieci-quindici anni fa, che giravano su macchine decine di volte più deboli. Perché? Perché la degradazione in una rete del genere non si ferma su un solo arco del grafo, ma si moltiplica a ogni giuntura.

I rallentamenti rallentano tutti

Nel suo libro Martin divideva il tempo in «domini», nanosecondi per chi fa sistemi, microsecondi per gli sviluppatori applicativi e secondi per gli utenti, intendendo che perdere un paio di millisecondi non fa differenza per questi ultimi. Forse funziona per l'enterprise sanguinolento e i sistemi aziendali, ma in realtà il dominio del tempo è uno solo. I nanosecondi persi si sommano in microsecondi, questi in millisecondi, questi in un frame sceso, questo in una scena ingiocabile, questa nel rinvio della release.

16.6 ms a frame @ 60 fps
  - 4.0 ms  render
  - 3.0 ms  fisica
  - 2.0 ms  animazione
  - 5.6 ms  logica di gameplay
  --------
  = 2.0 ms  margine

// il "bel" strato wrapper ECS ha aggiunto 0.0008 ms per entità
// × 30 000 entita = 2.4 ms -> margine in negativo -> frame in stallo

Prendiamo il render a 60 fps, sono in tutto 16 millisecondi a frame, e in questo budget bisogna far stare fisica, animazione, AI, render e audio tutti insieme. Se da qualche parte nelle viscere del sistema di particelle giace una «pulita» astrazione con una chiamata virtuale per ogni particella, allora con diecimila particelle in scena è già mezzo millisecondo, il tre percento di tutto il budget del frame, speso non per le particelle, ma per la bellezza del codice. Una singola classe del genere non farà cadere il gioco, ma in un progetto reale di soluzioni "pulite" come questa ce ne sono centinaia, e il calo si accumula non da un solo punto, ma da mille piccoli, ciascuno dei quali preso singolarmente è giustificato dalla leggibilità.

Le vittime del clean code

Scambiare l'efficienza del software con l'efficienza del programmatore può sembrare un affare vantaggioso, finché a pagarlo è qualcun altro, lontano dal vostro studio. Le metodologie che promuovono la bellezza del codice nell'editor come valore supremo portano con sé anche una notevole degradazione della qualità dei giochi e degli strumenti, ed ecco l'ironia che emerge: pure il calo di produttività degli stessi sviluppatori, per i quali tutto questo era stato presumibilmente messo in piedi.

Ho cominciato con la citazione di Sweeney sul 10% di performance per la comodità del programmatore, ma il guaio è che lo sviluppo di giochi oggi si rivela dappertutto più difficile, più noioso e meno produttivo perché il sistema ha imparato a giustificare la mediocrità con belle parole. Il clean code divora prima la velocità in nome della comodità dello sviluppatore, e poi si prende anche la velocità del progetto e la comodità dello sviluppo.

Perciò, la prossima volta che vi porteranno il libro rosso su qualcosa di pulito e nobile, tirate il profiler a chi vi porta il dono, magari ci ripensa. Il codice, certo, non deve essere sporco, ma la pulizia fine a sé stessa si trasforma in un altro tipo di debito tecnico, e le idee, per quanto belle siano, devono comunque superare la prova dell'hardware reale, del tempo di build e della quantità di imprecazioni da parte dei designer.

P.S. Anche qui se la prendono con il clean code e Qui

P.P.S. Ho anche aperto un sito su github pages, dove ho raccolto una parte degli articoli che riguardano lo sviluppo e i giochi (https://dalerank.github.io/). Gli articoli sono tradotti in inglese e in parte in italiano, per cui un grazie particolare ai colleghi e al nostro reparto di localizzazione.

← Tutti gli articoli