Resumen de los episodios anteriores

  1. Between 2020 and 2022, Darktable underwent a mass-destruction enterprise, by a handful of guys with more freetime and benevolence than actual skills,
  2. In 2022, I started noticing an annoying lag  between GUI interactions with sliders controls and feedback/update of said sliders. For lack of feedback stating that the value change was recorded, users could change it again, thereby starting additionnal pipeline recomputes and effectively freezing their computer because stupid GUI never said “got you, wait for a bit now”.
  3. I discovered that pipeline recomputations orders were issued twice per click (once on “button pushed”, once on “button released” events), and once again for each mouse motion, but also that the GUI states were updated seemingly after pipe recompute.
  4. I fixed that by almost rewriting the custom GUI controls (Bauhaus lib). I thought that preventing reckless recompute orders was gonna solve the lag : it didn’t. Then, I discovered that requesting a new pipeline recompute before the previous ended waited for the previous to end, despite a shutdown mechanism implemented many years ago that should have worked.
  5. I fixed that by implementing a kill-switch mechanism on pipelines, following comments in the code from the 2010’s and internal utilities that may well have never worked. This did not always work because the kill order came often with a noticeable delay. Once again, the GUI lag was not fixed.

Episodio 5: pagando la deuda técnica

Lo que descubrí realmente debería llegar a los manuales de informática en el capítulo sobre lo que no hacer si quieres escribir una aplicación semi-confiable.

Así que, siempre que se cambiaba un parámetro de procesamiento de imágenes en un módulo, se enviaba una solicitud para agregar una nueva entrada en el historial a la base de datos (a menudo más de una vez por interacción, como se muestra anteriormente). Las entradas del historial no son más que una instantánea de los parámetros internos de un módulo (incluyendo máscaras). Si se detectó un cambio en comparación con la entrada de historial anterior, se estableció una bandera PIPELINE_STATE en el valor DIRTY para indicar que la canalización necesitaría una recomputación y se enviaba un gtk_widget_queue_draw() que, como su nombre indica, pide a Gtk que vuelva a dibujar la vista previa principal del cuarto oscuro y la miniatura de navegación, pero de forma asincrónica (entiéndase: siempre que tenga tiempo, después de que todo lo previamente iniciado se complete). Esto tendrá su importancia más adelante.

Me tomó un tiempo muy largo para averiguar cómo se iniciaba realmente la canalización, porque ninguno de los códigos adjuntos a los módulos y a la canalización contenía nada que dijera “ve a computar eso”. En otras palabras, ninguno del código del módulo contenía ninguna instrucción directa de recomputación.

Tuve que revertir el código de la canalización desde el otro extremo, buscando cómo podía iniciarse la canalización y buscando cada opción, hasta que me di cuenta de lo innombrable: la primera generación de desarrolladores de Darktable había cableado una función callback al evento redraw en la vista previa principal del cuarto oscuro y la miniatura de navegación, pero en un lugar completamente no relacionado del código. En ese callback de la GUI, se comprobaba el valor de la bandera PIPELINE_STATE, y se enviaba el pixmap del backbuffer directamente al widget si la bandera era VALID o se pedía una recomputación de la canalización si la bandera era DIRTY, y esa recomputación en sí misma solicitaba un gtk_widget_queue_draw() al completarse.

Este método tiene un mérito: es programación perezosa. Luego tiene una carga de problemas e inconvenientes:

  1. no es amigable para los desarrolladores, especialmente en un proyecto de software donde buscar en el código y los comentarios son toda la documentación que podemos soñar. Llevo muchas horas entender la lógica a través de la arqueología del programa. Si una orden es emitida, quiero leer command_issued() en el lugar correcto del código, porque C ya es lo suficientemente difícil de seguir sin mezclar acertijos en la depuración.
  2. dado que gtk_widget_queue_draw() (llamado dos veces en el peor de los casos) solo se agrega a la cola y se procesa de forma asincrónica, suma cualquier retraso que Gtk pueda sufrir (mientras procesa otros bits de la GUI o tramas anteriores) antes de comenzar siquiera una recomputación de canalización, lo cual es innecesario ya que la canalización vive en su propia hebra en paralelo,
  3. la gran MIDI turducken, escuchando eventos de apuntar, teclado y MIDI para despachar accesos directos, parecía haber sobrecargado la GUI global con oyentes recorriendo todos los accesos directos conocidos, lo que hizo que Gtk sufriera un retraso perceptible,
  4. impide que cualquier mecanismo de interruptor de apagado sea útil, tanto por los retrasos como por la intercalación de lecturas de banderas con bloqueos de hebras (y condiciones de carrera). Además, esperar para adquirir el bloqueo de hilo de la canalización (mutex) congelaría la hebra de la GUI durante el tiempo correspondiente, lo que probablemente era una de las causas del retraso del control deslizante antes de actualizar su posición,
  5. las llamadas encadenadas al callback de evento redraw a través de gtk_widget_queue_draw(), promovieron bucles de tartamudeo “interminables” de redibujos intermedios (inútiles) que parecieron afectar más a las personas con computadoras lentas que a aquellas con bestias de poder. Estos fueron particularmente difíciles de reproducir, dependiendo del rendimiento del hardware, por lo que pueden encontrar foros donde la gente está convencida de que Darktable es el software más lento de la historia mientras otros informan un excelente rendimiento.

