Otimização de Inferência

Visão geral

Otimização de inferência é o conjunto de técnicas usadas para fazer um modelo treinado rodar mais rápido, mais barato e/ou dentro de orçamentos mais restritos de memória e energia no momento do deployment, mantendo a acurácia (ou a qualidade da saída) dentro de limites aceitáveis. Isso importa tanto em sistemas clássicos de aprendizado de máquina (machine learning) quanto em sistemas modernos de modelos de linguagem grandes (LLMs), onde o custo de inferência muitas vezes domina o custo total quando um modelo está em produção.

Objetivos comuns incluem:

  • Menor latência (tempo até o primeiro token, tempo de requisição de ponta a ponta)
  • Maior throughput (requisições/seg ou tokens/seg por dispositivo)
  • Menor pegada de memória (caber em GPUs/CPUs menores ou no dispositivo)
  • Menor energia/custo (gasto em nuvem, uso de bateria)
  • Atender aos SLOs do produto (veja SLOs para Funcionalidades de IA)

Técnicas centrais cobertas aqui:

  • Quantização (quantization) (pesos/ativações de menor precisão)
  • Destilação (distillation) (treinar um “aluno” menor/mais rápido para imitar um “professor” maior)
  • Poda / esparsidade (pruning / sparsity) (remover pesos, canais ou componentes inteiros)
  • Otimizações em nível de sistemas (systems-level optimizations) (cache KV, kernels otimizados, compilação, batching, decodificação especulativa etc.)

Este artigo foca em acelerações em nível de modelo e em nível de runtime; para padrões de arquitetura de serving, veja Serving de LLM e Serving de Modelos. Para direcionadores de custo e mentalidade de benchmarking, veja Custo/Desempenho.

O que de fato torna a inferência lenta?

O tempo de inferência normalmente é dominado por um (ou mais) de:

  1. Trabalho limitado por computação (compute-bound work): muitos FLOPs (por exemplo, grandes multiplicações de matrizes em transformers).
  2. Largura de banda de memória (memory bandwidth): mover pesos/ativações da memória para unidades de computação (frequentemente o gargalo em modelos grandes).
  3. Overheads de lançamento de kernel (kernel launch / overheads): overhead de Python, muitas operações pequenas, pouca fusão.
  4. Dependências sequenciais (sequential dependencies): a decodificação autorregressiva gera tokens um a um (limita paralelismo).
  5. Fila e contenção (queueing and contention): formação de lote (batch), GPUs multi-tenant, gargalos de CPU na tokenização.

Um modelo mental útil:

  • Prefill (processar o prompt) tende a ser mais paralelizável e se beneficia de batching.
  • Decode (geração token a token) tende a ser sensível à latência e frequentemente se torna limitado por memória (memory-bound) devido a leituras/escritas do cache KV.

A otimização de inferência funciona reduzindo:

  • a quantidade de computação,
  • a quantidade de tráfego de memória,
  • ou o overhead de orquestrar o trabalho.

Quantização

O que é

Quantização representa números (pesos, ativações, cache KV) usando menos bits do que FP32 — comumente FP16/BF16, INT8, ou até INT4. Os principais benefícios:

  • Modelo menor (menos uso de VRAM/DRAM)
  • Maior largura de banda efetiva (mais pesos cabem em cache; menos bytes são movidos)
  • Multiplicações de matrizes potencialmente mais rápidas se o hardware tiver unidades rápidas de baixa precisão

O trade-off é o erro de quantização (quantization error), que pode reduzir a acurácia ou alterar saídas de LLMs.

Tipos comuns de quantização

  1. Quantização apenas de pesos (weight-only quantization)

    • Quantiza pesos (por exemplo, INT8/INT4), mantém ativações em FP16/BF16.
    • Muitas vezes é boa para LLMs porque quantizar ativações pode ser mais complexo.
    • Melhora a pegada de memória e pode melhorar a velocidade se os kernels forem otimizados.
  2. Quantização de pesos + ativação (weight + activation quantization) (INT8)

    • Quantiza pesos e ativações.
    • Comum em visão/CNNs e em muitos modelos encoder.
    • Em geral precisa de calibração ou treinamento ciente de quantização (QAT) para manter a acurácia.
  3. Quantização do cache KV (KV cache quantization) (LLMs)

    • O cache KV pode dominar a memória durante decodificação com contextos longos.
    • Quantizar KV (por exemplo, FP16 → INT8) pode reduzir memória e melhorar throughput em sequências longas, com potencial impacto na qualidade.
  4. FP16/BF16/FP8

    • Não é “quantização inteira”, mas ainda é de menor precisão que FP32.
    • FP16/BF16 é padrão em GPUs; FP8 é cada vez mais usado em GPUs da classe Hopper para velocidade/throughput em algumas pilhas.

