Riepilogo delle puntate precedenti

  1. Tra il 2020 e il 2022, Darktable è stato oggetto di un’impresa di distruzione di massa, da parte di una manciata di tizi con più tempo libero e benevolenza che vere competenze,
  2. Nel 2022, ho iniziato a notare un fastidioso ritardo  tra le interazioni dell’interfaccia grafica con i controlli a cursore e il feedback/aggiornamento di detti cursori. Per mancanza di un feedback che indicasse che la modifica del valore era stata registrata, gli utenti potevano cambiarlo di nuovo, avviando così ulteriori ricalcoli della pipeline e di fatto congelando il computer perché la stupida interfaccia grafica non diceva mai «ricevuto, ora aspetta un momento».
  3. Ho scoperto che gli ordini di ricalcolo della pipeline venivano emessi due volte per clic (una volta sull’evento «pulsante premuto», una volta sull’evento «pulsante rilasciato»), e ancora una volta per ogni movimento del mouse, ma anche che gli stati dell’interfaccia grafica venivano aggiornati apparentemente dopo il ricalcolo della pipe.
  4. Ho sistemato la cosa riscrivendo quasi del tutto i controlli personalizzati dell’interfaccia grafica (libreria Bauhaus). Pensavo che impedire gli ordini di ricalcolo sconsiderati avrebbe risolto il ritardo: non è stato così. Poi ho scoperto che richiedere un nuovo ricalcolo della pipeline prima che il precedente terminasse attendeva la fine del precedente, nonostante un meccanismo di arresto implementato molti anni prima che avrebbe dovuto funzionare.
  5. Ho sistemato la cosa implementando un meccanismo di kill-switch sulle pipeline, seguendo i commenti nel codice risalenti agli anni 2010 e utilità interne che potrebbero benissimo non aver mai funzionato. Questo non funzionava sempre perché l’ordine di terminazione arrivava spesso con un ritardo evidente. Ancora una volta, il ritardo dell’interfaccia grafica non era risolto.

Episodio 5: pagare il debito tecnico

Quello che ho scoperto dovrebbe davvero finire nei manuali di informatica, nel capitolo su cosa non fare se vuoi scrivere un’applicazione semi-affidabile.

Dunque, ogni volta che un parametro di elaborazione dell’immagine veniva modificato in un modulo, veniva inviata una richiesta per aggiungere una nuova voce di cronologia nel database (spesso più di una volta per interazione, come mostrato sopra). Le voci di cronologia non sono altro che un’istantanea dei parametri interni di un modulo (comprese le maschere). Se veniva rilevata una modifica rispetto alla voce di cronologia precedente, un flag PIPELINE_STATE veniva impostato al valore DIRTY per indicare che la pipe avrebbe avuto bisogno di un ricalcolo e veniva inviata una gtk_widget_queue_draw() che, come suggerisce il nome, chiede a Gtk di ridisegnare l’anteprima principale della camera oscura e la miniatura di navigazione, ma in modo asincrono (leggi: quando trova il tempo, dopo che tutto ciò che è stato avviato in precedenza è completato). Questo avrà la sua importanza più avanti.

Mi ci è voluto moltissimo tempo per capire come la pipeline venisse effettivamente avviata, perché nessuna parte del codice associato ai moduli e alla pipeline conteneva qualcosa che dicesse «vai a calcolare quello». In altre parole, nessuna parte del codice dei moduli conteneva un’istruzione esplicita di ricalcolo.

Ho dovuto fare reverse-engineering del codice della pipeline dall’altra estremità, cercando come la pipeline potesse essere avviata e facendo il grep di ogni opzione, finché non ho capito l’indicibile: la prima generazione di sviluppatori di Darktable aveva collegato una funzione di callback all’evento redraw sull’anteprima principale della camera oscura e sulla miniatura di navigazione, ma in un punto del codice del tutto scollegato. In quel callback dell’interfaccia grafica, veniva controllato il valore del flag PIPELINE_STATE, e o si inviava direttamente la pixmap del backbuffer al widget se il flag era VALID, oppure si chiedeva un ricalcolo della pipe se il flag era DIRTY, e quel ricalcolo richiedeva a sua volta una gtk_widget_queue_draw() al termine.

