Trilha 07 · Assembly e baixo nível

O que o compilador faz

O compilador não apenas traduz — ele transforma. Com -O2, ele elimina loops, inline funções, usa SIMD e gera código que seria difícil de escrever à mão. Veja a diferença.

① Intuição

Compilar ≠ traduzir literalmente

Um compilador sem otimização (-O0) traduz cada linha de C para uma sequência de instruções assembly correspondente — variáveis locais na pilha, frames explícitos, loops como estão escritos. O resultado é correto mas lento.

Com -O2 ou -O3, o compilador analisa todo o programa como um grafo de dados e transforma agressivamente: elimina variáveis locais que podem virar registradores, detecta padrões matemáticos (fórmulas fechadas para somas), substitui branches por instruções condicionais sem desvio, e vetoriza loops com SIMD. O resultado pode ser irreconhecível — mas semanticamente idêntico ao original.

Por que não compilar sempre com -O3? (1) Compilação fica mais lenta (minutos vs. segundos). (2) Código com UB (undefined behavior) pode ter comportamento surpreendente — o compilador assume que UB não acontece e otimiza agressivamente ao redor disso. (3) Bugs dependentes de ordem de execução podem ser mascarados por -O0 e aparecer só com otimizações. (4) Debugging fica difícil — variáveis são eliminadas, linhas reordenadas, funções inlined.
② Visualização interativa

Explorador de otimizações

Compare o assembly gerado com -O0 (sem otimização) e -O2 (otimizado) para diferentes funções C. Note como o compilador transforma completamente o código.

Nível de otimização:
CÓDIGO C
int add(int a, int b) {
  return a + b;
}
ASSEMBLY GERADO-O0
; -O0: sem otimização (cada var na pilha)
add:
  push  ebp
  mov   ebp, esp
  sub   esp, 8        ; espaço para a e b
  mov   [ebp-4], edi  ; salva 'a' na pilha
  mov   [ebp-8], esi  ; salva 'b' na pilha
  mov   eax, [ebp-4]  ; carrega 'a'
  add   eax, [ebp-8]  ; soma 'b'
  pop   ebp
  ret
TÉCNICAS DE OTIMIZAÇÃO USADAS
Sem stack frame · LEA como ADDStack frame explícito · Variáveis locais na pilha · Nenhuma otimização

Dica: use gcc -O2 -S -o saida.s seu_codigo.c para ver o assembly que o GCC gera do seu código. Online: godbolt.org mostra assembly colorido em tempo real enquanto você digita C/C++/Rust.

③ Explicação técnica

Técnicas de otimização de compiladores

// As principais transformações que o compilador aplica

// 1. CONSTANT FOLDING — avalia expressões constantes em compilação
int x = 2 * 3 * 7;   // → mov eax, 42 (sem multiplicação em runtime!)

// 2. DEAD CODE ELIMINATION — remove código que nunca executa
if (0) { printf("nunca"); }  // → completamente removido

// 3. LOOP UNROLLING — desdoblamento de loop
// Original: for (i=0; i<4; i++) a[i] *= 2;
// Após unroll:
a[0] *= 2; a[1] *= 2; a[2] *= 2; a[3] *= 2;
// Vantagem: menos branches, mais instruções independentes para ILP

// 4. STRENGTH REDUCTION — troca op cara por op barata
x * 4   // → shl eax, 2  (shift é mais rápido que MUL)
x / 8   // → sar eax, 3  (shift aritmético)

// 5. COMMON SUBEXPRESSION ELIMINATION (CSE)
int a = x*x + 1;
int b = x*x + 2;   // x*x calculado só uma vez → reutilizado

// 6. VECTORIZATION (auto-SIMD)
// for (i=0; i<N; i++) c[i] = a[i] + b[i];
// → processa 8 floats por ciclo com AVX2 (ymm registers)

Quando o compilador NÃO pode otimizar