Así que arreglé toda la lógica:

  1. haciendo que el callback redraw fuera estúpido (dibujando cualquier buffer de pixmap disponible, incondicionalmente),
  2. manejando recomputaciones explícitas de canalización en el código de módulos e historia, con las recomputaciones de canalización solicitando un redibujado del widget al completarse la canalización (sí, es más código y es tedioso, pero ahora puedes optimizar recomputaciones manualmente — el rendimiento importa),
  3. eliminando el manejo especial de elementos de “historial” duplicados (conduciendo a cierta contaminación al tratar con máscaras, esto necesitará ser corregido más tarde).

Podrías pensar que eso era un problema resuelto y un trabajo bien hecho, pero eso deja fuera de la ecuación a los genios de Darktable.

Verán, los módulos crop (recorte) y perspective (perspectiva) son módulos especiales: abrirlos habilita un “modo de edición” que desactiva cualquier recorte para mostrar la imagen completa. Esto es necesario para arrastrar el marco de recorte (o ajustar otras posiciones) desde la vista previa principal, sobre la imagen original completa. El problema es que no había una forma explícita de pedir una recomputación de canalización…nada más que añadiendo un nuevo elemento de historial. Así que los módulos añadieron un elemento de historial falso (luego revertido) solo para invalidar la canalización y llamar a la función gtk_widget_queue_draw(). Pero luego, eso contaminaba la pila de historial con pasos “vacíos”, así que otro tipo añadió un caso de manejo especial que fusionaba pasos de historial si no sucedían cambios en los parámetros. Pero luego, la pila de historial (del módulo historia, tal como se almacena en la base de datos) no sigue la pila de historial de deshacer/rehacer, llevando a los usuarios a malentendidos sobre lo que deshacer/rehacer realmente hace.

Y así es como, damas y caballeros, un diseño deficiente promueve un diseño aún más pobre en un crecimiento interminable de locura.

Recuerden que todo esto surge de la necesidad de hacer funcionar el interruptor de apagado de la canalización, para que puedas interrumpir una recomputación a la mitad cuando sabes que su salida será descartada de todos modos. Así que para eso tuve que mover la solicitud de recomputación fuera del código de Gtk y llamarlo en todos los lugares requeridos. Pero luego tuve que reconfigurar la lógica de actualización de la canalización en los módulos crop, perspective y rotation, liquify y borders, y aún tengo que arreglar retouch (lo que es el peor dolor PITA del lote).

Además de hacerlo más claro de leer y posible optimizar las llamadas, la lógica actual también inicia la canalización fuera de la hebra de la GUI, sin esperar que Gtk por favor encuentre el tiempo para redibujar el marco. Como de costumbre, las personas con CPUs locas notarán poco o ningún beneficio, en cuanto al rendimiento, lo cual es probablemente por qué originalmente esto no es un problema en el equipo de Darktable.

Episodio 6: pagando los intereses de la deuda técnica

Así que, en ese punto, había hecho que las recomputaciones de canalización fueran explícitas desde los módulos y controles de GUI, y despachado con moderación (lo cual es el beneficio de despacharlas explícitamente). Y aún así noté que jugar con módulos que venían tarde en la canalización era lento. De hecho, lanzar ansel -d perf mostró que todo el canalización, comenzando en el módulo demosaicing, se recomputó aunque yo estaba interactuando con un módulo tardío que tomaba su entrada de color balance.