Quantização pós-treinamento (PTQ) vs treinamento ciente de quantização (QAT)

  • PTQ: quantizar após o treinamento usando um conjunto de dados de calibração.

    • Caminho mais rápido para deployment; frequentemente “bom o suficiente”.
    • Risco: maior queda de acurácia para modelos sensíveis.
  • QAT: simular quantização durante o treinamento/ajuste fino.

    • Tipicamente melhor retenção de acurácia.
    • Mais engenharia e custo de treinamento.

Granularidade e escalonamento

A quantização normalmente mapeia floats para inteiros usando uma escala (e, opcionalmente, um zero-point):

  • Escalonamento por tensor (per-tensor scaling): uma escala para um tensor inteiro (rápido, menos preciso).
  • Escalonamento por canal (per-channel scaling): escala separada por canal de saída (frequentemente melhor para pesos).
  • Quantização por grupos (group-wise quantization): usada com frequência em métodos INT4 apenas de pesos para LLMs (equilibra acurácia e overhead de metadados).

Esses detalhes importam porque determinam quanto erro é introduzido e quão eficientemente o runtime consegue computar.

Exemplo prático: quantização dinâmica do PyTorch (CPU)

A quantização dinâmica frequentemente é eficaz para modelos do tipo transformer em CPU porque mira camadas nn.Linear.

import torch
from torch import nn

# Suppose you have a trained model on CPU (e.g., a small encoder).
model = ...  # nn.Module
model.eval()

# Dynamic quantization: weights become int8, activations quantized on the fly.
qmodel = torch.quantization.quantize_dynamic(
    model,
    {nn.Linear},
    dtype=torch.qint8
)

# Run inference
with torch.no_grad():
    out = qmodel(torch.randn(1, 128))

Notas:

  • Os ganhos de velocidade dependem fortemente do suporte a instruções da CPU (por exemplo, AVX512-VNNI) e da estrutura do modelo.
  • A quantização dinâmica é menos comum para grandes LLMs decoder-only no modo eager do PyTorch; runtimes especializados são típicos.

Exemplo prático: quantização 4-bit apenas de pesos para LLMs (GPU)

Muitos deployments de LLMs usam quantização 8-bit ou 4-bit apenas de pesos com kernels otimizados.

from transformers import AutoModelForCausalLM, AutoTokenizer

model_id = "your-llm"
tok = AutoTokenizer.from_pretrained(model_id)

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    device_map="auto",
    load_in_4bit=True,          # requires bitsandbytes-compatible setup
    torch_dtype="auto"
)

prompt = "Explain quantization in one paragraph."
inputs = tok(prompt, return_tensors="pt").to(model.device)

out = model.generate(**inputs, max_new_tokens=120)
print(tok.decode(out[0], skip_special_tokens=True))

Na prática, throughput e qualidade dependem de:

  • esquema de quantização (por exemplo, tamanho do grupo, simétrico vs assimétrico),
  • implementação do kernel,
  • e se você está limitado por memória (comum no decode).

Calibração e avaliação

Para PTQ, os dados de calibração (calibration data) devem corresponder às distribuições de produção (comprimentos de entrada, domínios, modalidades). Após quantizar, avalie com:

  • métricas de tarefa (acurácia/F1 etc.),
  • e métricas comportamentais para LLMs (win-rate, pontuação por rubrica), veja Avaliação em Produção.

Também monitore regressões pós-deploy (latência e qualidade), veja Monitoramento.

Quando a quantização mais ajuda

A quantização tende a brilhar quando a inferência é limitada por largura de banda de memória:

  • Modelos grandes cujos pesos não cabem em cache
  • Workloads de LLMs com muito decode
  • Deployments na borda com restrições de memória apertadas (veja Inferência na Borda / No Dispositivo)

A quantização pode ajudar menos (ou até prejudicar) quando:

  • os kernels não são otimizados para o seu formato de quantização,
  • você se torna limitado por computação sem aceleração de baixa precisão,
  • a sensibilidade de acurácia é alta e você não pode arcar com QAT/destilação.

