Resumo dos episódios anteriores
- Entre 2020 e 2022, o Darktable passou por um empreendimento de destruição em massa, conduzido por um punhado de sujeitos com mais tempo livre e benevolência do que competência de verdade,
- Em 2022, comecei a notar um lag incômodo entre as interações da interface gráfica com os controles deslizantes e o feedback/atualização dos ditos controles. Por falta de um feedback indicando que a mudança de valor havia sido registrada, os usuários podiam mudá-lo de novo, iniciando assim recomputações adicionais do pipeline e efetivamente travando seus computadores, porque a estúpida interface gráfica nunca dizia “entendi, agora espere um pouco”.
- Descobri que as ordens de recomputação do pipeline eram emitidas duas vezes por clique (uma no evento de “botão pressionado”, outra no de “botão solto”), e mais uma vez a cada movimento do mouse, mas também que os estados da interface gráfica eram atualizados aparentemente após a recomputação do pipe.
- Corrigi isso reescrevendo quase por completo os controles personalizados da interface gráfica (biblioteca Bauhaus). Achei que impedir as ordens imprudentes de recomputação fosse resolver o lag: não resolveu. Então descobri que solicitar uma nova recomputação do pipeline antes de a anterior terminar esperava a anterior terminar, apesar de um mecanismo de desligamento implementado muitos anos atrás que deveria ter funcionado.
- Corrigi isso implementando um mecanismo de kill-switch nos pipelines, seguindo comentários no código dos anos 2010 e utilitários internos que bem podem nunca ter funcionado. Isso nem sempre funcionava porque a ordem de cancelamento vinha muitas vezes com um atraso perceptível. Mais uma vez, o lag da interface gráfica não foi corrigido.
…
Episódio 5: pagando a dívida técnica
O que descobri deveria realmente entrar para os manuais de ciência da computação no capítulo sobre o que não fazer se você quer escrever uma aplicação minimamente confiável.
Então, sempre que um parâmetro de processamento de imagem era alterado em um módulo, uma solicitação era enviada para adicionar uma nova entrada de histórico no banco de dados (muitas vezes mais de uma vez por interação, como mostrado acima). As entradas de histórico não passam de um instantâneo dos parâmetros internos de um módulo (incluindo máscaras). Se uma mudança fosse detectada em relação à entrada de histórico anterior, um flag PIPELINE_STATE era definido com o valor DIRTY para indicar que o pipe precisaria de uma recomputação e um gtk_widget_queue_draw() era enviado, o que, como o nome sugere, pede ao Gtk que redesenhe a pré-visualização principal da sala escura e a miniatura de navegação, mas de forma assíncrona (entenda: sempre que ele encontrar tempo, depois que tudo o que foi iniciado antes for concluído). Isso terá sua importância mais adiante.
Levei um tempo bem longo para descobrir como o pipeline era de fato iniciado, porque nenhum dos códigos vinculados aos módulos e ao pipeline continha algo dizendo “vá computar isso”. Em outras palavras, nenhum dos códigos dos módulos continha qualquer instrução explícita de recomputação.
Tive que fazer engenharia reversa do código do pipeline pela outra ponta, procurando como o pipeline poderia ser iniciado e fazendo grep de cada opção, até que descobri o indizível: a primeira geração de desenvolvedores do Darktable havia conectado uma função de callback ao evento redraw da pré-visualização principal da sala escura e da miniatura de navegação, mas em um lugar completamente não relacionado no código. Nesse callback da interface gráfica, o valor do flag PIPELINE_STATE era verificado, e ou enviava o pixmap do backbuffer diretamente para o widget se o flag fosse VALID, ou pedia uma recomputação do pipe se o flag fosse DIRTY, e essa recomputação ela mesma solicitava um gtk_widget_queue_draw() ao ser concluída.
Esse método tem um mérito: é programação preguiçosa. Depois, tem um monte de desvantagens e problemas:
- não é amigável para o desenvolvedor, especialmente em um projeto de software onde o grep de código e os comentários são toda a documentação com que podemos sonhar. Foram muitas horas para entender a lógica por meio de arqueologia de programa. Se um comando é emitido, quero ler
command_issued()no lugar certo do código, porque C já é difícil o bastante de acompanhar sem misturar charadas no debug. - como o
gtk_widget_queue_draw()(chamado duas vezes no pior cenário) só é adicionado à fila e processado de forma assíncrona, ele acrescenta qualquer lag que o Gtk possa sofrer (enquanto processa outros pedaços da interface gráfica ou quadros anteriores) antes que qualquer recomputação do pipeline sequer seja iniciada, o que é desnecessário já que o pipeline vive em sua própria thread em paralelo, - o grande turducken MIDI, que escuta eventos de apontamento, teclado e MIDI para despachar atalhos, parecia ter sobrecarregado a interface gráfica global com listeners iterando sobre todos os atalhos conhecidos, o que fazia o Gtk travar a ponto de se tornar perceptível,
- impede que qualquer mecanismo de kill-switch seja útil, tanto por causa dos atrasos quanto porque as leituras de flags estavam intercaladas com travas de thread (e condições de corrida). Além disso, esperar para adquirir a trava (mutex) da thread do pipeline congelaria a thread da interface gráfica durante o tempo correspondente, o que provavelmente era uma das causas do lag do controle deslizante antes de atualizar sua posição,
- as chamadas encadeadas ao callback do evento
redraw, por meio dogtk_widget_queue_draw(), promoviam laços “intermináveis” de travamento de redesenhos intermediários (inúteis) que pareciam atingir as pessoas com computadores lentos mais do que aquelas com máquinas poderosas. Esses eram particularmente difíceis de reproduzir, dependendo do desempenho do hardware, então você encontra fóruns onde há gente convencida de que o Darktable é o software mais lento de todos, enquanto outros relatam excelente desempenho.
Então corrigi toda a lógica ao:
- tornar o callback de
redrawburro (desenhando qualquer buffer de pixmap que estivesse disponível, incondicionalmente), - tratar as recomputações explícitas do pipeline no código dos módulos e do histórico, com as recomputações do pipeline pedindo um redesenho do widget ao serem concluídas (sim, é mais código, e é tedioso, mas agora você pode otimizar as recomputações manualmente — desempenho importa),
- remover o tratamento especial de itens de histórico “duplicados” (o que leva a alguma poluição ao lidar com máscaras, isso precisará ser corrigido depois).
Você pode pensar que foi um problema resolvido e um trabalho bem feito, mas isso é deixar os gênios do Darktable fora da equação.
Veja, os módulos de recorte e perspectiva são módulos especiais: abri-los ativa um “modo de edição” que desabilita qualquer recorte para mostrar a imagem inteira. Isso é necessário para arrastar o quadro de recorte (ou ajustar outros posicionamentos) a partir da pré-visualização principal, sobre a imagem original completa. O problema é que não havia uma forma explícita de pedir uma recomputação do pipe… além de adicionar um novo item de histórico. Então os módulos adicionavam um item de histórico falso (revertido depois) apenas para invalidar o pipe e chamar a função gtk_widget_queue_draw(). Mas então isso poluía a pilha de histórico com passos “vazios”, então outro sujeito adicionou um caso de tratamento especial que mesclava passos de histórico se nenhuma mudança de parâmetro acontecesse. Mas então, a pilha de histórico (do módulo de histórico, como armazenada no banco de dados) não segue a pilha de histórico de desfazer/refazer, levando os usuários a mal-entendidos sobre o que desfazer/refazer realmente faz.
E é assim, senhoras e senhores, que um design de merda promove um design ainda mais de merda em uma expansão sem fim de loucura.
Lembre-se de que tudo isso decorre da necessidade de fazer o kill-switch do pipe funcionar, para que você possa interromper uma recomputação no meio quando sabe que sua saída será descartada de qualquer forma. Então, para isso, tive que mover a solicitação de recomputação para fora do código do Gtk, e chamá-la em todos os lugares necessários. Mas então tive que reconectar a lógica de atualização do pipeline nos módulos de recorte, perspectiva e rotação, liquefazer e bordas, e ainda tenho que corrigir o retoque (que é o pior pé no saco de todos).
Além de torná-la mais clara de ler, e possível de otimizar as chamadas, a lógica atual também inicia o pipe fora da thread da interface gráfica, sem esperar que o Gtk por favor encontre tempo para redesenhar o quadro. Como de costume, as pessoas com CPU insana notarão pouco ou nenhum benefício, em termos de desempenho, o que provavelmente é o motivo de isso ser um não-problema na equipe do Darktable em primeiro lugar.
Episódio 6: pagando os juros atrasados da dívida técnica
Então, nesse ponto, eu havia tornado as recomputações do pipeline explícitas a partir dos módulos e dos controles da interface gráfica, e as havia despachado com parcimônia (que é o benefício de despachá-las explicitamente). E ainda assim, notei que mexer em módulos que vêm tarde no pipe era lento. De fato, executar ansel -d perf mostrou que
todo o pipeline, começando no módulo de interpolação cromática, era recomputado mesmo eu estando interagindo com um módulo tardio que recebia sua entrada do balanço de cor.
O Darktable sempre teve um cache de pixels. Ele basicamente armazena os estados intermediários da imagem, entre os módulos. Então, ter recomputações do pipe começando muito abaixo do módulo atual significava que ele era praticamente inútil. Acontece que o cache usava apenas 8 linhas de cache, o que subutiliza muito as quantidades insanas de RAM de hoje. Mas aumentar isso para 64 não ajudou com os cache misses: o cache ainda era praticamente inútil, e a maior parte do pipe ainda era recomputada.
Precisamos fazer uma pausa aqui. Até um engenheiro mecânico sem uma educação adequada em programação como eu sabe o que é um cache LRU :
- você cria uma lista fixa de slots (linhas de cache),
- quando você tem algo para armazenar em cache, você aloca um buffer de memória de tamanho previamente conhecido para um desses slots e lhe atribui um identificador único. Isso pode ser um checksum, um hash aleatório ou até mesmo um timestamp, ele só precisa ser cozinhado sempre da mesma forma e levar a algo único,
- quando você precisa de dados associados a algum identificador único, você consulta a lista de slots e procura se aquele ID é conhecido:
- se for, você busca o buffer associado a ele,
- se não for:
- se você ainda tem slots vazios, você cria o buffer associado e copia os dados para reutilização posterior,
- se não tem, você limpa o slot mais antigo e o reutiliza para hospedar seus novos dados.
Nesse processo, você só precisa saber o tamanho dos buffers e os IDs. É muito geral, você pode armazenar em cache qualquer coisa, até objetos diferentes, seu cache não precisa estar ciente do conteúdo, nem mesmo de como os IDs são gerados. É limpo, é elegante, é despretensioso, é genérico, eu confiaria minha vida a ele porque é muito mais robusto do que qualquer sistema de segurança que você encontra em carros modernos.
Então, quando algo tão simples não funciona, geralmente é porque alguém tentou algo “esperto” e falhou. O que a equipe do Darktable normalmente faz nesse caso é abrir caminho a switch case por todos os casos-limite patológicos e transformá-lo em algo ainda mais complicado (tratando todas as exceções manualmente com heurísticas), só para garantir que ninguém mais tenha a chance de encontrar a causa raiz do erro.
Por exemplo, houve tentativas de reponderar a prioridade das linhas de cache para garantir que o módulo anterior ao que está sendo editado no momento na interface gráfica estivesse em cache. Não só não funcionou, como reforçou os laços entre o código do pipeline e o código da interface gráfica, de uma forma que nem sequer era thread-safe (razão pela qual não funcionava). As coisas da interface gráfica deveriam acontecer na entrada e na saída das computações do pipeline, não no meio, porque de novo, threads diferentes, mas também porque isso viola o princípio da modularidade (mantenha as camadas do programa separadas e encapsuladas o máximo possível), e este software precisa parar de fazer tudo depender de tudo.
De novo, levei 8 meses, incluindo pausas obrigatórias daquele completo showzinho de horrores, para chegar ao fundo do problema de uma forma que levasse a uma solução simplificadora. E vou apresentar as descobertas de forma linear, como uma história, mas tenha em mente que comecei a descobrir as coisas de um jeito difuso e aleatório porque está tudo espalhado no código-fonte, então vai parecer menos bagunçado do que de fato era.
Começamos com o ID único. O que realmente representa o estado de um módulo de forma única? Bem, um checksum “criptográfico” de seus parâmetros internos. Legal, então o Darktable tinha isso implementado há muito tempo. Só que não levava em conta o número da instância do módulo, e lidava com todo tipo de if no processo. Não completo, não robusto, nem sequer necessário. Faça hash de tudo, o hash vai representar o estado das variáveis.
Sim, mas os módulos podem ser reordenados, então como cuidamos da ordem do pipeline? Bem, você pega todos os hashes de todos os módulos, na ordem do pipeline, e começa a acumular linearmente. Ótimo. Só que o Darktable na verdade tinha 2 deles, um para fins da interface gráfica que começava do fim do pipeline (ou seja, na ordem inversa), um para fins do pipeline, na ordem do pipeline mas inacessível a partir da interface gráfica (por exemplo… para obter um histograma), e de novo, ambos misturando isso com todo tipo de verificações para tratar casos especiais (conta-gotas de cor, pré-visualização de máscara, etc.).
Sem mencionar que o estado interno do módulo não varia dependendo de você estar na pré-visualização completa ou na miniatura de navegação, na sala escura. E, ainda assim, o checksum era totalmente recomputado duas vezes, uma para cada pipeline. Na verdade, faça disso quatro vezes, já que também há o checksum da interface gráfica (usado principalmente para os módulos de perspectiva e retoque)
E, por último mas não menos importante, quando com zoom ampliado na sala escura, apenas a porção visível da imagem (a Região de Interesse, ou ROI) é computada, o que significa que precisamos rastrear onde estamos na imagem em nosso mecanismo de cache. Mas isso foi completamente deixado de fora do checksum. Um bug grande aqui, e antigo.
Então, como o Darktable ainda conseguia “funcionar”, você pergunta?
Bem, esvaziando mais ou menos inteiramente o cache em qualquer operação patológica: zoom, panorâmica, pré-visualização de máscara, conta-gotas de cor, ativar/desativar o estado de edição dos módulos de recorte e perspectiva. Essa é uma forma de lidar com a consistência sem lidar com a consistência: incendeie tudo. Tornando-o praticamente inútil, como mostram as estatísticas de cache hits muito baixos (basta iniciar ansel -d dev para exibi-las).
Como resolvi o problema?
- Quando uma nova entrada de histórico de módulo é adicionada, o checksum dos parâmetros é computado, levando em conta os parâmetros, as máscaras, as opções de mesclagem, o número da instância, a ordem no pipeline, etc. Ou seja, todos os pipelines compartilham o mesmo checksum/ID aqui (um possível uso futuro seria salvá-lo no banco de dados),
- Antes de um pipeline ser computado, computamos o checksum global de todos os módulos, do início ao fim, levando em conta o estado de exibição da máscara, o checksum dos módulos anteriores e a ROI (tamanho e coordenadas). Esse checksum pode ser acessado diretamente depois, sem computação adicional.
- O cache lida com esse checksum global, e apenas com ele. Sem ses, sem mas, sem heurísticas, sem condições, sem contornos.
- Os módulos podem solicitar um bypass do cache, por exemplo ao usar o conta-gotas de cor. Isso contamina os módulos posteriores no pipeline antes de o pipe ser computado, então o estado sem cache é conhecido cedo e não afeta os módulos a montante. Isso só deveria ser um contorno até que os conta-gotas de cor possam realmente usar as linhas de cache diretamente, e poderia ser reutilizado para futuros módulos que fazem coisas não padronizadas (pintura?).
Benefícios:
- O checksum interno, por módulo, é computado uma única vez para todos os pipelines,
- Como o checksum global, por pipeline, de cada módulo é conhecido antes de iniciar a recomputação do pipeline:
- ele também pode ser usado para a sincronização da interface gráfica, então mesclei os dois checksums do Darktable em um só,
- ele é constante dentro do escopo do pipeline, permitindo compartilhar linhas de cache entre vários pipelines (por exemplo, interpolação cromática e redução de ruído) com problemas limitados de travamento de thread1
- Os módulos que fazem coisas estranhas têm uma forma uniforme e previsível de solicitar um bypass do cache a partir de eventos da interface gráfica, caso precisem disso.
Essa lógica não é apenas mais eficiente (menos computações), ela também é mais simples e pode ser estendida para recursos interessantes. Da perspectiva do cache, não lidamos com nada além de um checksum, cada estado de módulo de interesse está convolvido nele.
Mas, mais importante, o cache finalmente é útil, especialmente ao ir e voltar no histórico de edição, usando desfazer/refazer, ou ativar/desativar módulos. A responsividade geral da interface gráfica está muito melhor.
Tenho certeza de que há armadilhas e detalhes ainda não descobertos que esqueci de reconectar à nova lógica, e o módulo de retoque ainda está praticamente quebrado, mas adaptar-se a algo tão simples deveria ser factível.
Enquanto isso, no Darktable 4.8
- O checksum do pipeline é computado durante a execução do pipeline, então é desconhecido do lado de fora,
- Por causa disso, eles não desduplicaram os checksums da interface gráfica vs. do pipeline… boa sorte rastreando inconsistências entre os dois no futuro,
- O código de tratamento de cache deles é mais que o dobro do tamanho do meu e usa heurísticas (sobre o tipo de pipeline, tipo de módulo, estado de exibição de máscaras, uso de conta-gotas de cor, e dicas de cache definidas manualmente nos módulos) para contornar problemas. O cache não é mais agnóstico quanto ao conteúdo e boa sorte depurando esses espaguetes.2
- Eles ainda computam inteiramente o checksum (interno) dos parâmetros do módulo duas vezes, uma para cada pipeline,
- Eles levaram quase 2 anos para chegar lá (desde o lançamento da 4.0),
- Eu adoraria ver as estatísticas de cache hits/misses deles (será que quero reviver meu TEPT abrindo aquele softwarezinho de novo? Passo, obrigado).
- As pessoas que acham que ter mais macacos agitando as mãos no ar garante melhor qualidade deveriam parar de pensar.
Conclusão
A quantidade de tempo gasto e de merda recém-quebrada para consertar até chegar lá foi devidamente insuportável, mas foi piorada por código espalhado de forma não modular, sem uma distinção clara entre o que pertence à interface (gráfica) do usuário, o que pertence ao backend, o que pertence aos históricos dos módulos e o que pertence aos nós do pipeline. A coisa do cache só levou 8 meses, principalmente arqueologia e engenharia reversa, por cima do que já havia sido feito nos controles da interface gráfica e nas recomputações explícitas do pipeline.
Ainda há problemas a corrigir:
- o número de linhas de cache disponíveis é uma preferência do usuário e não verifica a memória disponível restante no dispositivo,
- o módulo de histograma/scopes está praticamente quebrado por design, porque era tratado por meio de heurísticas especiais (agora removidas) em um módulo que é invisível na interface gráfica (
gamma.c). A nova lógica torna possível forçar seu cache e buscar a linha de cache a partir da thread da interface gráfica. - os histogramas internos dos módulos não são desenhados imediatamente ao entrar na sala escura,
- o tratamento dos conta-gotas de cor poderia ser simplificado e tornado mais elegante,
- o tratamento do histórico ainda tem alguns casos-limite.
No entanto, como me recuso a “corrigir” qualquer coisa se minha correção não torna as coisas mais simples, essa estratégia está começando a compensar porque o código está muito mais linear, com menos casos para testar, e, em última análise, ligeiramente mais rápido. À medida que avanço, ele vai aos poucos se tornando mais legível e mais corrigível. Depois, é claro, sacudir o núcleo do software a esse ponto fatalmente vai quebrar coisas (que não deveriam quebrar se o código fosse modular).
Aí vem a legítima pergunta: por que se dar ao trabalho de corrigir o feio legado do Ansel/Darktable e não partir para algo melhor, mais rápido e mais reluzente (como o Vkdt)? Bem, o Vkdt (ou qualquer outra coisa nova) vai continuar sendo um protótipo tosco, competindo com outros protótipos toscos (isso é o Código Aberto em poucas palavras), a anos de distância de um produto de uso geral. Adicionar mais um protótipo inacabado/meia-boca à paisagem não vai fazer bem algum. Seria bom ter algo não desleixado e razoavelmente terminado, para variar. Além disso, o código (muito) antigo do Darktable é limpo e sólido (bem, na maior parte), foram apenas os últimos anos que tomaram um rumo para o mais merda. O git blame sempre mostra os mesmos 3 nomes nas linhas realmente de merda, a ponto de às vezes eu me pegar deletando automaticamente as linhas correspondentes quando via quem as escreveu, por hábito.
Há também o medo de que, não importa o quão rápido o Vulkan torne o Vkdt, o que realmente torna o Darktable de merda são as decisões ruins, as prioridades ruins, os erros de programação, as lições não aprendidas, e se esses erros forem reproduzidos no Vkdt, pode levar mais tempo para perceber as consequências com mais potência, mas, em última análise, as coisas vão seguir o mesmo caminho. Ter mais recursos torna mais acessível ser estúpido… até que deixa de ser e você percebe o quão preso está.
Translated from English by : Claude. In case of conflict, inconsistency or error, the English version shall prevail.
The source code actually has a 10-years-old
TODOcomment detailing how to do that. ↩︎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. ↩︎