Darktable ha tenido una caché de píxeles desde siempre. Básicamente almacena los estados intermedios de la imagen, entre módulos. Así que tener recomputaciones de canalización que comiencen mucho por debajo del módulo actual significaba que era mayormente inservible. Resultó que la caché usaba solo 8 líneas de caché, lo cual es aprovechar muy poco las enormes cantidades de RAM actuales. Pero aumentar eso a 64 no ayudó con los fallos de la caché: la caché seguía siendo mayormente inservible, y la mayor parte de la canalización todavía se recomputaba.

Necesitamos hacer una pausa aquí. Incluso un ingeniero mecánico sin una educación adecuada en programación como yo sabe qué es un caché LRU :

  1. creas una lista fija de ranuras (líneas de caché),
  2. una vez que tienes algo para caché, asignas un búfer de memoria de tamaño previamente conocido a una de esas ranuras y le asignas un identificador único. Eso podría ser una suma de verificación, un hash aleatorio o incluso un timestamp, solo tiene que cocinarse siempre de la misma manera y llevar a algo único,
  3. cuando necesitas datos asociados con algún identificador único, consultas la lista de ranuras y buscas si ese ID es conocido:
  • si lo es, obtienes su buffer asociado,
  • si no lo es:
  • si todavía tienes ranuras vacías, creas el buffer asociado y copias los datos para su reutilización posterior,
  • si no, vacías la ranura más antigua y la reutilizas para alojar tus nuevos datos.

En ese proceso, solo necesitas conocer el tamaño de los buffers y los ID. Es muy general, puedes almacenar en caché cualquier cosa, incluso objetos diferentes, tu caché no tiene que ser consciente del contenido, ni siquiera de cómo se generan los ID. Es limpio, es elegante, es modesto, es genérico, confiaría en ello con mi vida porque es mucho más robusto que cualquier sistema de seguridad que encuentres en los coches modernos.

Entonces, cuando algo tan simple no funciona, generalmente es porque alguien intentó algo ‘ingenioso’ y falló. Lo que el equipo de Darktable suele hacer en esos casos es switch case para abordar todos los casos patológicos y convertirlo en algo aún más complicado (manejan todas las excepciones manualmente con heurísticas), solo para asegurarse de que nadie luego tenga la oportunidad de encontrar la causa raíz del error.

Por ejemplo, hubo intentos de volver a ponderar la prioridad de las líneas de caché para asegurar que el módulo antes del que se está editando actualmente en la GUI fuera almacenado en caché. No solo no funcionó, sino que reforzó los lazos entre el código del pipeline y el código de la GUI, de una manera que ni siquiera era segura para hilos (que es la razón por la que no funcionó). Las cuestiones de GUI deberían ocurrir en la entrada y en la salida de las computaciones del pipeline, no entre ellas, porque nuevamente, diferentes hilos, pero también viola el principio de modularidad (mantén las capas del programa separadas y contenidas todo lo posible), y este software necesita dejar de hacer que todo dependa de todo.

De nuevo, me llevó 8 meses, incluyendo descansos obligatorios de ese desastroso espectáculo, para llegar al fondo del problema de una manera que condujo a una solución simplificadora. Y presentaré los hallazgos de manera lineal, como una historia, pero ten en cuenta que comencé a descubrir cosas de una manera confusa y aleatoria porque todo está disperso en el código fuente, así que parecerá menos desordenado de lo que realmente fue.

Comenzamos con el ID único. ¿Qué realmente representa de manera única el estado de un módulo? Bueno, una suma de verificación ‘criptográfica’ de sus parámetros internos. Genial, así que Darktable lo tenía implementado desde hace mucho tiempo. Excepto que no tenía en cuenta el número de instancia del módulo, y lidiaba con todo tipo de if en el proceso. No completo, no robusto, ni siquiera necesario. Hashea todo, el hash representará el estado de las variables.

Sí, pero los módulos pueden ser reordenados, así que ¿cómo manejamos el orden del pipeline? Bueno, tomas todos los hashes de todos los módulos, en orden del pipeline, y comienzas a acumularlos linealmente. Genial. Excepto que Darktable en realidad tenía 2 de esos, uno para propósitos de GUI que comenzaba desde el final del pipeline (entonces, en orden inverso), uno para propósitos de pipeline, en el orden del pipeline pero inaccesible desde la GUI (por ejemplo… para obtener un histograma), y de nuevo, ambos mezclando eso con todo tipo de comprobaciones para manejar casos especiales (selector de color, vista previa de máscaras, etc.).