Poda e esparsidade

O que é

Poda (pruning) remove parâmetros ou estruturas de um modelo para reduzir computação e/ou memória:

  • Poda não estruturada (unstructured pruning): zerar pesos individuais (esparsidade irregular).
  • Poda estruturada (structured pruning): remover canais, heads, neurônios ou blocos inteiros (amigável ao hardware).
  • Esparsidade N:M (N:M sparsity): padrões estruturados como “2 de 4 pesos são não-zero” (suportado por algumas GPUs da NVIDIA).

A ideia central: muitos modelos são superparametrizados; você pode remover partes com perda limitada de qualidade se fizer ajuste fino depois.

Não estruturada vs estruturada: o ponto prático chave

  • Esparsidade não estruturada pode produzir altas taxas de esparsidade (por exemplo, 80–90%), mas ganhos de velocidade exigem kernels esparsos e frequentemente hardware/runtime especializado. Caso contrário, você só tem uma multiplicação de matrizes densa com muitos zeros (sem ganho real de velocidade).
  • Poda estruturada geralmente gera melhorias mais confiáveis de latência porque reduz dimensões de matrizes — kernels densos padrão ficam mais rápidos.

Alvos típicos de poda em transformers

  • Heads de atenção (remover heads de baixa importância)
  • Dimensão intermediária de MLP (reduzir o tamanho do FFN)
  • Camadas inteiras (layer dropping)
  • Especialistas de MoE (reduzir especialistas ativos, ou destilar para denso)

Fluxo de trabalho

  1. Escolher uma estratégia de poda e a esparsidade/tamanho alvo.
  2. Podar com base em magnitude, análise de sensibilidade ou máscaras aprendidas.
  3. Ajustar fino (fine-tune) para recuperar qualidade (frequentemente essencial).
  4. Validar latência com o runtime real (não assuma esparsidade ⇒ velocidade).

Esparsidade N:M (consciente de hardware)

Alguns aceleradores oferecem ganhos para padrões específicos (por exemplo, 2:4). Isso pode trazer ganhos reais se:

  • sua poda impõe o padrão correto,
  • e sua pilha de inferência usa kernels esparsos.

Este é um bom exemplo de por que otimização de inferência é co-design de sistemas + modelo.

Destilação (knowledge distillation)

O que é

Destilação treina um modelo aluno menor/mais rápido para corresponder a um modelo professor maior. Em vez de treinar apenas com rótulos “duros”, o aluno aprende com as saídas do professor (frequentemente chamadas de “alvos suaves”), capturando informação mais rica.

A destilação é especialmente valiosa quando:

  • você quer grandes ganhos de velocidade (por exemplo, modelo 10× menor),
  • a quantização sozinha não é suficiente,
  • ou você precisa de um modelo que caiba no dispositivo.

A destilação também é uma estratégia comum para criar LLMs “mini” ajustados para um domínio estreito.

Objetivos de destilação

Componentes comuns de loss:

  • Correspondência de logits (logit matching) (classificação): o aluno corresponde aos logits do professor, frequentemente com escalonamento por temperatura.
  • Divergência KL (KL divergence) entre distribuições do professor e do aluno.
  • Correspondência de estado oculto (hidden-state matching): corresponder representações intermediárias.
  • Destilação em nível de sequência (sequence-level distillation) (LLMs): o professor gera saídas; o aluno treina nelas (frequentemente combinado com ajuste fino supervisionado).

Uma forma conceitual simples (classificação):

[ \mathcal{L} = \alpha \cdot \mathcal{L}_{hard}(y, p_s) + (1-\alpha) \cdot T^2 \cdot KL(p_t^{(T)} \Vert p_s^{(T)}) ]

Onde (T) é a temperatura; (p_t, p_s) são probabilidades do professor/aluno.

Exemplo prático: destilando um classificador de texto (esboço)

# Pseudocode sketch (not runnable end-to-end)
for batch in dataloader:
    x, y = batch

    with torch.no_grad():
        teacher_logits = teacher(x)

    student_logits = student(x)

    hard_loss = cross_entropy(student_logits, y)
    soft_loss = kl_div(
        log_softmax(student_logits / T),
        softmax(teacher_logits / T)
    ) * (T * T)

    loss = alpha * hard_loss + (1 - alpha) * soft_loss
    loss.backward()
    optimizer.step()

