Performance em Clojure com Muitos Dados: Transducers, Transients e core.async
Código Clojure que é instantâneo no REPL sobre cem maps pode se arrastar sobre alguns milhões. Escrevo Clojure desde 2017, e a maior parte do trabalho de performance que fiz em backends com muitos dados se resume ao mesmo punhado de movimentos. Nenhum deles é esperto. São sobre entender o que a versão conveniente aloca, e saber quando trocar um pouco de elegância por muito throughput.
Regra 0: Meça, Não Chute
Toda afirmação de performance abaixo deveria ser verificada no seu workload, não tomada como fé — incluindo as minhas. Na JVM, minhas duas ferramentas são a criterium para microbenchmarks (ela lida com o warmup do JIT e o ruído estatístico, o que um (time ...) ingênuo não faz) e o clj-async-profiler para flamegraphs de onde o tempo realmente vai.
(require '[criterium.core :refer [quick-bench]])
(quick-bench (my-pipeline records))
O número de vezes que eu perfilei e descobri que o gargalo estava em um lugar que eu jamais teria chutado é exatamente o número de vezes que eu perfilei.
Lazy Sequences São Convenientes e Uma Armadilha
O pipeline thread-last idiomático é lindo e, em dados grandes, desperdiçador:
;; Allocates an intermediate lazy seq at every step
(->> records
(map parse)
(filter valid?)
(map enrich)
(into []))
Cada estágio produz sua própria lazy sequence, com cons cells e closures por elemento. Lazy seqs também são chunked em blocos de 32, então você pode realizar mais do que pediu. E se você acidentalmente segurar a cabeça de uma lazy seq grande, você mantém tudo em memória.
Transducers resolvem isso separando a transformação da sequência. Faça comp de uma pilha de transducers e eles se fundem em uma única passada sem coleções intermediárias:
;; One pass, no intermediate seqs, no laziness to reason about
(into []
(comp (map parse)
(filter valid?)
(map enrich))
records)
Mesma lógica, mesma legibilidade assim que seu olho se ajusta, mas as alocações intermediárias sumiram. Em um dataset grande, isso é rotineiramente a diferença entre confortável e doloroso, e o transducer é reutilizável — o mesmo (comp ...) funciona com into, transduce, sequence ou um canal core.async.
Transients para a Construção
Quando você está construindo uma coleção grande reduzindo sobre a entrada, a imutabilidade persistente te cobra uma alocação por passo. Transients te dão coleções localmente mutáveis, contidas em uma thread, que você converte de volta para persistente no fim — mesmo resultado, uma fração do lixo:
(defn index-by-id [records]
(persistent!
(reduce (fn [acc r] (assoc! acc (:id r) r))
(transient {})
records)))
A regra é estreita e importante: transients são para uma construção single-threaded onde você controla o ciclo de vida inteiro, e você nunca compartilha um entre threads. Dentro dessa caixa eles são seguros e rápidos; fora dela são um tiro no pé. Este é o único lugar em que eu abro mão da imutabilidade padrão do Clojure, e só porque a mutação nunca escapa da função.
Mate Reflection em Loops Quentes
Chamadas reflexivas na JVM são lentas, e Clojure vai emiti-las silenciosamente quando não consegue inferir um tipo. Ligue o aviso e você vai achá-las:
(set! *warn-on-reflection* true)
Para caminhos numéricos quentes, boxing é o outro imposto — cada objeto Long/Double alocado em um loop apertado é pressão no alocador. Trabalhar sobre arrays primitivos com areduce/amap mantém a matemática primitiva de ponta a ponta:
;; No boxing: primitive doubles throughout the loop
(defn sum ^double [^doubles xs]
(areduce xs i acc 0.0 (+ acc (aget xs i))))
Você não quer type hints espalhados pela base de código toda — são ruído em caminhos frios. Você quer eles exatamente onde o profiler apontou.
Paralelismo: core.async para Fan-Out I/O-Bound
Quando o gargalo é computação pura CPU-bound sobre uma coleção "foldable", o clojure.core.reducers/fold vai paralelizar um fold entre os cores quase de graça. Mas a maior parte do trabalho de backend com muitos dados é I/O-bound — enriquecer registros a partir de um banco ou de um serviço HTTP — e aí a ferramenta é um pipeline de core.async.
(require '[clojure.core.async :as a])
(defn process-all [records concurrency]
(let [in (a/chan 1024)
out (a/chan 1024)]
;; pipeline-blocking: N parallel workers for blocking I/O.
;; Backpressure is built in — if `out` fills up, workers stop pulling `in`.
(a/pipeline-blocking concurrency out (map enrich-via-io) in)
(a/onto-chan!! in records) ; feed the input and close it
(a/<!! (a/into [] out)))) ; drain the output
pipeline-blocking roda concurrency workers sobre trabalho bloqueante; use pipeline puro para transformações CPU-bound e pipeline-async quando o trabalho já é assíncrono. Os canais buffered te dão backpressure de graça: um consumidor lento lá na frente naturalmente segura os workers, então você processa um stream de entrada enorme com uma pegada de memória limitada e estável em vez de puxar tudo para uma seq e torcer para caber. Essa propriedade de backpressure é a verdadeira razão de eu recorrer ao core.async aqui em vez do pmap — pmap não tem controle de fluxo e seu comportamento de chunking surpreende as pessoas em workloads desiguais.
Trade-Offs, Nomeados
- Transducers são menos familiares. Um time que não os viu lê
(comp (map ...) (filter ...))mais devagar do que um thread-last. Isso é um custo real em código compartilhado; eu os reservo para os caminhos onde a alocação realmente importa e deixo o thread-last onde é claro e frio. - Transients e arrays primitivos abrem mão da rede de segurança do Clojure. Mutáveis, sem boxing, e implacáveis. Mantenha-os locais e escondidos atrás de uma fronteira de função pura.
- core.async adiciona concorrência sobre a qual você tem que raciocinar. Canais, tamanhos de buffer e semântica de fechamento são sua própria fonte de bugs. Para um map paralelo simples sobre alguns itens,
pmapou um punhado defutures é menos maquinaria. - Acima de tudo: não faça nada disso antes de medir. O pipeline lazy idiomático é o padrão certo. Você recorre a essas ferramentas quando um profiler te manda, no caminho específico que ele aponta — não em todo lugar, e não preventivamente.
O tema é que Clojure te dá um padrão rápido e imutável e um conjunto de saídas de emergência claramente marcadas para quando o padrão não é rápido o suficiente. Bom trabalho de performance é saber qual saída, e — mais importante — saber que você ganhou o direito de abri-la.