A DCT rápida: a AAN e a escala que sai de graça
Olá leitor, seja muito bem vindo de volta a mais uma etapa da nossa jornada aqui no Portal da Micilini! 😊
Este é o décimo nono artigo da nossa série, e eu preciso começar com um pedido de desculpas. 😅
No fim do artigo passado eu prometi que o próximo seria o julgamento: o N.148i cara a cara com JPEG, PNG e WebP.
Eu já estava com tudo preparado, os scripts prontos, as imagens escolhidas... e aí eu resolvi medir uma coisa que ainda não tinha medido.
O tempo.
E o que eu vi me fez engavetar o benchmark na hora. 😬
O susto que mudou o plano
Comprimindo uma imagem de 512×512 no modo otimizado, foi isto que o cronômetro me mostrou:
Encode: 68,76 ms
Decode: 47,93 ms68 e 47 milísegundos é extremamente rápido, não? Sim, porém...
Pra você ter uma referência: o JPEG faz a mesma coisa em poucos milissegundos, sendo assim, é certo dizer que nós estávamos muito atrás em ordem de grandeza
Aí eu parei e pensei em duas coisas.
Primeiro: faz sentido publicar um benchmark que diz "o codec comprime bem, mas demora uma eternidade"? Não faz. Seria como entrar numa corrida com o pneu murcho de propósito, sabendo que o pneu está murcho.
Segundo, e mais importante: a Fase 5 vai empilhar predição intra-bloco, quantização adaptativa, codificação aritmética e filtros de pós-processamento. Todas essas técnicas melhoram a compressão e pioram a velocidade.
Construir tudo isso em cima de uma base lenta seria acumular dívida. Então mudei o plano: primeiro a gente conserta a velocidade, depois julga. 🔧
E não é um artigo só. Temos seis pela frente nessa jornada de otimização, e eu prometo que vale cada um:
- 🏎️ Este aqui: a DCT rápida (AAN) e o truque da escala.
- ⚙️ O próximo: afiando o encoder.
- 🔓 Afiando o decoder.
- 🤖 Descendo ao SIMD — falando a língua do processador.
- 🎯 SIMD na prática, com três armadilhas.
- 🧵 Usando todos os núcleos.
Pega o café ☕, que hoje o assunto é velocidade! 🚀
Regra número um: meça antes de otimizar
Antes de tocar numa linha de código, existe uma regra que separa quem otimiza de quem só mexe no código torcendo pra melhorar:
Meça primeiro. Otimizar no chute é a forma mais eficiente de perder tempo consertando o que não era problema.Então eu medi por partes. E o encode se dividia mais ou menos assim:
- 🎨 Conversão de cor (RGB → YCbCr) e subsampling: uma fatia pequena.
- 🌊 DCT: a maior fatia, de longe.
- 📦 Quantização, zig-zag, RLE e Huffman: relativamente baratos.
Faz todo sentido. A DCT é a única etapa que faz matemática pesada de verdade, representando 1024 multiplicações de ponto flutuante por bloco, e temos milhares de blocos numa imagem.
Dito isso, definimos o nosso primeiro alvo!
Mas antes de mexer na DCT, tem uma coisa muito mais fácil pra fazer primeiro. E eu confesso essa com um pouco de vergonha. 🙈
Otimização zero: a mais fácil da sua vida
Durante toda a série, a gente vem compilando todo o projeto assim:
gcc main.c header.c ppm.c tables.c dct.c huffman.c encoder.c decoder.c -o n148i -lmRepara no que não está aí: nenhuma flag de otimização.
Sem flag, o GCC compila em modo : tradução literal do que você escreveu, na ordem que você escreveu, sem nenhuma esperteza. Isso é ótimo pra depurar (o programa executa exatamente como está no papel), mas é péssimo pra velocidade.-O0
Para melhorar isso, basta adicionar o : -O2
gcc main.c header.c ppm.c tables.c dct.c huffman.c encoder.c decoder.c -o n148i -lm -O2E o resultado será este:
Compilação │ Encode │ Decode
────────────────────┼──────────┼──────────
Sem flag (-O0) │ 68,76 ms │ 47,93 ms
Com -O2 │ 17,85 ms │ 20,34 ms
────────────────────┼──────────┼──────────
Ganho │ 3,9x │ 2,4xQuase 4 vezes mais rápido, sem mudar uma única linha de código. 🤯
O que o faz?-O2
Um monte de coisa que a gente faria na mão se tivesse paciência: mantém variáveis em registradores em vez de ir na memória toda hora, desenrola laços pequenos, elimina cálculos repetidos, funde operações.
É o compilador aplicando décadas de conhecimento acumulado por você, de graça.
💡 Lição prática: se você está medindo performance de código C sem -O2, os seus números não valem nada. É a primeira coisa a checar, sempre.
Pronto, o tempo caiu em 4× e ainda nem começamos. Agora vamos ao trabalho de verdade. 💪
A promessa que ficou pendente lá no artigo 9
Lembra do bônus no fim do artigo sobre a DCT? Eu escrevi isto:
DCT naive: 4096 multiplicações por bloco DCT separável: 1024 multiplicações por bloco DCT fast (Loeffler): cerca de 176 multiplicações por blocoE depois completei: "como o próximo passo já vai dividir cada coeficiente por um valor mesmo, dá pra embutir o fator de escala do fast DCT de graça dentro da tabela de quantização. Fica anotado esse gancho pro futuro 😉"
Pois é. O futuro chegou. 🎉
Vamos implementar a AAN — sigla de Arai-Agui-Nakajima, que são os três pesquisadores japoneses que publicaram o algoritmo em 1988.
Observação: é ela que a usa até hoje no modo rápido.libjpeg
A ideia por trás da AAN
A DCT separável que a gente escreveu faz 8 multiplicações por amostra, com 8 amostras, em 2 dimensões: 1024 por bloco. Cada uma dessas multiplicações usa um cosseno da tabela.
A AAN parte de uma observação: aqueles cossenos todos têm simetrias entre si.
Pensa no , que vale aproximadamente 0,7071. Ele aparece em vários lugares da conta. E vários pares de termos são, na verdade, somas e diferenças das mesmas quantidades, calculadas repetidamente.cos(π/4)
Se você reorganizar a ordem das operações pra calcular cada quantidade intermediária uma vez só e reaproveitar, a maioria das multiplicações desaparece e vira soma, que é uma operação bem mais barata pro processador. ➕
O resultado é impressionante: a AAN faz uma DCT 1D de 8 pontos com apenas 5 multiplicações e 29 somas. Contra 64 multiplicações da versão separável.