Destilação para LLMs na prática

Padrões comuns:

  • Destilação de instruções (instruction distillation): o professor produz respostas de alta qualidade para prompts curados; o aluno faz ajuste fino nisso.
  • Destilação de domínio (domain distillation): o professor gera perguntas e respostas, resumos ou saídas estruturadas específicas de domínio.
  • Destilação de política (policy distillation): comprimir um modelo “com uso de ferramentas” ou “alinhado” em um menor.

O controle de qualidade importa: se o professor estiver errado ou for inseguro, o aluno herdará esse comportamento (muitas vezes com mais confiança). Em ambientes de produção, trate dados sintéticos do professor como um dataset que requer validação e governança (veja Validação de Dados e Privacidade em Logs quando prompts contêm conteúdo sensível).

Destilação vs quantização vs poda

  • A destilação pode gerar as maiores reduções de latência porque muda tamanho/arquitetura do modelo.
  • A quantização geralmente é a mais rápida de implementar e não requer treinamento (PTQ).
  • A poda fica no meio; a poda estruturada pode ser muito eficaz se você puder ajustar fino e sua arquitetura for adequada.

Na prática, equipes frequentemente combinam:

  • Destilar → depois quantizar o aluno menor.
  • Podar → ajustar fino → quantizar para deployment.

Outras técnicas de alto impacto para velocidade de inferência

Quantização/destilação/poda são centrais, mas deployments modernos (especialmente LLMs) dependem fortemente de otimizações de runtime e algorítmicas.

Fusão de operadores e compilação

Muitos modelos em frameworks eager rodam como muitas operações pequenas. Compiladores e otimizadores de grafo podem fundir operações e escolher kernels melhores.

Abordagens comuns:

  • Pilhas de torch compile (por exemplo, Inductor)
  • Compilação no estilo XLA
  • Exportação ONNX + otimizações de grafo do ONNX Runtime
  • Construção de engine no estilo TensorRT

Orientação prática:

  • Meça a latência de ponta a ponta após compilação, não apenas o tempo de kernel.
  • Cuidado com dinamismo de shape (comprimentos variáveis de sequência), que pode reduzir oportunidades de fusão.

Otimizações de atenção (transformers)

A atenção pode ser cara; kernels otimizados podem acelerar significativamente.

  • Kernels no estilo FlashAttention: reduzem tráfego de memória com tiling e recomputação quando necessário (trocando computação por largura de banda).
  • Atenção paginada (paged attention) / paginação de cache KV (KV cache paging): gerencia o cache KV de forma mais eficiente para muitas sequências concorrentes.
  • Atenção multi-query (multi-query attention, MQA) / atenção grouped-query (grouped-query attention, GQA): reduz tamanho do cache KV ao compartilhar chaves/valores entre heads (escolha arquitetural; frequentemente presente em LLMs modernos).

Contexto relacionado: Arquitetura Transformer.

Gerenciamento do cache KV

Para LLMs decoder-only, o cache KV é central:

  • Reusar o cache KV entre passos de decode (padrão).
  • Quantizar o cache KV (redução de memória; pode melhorar throughput em contextos longos).
  • Limitar contexto máximo ou usar recuperação (retrieval) para evitar contextos enormes (tema de design de sistema; veja Padrões de Design de Sistemas de LLM).

Batching e agendamento

Mesmo um modelo perfeitamente otimizado pode ter desempenho ruim se o serving for ineficiente:

  • Batching dinâmico / batching contínuo (dynamic batching / continuous batching): combinar requisições que chegam próximas no tempo.
  • Micro-batching: trocar um pouco de latência por maior throughput.
  • Separação de prefill/decode: agendar processamento do prompt e geração de token de forma diferente para melhorar a utilização de GPU.

Isso normalmente é implementado na camada de serving; veja Serving de LLM e Cache & Rate Limiting.

Decodificação especulativa (LLMs)

Decodificação especulativa (speculative decoding) usa um modelo pequeno rascunho para propor múltiplos tokens, e então o modelo maior alvo os verifica. Se muitos tokens forem aceitos, você reduz o número de forward passes caros do modelo alvo.

Quando ajuda:

  • O modelo rascunho é muito mais rápido
  • A taxa de aceitação é alta (as propostas do rascunho batem com frequência com as saídas do alvo)
  • Seu workload tem muito decode

Trade-offs:

  • Mais complexidade (dois modelos, orquestração)
  • Benefícios dependem da distribuição de prompts e de configurações de geração