// Quando o compilador NÃO pode otimizar — volatile e barreiras

// O compilador assume que variáveis locais só mudam pelo código que ele vê.
// Para hardware/threads que mudam variáveis "por fora":
volatile int* reg = (volatile int*)0xF000;
*reg = 1;  // NÃO pode ser removido como "escrita morta"
*reg = 2;  // NÃO pode ser combinado com o anterior

// Barreira de memória — impede reordenação
__asm__ volatile ("mfence" ::: "memory");
// Garante que todas as escritas anteriores são visíveis antes desta barreira

// Por que isso importa? O compilador pode reordenar:
x = 1;     // pode ser movido depois de y = 2 se não houver dependência
y = 2;     // em outro thread, a ordem x=1 → y=2 pode não ser garantida!
// → Use std::atomic ou mutex para sincronização correta entre threads
Link-Time Optimization (LTO): normalmente, o compilador otimiza cada arquivo .c separadamente. Com LTO (-flto), o compilador salva o IR (intermediate representation) nos objetos e otimiza o programa inteiro durante o link — permitindo inlining cross-file, eliminação de funções mortas globais e propagação de constantes entre arquivos. O Firefox usa LTO, ganhando 3–5% de velocidade de startup. Custo: link 2–3× mais lento.
④ Projeto para programar

Medindo o impacto das otimizações

Mini projeto: compile a mesma função de soma com -O0, -O1, -O2, -O3 e -Os (otimizar tamanho). Use time para medir o tempo de execução de N=100 milhões de iterações. Plote a speedup relativa a -O0. Qual nível dá o maior ganho? Qual a diferença entre -O2 e -O3?

Projeto principal: encontre um caso onde o compilador não consegue vetorizar automaticamente (aliasing de ponteiros) e adicione __restrict__ para indicar que os ponteiros não se sobrepõem. Compare o assembly antes e depois com -fopt-info-vec (GCC) que reporta quais loops foram vetorizados e por quê alguns não foram.

Desafio extra: use Profile-Guided Optimization (PGO): compile com -fprofile-generate, execute com dados reais, depois recompile com -fprofile-use. O compilador agora sabe quais branches são quentes (frequentes) e os otimiza agressivamente. Meça speedup em um parser JSON ou processador de texto com corpus real.

⑤ Exercícios rápidos

Teste sua intuição

O que é constant folding e quando o compilador pode aplicá-lo?
Qual é o trade-off do function inlining?
O que volatile garante em C e o que ele não garante?
⑥ Aplicações no mundo real

Onde você encontra isso

🌐

JavaScript JIT (V8, SpiderMonkey)

Engines modernas de JS compilam "hot" funções para código de máquina em runtime (JIT). V8 usa Maglev (JIT de média tier) e Turbofan (JIT de alta tier). Turbofan faz inline, type specialization (float64 vs. int32), range analysis e vetorização — transformações semelhantes às de um compilador AOT, mas com informações de tipo disponíveis apenas em runtime.

🤖

Compiladores de IA (XLA, TorchInductor)

TensorFlow usa XLA; PyTorch 2.x usa TorchInductor sobre LLVM/Triton. Ambos compilam grafos de operações de tensores para código nativo: fusão de kernels (elimina buffers intermediários), tiling para cache, geração de SIMD/CUDA. Um modelo de linguagem como o GPT-4 seria impossível de servir sem essas otimizações de compilador.

🚀

Auto-vetorização e álgebra linear

NumPy, BLAS (OpenBLAS, MKL) e libc funções como memcpy usam SIMD manualmente ou via auto-vetorização. Uma multiplicação de matrizes 1024×1024 bem otimizada em AVX-512 processa 512 bits (16 floats) por ciclo, por unidade de execução. A diferença entre código naïve e código vetorizado: 20–100×.

← Anterior: Pilha e chamadas ✓ Concluir trilha: Assembly e baixo nível