Questo metodo ha un pregio: è programmazione pigra. Poi ha una marea di svantaggi e problemi:

  1. non è adatto agli sviluppatori, specialmente in un progetto software dove il grep del codice e i commenti sono tutta la documentazione che possiamo sognare. Ci sono volute molte ore per comprendere la logica attraverso l’archeologia del programma. Se viene emesso un comando, voglio leggere command_issued() nel punto giusto del codice, perché il C è già abbastanza difficile da seguire senza mescolarci enigmi nel debug.
  2. poiché gtk_widget_queue_draw() (chiamata due volte nello scenario peggiore) viene solo aggiunta alla coda ed elaborata in modo asincrono, aggiunge qualsiasi ritardo di cui Gtk potrebbe soffrire (mentre elabora altre parti dell’interfaccia grafica o i fotogrammi precedenti) prima ancora che qualsiasi ricalcolo della pipeline venga avviato, il che è inutile dato che la pipeline vive nel proprio thread in parallelo,
  3. il grande turducken MIDI, in ascolto di eventi di puntamento, tastiera e MIDI per smistare le scorciatoie, sembrava aver sovraccaricato l’interfaccia grafica globale con listener che ciclavano su tutte le scorciatoie note, il che faceva rallentare Gtk al punto da diventare percettibile,
  4. impedisce a qualsiasi meccanismo di kill-switch di essere utile, sia a causa dei ritardi sia perché le letture dei flag erano intervallate a blocchi di thread (e race condition). Inoltre, l’attesa per acquisire il blocco del thread della pipeline (mutex) congelava il thread dell’interfaccia grafica per il tempo corrispondente, il che era probabilmente una delle cause del ritardo del cursore prima dell’aggiornamento della sua posizione,
  5. le chiamate concatenate al callback dell’evento redraw, tramite gtk_widget_queue_draw(), favorivano cicli di «scatti infiniti» di ridisegni intermedi (inutili) che sembravano colpire le persone con computer lenti più di quelle con bestie di potenza. Questi erano particolarmente difficili da riprodurre, a seconda delle prestazioni dell’hardware, così puoi trovare forum dove le persone sono convinte che Darktable sia il software più lento mai esistito mentre altre riportano prestazioni eccellenti.

Così ho sistemato l’intera logica:

  1. rendendo stupido il callback redraw (disegnando qualsiasi buffer pixmap fosse disponibile, incondizionatamente),
  2. gestendo i ricalcoli espliciti della pipeline nel codice dei moduli e della cronologia, con i ricalcoli della pipeline che chiedono un ridisegno del widget al completamento della pipeline (sì, è più codice, ed è tedioso, ma ora puoi ottimizzare i ricalcoli manualmente — le prestazioni contano),
  3. rimuovendo la gestione speciale delle voci di cronologia «duplicate» (che porta a un certo inquinamento quando si ha a che fare con le maschere, questo dovrà essere sistemato più avanti).

Potresti pensare che il problema fosse risolto e il lavoro ben fatto, ma questo significa lasciare i geni di Darktable fuori dall’equazione.

Vedi, i moduli ritaglio e prospettiva sono moduli speciali: aprirli abilita una «modalità di modifica» che disabilita qualsiasi ritaglio per mostrare l’immagine intera. Questo è necessario per trascinare la cornice di ritaglio (o regolare altri posizionamenti) dall’anteprima principale, sopra l’immagine originale intera. Il problema è che non c’era un modo esplicito per chiedere un ricalcolo della pipe… se non aggiungere una nuova voce di cronologia. Così i moduli aggiungevano una falsa voce di cronologia (poi annullata) solo per invalidare la pipe e chiamare la funzione gtk_widget_queue_draw(). Ma questo inquinava lo stack della cronologia con passi «vuoti», così un altro tizio aggiunse un caso di gestione speciale che univa i passi di cronologia se non avveniva alcuna modifica dei parametri. Ma poi, lo stack della cronologia (dal modulo cronologia, così come memorizzato nel database) non segue lo stack della cronologia annulla/ripeti, portando gli utenti a fraintendimenti su cosa faccia davvero annulla/ripeti.