Saída antecipada e cascatas

Para algumas tarefas, você pode parar cedo quando a confiança é alta:

  • Redes com saída antecipada (early-exit networks) (o classificador sai em camadas intermediárias)
  • Cascatas/roteadores de modelo (model cascades/routers): tentar um modelo pequeno primeiro, e recorrer a um maior se necessário

Isso costuma ser uma otimização de produto/sistema em vez de puramente de modelo; veja Padrões de Design de Sistemas de LLM.

Restrições de entrada e saída

Às vezes, o token mais barato é o que você não gera:

  • Reduzir o máximo de tokens de saída quando aceitável
  • Usar saídas estruturadas (schemas JSON) para reduzir retries
  • Melhorar prompts para encurtar respostas (apps com LLM)
  • Usar melhor tokenização ou normalizar entradas para reduzir comprimento de sequência (específico de domínio)

Estas são “otimizações de inferência” em termos de custo mesmo se o modelo não mudar.

Colocando em prática: um fluxo de trabalho de otimização

1) Defina o que você está otimizando

Seja explícito sobre:

  • Percentis de latência (p50/p95/p99)
  • Throughput (tokens/seg, req/seg)
  • Teto de memória (VRAM/DRAM)
  • Limiares de qualidade (métrica de tarefa, win-rate, restrições de segurança)

Conecte isso a objetivos operacionais e orçamentos; veja Custo/Desempenho.

2) Faça profiling do baseline

Colete:

  • Decomposição de latência de ponta a ponta (tokenização, prefill, decode, pós-processamento)
  • Utilização e memória de GPU
  • Tamanho de batch e atrasos de fila
  • Operadores quentes (matmul, attention, layernorm, softmax)

3) Escolha a técnica mais simples que atenda aos objetivos

Heurísticas típicas de decisão:

  • Precisa caber em hardware menor: quantização (INT8/INT4), destilação, otimizações de cache KV.
  • Precisa de menor latência p95: compilação, otimizações de kernel, reduzir comprimento de saída, decodificação especulativa.
  • Precisa de maior throughput: batching, quantização, atenção otimizada, melhor agendamento.

4) Valide a qualidade corretamente

Quantização e poda podem causar regressões sutis:

  • Mudanças no comportamento de tokens raros
  • Falhas de segurança/formatação
  • Degradação específica de domínio

Use avaliação offline e depois rollout em fases/avaliação online; veja Avaliação em Produção.

5) Faça deploy com monitoramento e rollback

Acompanhe:

  • percentis de latência,
  • taxas de OOM,
  • fragmentação de memória de GPU,
  • proxies de qualidade (feedback do usuário, checagens automatizadas),
  • e drift (mudanças na distribuição de entrada podem invalidar a calibração).

Veja Monitoramento e Registro de Modelos para práticas seguras de promoção/rollback.

Armadilhas comuns

  • Assumir que compressão ⇒ velocidade: poda ou INT4 sem kernels otimizados pode apenas reduzir memória, não latência.
  • Benchmarking da coisa errada: microbenchmarks podem parecer ótimos enquanto o p95 de ponta a ponta mal muda por causa de filas ou gargalos de CPU.
  • Ignorar decode vs prefill: uma otimização pode ajudar prefill mas não decode (ou vice-versa).
  • Dados de calibração ruins: PTQ pode falhar se a calibração não corresponder a entradas reais.
  • Otimizar demais antes de arrumar o serving: batching, agendamento e caching frequentemente entregam ganhos maiores do que ajustes de baixo nível nas primeiras iterações.

Resumo

A otimização de inferência é uma caixa de ferramentas que abrange métodos numéricos (quantização), compressão de modelo (poda, destilação) e melhorias de sistemas/runtime (compilação, kernels, batching, algoritmos de decodificação). Os melhores resultados geralmente vêm da combinação de abordagens e da validação com workloads realistas e alvos de qualidade.

Se você está otimizando LLMs em produção, um caminho comum de alto ROI é:

  1. Ajustar corretamente a pilha de serving (batching/agendamento/cache KV).
  2. Usar kernels de atenção otimizados e compilação onde estiver estável.
  3. Aplicar quantização apenas de pesos (INT8/INT4) com avaliação cuidadosa.
  4. Considerar destilação ou cascatas quando você precisa de melhorias em degrau de custo/latência.