Batching e Agendamento de Inferência
Visão geral
Agrupamento e escalonamento de inferência são as técnicas centrais usadas por servidores de modelos em produção para trocar latência (quanto tempo uma única requisição espera e executa) por vazão (quantas requisições/tokens por segundo o sistema consegue produzir). Elas ficam na fronteira entre a execução do modelo (kernels de GPU) e o comportamento de sistemas (filas, concorrência, justiça e SLOs).
No serving moderno de LLMs, agrupar e escalonar não é apenas “combinar N requisições e executar uma única passagem direta grande (forward pass)”. Isso inclui:
- Enfileirar requisições de entrada e decidir quando iniciá-las
- Formação de lote (batch formation) (quais requisições compartilham um passo de GPU)
- Escalonamento por passo (per-step scheduling) para decodificação autorregressiva (agrupamento contínuo (continuous batching) / agrupamento em nível de iteração (iteration-level batching))
- Priorização e justiça entre locatários (tenants) e tipos de requisição
- Controle de admissão (admission control) e retropressão (backpressure) para manter a latência de cauda limitada
- Políticas sensíveis a memória influenciadas pelo Cache KV
Este artigo foca na mecânica prática e na teoria subjacente que explica por que essas técnicas funcionam.
Por que o agrupamento ajuda: utilização de GPU e amortização
GPUs são mais eficientes quando têm trabalho suficiente para:
- Preencher suas unidades de computação (ocupação de SM (SM occupancy))
- Reduzir o overhead de lançamento de kernel
- Usar a largura de banda de memória de forma eficiente
- Aproveitar caminhos otimizados de multiplicação de matrizes (GEMM) com matrizes maiores
Para inferência de transformadores (ver Arquitetura Transformer), muitas operações escalam bem com o tamanho do lote. Uma intuição simplificada:
- Sem agrupamento, cada requisição executa GEMMs pequenos → baixa utilização → poucos tokens/seg
- Com agrupamento, o servidor executa GEMMs maiores → melhor utilização → mais tokens/seg
No entanto, o agrupamento introduz tempo de espera: as requisições precisam esperar em uma fila até que um lote seja formado. Esta é a troca fundamental entre vazão e latência.
Métricas de latência que importam para LLMs
A latência de LLM é multidimensional:
- Atraso de enfileiramento: tempo gasto esperando para ser escalonado/agrupado
- TTFT (tempo até o primeiro token) (time-to-first-token): crítico para UX de chat
- TPOT (tempo por token de saída) (time per output token): impacta a suavidade do streaming e o tempo total de conclusão
- Latência E2E (ponta a ponta) (end-to-end latency): tempo total até o token final ser produzido
Estratégias de agrupamento frequentemente melhoram o TPOT (maior vazão), mas podem piorar o TTFT (espera por um lote ou por um slot de escalonamento). Sistemas práticos otimizam ambos com políticas diferentes para “prefill” vs “decode”.
Fundamentos teóricos: Lei de Little e tradeoffs de filas
Duas ideias-chave da Teoria das Filas são úteis:
Lei de Little
Se um sistema é estável, então:
- (L = \lambda W)
Onde:
- (L) é o número médio de requisições no sistema (fila + serviço)
- (\lambda) é a taxa de chegada (requisições/seg)
- (W) é o tempo médio no sistema (seg)
Aumentar o agrupamento tipicamente aumenta a eficiência de serviço (mais capacidade de vazão), mas também tende a aumentar (L) (mais itens esperando para formar lotes), o que pode aumentar (W) a menos que a capacidade suba o suficiente.
Utilização e latência de cauda
À medida que a utilização se aproxima de 100%, as filas crescem rapidamente e a latência de cauda explode. Na prática, muitos serviços de inferência de baixa latência visam uma “folga” de utilização (por exemplo, 60–85%) para manter P95/P99 sob controle.
O agrupamento permite aumentar a capacidade efetiva, mas um escalonamento ruim ainda pode empurrar você para alta latência de cauda sob tráfego em rajadas.
Tipos de agrupamento na inferência
Agrupamento estático (offline ou tamanhos de lote fixos)
Requisições são agrupadas em lotes de tamanho fixo (por exemplo, batch=8). Isso é comum em jobs de inferência offline, mas menos adequado para serving online porque o tráfego é em rajadas e os comprimentos das requisições variam.
Prós:
- Simples
- Formatos de kernel previsíveis
Contras:
- Alta latência quando o tráfego é baixo
- Desperdiça computação com padding quando os comprimentos de sequência diferem
Agrupamento dinâmico (agrupamento em nível de requisição) (request-level batching)
O agrupamento dinâmico forma lotes a partir de uma fila até restrições como:
max_batch_sizemax_batch_delay_ms(quanto tempo esperar antes de disparar um lote menor)- formatos compatíveis (por exemplo, mesmo bucket de comprimento de prompt)
Isso é comum em servidores de inferência gerais (por exemplo, agrupamento dinâmico no estilo Triton para modelos codificadores (encoder)).
Tradeoff principal:
max_batch_delay_msmaior → melhor vazão, pior TTFTmax_batch_delay_msmenor → melhor TTFT, potencialmente pior eficiência de GPU
Micro-agrupamento (micro-batching) (divisão intra-requisição)
Um único lote grande pode ser dividido em micro-lotes para caber em memória, em estágios de pipeline, ou para sobrepor computação e comunicação (comum com paralelismo de modelo). Isso é mais sobre encaixe e pipeline do que sobre enfileiramento, mas interage com o escalonamento porque muda a granularidade dos passos.
O que torna o agrupamento de LLMs especial: prefill vs decode
A inferência autorregressiva de LLMs tem duas fases distintas:
Prefill (processamento do prompt)
- Entrada: os tokens completos do prompt
- Saída: cache KV construído para todos os tokens do prompt + primeiros logits
- Computação: relativamente pesada; atenção sobre o comprimento do prompt
- Normalmente uma única vez por requisição
Decode (geração de tokens)
- Cada passo gera (tipicamente) um token por requisição
- Usa o cache KV para evitar recomputar tokens passados (ver Cache KV)
- A computação por passo é menor, mas repetida muitas vezes
- Altamente sensível ao escalonamento porque as requisições podem estar em diferentes posições de decode
Agrupar inferência de LLMs com eficiência exige lidar com muitas requisições em diferentes estágios e comprimentos.
Padding e lotes irregulares: o assassino oculto de vazão
Se você agrupa sequências de comprimentos diferentes de forma ingênua, muitas vezes você as preenche (pad) até um comprimento comum. O padding desperdiça computação:
- Para atenção, tokens desperdiçados podem ser especialmente caros
- Para decode, elementos do lote podem terminar em tempos diferentes
Mitigações comuns:
- Agrupamento em buckets por comprimento: manter múltiplas filas para diferentes comprimentos de prompt
- Agrupamento irregular (ragged batching): representar sequências de comprimento variável sem padding total (dependente da implementação)
- Agrupamento por tokens (token-based batching): limitar pelo total de tokens (soma dos comprimentos) em vez do número de requisições
- Layouts avançados de KV (por exemplo, KV paginado (paged KV)) para suportar empacotamento flexível (ver Cache KV)
Agrupamento contínuo (escalonamento em nível de iteração)
Uma característica definidora das pilhas modernas de serving de LLMs (por exemplo, designs tipo vLLM) é o agrupamento contínuo:
- O servidor executa decode em iterações (passos).
- Em cada iteração, ele forma um lote a partir de sequências atualmente ativas mais novas chegadas que estão prontas para prefill.
- Quando uma sequência termina, ela é removida imediatamente sem esperar o lote inteiro concluir.
Isso evita o problema do “lote em passo travado (lockstep batch)”, em que requisições rápidas/curtas esperam atrás de requisições lentas/longas.
Por que melhora vazão e latência
- Mantém a GPU ocupada ao sempre ter um lote ativo
- Reduz o bloqueio no início da fila (head-of-line blocking)
- Melhora justiça e latência de cauda em relação a lotes fixos
Complexidade de escalonamento
O agrupamento contínuo transforma o serving em um problema de escalonamento:
- Quais requisições devem entrar no conjunto ativo agora?
- Quantos prefills vs decodes devem ser executados nesta iteração?
- Como gerenciar a memória do cache KV para que o conjunto ativo caiba?
Políticas de escalonamento: o que otimizar (e para quem)
Um escalonador normalmente equilibra vários objetivos:
- Maximizar vazão (tokens/seg, requisições/seg)
- Cumprir SLOs (P95 de TTFT, P99 de E2E)
- Justiça entre usuários/locatários
- Previsibilidade sob carga em rajadas
- Segurança de memória (evitar OOM controlando KV ativo)
Políticas comuns e suas implicações:
FIFO (primeiro a entrar, primeiro a sair) (first-in-first-out)
Prós:
- Simples, “justo” pelo tempo de chegada
Contras:
- Pode ser péssimo para latência de cauda quando os tamanhos das requisições variam (bloqueio no início da fila)
Menor job primeiro / menor tempo restante de processamento (SRPT) (shortest-job-first / shortest-remaining-processing-time)
Intuição: priorizar requisições mais curtas primeiro.
Prós:
- Minimiza o tempo médio de conclusão em teoria
Contras:
- Pode causar fome (starvation) de requisições longas, a menos que seja controlado
- O “tamanho do job” é incerto para LLMs, a menos que você conheça
max_tokense o comprimento do prompt
Primeiro prazo mais cedo (EDF) (earliest-deadline-first)
Se requisições têm prazos (por exemplo, TTFT < 500ms), escalone as mais urgentes.
Prós:
- Encaixe natural para sistemas orientados a SLO
Contras:
- Exige estimar tempo de serviço; sobrecarga ainda pode causar violações
Escalonamento justo ponderado (weighted fair scheduling)
Alocar capacidade proporcional a pesos (locatário A recebe 70%, locatário B recebe 30%).
Prós:
- Controle multi-locatário e previsibilidade
Contras:
- Vazão bruta ligeiramente menor do que políticas puramente otimizadoras de vazão
Na prática, servidores de LLMs frequentemente implementam híbridos: FIFO dentro de classes de prioridade, com controle de admissão e tratamento especial para TTFT.
Restrições práticas de agrupamento: “tamanho do lote” não é suficiente
Para LLMs, geralmente é melhor agrupar por tokens e memória, não apenas por requisições.
Restrições típicas incluem:
max_num_seqs: máximo de sequências concorrentesmax_batched_tokens: limite de total de tokens processados no prefillmax_active_kv_bytes: limite do footprint de memória do cache KVmax_wait_ms: limite de enfileiramento para TTFTmax_decode_batch_size: limite de concorrência de decode para latência por token
Uma requisição com prompt de 10k tokens é “maior” do que uma com prompt de 200 tokens. Escalonamento sensível a tokens evita que um único prompt enorme domine um lote.
Um loop simples de agrupamento dinâmico (conceitual)
Abaixo está um esboço simplificado de agrupamento dinâmico em nível de requisição (mais adequado para modelos codificadores ou para prefill de LLMs do que para decodificação contínua completa):
import time
from collections import deque
MAX_BATCH_SIZE = 16
MAX_WAIT_MS = 5
queue = deque()
def get_next_batch():
start = time.time()
batch = []
while len(batch) < MAX_BATCH_SIZE:
if queue:
batch.append(queue.popleft())
else:
# no work; brief sleep to avoid busy-wait
time.sleep(0.0005)
waited_ms = (time.time() - start) * 1000
if batch and waited_ms >= MAX_WAIT_MS:
break
return batch
def serve_forever(model):
while True:
batch = get_next_batch()
if not batch:
continue
# Collate inputs, pad/bucket as needed
outputs = model.forward(batch)
# Route outputs back to clients
Isso ilustra os principais controles: MAX_BATCH_SIZE e MAX_WAIT_MS. Sistemas reais adicionam bucketing por comprimento, limites por tokens, prioridades e cancelamento.
Esboço de agrupamento contínuo para decode de LLM
Um loop conceitual de agrupamento contínuo tem um “conjunto ativo”:
active = [] # sequences currently decoding
waiting = deque() # new requests waiting to start
MAX_ACTIVE = 256
MAX_PREFILL_TOKENS = 8192
def iteration():
global active
# 1) Admit new requests for prefill if capacity allows
prefill_batch = []
prefill_tokens = 0
while waiting and len(active) + len(prefill_batch) < MAX_ACTIVE:
req = waiting[0]
if prefill_tokens + req.prompt_len > MAX_PREFILL_TOKENS:
break
waiting.popleft()
prefill_batch.append(req)
prefill_tokens += req.prompt_len
# 2) Run prefill (build KV) for newly admitted requests
if prefill_batch:
model.prefill(prefill_batch)
active.extend(prefill_batch)
# 3) Run one decode step for all active sequences (or a chosen subset)
logits = model.decode_one_token(active)
sampled = sample_tokens(logits)
# 4) Append tokens, evict completed sequences
next_active = []
for seq, tok in zip(active, sampled):
seq.append(tok)
if not seq.is_done():
next_active.append(seq)
else:
seq.finish()
active = next_active
Servidores reais frequentemente fazem uma seleção mais sofisticada do que “decodificar todos a cada iteração”, mas a estrutura mostra por que o agrupamento contínuo reduz tempo desperdiçado e mantém a GPU ocupada.
Enfileiramento e controle de admissão: prevenindo colapso por sobrecarga
O agrupamento melhora a eficiência, mas a sobrecarga ainda pode causar filas descontroladas. Duas técnicas essenciais:
Retropressão
Quando a fila cresce demais ou o TTFT estimado vai violar o SLO:
- Rejeitar requisições (HTTP 429 / gRPC RESOURCE_EXHAUSTED)
- Descartar tráfego de baixa prioridade
- Degradar com elegância (menos tokens máximos, decodificação de menor qualidade, rotear para um modelo menor)
Limites de concorrência
Controlar quantas sequências podem estar ativas ao mesmo tempo (frequentemente atrelado à memória do cache KV). Isso previne OOM e estabiliza a latência.
Uma heurística prática: se o cache KV estiver perto do limite, pare de admitir novas sequências mesmo que a GPU tenha folga de computação.
Justiça e multi-locatário
Em ambientes de serving compartilhados, você frequentemente precisa de alta vazão e desempenho previsível por locatário.
Abordagens comuns:
- Filas separadas por locatário com admissão por round-robin ponderado
- Limites de concorrência por locatário para impedir que um locatário monopolize a memória de KV
- Classes de prioridade (interativo vs jobs em lote)
- Preempção (preemption) (avançado): pausar/evictar sequências de baixa prioridade para liberar capacidade (exige gerenciamento cuidadoso de KV)
Justiça está fortemente acoplada ao agrupamento contínuo: mesmo que o decode seja eficiente, uma enxurrada de gerações longas pode degradar o TTFT para novas requisições interativas, a menos que a admissão seja controlada.
Interações com cache KV e layout de memória
Decisões de agrupamento e escalonamento são restringidas pelo cache KV:
- Cada sequência ativa consome memória de KV proporcional a (camadas × heads × head_dim × comprimento_da_sequência)
- Contextos longos e muitas sequências concorrentes podem dominar a memória da GPU
- Fragmentação e overhead de alocação podem importar em QPS alto
Pilhas modernas de serving usam gerenciamento especializado de cache KV (frequentemente “paginado” ou baseado em blocos) para permitir admissão/evicção flexível e reduzir fragmentação. Isso é coberto em Cache KV, mas o principal ponto aqui é:
Seu escalonador é, em parte, um escalonador de memória. O melhor plano de vazão é inútil se ele causar OOM ou forçar movimentação excessiva de KV.
Controles de latência vs vazão que você pode ajustar
Controles comuns em sistemas de serving de LLM:
- Atraso máximo de lote / atraso de fila: limita o impacto no TTFT
- Máximo de sequências ativas: limita memória de KV e estabiliza o TPOT do decode
- Máximo de tokens de prefill por iteração: impede prompts enormes de bloquear muitos pequenos
- Prefill em blocos (chunked prefill): dividir prompts muito longos entre iterações para reduzir bloqueio no início da fila
- Granularidade de streaming: com que frequência você envia tokens para os clientes
- Priorizar prefills (TTFT) vs priorizar decodes (vazão total)
Um padrão típico de produção para chat:
- Manter um limite rígido de atraso de fila (por exemplo, poucos ms até dezenas de ms)
- Priorizar admitir novos prefills até um limite de tokens
- Manter a iteração de decode rápida e consistente para fornecer streaming suave
Relação com decodificação especulativa
A Decodificação Especulativa muda a economia do escalonamento:
- Ela pode reduzir o número de passos “caros” do verificador por token de saída
- Ela pode aumentar o comportamento em rajadas (tokens chegam em blocos)
- Ela pode deslocar a estratégia ótima de agrupamento porque a carga de trabalho do modelo verificador se torna mais variável
Na prática, a decodificação especulativa frequentemente se beneficia da mesma infraestrutura de agrupamento contínuo, mas escalonadores podem precisar considerar a carga do verificador e as taxas de aceitação.
Exemplos práticos e regras gerais
Exemplo 1: comportamento em baixo tráfego vs pico de tráfego
- Em baixo tráfego, agrupamento agressivo aumenta a latência porque as requisições esperam a formação do lote.
- Em pico de tráfego, o agrupamento ajuda: a fila naturalmente enche, e você obtém alta utilização sem atraso artificial.
Regra geral: defina max_batch_delay_ms com base no seu SLO de TTFT, não em metas de vazão. Ganhos de vazão frequentemente vêm “de graça” no pico de carga.
Exemplo 2: um prompt muito longo prejudica todo mundo
Se você admitir um prompt de 20k tokens em um lote de prefill, ele pode monopolizar a iteração e atrasar muitos prompts curtos.
Mitigações:
- Limites de tokens por iteração de prefill
- Filas baseadas em comprimento
- Prefill em blocos (processar prompts longos em fatias)
Exemplo 3: chat multi-locatário + sumarização em segundo plano
- Requisições de chat: curtas, sensíveis à latência
- Sumarização: longa, orientada à vazão
Política prática:
- Duas classes de prioridade
- Filas separadas
- Alocar uma fração fixa das iterações de decode (ou slots ativos) para trabalho em segundo plano
- Impor atraso máximo de fila por classe; descartar carga em segundo plano primeiro
Observabilidade: o que medir
Para ajustar agrupamento/escalonamento, acompanhe:
- Profundidade da fila e distribuição de tempo de espera na fila
- TTFT P50/P95/P99 e E2E P50/P95/P99
- tokens/seg de decode e contagem de sequências ativas
- Vazão de tokens de prefill vs vazão de tokens de decode
- Utilização de GPU, detalhamento de tempo por kernel
- Uso do cache KV (bytes, fragmentação, evicções)
- Amplificação de cauda durante rajadas (como o P99 cresce vs QPS)
Bom trabalho de escalonamento é empírico: tipicamente você combina um modelo teórico (custo em tokens, custo em memória) com medições em produção.
Modos de falha comuns
- Agrupar demais (over-batching): ótimos benchmarks de vazão, TTFT terrível em produção
- Sem controle de admissão: fila descontrolada sob rajada → timeouts → retries → rajada pior (ciclo de feedback)
- Bloqueio no início da fila: prompts/gerações longos dominam lotes compartilhados
- Injustiça: gerações longas de um locatário consomem KV e deixam outros sem recursos
- Desperdício por padding: agrupamento ingênuo por contagem de requisições em vez de agrupamento sensível a tokens/comprimento
Resumo
Agrupamento e escalonamento de inferência são as alavancas centrais para tornar o serving de LLMs custo-efetivo enquanto atende SLOs de latência:
- Agrupamento aumenta a eficiência da GPU, mas introduz atraso de enfileiramento.
- Para LLMs, agrupamento contínuo e escalonamento em nível de iteração são cruciais porque o decode ocorre em passos e as requisições diferem em comprimento.
- Escalonadores eficazes são sensíveis a tokens e memória, não apenas à contagem de requisições.
- Controle de admissão e políticas de justiça são necessários para manter a latência de cauda limitada e evitar colapso por sobrecarga.
- Restrições do cache KV moldam fortemente decisões viáveis de escalonamento; veja Cache KV.
- Técnicas avançadas como Decodificação Especulativa interagem com o escalonamento e podem deslocar o comportamento ótimo de agrupamento.
Na prática, a “melhor” estratégia depende do seu mix de tráfego (interativo vs lote), tamanho do modelo, comprimentos de contexto e SLOs — portanto, implantações bem-sucedidas combinam escalonamento bem fundamentado com medição cuidadosa e ajuste iterativo.