O preço: a saída vem "torta"
Não existe almoço grátis, e o preço da AAN é peculiar.
Ela não produz exatamente os coeficientes da DCT. Ela produz os coeficientes multiplicados por um fator conhecido, e esse fator é diferente pra cada posição da matriz:
Saída da AAN[u][v] = DCT_verdadeira[u][v] × fator(u,v)E os fatores vêm de uma tabelinha fixa, a mesma pra qualquer imagem do mundo:
Escala AAN por índice:
1.000000 1.387040 1.306563 1.175876
1.000000 0.785695 0.541196 0.275899E o fator de um coeficiente é (u,v).escala[u] × escala[v] × 8
Agora vem o problema óbvio: se a gente precisasse corrigir essa escala dividindo cada coeficiente pelo seu fator, seriam 64 divisões por bloco.
E divisão é uma das operações mais caras que existem. Boa parte do ganho iria embora pelo ralo. 😩
E aqui está a sacada
A gente não precisa corrigir.
Repara no que acontece logo depois da DCT no nosso pipeline: a quantização, que divide cada coeficiente pela tabela Q.
Ou seja, o coeficiente já vai ser dividido por alguma coisa de qualquer jeito. Então, em vez de:
1. corrigir a escala: coef = saída_AAN / fator
2. quantizar: resultado = arredondar(coef / Q)A gente simplesmente embute o fator na tabela de quantização, uma vez só, na inicialização do programa:
tabela_ajustada[u][v] = 1 / (Q[u][v] × fator(u,v))E a quantização vira uma multiplicação:
resultado = arredondar(saída_AAN × tabela_ajustada)Olha o que ganhamos de graça nesse único movimento:
- ✅ A correção de escala desapareceu do caminho quente.
- ✅ As 64 divisões viraram 64 multiplicações (multiplicação é bem mais rápida).
- ✅ A conta que sobrou é feita uma vez só, na inicialização.
Era exatamente isso que eu tinha adiantado no artigo 9, ou seja: gancho pago. 😎
O código: a passada AAN
Vamos ao código...
Este é o coração da AAN, onde faremos uma passada 1D sobre 8 amostras. Antes de ler linha por linha, faz um exercício: conta quantas multiplicações tem aí. (Procura os números com casas decimais.)
Dito isso, Abra o src/dct.c e adicione:
// ============================================================
// SCALAR AAN — 5 multiplications per 1-D pass instead of 64
// ============================================================
static void fdct_pass(float *p, int stride) {
float t0,t1,t2,t3,t4,t5,t6,t7,t10,t11,t12,t13,z1,z2,z3,z4,z5,z11,z13;
t0 = p[0*stride] + p[7*stride]; t7 = p[0*stride] - p[7*stride];
t1 = p[1*stride] + p[6*stride]; t6 = p[1*stride] - p[6*stride];
t2 = p[2*stride] + p[5*stride]; t5 = p[2*stride] - p[5*stride];
t3 = p[3*stride] + p[4*stride]; t4 = p[3*stride] - p[4*stride];
t10 = t0 + t3; t13 = t0 - t3;
t11 = t1 + t2; t12 = t1 - t2;
p[0*stride] = t10 + t11;
p[4*stride] = t10 - t11;
z1 = (t12 + t13) * 0.707106781f;
p[2*stride] = t13 + z1;
p[6*stride] = t13 - z1;
t10 = t4 + t5;
t11 = t5 + t6;
t12 = t6 + t7;
z5 = (t10 - t12) * 0.382683433f;
z2 = 0.541196100f * t10 + z5;
z4 = 1.306562965f * t12 + z5;
z3 = t11 * 0.707106781f;
z11 = t7 + z3;
z13 = t7 - z3;
p[5*stride] = z13 + z2;
p[3*stride] = z13 - z2;
p[1*stride] = z11 + z4;
p[7*stride] = z11 - z4;
}Contou? São cinco: os (duas vezes, mas é a mesma constante), 0.707106781f, 0.382683433f e 0.541196100f. Todo o resto são somas e subtrações. 🎉1.306562965f
E repara naquele parâmetro , ele é um truquezinho útil. Com stride, a função percorre uma linha. Com stride = 1, ela percorre uma coluna do mesmo array.stride = 8
Uma função só serve pras duas passadas:
static void dct_scalar(const float block[64], float coef[64]) {
float t[64];
for (int i = 0; i < 64; i++) t[i] = block[i] - 128.0f; // level shift
for (int r = 0; r < 8; r++) fdct_pass(&t[r*8], 1); // linhas
for (int c = 0; c < 8; c++) fdct_pass(&t[c], 8); // colunas
memcpy(coef, t, sizeof(t));
}💡 Repara que agora usamos em vez de float. Nesta etapa a diferença de velocidade é pequena, mas isso vai importar muito lá no artigo do SIMD: com registradores vetoriais, cabem 8 double no lugar de 4 float. É o dobro de trabalho por instrução. double
A inversa, e um aviso de quem apanhou
A IDCT rápida segue a mesma lógica, na ordem contrária:
static void idct_pass(float *p, int stride) {
float t0,t1,t2,t3,t4,t5,t6,t7,t10,t11,t12,t13,z5,z10,z11,z12,z13;
t0 = p[0*stride]; t1 = p[2*stride];
t2 = p[4*stride]; t3 = p[6*stride];
t10 = t0 + t2;
t11 = t0 - t2;
t13 = t1 + t3;
t12 = (t1 - t3) * 1.414213562f - t13;
t0 = t10 + t13; t3 = t10 - t13;
t1 = t11 + t12; t2 = t11 - t12;
t4 = p[1*stride]; t5 = p[3*stride];
t6 = p[5*stride]; t7 = p[7*stride];
z13 = t6 + t5; z10 = t6 - t5;
z11 = t4 + t7; z12 = t4 - t7;
t7 = z11 + z13;
t11 = (z11 - z13) * 1.414213562f;
z5 = (z10 + z12) * 1.847759065f;
t10 = z5 - z12 * 1.082392200f;
t12 = z5 - z10 * 2.613125930f;
t6 = t12 - t7;
t5 = t11 - t6;
t4 = t10 - t5;
p[0*stride] = t0 + t7; p[7*stride] = t0 - t7;
p[1*stride] = t1 + t6; p[6*stride] = t1 - t6;
p[2*stride] = t2 + t5; p[5*stride] = t2 - t5;
p[3*stride] = t3 + t4; p[4*stride] = t3 - t4;
}Agora o aviso, e eu falo por experiência dolorosa: a AAN inversa tem sinais traiçoeiros. 🐛
Na minha primeira tentativa, a DCT direta batia perfeitamente com a versão lenta, onde eu conferi coeficiente por coeficiente, erro na ordem de 10⁻⁵. Perfeito. Mas o roundtrip (ida e volta) devolvia lixo puro.
Eu tinha invertido dois sinais na parte ímpar, e trocado a ordem de dois resultados na saída. Detalhe suficiente pra destruir tudo, e sutil o bastante pra não saltar aos olhos.
Se você for implementar, teste a ida e a volta separadamente:
- Primeiro confira se a AAN direta bate com a DCT separável (dividindo pelos fatores de escala). Se bater, a ida está certa.
- Só depois teste o roundtrip.
Assim, quando quebrar, você sabe exatamente em qual metade está o problema. Testar as duas juntas é como procurar uma agulha em dois palheiros ao mesmo tempo. 🪡
Embutindo a escala no encoder
Agora a parte que paga o gancho. No , criamos uma tabela com os recíprocos já ajustados:src/encoder.c
// The fast transform leaves every coefficient multiplied by a known
// constant. Rather than dividing it back out on every block, the
// constant is folded into the quantization table once, at startup.
// Quantizing then becomes a multiplication instead of a division.
typedef struct {
float reciprocal[64];
} ScaledQuant;
static void build_scaled_quant(int quantization[8][8], ScaledQuant *out) {
for (int row = 0; row < 8; row++) {
for (int column = 0; column < 8; column++) {
out->reciprocal[row * 8 + column] = (float)(1.0 /
(quantization[row][column] * aan_scale_factor(row, column)));
}
}
}E a quantização, que antes dividia, agora multiplica:
static int tokenize_block(const float block[64],
const ScaledQuant *quantization,
int *previous_dc, Token tokens[64]) {
float coefficients[64];
dct_block_fast(block, coefficients);
int zigzag[64];
for (int i = 0; i < 64; i++) {
int index = ZIGZAG[i];
zigzag[i] = (int)lrintf(
coefficients[index] * quantization->reciprocal[index]);
}
/* ... o resto continua igual ... */
}Repara como ficou limpo: nenhum vestígio da escala da AAN aparece no caminho quente. Ela vive escondida na tabela. 😌
E no decoder, o que faremos?
O mesmo raciocínio, espelhado. A IDCT rápida espera receber os coeficientes já multiplicados pelas constantes AAN, então a gente embute isso na tabela de dequantização da seguinte forma:
// The fast inverse transform expects its input already multiplied by
// the AAN constants, so those are folded into the dequantization
// table exactly as they were folded into the encoder's table.
typedef struct {
float multiplier[64];
} ScaledDequant;
static void build_scaled_dequant(int quantization[8][8], ScaledDequant *out) {
for (int row = 0; row < 8; row++) {
for (int column = 0; column < 8; column++) {
out->multiplier[row * 8 + column] = (float)(
quantization[row][column] *
aan_scale_factor(row, column) / 64.0);
}
}
}E a dequantização fica assim:
// Undo quantization and zig-zag ordering in one pass.
float coefficients[64];
for (int i = 0; i < 64; i++) {
int index = ZIGZAG[i];
coefficients[index] = zigzag[i] * quantization->multiplier[index];
}
idct_block_fast(coefficients, block);💡 Aquele no meio da conta é o ajuste final de normalização da AAN inversa. Se você tirar, a imagem sai com o contraste completamente errado./ 64.0
Foi um dos meus bugs, aliás, eu cheguei a aplicar a divisão duas vezes (uma na tabela e outra no código) e o PSNR despencou de 43 dB para 13 dB. 😵
Os resultados
Primeiro, a transformada isolada. Rodando nos 6144 blocos de uma imagem 512×512 em 4:2:0, teve o seguinte resultado:
Transformada │ Separável │ AAN │ Ganho
─────────────┼───────────┼─────────┼───────
DCT direta │ 3,34 ms │ 0,69 ms │ 4,8x
IDCT inversa │ 5,08 ms │ 0,66 ms │ 7,6xE o mais importante: os coeficientes são matematicamente os mesmos. Conferi contra a versão lenta e o erro máximo ficou em 0,000041, puro arredondamento de ponto flutuante. Não é uma aproximação; é a mesma transformada, só que calculada de forma mais esperta.
Agora vamos ao pipeline completo, medindo a jornada deste artigo:
Estágio │ Encode │ Decode
─────────────────────────┼──────────┼──────────
Artigo 18 (sem -O2) │ 68,76 ms │ 47,93 ms
Só ligando -O2 │ 17,85 ms │ 20,34 ms
+ DCT AAN e escala │ 9,86 ms │ 15,81 ms
─────────────────────────┼──────────┼──────────
Ganho total │ 7,0x │ 3,0xSete vezes mais rápido no encode, três vezes no decode, e olha que ainda estamos no primeiro artigo da série de otimização. 🎉