E questo, signore e signori, è come un design di merda ne promuova uno ancora più di merda in una sconfinata proliferazione di follia.

Ricorda che tutto ciò deriva dalla necessità di far funzionare il kill-switch della pipe, in modo da poter interrompere un ricalcolo a metà quando sai che il suo output verrà comunque scartato. Quindi per farlo ho dovuto spostare la richiesta di ricalcolo fuori dal codice Gtk, e chiamarla ovunque fosse necessario. Ma poi ho dovuto ricablare la logica di aggiornamento della pipeline nei moduli ritaglio, prospettiva e rotazione, liquefazione e bordi, e devo ancora sistemare ritocco (che è la rogna peggiore del gruppo).

Oltre a renderla più chiara da leggere e a rendere possibile ottimizzare le chiamate, la logica attuale avvia anche la pipe al di fuori del thread dell’interfaccia grafica, senza aspettare che Gtk trovi per cortesia il tempo di ridisegnare il fotogramma. Come al solito, le persone con CPU folli noteranno un beneficio minimo o nullo in termini di prestazioni, il che è probabilmente il motivo per cui questo non è affatto un problema per il team di Darktable in primo luogo.

Episodio 6: pagare gli interessi arretrati sul debito tecnico

Dunque, a quel punto, avevo reso espliciti i ricalcoli della pipeline dai moduli e dai controlli dell’interfaccia grafica, e li avevo distribuiti con parsimonia (che è il vantaggio di distribuirli esplicitamente). Eppure, notavo che giocare con i moduli che arrivano tardi nella pipe era lento. Di fatto, lanciando ansel -d perf risultava che tutta la pipeline, a partire dal modulo demosaicizzazione, veniva ricalcolata anche se stavo interagendo con un modulo tardivo che prendeva il suo input dal bilanciamento del colore.

Darktable ha sempre avuto una cache dei pixel. In sostanza memorizza gli stati intermedi dell’immagine, tra un modulo e l’altro. Quindi, avere i ricalcoli della pipe che partivano molto più in basso rispetto al modulo corrente significava che era per lo più inutile. È risultato che la cache usava solo 8 linee di cache, il che sfrutta davvero poco le folli quantità di RAM di oggi. Ma aumentarle a 64 non ha aiutato con i cache miss: la cache era ancora per lo più inutile, e la maggior parte della pipe veniva ancora ricalcolata.

Dobbiamo fare una pausa qui. Persino un ingegnere meccanico senza una vera formazione in programmazione come me sa cos’è una cache LRU :

  1. crei una lista fissa di slot (linee di cache),
  2. una volta che hai qualcosa da mettere in cache, allochi un buffer di memoria di dimensione precedentemente nota a uno di quegli slot e gli assegni un identificatore univoco. Potrebbe essere un checksum, un hash casuale o persino un timestamp, deve solo essere preparato sempre nello stesso modo e portare a qualcosa di univoco,
  3. quando hai bisogno dei dati associati a un certo identificatore univoco, interroghi la lista degli slot e cerchi se quell’ID è noto:
    • se lo è, recuperi il buffer associato,
    • se non lo è:
      • se hai ancora slot vuoti, crei il buffer associato e copi i dati per un riutilizzo successivo,
      • se non ne hai, svuoti lo slot più vecchio e lo riutilizzi per ospitare i tuoi nuovi dati.

In questo processo, hai bisogno solo di conoscere la dimensione dei buffer e gli ID. È molto generale, puoi mettere in cache qualsiasi cosa, anche oggetti diversi, la tua cache non deve essere consapevole del contenuto, e nemmeno di come vengono generati gli ID. È pulito, è elegante, è discreto, è generico, ci affiderei la mia vita perché è molto più robusto di qualsiasi sistema di sicurezza tu trovi nelle auto moderne.