Sin mencionar que el estado interno del módulo no varía si estás en la vista previa completa o en la miniatura de navegación, en el cuarto oscuro. Y aun así, el checksum se volvía a calcular completamente dos veces, una para cada pipeline. En realidad, hazlo cuatro veces, ya que también está el checksum de la GUI (usado principalmente para módulos de perspectiva y retoque).

Y, por último pero no menos importante, cuando estás acercado en el cuarto oscuro, solo se calcula la porción visible de la imagen (la Región de Interés, también conocida como ROI), lo que significa que necesitamos seguir el rastro de dónde estamos en la imagen en nuestro mecanismo de almacenamiento en caché. Pero eso se omitió completamente en el checksum. Gran error aquí, y antiguo.

Entonces, ¿cómo hizo Darktable para que aún ‘funcionara’, preguntas?

Bueno, vaciando más o menos completamente la caché en cualquier operación patológica: zoom, paneo, vista previa de máscaras, selector de color, habilitación/desactivación del estado de edición de los módulos recorte y perspectiva. Esa es una manera de lidiar con la consistencia sin lidiar con la consistencia: destrozarla. Haciéndola casi inútil, como muestran las estadísticas de aciertos de caché muy bajas (solo comienza ansel -d dev para mostrarlo).

¿Cómo resolví el problema?

  1. Cuando se agrega una nueva entrada de historial de módulo, se calcula el checksum de parámetros, teniendo en cuenta los parámetros, máscaras, opciones de fusión, número de instancia, orden en el pipeline, etc. Lo que significa que todos los pipelines comparten el mismo checksum/ID aquí (un posible uso futuro sería guardarlo en la base de datos),
  2. Antes de que se compute el pipeline, calculamos el checksum global de todos los módulos, de principio a fin, teniendo en cuenta el estado de visualización de máscara, el checksum de los módulos anteriores y el ROI (tamaño y coordenadas). Este checksum puede ser accedido directamente después, sin cálculo adicional.
  3. La caché se maneja con este checksum global, y solo eso. Sin si, sin pero, sin heurísticas, sin condiciones, sin atajos.
  4. Los módulos pueden solicitar una omisión de caché, por ejemplo al usar el selector de color. Esto contamina a los módulos posteriores en el pipeline antes de que se compute el pipeline, por lo que el estado sin caché se conoce temprano y no afecta a los módulos ascendentes. Eso debería ser solo una solución temporal antes de que los selectores de color puedan usar líneas de caché directamente, y podría ser reutilizada para futuros módulos que hagan cosas no estándar (¿pintura?).

Beneficios:

  1. El checksum interno, a nivel de módulo, se calcula una vez para todos los pipelines,
  2. Debido a que el checksum global, a nivel de pipeline, de cada módulo se conoce antes de comenzar el recompte del pipeline:
    • también puede usarse para la sincronización de GUI, así que fusioné ambos checksums de Darktable en uno solo,
    • es constante dentro del alcance del pipeline, lo que permite compartir líneas de caché entre varios pipelines (por ejemplo, interpolación y reducción de ruido) con problemas limitados de bloqueo de hilos1
  3. Los módulos que hacen cosas extrañas tienen una manera uniforme y predecible de solicitar una omisión de caché desde eventos de GUI, si lo necesitan.

Esta lógica no solo es más eficiente (menos cálculos), también es más simple y puede extenderse para funciones interesantes. Desde la perspectiva de la caché, no tratamos con nada más que un checksum, el estado de interés de cada módulo está convolucionado en él.

Pero, más importante aún, la caché finalmente es útil, especialmente al retroceder y avanzar en el historial de edición, usando deshacer/rehacer, o habilitando/deshabilitando módulos. La capacidad de respuesta general de la GUI es mucho mejor.

Estoy seguro de que hay detalles por descubrir y detalles que olvidé re-conectar a la nueva lógica, y el módulo retoque todavía está mayormente roto, pero adaptarse a algo tan simple debería ser viable.