O teste que realmente importa...
De que adianta ser rápido se a saída mudou? Essa é a pergunta que qualquer otimização precisa responder, e ela vale mais que qualquer número de velocidade.
Rodei as cinco imagens de teste comparando a saída antes e depois, e se liga no resultado:
Imagem │ Artigo 18 │ Artigo 19 │ PSNR
──────────┼─────────────────┼─────────────────┼───────
portrait │ 26.711 B │ 26.708 B │ igual
smooth │ 30.867 B │ 30.805 B │ igual
texture │ 54.317 B │ 54.318 B │ igual
micro │ 35.497 B │ 35.492 B │ igual
colorful │ 25.934 B │ 25.933 B │ igualO PSNR ficou idêntico em todas. Os tamanhos variam em alguns bytes, no , 62 bytes em 30 mil, ou 0,2%.smooth
De onde vem essa diferencinha? Do arredondamento.
A versão antiga usava sobre round() a nova usa double; sobre lrintf()float. Quando um coeficiente cai exatamente no meio do caminho entre dois inteiros, as duas funções podem escolher lados diferentes.
Isso muda um coeficiente aqui e ali, e o Huffman gera alguns bits a mais ou a menos.
É uma diferença real e eu não vou varrer pra debaixo do tapete.
Mas é irrelevante na prática: o formato é o mesmo, a qualidade é a mesma, e o tamanho oscila em fração de por cento, às vezes pra cima, às vezes pra baixo.
O que ainda incomoda?
Terminamos o artigo 7× mais rápidos, mas sendo honesto sobre o que sobrou:
- 🔁 A DCT ainda roda duas vezes. Lembra do modo otimizado do artigo 18? Ele faz duas passadas na imagem: uma pra contar as frequências dos símbolos, outra pra escrever os bits. E as duas refazem toda a matemática. É desperdício puro, e é o primeiro alvo do próximo artigo.
- ✍️ A escrita de bits é feita bit a bit. Um código Huffman de 12 bits custa 12 iterações de laço, com deslocamento, máscara e teste em cada uma.
- 🔍 A varredura de coeficientes olha os 63. Mesmo quando só uns 10 sobreviveram à quantização.
Cada um desses é um pedaço do próximo artigo. 😉
Repositório no GitHub
E sim, você pode acompanhar a evolução desse CODEC de imagens em um repositório no GitHub, commit por commit 👋
Segue o link abaixo do commit desse artigo, onde implementamos a transformada rápida AAN e as tabelas de quantização pré-escaladas:
Resumo
Recapitulando o primeiro passo da nossa jornada de otimização:
- Meça antes de otimizar. A DCT era a maior fatia do tempo; mexer em qualquer outra coisa teria sido desperdício.
- A flag
deu 3,9× de graça, sem mudar uma linha. Medir performance sem otimização do compilador não vale nada.-O2 - A AAN faz uma passada 1D com 5 multiplicações em vez de 64, explorando as simetrias entre os cossenos. Ganho de 4,8× na direta e 7,6× na inversa.
- A AAN deixa cada coeficiente multiplicado por uma constante conhecida, e essa constante é embutida na tabela de quantização, de graça. As divisões viram multiplicações de quebra. Gancho do artigo 9 pago! 🤝
- A AAN inversa tem sinais traiçoeiros. Teste a ida e a volta separadamente, nessa ordem.
- A saída não mudou: PSNR idêntico, tamanho oscilando em 0,2% por causa do arredondamento de
.float - Resultado: encode 7,0× mais rápido, decode 3,0× mais rápido.
E olha que ainda estamos no começo. 😄
No próximo artigo, vamos afiar o encoder: matar aquela segunda passada da DCT guardando os coeficientes, trocar a escrita bit a bit por um acumulador de 64 bits, e ensinar o codec a pular direto de um coeficiente sobrevivente ao próximo em vez de varrer os 63.
E vou contar a história de uma otimização que parecia genial, produzia arquivos do tamanho certo, não gerava nenhum aviso... e destruía a imagem completamente.
E não vou mentir, eu levei um bom tempo pra entender o porquê, e a explicação é uma das mais bonitas da série. 🐛
Até a próxima! 👋