Quindi quando qualcosa così semplice non funziona, di solito è perché qualcuno ha provato qualcosa di «furbo» e ha fallito. Quello che il team di Darktable fa tipicamente in quel caso è farsi strada a colpi di switch case attraverso tutti i casi limite patologici e trasformarlo in qualcosa di ancora più complicato (gestendo manualmente tutte le eccezioni con euristiche), giusto per assicurarsi che nessuno più tardi abbia la possibilità di trovare la causa radice dell’errore.

Per esempio, c’erano stati tentativi di ripesare la priorità delle linee di cache per assicurarsi che il modulo precedente a quello attualmente in modifica nell’interfaccia grafica fosse in cache. Non solo non funzionava, ma rafforzava i legami tra il codice della pipeline e il codice dell’interfaccia grafica, in un modo che non era nemmeno thread-safe (motivo per cui non funzionava). Le cose dell’interfaccia grafica dovrebbero accadere all’ingresso e all’uscita dei calcoli della pipeline, non nel mezzo, perché di nuovo, thread diversi, ma anche perché viola il principio di modularità (mantenere i livelli del programma separati e racchiusi il più possibile), e questo software deve smettere di far dipendere ogni cosa da ogni cosa.

Di nuovo, mi ci sono voluti 8 mesi, incluse pause obbligate da quel completo disastro, per andare a fondo del problema in un modo che porti a una soluzione semplificatrice. E presenterò i risultati in modo lineare, come una storia, ma tieni presente che ho iniziato a scoprire le cose in modo confuso e casuale perché è tutto sparpagliato nel codice sorgente, quindi apparirà meno caotico di quanto lo fosse davvero.

Iniziamo con l’ID univoco. Cosa rappresenta veramente lo stato di un modulo in modo univoco? Beh, un checksum «crittografico» dei suoi parametri interni. Bello, quindi Darktable aveva quello implementato da tanto tempo. Solo che non teneva conto del numero di istanza del modulo, e trattava ogni sorta di if nel processo. Non completo, non robusto, e nemmeno necessario. Fai l’hash di tutto, l’hash rappresenterà lo stato delle variabili.

Già, ma i moduli possono essere riordinati, quindi come ci occupiamo dell’ordine della pipeline? Beh, prendi tutti gli hash di tutti i moduli, nell’ordine della pipeline, e inizi ad accumulare linearmente. Ottimo. Solo che Darktable in realtà ne aveva 2 di quelli, uno per scopi dell’interfaccia grafica che partiva dalla fine della pipeline (quindi, in ordine inverso), uno per scopi della pipeline, nell’ordine della pipeline ma inaccessibile dall’interfaccia grafica (per esempio… per ottenere un istogramma), e di nuovo, entrambi mescolavano ciò con ogni sorta di controlli per gestire i casi speciali (selettore colore, anteprima maschera, ecc.).

Per non parlare del fatto che lo stato interno del modulo non varia a seconda che tu sia nell’anteprima intera o nella miniatura di navigazione, nella camera oscura. Eppure, il checksum veniva interamente ricalcolato due volte, una per ogni pipeline. Anzi, mettine quattro, dato che c’è anche il checksum dell’interfaccia grafica (usato per lo più per i moduli prospettiva e ritocco)

E, ultimo ma non meno importante, quando si è ingranditi nella camera oscura, viene calcolata solo la porzione visibile dell’immagine (la Regione di Interesse, ovvero ROI), il che significa che dobbiamo tenere traccia di dove siamo nell’immagine nel nostro meccanismo di caching. Ma questo era stato completamente tralasciato dal checksum. Grosso bug qui, e vecchio.

Allora, come faceva Darktable a «funzionare» comunque, ti chiedi?

Beh, svuotando più o meno interamente la cache a ogni operazione patologica: zoom, panoramica, anteprima maschera, selettore colore, abilitazione/disabilitazione dello stato di modifica dei moduli ritaglio e prospettiva. È un modo di gestire la coerenza senza gestire la coerenza: dargli fuoco. Rendendola per lo più inutile, come mostrano le statistiche molto basse di cache hit (basta avviare ansel -d dev per mostrarlo).