Mientras tanto en Darktable 4.8

  1. El checksum del pipeline se calcula durante la ejecución del pipeline, por lo que no se conoce desde fuera,
  2. Debido a esto, no deduplicaron los checksums de GUI vs. pipeline… buena suerte rastreando inconsistencias entre ambos en el futuro,
  3. Su código de manejo de caché  es más del doble de grande que el mío  y usa heurísticas (sobre el tipo de pipeline, tipo de módulo, estado de visualización de máscaras, uso de selector de color, y sugerencias de caché definidas manualmente en los módulos) para resolver problemas. La caché ya no es agnóstica al contenido y buena suerte depurando esos espaguetis.2
  4. Todavía están computando completamente los parámetros del módulo (checksum interno) dos veces, una para cada pipeline,
  5. Les tomó casi 2 años llegar ahí (desde el lanzamiento 4.0),
  6. Me encantaría ver sus estadísticas de aciertos/fallos de caché (¿quiero revivir mi PTSD abriendo ese software nuevamente? Paso, gracias).
  7. Las personas que piensan que tener más monos agitando sus manos en el aire garantiza una mejor calidad deberían dejar de pensar.

Conclusión

La cantidad de tiempo gastado y la cantidad de cosas rotas recientemente que hay que arreglar para llegar ahí fue realmente insoportable, pero se agravó por código disperso de una manera no modular sin una distinción clara entre lo que pertenece a la (G)UI, lo que pertenece al backend, lo que pertenece a las historias de los módulos y lo que pertenece a los nodos del pipeline. Lo de la caché solo tomó 8 meses, mayormente arqueología e ingeniería inversa, además de lo que ya se había hecho en controles de GUI y recomputaciones explícitas de pipeline.

Todavía hay problemas por arreglar:

  • el número de líneas de caché disponibles es una preferencia del usuario y no verifica la memoria disponible en el dispositivo,
  • el módulo de histograma/espectroscopía está mayormente roto por diseño, porque se manejaba a través de heurísticas especiales (ahora eliminadas) en un módulo que es invisible en la GUI (gamma.c). La nueva lógica hace posible forzar a almacenarlo en caché y para obtener la línea de caché desde el hilo de GUI.
  • los histogramas internos del módulo no se dibujan inmediatamente al entrar en el cuarto oscuro,
  • el manejo de los selectores de color podría simplificarse y hacerse más elegante,
  • aún hay algunos casos extremos en el manejo de historias.

Sin embargo, dado que me niego a ‘arreglar’ algo si mi arreglo no simplifica las cosas, esa estrategia empieza a dar frutos porque el código es mucho más lineal, con menos casos de prueba, y en última instancia, ligeramente más rápido. A medida que avanzo, está volviéndose más legible y más reparable. Entonces, por supuesto, sacudir el núcleo del software en esa medida está destinado a romper cosas (que no deberían romperse si el código fuera modular).

Aquí surge la pregunta legítima de por qué molestarse en arreglar el feo legado de Ansel/Darktable y no pasar a algo mejor, más rápido y más brillante (como Vkdt)? Bueno, Vkdt (o cualquier otra cosa nueva) seguirá siendo un prototipo áspero, compitiendo con otros prototipos ásperos (eso es Open Source en pocas palabras), a años de distancia de un producto generalmente utilizable. Agregar otro prototipo inacabado/a medias al paisaje no hará ningún bien. Sería bueno tener algo no descuidado y bastante terminado, por un cambio. Además, el código (muy) antiguo de Darktable es limpio y robusto (bueno, en su mayor parte), solo los últimos años han tomado un giro hacia lo peor. git blame siempre muestra los mismos 3 nombres en las líneas realmente malas, hasta el punto donde a veces me encuentro borrando automáticamente las líneas correspondientes cuando veo quién las escribió, por costumbre.

También existe el temor de que, sin importar cuán rápido Vulcan haga Vkdt, lo que realmente hace a Darktable malo son las malas decisiones, las malas prioridades, los errores de programación, las lecciones no aprendidas, y si esos errores se reproducen en Vkdt, podría tomar más tiempo darse cuenta de las consecuencias con más recursos, pero en última instancia las cosas irán por el mismo camino. Tener más recursos hace que sea más asequible ser estúpido… hasta que no lo es y te das cuenta de cuán atrapado estás.


Translated from English by : ChatGPT. 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. ↩︎