Come ho risolto il problema?

  1. Quando viene aggiunta una nuova voce di cronologia di un modulo, viene calcolato il checksum dei parametri, tenendo conto di parametri, maschere, opzioni di fusione, numero di istanza, ordine nella pipeline, ecc. Il che significa che tutte le pipeline condividono qui lo stesso checksum/ID (un possibile uso futuro sarebbe salvarlo nel database),
  2. Prima che una pipeline venga calcolata, calcoliamo il checksum globale di tutti i moduli, dall’inizio alla fine, tenendo conto dello stato di visualizzazione della maschera, del checksum dei moduli precedenti e della ROI (dimensione e coordinate). A questo checksum si può accedere direttamente in seguito, senza calcoli aggiuntivi.
  3. La cache si occupa di questo checksum globale, e solo di quello. Nessun se, nessun ma, nessuna euristica, nessuna condizione, nessuna soluzione tampone.
  4. I moduli possono richiedere un bypass della cache, per esempio quando si usa il selettore colore. Questo contamina i moduli successivi nella pipeline prima che la pipe venga calcolata, così lo stato senza cache è noto in anticipo e non influisce sui moduli a monte. Questo dovrebbe essere solo una soluzione tampone finché i selettori colore non possano effettivamente usare le linee di cache direttamente, e potrebbe essere riutilizzato per futuri moduli che fanno cose non standard (dipingere?).

Vantaggi:

  1. Il checksum interno, a livello di modulo, è calcolato una volta sola per tutte le pipeline,
  2. Poiché il checksum globale, a livello di pipeline, di ogni modulo è noto prima di avviare il ricalcolo della pipeline:
    • può essere usato anche per la sincronizzazione dell’interfaccia grafica, così ho unito entrambi i checksum di Darktable in uno solo,
    • è costante nell’ambito della pipeline, permettendo di condividere le linee di cache tra più pipeline (per esempio, demosaicizzazione e riduzione del rumore) con problemi limitati di blocco dei thread1
  3. I moduli che fanno cose strane hanno un modo uniforme e prevedibile di richiedere un bypass della cache dagli eventi dell’interfaccia grafica, qualora ne avessero bisogno.

Questa logica non è solo più efficiente (meno calcoli), è anche più semplice e può essere estesa per funzioni interessanti. Dal punto di vista della cache, non abbiamo a che fare con nient’altro che un checksum, ogni stato di un modulo di interesse vi è convoluto.

Ma, cosa più importante, la cache è finalmente utile, specialmente quando si va avanti e indietro nella cronologia di modifica, usando annulla/ripeti, o abilitando/disabilitando i moduli. La reattività complessiva dell’interfaccia grafica è molto migliore.

Sono sicuro che ci siano insidie e dettagli non scoperti che mi sono dimenticato di ricablare alla nuova logica, e il modulo ritocco è ancora per lo più rotto, ma adattarsi a qualcosa di così semplice dovrebbe essere fattibile.

Nel frattempo in Darktable 4.8

  1. Il checksum della pipeline è calcolato durante l’esecuzione della pipeline, quindi è sconosciuto all’esterno,
  2. Per questo motivo, non hanno deduplicato i checksum dell’interfaccia grafica e della pipeline… buona fortuna a rintracciare le incoerenze tra i due in futuro,
  3. Il loro codice di gestione della cache  è più del doppio più grande del mio  e usa euristiche (sul tipo di pipeline, il tipo di modulo, lo stato di visualizzazione delle maschere, l’uso del selettore colore, e suggerimenti di caching definiti manualmente nei moduli) per aggirare i problemi. La cache non è più agnostica rispetto al contenuto e buona fortuna a fare il debug di questi spaghetti.2
  4. Stanno ancora calcolando interamente il checksum (interno) dei parametri del modulo due volte, una per ogni pipeline,
  5. Ci hanno messo quasi 2 anni per arrivarci (dalla versione 4.0),
  6. Mi piacerebbe vedere le loro statistiche di cache hit/miss (voglio davvero far rivivere il mio PTSD aprendo mai più quel soft? Passo, grazie).
  7. Le persone che pensano che avere più scimmie che agitano le mani in aria garantisca una qualità migliore dovrebbero smettere di pensare.

Conclusione

La quantità di tempo trascorso e di roba rotta di recente da sistemare per arrivarci è stata a dir poco insopportabile, ma è stata peggiorata dal codice sparpagliato in modo non modulare senza una chiara distinzione tra ciò che appartiene all’interfaccia (grafica), ciò che appartiene al backend, ciò che appartiene alle cronologie dei moduli e ciò che appartiene ai nodi della pipeline. La faccenda della cache ha richiesto solo 8 mesi, per lo più archeologia e reverse-engineering, oltre a ciò che era già stato fatto nei controlli dell’interfaccia grafica e nei ricalcoli espliciti della pipeline.

Ci sono ancora problemi da sistemare:

  • il numero di linee di cache disponibili è una preferenza dell’utente e non controlla la memoria disponibile rimasta sul dispositivo,
  • il modulo istogramma/scope è per lo più rotto per progettazione, perché era gestito tramite euristiche speciali (ora rimosse) su un modulo che è invisibile nell’interfaccia grafica (gamma.c). La nuova logica rende possibile forzarne il caching e recuperare la linea di cache dal thread dell’interfaccia grafica.
  • gli istogrammi interni dei moduli non vengono disegnati immediatamente all’ingresso nella camera oscura,
  • la gestione dei selettori colore potrebbe essere semplificata e resa più elegante,
  • la gestione della cronologia ha ancora alcuni casi limite.

Tuttavia, poiché mi rifiuto di «sistemare» qualsiasi cosa se la mia correzione non rende le cose più semplici, quella strategia sta iniziando a dare i suoi frutti perché il codice è molto più lineare, con meno casi da testare, e alla fine leggermente più veloce. Man mano che faccio progressi, sta lentamente diventando più leggibile e più sistemabile. Poi, ovviamente, scuotere il nucleo del software a tal punto è destinato a rompere delle cose (che non dovrebbero rompersi se il codice fosse modulare).

Arriva la legittima domanda: perché preoccuparsi di sistemare la brutta eredità di Ansel/Darktable e non passare a qualcosa di migliore, più veloce e più scintillante (come Vkdt)? Beh, Vkdt (o qualsiasi altra novità) resterà un prototipo grezzo, in competizione con altri prototipi grezzi (questo è l’Open Source in poche parole), a anni di distanza da un prodotto utilizzabile in generale. Aggiungere un altro prototipo incompiuto/fatto a metà al panorama non porterà alcun beneficio. Sarebbe bello avere qualcosa che non sia scadente e ragionevolmente finito, tanto per cambiare. Del resto, il (molto) vecchio codice di Darktable è pulito e solido (beh, per la maggior parte), sono solo gli ultimi anni ad aver preso una piega verso il peggio. git blame mostra sempre gli stessi 3 nomi sulle righe davvero di merda, al punto che a volte mi ritrovo a cancellare automaticamente le righe corrispondenti quando vedo chi le ha scritte, per abitudine.

C’è anche il timore che, per quanto Vulkan renda veloce Vkdt, ciò che rende davvero Darktable di merda sono le decisioni sbagliate, le priorità sbagliate, gli errori di programmazione, le lezioni non imparate, e se quegli errori vengono riprodotti su Vkdt, potrebbe volerci più tempo per rendersi conto delle conseguenze con più cavalli di potenza, ma alla fine le cose andranno allo stesso modo. Avere più risorse rende più conveniente essere stupidi… finché non lo è più e ti rendi conto di quanto sei intrappolato.


Translated from English by : Claude. In case of conflict, inconsistency or error, the English version shall prevail.

  1. The source code actually has a 10-years-old TODO comment detailing how to do that. ↩︎

  2. It should be noted that “my” cache code is actualy pretty much how Roman Lebedev and Johannes Hanika wrote it 10 years ago. I simplified a couple of things, mostly removing stuff added since then, and added nothing of my own, because it’s a Garbage In/Garbage Out situation where you should rather clean your input rather than trying to handle any corner case internally through unlegible heuristics. ↩︎