CHAPTER 01

Capítulo 1: A filosofia de design do vLLM e uma visão geral da arquitetura

Projeto pertencente: vllm-project/vllm · Progresso do livro: Capítulo 1 / 14 · Status de verificação: FACT com números de linha realmente ancorados

Suponha que você tenha uma A100 e queira usar o LLaMA-7B para oferecer um serviço de inferência online. A abordagem mais simples é: chega uma requisição, executa-se um model.generate() e retorna-se o resultado. Essa solução entra em colapso imediatamente quando a concorrência aumenta — não porque o poder de computação da GPU seja insuficiente, mas por dois motivos: primeiro, a memória de vídeo é consumida por fragmentação. A geração autorregressiva precisa armazenar em cache os tensores Key/Value de cada camada (KV Cache). Se cada requisição pré-alocar um bloco contíguo inteiro de memória de vídeo com base em max_model_len, uma requisição de 4096 tokens ocuparia dezenas de MB, enquanto a sequência realmente gerada pode ter apenas 200 tokens. Pior ainda, requisições de comprimentos diferentes entram e saem alternadamente, e os blocos contíguos de memória de vídeo são divididos em pedaços irregulares; no fim, embora o total seja suficiente, não se encontra um espaço contíguo grande o bastante — este é o clássico problema de fragmentação de memória de vídeo. Segundo, a eficiência de batching é baixa. O batching estático tradicional exige que todas as requisições de um batch comecem e terminem ao mesmo tempo. Mas o comprimento de saída de tarefas de geração é naturalmente imprevisível: uma requisição pode parar com 10 tokens, enquanto outra precisa gerar 2000. Depois que a requisição curta termina, o slot de batch que ela ocupava só pode esperar ocioso até a requisição longa terminar, e a utilização da GPU despenca. As duas bases de design do vLLM visam exatamente esses dois pontos problemáticos: PagedAttention elimina a fragmentação de memória de vídeo com um mecanismo de paginação, e Continuous Batching elimina a ociosidade do batching com agendamento em nível de iteração. Este capítulo não aprofunda os detalhes de implementação desses dois mecanismos (esse é o tema dos capítulos 2 e 4), mas primeiro estabelece um mapa global: como é a arquitetura de processos do vLLM v1, como as responsabilidades de cada camada são divididas e por quais componentes uma requisição passa desde a entrada no sistema até a emissão de tokens. Entendendo esse mapa, a leitura do código-fonte de cada capítulo seguinte terá um ponto de apoio.

Arquitetura de processos: por que o vLLM não é um programa de processo único

Modelo intuitivo

Imagine o vLLM como um restaurante. A recepção (API Server) é responsável por atender os clientes e registrar os pedidos; o núcleo da cozinha (EngineCore) decide qual prato preparar primeiro e em qual fogão; cada fogão (GPU Worker) é operado exclusivamente por um cozinheiro. Se uma única pessoa atendesse e cozinhasse ao mesmo tempo, nos horários de pico inevitavelmente haveria confusão — é por isso que o vLLM separa esses papéis em processos independentes.

〔Inferência de design e trade-offs de arquitetura〕

A motivação central dessa divisão em múltiplos processos éseparação de responsabilidades: parsing HTTP, tokenization e carregamento de dados multimodais são operações intensivas em CPU e potencialmente bloqueantes, enquanto a propagação direta do modelo é intensiva em GPU. Se estivessem no mesmo processo, o GIL do Python faria com que ambos se prejudicassem mutuamente. Após a divisão em processos independentes, o API Server pode continuar recebendo novas requisições, o EngineCore pode continuar agendando e o GPU Worker pode continuar computando, com os três desacoplados por meio de filas de mensagens ZMQ.

Topologia de processos e relação de quantidades

A arquitetura de processos do vLLM v1 pode ser resumida em uma fórmula. ParaNGPUs, grau de paralelismo de tensorTP, grau de paralelismo de pipelinePP, grau de paralelismo de dadosDP, número de API ServersAem uma implantação:

Tipo de processoQuantidadeResponsabilidade
API ServerA(por padrão igual aDP)Processamento de requisições HTTP, pré-processamento de entrada, retorno de resultados em streaming
EngineCoreDP(padrão 1)Agendamento, gerenciamento de KV Cache, coordenação dos GPU Workers
GPU WorkerN(= DP × PP × TP)Carregamento de pesos, execução da propagação direta, gerenciamento de memória de vídeo
DP CoordinatorDP > 1quando for 1, caso contrário 0Balanceamento de carga entre ranks de DP e coordenação de ondas do MoE

📎 docs/design/arch_overview.md:113-113fornece a definição autoritativa desta tabela. Uma implantação típica de 4 GPUs em um único nó (vllm serve -tp=4) gera 1 API Server + 1 EngineCore + 4 GPU Workers = 6 processos📎 docs/design/arch_overview.md:115-115. Já uma implantação de 8 GPUs com TP=2/DP=4 expande para 4 + 4 + 8 + 1 = 17 processos📎 docs/design/arch_overview.md:123-123。

Há aqui um detalhe facilmente negligenciado:o número de API Servers segue por padrão o tamanho do DP. Quando--data-parallel-size 4, são iniciados automaticamente 4 API Servers, cada um conectado a todos os EngineCores via ZMQ em topologia muitos-para-muitos📎 docs/design/arch_overview.md:73-73. Isso significa que qualquer API Server pode rotear requisições para qualquer EngineCore, evitando gargalos de ponto único.

Fluxo de dados

A figura abaixo mostra o caminho completo de uma requisição entre os processos. Observe que cada nó está rotulado com nomes reais de classes e estruturas de dados:

mermaid
flowchart LR
    client["客户端 HTTP 请求"] --> api["API Server 进程<br/>输入预处理 + tokenization"]
    api -->|"EngineCoreRequest<br/>via ZMQ ADD"| core["EngineCore 进程<br/>Scheduler + KVCacheManager"]
    core -->|"SchedulerOutput<br/>via Executor"| worker["GPU Worker 进程<br/>ModelRunner.forward()"]
    worker -->|"ModelRunnerOutput<br/>token ids + logprobs"| core
    core -->|"EngineCoreOutputs<br/>via ZMQ"| api
    api -->|"流式 SSE 响应"| client

O ponto-chave desta figura é:entre o API Server e o EngineCore há passagem assíncrona de mensagens, e não chamada de função. A requisição é serializada naEngineCoreRequestestrutura (ummsgspec.Struct, ver📎 vllm/v1/engine/__init__.py:109-113), enviada via ZMQ com o tipo de mensagemADD📎 vllm/v1/engine/__init__.py:287-299. Após o processamento, o EngineCore empacota o resultado comoEngineCoreOutputse o retorna📎 vllm/v1/engine/__init__.py:256-260。

〔Inferência de design e trade-offs arquiteturais〕

A escolha de ZMQ em vez de gRPC ou memória compartilhada se deve ao fato de que ZMQ tem latência extremamente baixa em cenários de comunicação entre processos (nível de microssegundos) e suporta naturalmente topologias muitos-para-muitos e semântica de filas de mensagens. Para serviços de inferência, que são sensíveis à latência do primeiro token, a sobrecarga de comunicação deve ser a menor possível.

Reflexão de design: por que o EngineCore é um processo independente e não uma thread

Uma pergunta natural é: já que o EngineCore e o API Server estão na mesma máquina, por que não colocá-los no mesmo processo e usar comunicação por threads?

A resposta está no modo de operação do EngineCore. O EngineCore executa umbusy loop(busy loop), agendando continuamente requisições e distribuindo trabalho para os GPU Workers📎 docs/design/arch_overview.md:73-73. Esse loop não pode ser interrompido — uma vez bloqueado por parsing HTTP ou tokenization, toda a pipeline de inferência sofre bolhas. O processo independente garante que a fatia de CPU do EngineCore não seja preemptada pela lógica de frontend.

Além disso, o processo independente também trazisolamento de falhas: se o API Server travar por causa de alguma requisição malformada, o EngineCore e os GPU Workers não são afetados e podem continuar atendendo requisições encaminhadas por outros API Servers.

Modelo mental em camadas: fronteiras de responsabilidade da entrada até a GPU

Modelo intuitivo

Se a arquitetura de processos é "quem faz o quê e onde", então o modelo em camadas é "qual decisão cada camada toma". A organização do código do vLLM segue um princípio claro de camadas:a camada superior decide o que fazer, a camada inferior decide como fazer. A camada de entrada decide quais requisições aceitar, a camada central do engine decide quem processar primeiro, a camada de executor decide qual estratégia de paralelismo usar, e a camada de Worker decide como produzir resultados no hardware específico.

Estrutura em quatro camadas

Camada de entrada (Entrypoints)oferece duas formas de interação: a classeLLMpara inferência offline e o comandovllm servepara serviço online📎 docs/design/arch_overview.md:16-16📎 docs/design/arch_overview.md:56-56. A responsabilidade central desta camada é o pré-processamento de entrada — tokenization, carregamento de dados multimodais, parsing de parâmetros de amostragem — além da detokenization da saída e do retorno em streaming. Ela não se preocupa com estratégias de agendamento nem toca na GPU.

Camada central do engine (EngineCore)é o cérebro de todo o sistema. Ela mantém o Scheduler (que decide quais requisições processar em cada decode step) e o KV Cache Manager (que gerencia a memória de vídeo paginada), comunicando-se com os GPU Workers através da abstração Executor📎 docs/design/arch_overview.md:79-85. O design-chave desta camada éa separação entre agendamento e execução: o Scheduler apenas produz a decisão de "quais tokens rodar neste passo" (SchedulerOutput), e como executar concretamente na GPU é responsabilidade do Worker.

Camada de executor (Executor)é a ponte entre o EngineCore e os Workers. Ela encapsula as estratégias de execução distribuída — em processo único usaUniProcExecutor, em múltiplos processos usaMultiprocExecutor, em cluster Ray usaRayDistributedExecutor. A interface abstrata do Executor faz com que o EngineCore não precise saber se a base é uma única GPU ou 8 GPUs com TP.

Camada de Workercada GPU tem um processo Worker, que internamente mantém o ModelRunner e o objeto real de modelotorch.nn.Module📎 docs/design/arch_overview.md:171-191. O ModelRunner é responsável por preparar os tensores de entrada, capturar CUDA Graphs e executar o cálculo forward. Esta camada é o único lugar que opera diretamente a memória de vídeo da GPU e as streams CUDA.

Objeto de configuração: estado global que atravessa todas as camadas

Como as quatro camadas trocam informações entre si? A resposta éVllmConfig— um dataclass gigante contendo todas as configurações📎 vllm/config/vllm.py:357-357。

python
@config(config=ConfigDict(arbitrary_types_allowed=True))
class VllmConfig:
    """Dataclass which contains all vllm-related configuration."""
    model_config: ModelConfig = None
    cache_config: CacheConfig = Field(default_factory=CacheConfig)
    parallel_config: ParallelConfig = Field(default_factory=ParallelConfig)
    scheduler_config: SchedulerConfig = Field(default_factory=SchedulerConfig.default_factory)
    # ... 还有 20+ 个子配置

📎 vllm/config/vllm.py:363-371mostra os campos principais. A lógica por trás dessa escolha de design merece ser detalhada.

〔Inferência de design e trade-offs arquiteturais〕

A documentação explica claramente por que usar um grande objeto de configuração em vez de passar parâmetros dispersos:Escalabilidade. Suponha que seja necessário adicionar um novo recurso que afeta apenas o ModelRunner; basta adicionar um campo emVllmConfige o ModelRunner o lê diretamente, sem precisar modificar as assinaturas dos construtores de Engine, Worker e Model📎 docs/design/arch_overview.md:203-203. Em um framework de inferência em rápida evolução, essa capacidade de "adicionar campos sem alterar interfaces" reduz enormemente o atrito no desenvolvimento.

O custo é queVllmConfigse torna extremamente grande — como se pode ver em📎 vllm/config/vllm.py:356-3509, essa classe ultrapassa 3000 linhas de código, contendo dezenas de campos e métodos de validação.__post_init__O método📎 vllm/config/vllm.py:1405-2317chega a ter mais de 900 linhas, assumindo toda a validação cruzada entre itens de configuração e a derivação de valores padrão.

Hash e cache de configuração

VllmConfigHá também uma capacidade facilmente negligenciada, mas muito importante:compute_hash() 📎 vllm/config/vllm.py:464-580. Ele gera um hash curto para todos os itens de configuração que afetam a estrutura do grafo computacional.

python
def compute_hash(self, include_version: bool = True) -> str:
    factors: list[Any] = []
    vllm_factors: list[Any] = []
    if include_version:
        from vllm import __version__
        vllm_factors.append(__version__)
    if self.model_config:
        vllm_factors.append(self.model_config.compute_hash())
    # ... 逐个追加各子配置的哈希
    hash_str = safe_hash(str(factors).encode(), usedforsecurity=False).hexdigest()[:10]
    return hash_str

📎 vllm/config/vllm.py:479-580mostra o fluxo completo de cálculo do hash. Observe o aviso no comentário: "Whenever a new field is added to this config, ensure that it is included in the factors list if it affects the computation graph"📎 vllm/config/vllm.py:465-467。

〔Inferência de design e trade-offs arquiteturais〕

O propósito desse hash échave de cache do torch.compile. O vLLM usatorch.compilepara compilar o grafo forward do modelo, e o resultado da compilação é armazenado em disco. Na próxima inicialização, se o hash da configuração for o mesmo, o cache de compilação pode ser reutilizado diretamente, pulando o demorado processo de compilação. Se algum item de configuração que afeta o grafo computacional não for incluído no hash, isso causará erro de acerto de cache — usar um grafo compilado com a configuração antiga para executar a nova configuração, resultando em erro silencioso. É por isso que o comentário enfatiza repetidamente que "campos que afetam o grafo computacional devem ser incluídos no hash".

Walkthrough do ciclo de vida da requisição: do HTTP ao Token

Definição do cenário

Suponha que o cliente envie paravllm serveum serviço iniciado que envia uma requisição compatível com OpenAI/v1/completions, com prompt "The capital of France is", solicitando a geração de 16 tokens. Vamos rastrear a jornada completa dessa requisição pelo código-fonte.

Step 1: O API Server recebe e pré-processa

Após o processo do API Server receber a requisição HTTP, ele realiza tokenization e parsing dos parâmetros de amostragem, e então constróiEngineCoreRequest:

python
class EngineCoreRequest(
    msgspec.Struct,
    array_like=True,
    omit_defaults=True,
    gc=False,
):
    request_id: str
    prompt_token_ids: list[int] | None
    mm_features: list[MultiModalFeatureSpec] | None
    sampling_params: SamplingParams | None
    pooling_params: PoolingParams | None
    arrival_time: float
    lora_request: LoRARequest | None
    cache_salt: str | None
    data_parallel_rank: int | None
    prompt_embeds: torch.Tensor | None = None
    # ... 更多字段

📎 vllm/v1/engine/__init__.py:109-124define a estrutura central da requisição. Observemsgspec.Structcombinado comarray_like=Trueeomit_defaults=Truea combinação📎 vllm/v1/engine/__init__.py:109-113— isso serve paradesempenho de serialização。array_likefazer o msgspec codificar usando arrays posicionais em vez de dicionários,omit_defaultspular campos com valores padrão; a combinação dos dois reduz drasticamente o tamanho das mensagens ZMQ.

〔Inferência de design e trade-offs arquiteturais〕

gc=Falsediz ao msgspec para não gerar código de rastreamento de GC para essa estrutura📎 vllm/v1/engine/__init__.py:109-113. Para objetos de mensagem criados/destruídos com alta frequência, desativar o rastreamento de GC reduz a pressão sobre o coletor de lixo do Python, o que é uma otimização necessária em cenários que processam milhares de requisições por segundo.

Step 2: Agendamento do EngineCore

Após o EngineCore receber a requisição, o Scheduler a coloca na fila de espera. Em cada passo de agendamento, o Scheduler decide se inclui essa requisição no lote atual. Se incluída, o KV Cache Manager alocará blocos físicos para ela (operação central do PagedAttention, detalhada no Capítulo 2).

O resultado do agendamento é encapsulado comoSchedulerOutput, e enviado ao GPU Worker através do Executor.

Step 3: O GPU Worker executa o forward

O ModelRunner do Worker recebeSchedulerOutput, prepara os tensores de entrada (incluindo block table, slot mapping e outros metadados de attention), executa o forward do modelo e amostra o próximo token.

Step 4: Retorno do resultado

O token produzido pelo Worker é encapsulado comoEngineCoreOutput:

python
class EngineCoreOutput(
    msgspec.Struct,
    array_like=True,
    omit_defaults=True,
    gc=False,
):
    request_id: str
    new_token_ids: list[int]
    new_logprobs: LogprobsLists | None = None
    finish_reason: FinishReason | None = None
    stop_reason: int | str | None = None
    # ...

📎 vllm/v1/engine/__init__.py:199-217define a estrutura de saída.finish_reasoné umIntEnum, com valores incluindoSTOP、LENGTH、ABORT、ERROR、REPETITION 📎 vllm/v1/engine/__init__.py:68-69. O comentário explica por que usarIntem vez deStr:「Int rather than Str for more compact serialization」📎 vllm/v1/engine/__init__.py:56-57— mais uma otimização de tamanho de serialização.

MúltiplosEngineCoreOutputsão empacotados emEngineCoreOutputs, e retornados ao API Server via ZMQ📎 vllm/v1/engine/__init__.py:256-260。

Step 5: Retorno em streaming do API Server

Após o API Server receberEngineCoreOutputs, para cadaEngineCoreOutputrealiza detokenization e então envia ao cliente via streaming usando SSE (Server-Sent Events).

Sequência temporal completa

O diagrama de sequência abaixo mostra a interação completa entre processos, anotando os nomes reais de funções e estruturas de dados em cada passo:

mermaid
sequenceDiagram
    participant Client as 客户端
    participant API as API Server 进程
    participant Core as EngineCore 进程
    participant Sched as Scheduler
    participant Worker as GPU Worker 进程

    Client->>API: POST /v1/completions
    API->>API: tokenize(prompt) -> prompt_token_ids
    API->>Core: EngineCoreRequest via ZMQ ADD
    Core->>Sched: add_request(EngineCoreRequest)
    loop 每个 decode step
        Sched->>Sched: schedule() -> SchedulerOutput
        Sched->>Worker: execute_model(SchedulerOutput)
        Worker->>Worker: ModelRunner.forward() + sample()
        Worker-->>Sched: ModelRunnerOutput
        Sched->>Sched: update_from_output() -> EngineCoreOutput
        Core-->>API: EngineCoreOutputs via ZMQ
        API-->>Client: SSE chunk (new_token_ids)
    end
    Note over Sched: finish_reason != None 时请求退出

Informações-chave deste diagrama:Cada decode step produz umEngineCoreOutputsde retorno, em vez de esperar toda a sequência ser gerada para retornar. Isso é exatamente a manifestação do Continuous Batching — sequências concluídas saem imediatamente, novas requisições entram imediatamente, e a saída é retornada em streaming ao cliente.

Reflexões de design e armadilhas em produção

O padrão de "inicialização postergada" na validação de configuração

VllmConfig.__post_init__é o núcleo de todo o sistema de configuração. Não é uma simples atribuição de campos, mas sim umpipeline de validação multifásico:

1. Primeiro, analisa o modo do codificador multimodal📎 vllm/config/vllm.py:1416-1416

2. Em seguida, chamatry_verify_and_update_config(), permitindo que hooks de configuração específicos do modelo tenham a oportunidade de modificar a configuração📎 vllm/config/vllm.py:1434-1434

3. Depois, valida a consistência entre configuração paralela, configuração de quantização e configuração de LoRA📎 vllm/config/vllm.py:1442-1444

4. Por fim, trata verificações de compatibilidade de recursos de runtime como agendamento assíncrono, CUDA Graph, KV Transfer, etc.📎 vllm/config/vllm.py:1544-1635

〔Inferência de design e trade-offs arquiteturais〕

Esse padrão de "inicialização posterior" resolve uma contradição fundamental:existem dependências entre itens de configuração, mas o usuário pode defini-los em qualquer ordem. Por exemplo,async_schedulingse está habilitado depende do tipo de método em speculative_config, se o backend do executor oferece suporte, se pipeline parallelism é usado, entre várias outras condições📎 vllm/config/vllm.py:1544-1575. Se essa lógica fosse colocada no__set__do campo, formaria dependências circulares complexas. Centralizá-la em__post_init__para processamento sequencial torna a lógica clara e fácil de depurar.

Armadilha: conflito entre KV Connector e expandable_segments

📎 vllm/config/vllm.py:1219-1260O_verify_kv_transfer_compatem revela uma armadilha de produção muito sutil.

Ao usar KV Connector (como NIXL, Mooncake) para implantação com separação PD, esses connectors, por meio de mecanismos comoibv_reg_mrfixam (pin) as páginas de memória física do KV cache. Mas sePYTORCH_CUDA_ALLOC_CONF=expandable_segments:Truefor definido ao mesmo tempo, o alocador CUDA VMM do PyTorch pode, em runtime, remapear o mesmo endereço virtual para páginas físicas diferentes📎 vllm/config/vllm.py:1227-1233。

Qual é a consequência? A região de memória RDMA registrada pelo Connector aponta para páginas físicas que já não são válidas. A primeira transferência de KV entre nós reportaráIBV_WC_REM_ACCESS_ERRouNIXL_ERR_REMOTE_DISCONNECT 📎 vllm/config/vllm.py:1232-1233。

A estratégia do vLLM érejeição conservadora: desde que detecteexpandable_segments:Truee qualquer KV connector configurado, lança exceção diretamente📎 vllm/config/vllm.py:1249-1260. A única exceção é quandoenable_cumem_allocatorestá habilitado — porque o alocador CuMem desativaexpandable_segments 📎 vllm/config/vllm.py:1238-1241。

ao redor de seu próprio pool de memória

〔Inferência de design e trade-offs arquiteturais〕A lição deste caso é:registro de memória RDMA e remapeamento de memória virtual são semanticamente incompatíveisPYTORCH_CUDA_ALLOC_CONF。

. Qualquer funcionalidade que envolva pin de memória GPU (transferência KV, buffers de registro NCCL, etc.) deve garantir que as páginas físicas subjacentes não sejam movidas silenciosamente pelo alocador. Ao investigar esse tipo de problema, se você vir uma transferência RDMA falhar na primeira comunicação entre nós, a primeira reação deve ser verificar

__post_init__Armadilha: cadeia de degradação automática do agendamento assíncronoasync_schedulingA lógica de tratamento de📎 vllm/config/vllm.py:1544-1635emdemonstra uma。

cadeia de degradação automáticaasync_schedulingcuidadosamente projetadaNoneQuando o usuário não define

  • explicitamente (valor📎 vllm/config/vllm.py:1578-1587
  • ), o vLLM tenta habilitá-lo automaticamente, mas precisa verificar sequencialmente uma série de condições de incompatibilidade:📎 vllm/config/vllm.py:1588-1601
  • Se for modelo pooling, desabilitadisable_padded_drafter_batch=TrueSe o método speculative não estiver na lista de suporte, desabilita📎 vllm/config/vllm.py:1602-1610
  • Se📎 vllm/config/vllm.py:1611-1617
  • , desabilita📎 vllm/config/vllm.py:1618-1624
  • Se o backend do executor não oferecer suporte, desabilita📎 vllm/config/vllm.py:1625-1633

Se for ROCm DeepEP high-throughput DBO, desabilita📎 vllm/config/vllm.py:1639-1640。

Se for PP > 1 e usar V1 Model Runner, desabilita

Somente se todas as verificações passarem, habilita〔Inferência de design e trade-offs arquiteturais〕A filosofia de design desta cadeia de degradação é:

habilitar a configuração ótima por padrão, degradar silenciosamente e registrar aviso quando houver incompatibilidade

. Isso é muito mais amigável do que exigir que o usuário configure manualmente cada switch de compatibilidade. Mas o custo é — quando o desempenho fica abaixo do esperado, o usuário precisa vasculhar os logs para descobrir que o agendamento assíncrono foi desabilitado automaticamente. Em produção, se detectar throughput anormal, recomenda-se verificar se há o aviso "Async scheduling will be disabled" nos logs de inicialização.

1. Resumo do capítuloEste capítulo estabeleceu o modelo mental global do vLLM v1, com os pontos principais:

2. Os dois problemas fundamentais que o vLLM resolve: fragmentação de memória de vídeo (gerenciamento paginado do PagedAttention) e ociosidade no batching (agendamento em nível de iteração do Continuous Batching).A + DP + NArquitetura multiprocesso

3. : API Server (entrada) → EngineCore (agendamento) → GPU Worker (execução), três camadas de processos, comunicando-se assincronamente via ZMQ. O número de processos segue afórmula.

4. Modelo em quatro camadas: a camada de entrada é responsável pelo pré-processamento, a camada central do engine pelas decisões de agendamento, a camada do executor pela estratégia distribuída, e a camada Worker pela computação na GPU.compute_hash()VllmConfig é o estado global que atravessa todas as camadas__post_init__, suportando cache de compilação via

5. , e implementando validação entre itens de configuração e derivação de valores padrão via:HTTP → tokenize → EngineCoreRequest → Scheduler → Worker forward → EngineCoreOutputCiclo de vida da requisição

→ retorno em streaming via SSE.

Reflexões e autoavaliação do capítuloEngineCoreRequestQ1: Se omsgspec.Structparâmetroarray_like=True, omit_defaults=Truedearray_like=False, omit_defaults=Falsefor alterado de📎 vllm/v1/engine/__init__.py:109-113para o valor padrão (ou seja,📎 vllm/v1/engine/__init__.py:256-260), em quais cenários isso causaria problemas de desempenho? Analise combinando

e:array_like=TrueAnálise de referênciaomit_defaults=Truefaz o msgspec codificar structs usando arrays posicionais em vez de dicionários,EngineCoreRequestseria codificado como uma estrutura de dicionário contendo todos os nomes de campos, e o tamanho poderia inflar de 2 a 3 vezes. Em cenários de alta concorrência (milhares de requisições por segundo), o volume de mensagens ZMQ entre o API Server e o EngineCore aumentaria significativamente, levando ao aumento do custo de CPU com serialização/desserialização e ao desperdício de largura de banda de rede.EngineCoreOutputstambém usa esses dois parâmetros📎 vllm/v1/engine/__init__.py:256-260, e ele é gerado a cada decode step, com impacto ainda maior. Além disso,gc=Falsedesativa o rastreamento do GC, o que pode aliviar a pressão do GC do Python para objetos de alta frequência e curta duração.

Q2: EmVllmConfig.__post_init__,async_schedulinga lógica de ativação automática (📎 vllm/config/vllm.py:1576-1635) adota a estratégia de "verificar sequencialmente as condições de incompatibilidade e só ativar se todas passarem". Se uma nova funcionalidade incompatível com o agendamento assíncrono for adicionada, mas o desenvolvedor esquecer de adicionar o branch correspondente nessa cadeia de verificações, que problema isso causaria? Analise do ponto de vista do comportamento do sistema.

Análise de referência: Se o branch de verificação for esquecido, o agendamento assíncrono será ativado incorretamente. A suposição central do agendamento assíncrono é que "a decisão de agendamento do step atual não depende da saída do step anterior", o que permite ao EngineCore agendar o próximo step antes que a computação da GPU do step anterior tenha terminado. Se a nova funcionalidade violar essa suposição (por exemplo, alguma lógica de pós-processamento que precisa ler os logits do step anterior), o agendamento assíncrono causará condições de corrida ou resultados incorretos. De forma mais sutil, esse tipo de bug pode ser acionado apenas em determinadas sequências de concorrência, sendo difícil de reproduzir. É exatamente por isso que📎 vllm/config/vllm.py:1549-1552o caminho de ativação explícita adota a estratégia de "hard fail" — quando o usuário ativa ativamente, ele gera erro diretamente em vez de degradar silenciosamente, forçando o desenvolvedor a enfrentar o problema de compatibilidade.

Q3: VllmConfig.compute_hash()o comentário alerta que "campos que afetam o grafo computacional devem ser adicionados à lista factors" (📎 vllm/config/vllm.py:465-467). Suponha que um novo campoattention_sink_tokensafete a lógica de cálculo de attention, mas seja omitido no hash. Que tipo de falha isso acionaria em ambiente de produção? Por que esse tipo de falha é especialmente perigoso?

Análise de referência:compute_hash()a saída é usada como chave do cache de compilação do torch.compile. Seattention_sink_tokensafeta a estrutura do grafo computacional, mas não é incluído no hash, então quando o usuário muda deattention_sink_tokens=0paraattention_sink_tokens=4, o valor do hash permanece o mesmo, e o vLLM reutilizará o grafo compilado anteriormente (sem a lógica de sink token). O resultado é que o modelo produz silenciosamente saídas incorretas — sem erro, sem crash, apenas resultado errado. Esse tipo de falha é especialmente perigoso porque: (1) não dispara nenhuma exceção nem aviso de log; (2) a saída ainda é um texto que "parece razoável", apenas com qualidade reduzida ou comportamento anômalo; (3) para investigar, é necessário comparar o acerto do cache de compilação com as diferenças reais de configuração, com custo de localização extremamente alto. É por isso que o comentário enfatiza repetidamente que novos campos devem ser avaliados quanto a impactar ou não o grafo computacional.

Este capítulo parte da cena de crash de uma requisição de inferência ingênua, revelando dois conflitos fundamentais que o vLLM precisa resolver: fragmentação de memória de vídeo e ociosidade do batching, e apresenta as duas chaves: PagedAttention e Continuous Batching. Em seguida, fazemos uma visão geral da arquitetura do vLLM v1, esclarecendo o modelo de processos, a divisão em camadas dos componentes e o ciclo de vida completo de uma requisição. Com esse mapa global em mãos, o próximo capítulo aprofundará a estrutura de dados mais central do vLLM — Request, Sequence e o mecanismo de gerenciamento de blocos do KV Cache —, revelando como o PagedAttention implementa, no nível do código, o mapeamento de memória de vídeo com "continuidade lógica e dispersão física".

CHAPTER 02

Capítulo 2: Abstrações centrais: estruturas de dados de Request, Sequence e KV Cache

Projeto pertencente: vllm-project/vllm · Progresso do livro: Capítulo 2 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, estabelecemos o modelo mental em camadas do vLLM v1, sabendo que uma requisição parte do API Server, atravessa o EngineCore e finalmente chega ao Worker para execução. Mas como uma string JSON no corpo de uma requisição HTTP se transforma em um objeto interno do motor que pode ser agendado, rastreado e interrompido? Essa é a pergunta que a classe Request deve responder.

O sistema de especificações do KV Cache: de KVCacheSpec ao registro

Request resolve o problema de "quem deve calcular", enquantoKVCacheSpecresolve o problema de "onde calcular". No mundo do PagedAttention, o KV cache de cada camada do modelo precisa ser descrito com precisão: quantos heads ele tem, qual o tamanho de cada head, quantos tokens um bloco pode armazenar, se precisa de quantização. Essas informações são codificadas no sistema de herança deKVCacheSpec.

Modelo intuitivo: KVCacheSpec é a "planta baixa" da memória de vídeo

〔Inferência de design e trade-offs arquiteturais〕

Se imaginarmos a memória de vídeo da GPU como um terreno a ser desenvolvido,KVCacheSpecé a planta baixa de cada prédio (cada cache group): ela define quantos quartos (head slot) cada andar (cada bloco) tem, qual o tamanho de cada quarto (head_size), quantas pessoas podem morar (block_size tokens). EKVCacheConfigé o plano de layout de todo o complexo residencial — quantos edifícios no total, quanto terreno cada edifício ocupa, quais edifícios compartilham a mesma fundação (block table).

Sem este sistema de especificações, a alocação do KV cache só poderia depender de suposições codificadas manualmente, incapaz de suportar as diversas necessidades de modelos que vão desde MHA padrão até MLA, de atenção completa até janela deslizante, de FP16 até quantização FP8.

Estrutura de dados: árvore de herança e campos-chave do KVCacheSpec

KVCacheSpecé a classe base de todas as especificações, é uma@dataclass(frozen=True) 📎 vllm/v1/kv_cache_interface.py:150-152. frozen significa que o objeto de especificação é imutável uma vez criado — isso garante que múltiplos componentes (scheduler, Worker, KV Cache Manager) vejam a mesma especificação, sem inconsistências causadas por modificações em algum lugar.

A classe base define três propriedades abstratas que devem ser implementadas pelas subclasses:num_heads、tokens_per_state、state_content_size_bytes 📎 vllm/v1/kv_cache_interface.py:182-183. Essas três propriedades juntas determinampage_size_bytes— ou seja, o número de bytes ocupados por um block.

AttentionSpecé a subclasse mais central, ela introduznum_kv_heads、head_size、dtype、kv_quant_modee outros campos📎 vllm/v1/kv_cache_interface.py:485-498. Entre eles, o design do campotokens_per_stateé particularmente engenhoso: o valor padrão é 1, indicando que um state corresponde a um token; mas pode ser definido como um inteiro maior que 1 (como o MLA esparso do DeepSeek-V4 que comprime múltiplos tokens em um state), ou uma fração menor que 1 (como o block pooling do Whisper usandoFraction(1, block_pool_size)para indicar que um token corresponde a múltiplos states)📎 vllm/v1/kv_cache_interface.py:501-501。

FullAttentionSpecemAttentionSpecadicionasliding_windoweattention_chunk_size 📎 vllm/v1/kv_cache_interface.py:566-566. Note que sua docstring explica uma decisão de design importante: quando o alocador híbrido está desabilitado, as camadas de atenção com janela deslizante são tratadas como atenção completa no KV Cache Manager (alocando blocks para todos os tokens), mas em tempo de execução do modelo ainda são calculadas como janela deslizante📎 vllm/v1/kv_cache_interface.py:540-545. Esta é umaalocação conservadora, cálculo precisoestratégia.

MLAAttentionSpecé a especificação chave da série de modelos DeepSeek. Ela definehead_size_vcomo 0 por padrão📎 vllm/v1/kv_cache_interface.py:670, porque o MLA armazena apenas um latent vector, sem V independente.alignmentO campo é usado para preenchimento de alinhamento de página📎 vllm/v1/kv_cache_interface.py:646-652, o que é crucial para backends como FlashMLA que requerem alinhamento específico.

MambaSpecpor sua vez, não segue a rota de attention de forma alguma. Ele usashapesedtypestuplas para descrever a forma do tensor de estado📎 vllm/v1/kv_cache_interface.py:1027-1028,state_content_size_bytesé a soma de todos os tamanhos de tensores de estado📎 vllm/v1/kv_cache_interface.py:1048-1052. Omax_memory_usage_bytesdo Mamba tem três formas diferentes de cálculo dependendo demamba_cache_mode📎 vllm/v1/kv_cache_interface.py:1073-1084, o que reflete a complexidade do gerenciamento de estado do Mamba — ele não cresce linearmente como o attention, mas tem um tamanho de estado fixo.

Orientado a cenários: conversão de especificação para layout de memória de vídeo

Quando o motor inicia, ele precisa converter oKVCacheSpecde todas as camadas em um layout real de memória de vídeo. Este processo é realizado porKVCacheTensorecreate_kv_cache_views.

KVCacheTensordescreve a posição de um grupo de camadas de mesma forma na alocação do KV cache📎 vllm/v1/kv_cache_interface.py:1406-1427. Seus campos centrais sãolayer_strideeblock_stride: o primeiro é a distância em bytes entre camadas adjacentes, o segundo é a distância em bytes entre blocks adjacentes. A docstring explica em detalhes dois modos de layout: layout com camadas mais externas (layer-outermost) dá a cada camada uma região contígua, layout com blocks mais externos (block-outermost) faz com que cada block contenha as páginas de todas as camadas📎 vllm/v1/kv_cache_interface.py:1416-1416。

mermaid
flowchart LR
    subgraph spec["KVCacheSpec 层"]
        fas["FullAttentionSpec<br/>num_kv_heads=32<br/>head_size=128<br/>block_size=16"]
    end
    subgraph tensor["KVCacheTensor 层"]
        kt["KVCacheTensor<br/>size=2GB<br/>layer_stride=page*num_blocks<br/>block_stride=page"]
    end
    subgraph view["torch.Tensor 视图"]
        v1["layer_0: [B, H, N, C]"]
        v2["layer_1: [B, H, N, C]"]
        v3["layer_N: [B, H, N, C]"]
    end
    fas -->|"compute_layer_kv_cache_shape_bytes()"| kt
    kt -->|"create_kv_cache_views()"| v1
    kt -->|"create_kv_cache_views()"| v2
    kt -->|"create_kv_cache_views()"| v3

create_kv_cache_viewsA função é o núcleo deste processo📎 vllm/v1/kv_cache_interface.py:353-417. Ela recebe um buffer int8 plano, através detorch.as_stridedcria uma visão 4D para cada camada[B, H, N, C]. O parâmetro chave éstrides, que é calculado porcompute_layout_strides📎 vllm/v1/kv_cache_interface.py:314-350. Esta função, seguindo a ordem de dimensões especificada porlayout.stride_order, calcula os strides em bytes de cada dimensão em ordem reversa a partir da dimensão mais interna.

Há uma verificação de limite digna de nota: quando kernel_block_size é menor que spec.block_size (ou seja, um manager block é dividido em múltiplos kernel blocks), o código verifica se block_stride é igual a dense_page_size📎 vllm/v1/kv_cache_interface.py:381-382. Se não for igual, indica que há padding no layout, impossibilitando a divisão uniforme, e neste caso lança um ValueError com sugestão de correção explícita.

Reflexões de design: padrão de registro e extensibilidade

KVCacheSpecRegistryé o design chave para a extensibilidade do vLLM📎 vllm/v1/kv_cache_spec_registry.py:39-40. Ele mantém dois dicionários globais:_REGISTRY_KVCACHESPEC_LISTarmazena o mapeamento de classes spec para metadados,_REGISTRY_ROLE_MANAGERSarmazena o mapeamento de papéis para gerenciadores📎 vllm/v1/kv_cache_spec_registry.py:35-36。

get_manager_classO método demonstra a lógica central de busca do registro: ele percorre a MRO (ordem de resolução de métodos) da classe spec para cima, encontrando a primeira classe base registrada📎 vllm/v1/kv_cache_spec_registry.py:129-130. Isso significa que umCustomFullAttentionSpecpersonalizado, se não registrado separadamente, herdará automaticamente o gerenciador deFullAttentionSpec. Este tipo debusca baseada em herançafaz com que, ao adicionar novos tipos de spec, seja necessário registrar apenas as diferenças.

check_kv_cache_spec_registryO método valida na inicialização que os specs de todas as camadas estão registrados📎 vllm/v1/kv_cache_spec_registry.py:165-174. Note que ele usaraise ValueErrorem vez deassert, e o comentário explica claramente que isso é para ter efeito também em ambiente de produção📎 vllm/v1/kv_cache_spec_registry.py:165-174. Esta é uma decisão de engenharia importante: a flag-Odo Python remove asserts, mas erros de configuração em ambiente de produção devem ser expostos na inicialização, e não causar falhas apenas em tempo de execução.

〔Inferência de design e trade-offs arquiteturais〕

O design de inicialização tardia do registro (_ensure_registered) resolve um problema de dependência circular:kv_cache_interface.pyprecisa referenciar o registro para verificar tipos de spec, e o registro precisa importarsingle_type_kv_cache_managerpara obter a classe gerenciadora, que por sua vez depende dekv_cache_interface. Ao adiar o registro real para a primeira consulta, esse ciclo é quebrado.

Resumo do capítulo

Este capítulo analisou duas estruturas de dados centrais do vLLM v1.Requesté o veículo do ciclo de vida da requisição dentro do engine; por meio de listas duplas de tokens, contadores assíncronos de agendamento e mecanismo de block hash, ele sustenta as duas funcionalidades centrais: batching contínuo e prefix caching.KVCacheSpece sua hierarquia de herança definem a especificação de layout de memória de vídeo do KV cache, desde o padrãoFullAttentionSpecatéMLAAttentionSpec、MambaSpec, cobrindo necessidades de arquiteturas de modelos diversificadas. O padrão de registro permite adicionar novos tipos de spec sem modificar o código central, garantindo a extensibilidade do sistema.

Até aqui, já vimos como Request é convertido a partir de EngineCoreRequest e como ele sustenta decisões de agendamento por meio de contadores de estado, block hash e outros mecanismos. Mas como uma requisição externa atravessa o API Server, o chat template e o processamento multimodal até finalmente se tornar um EngineCoreRequest? O próximo capítulo entrará na camada de entrada de requisições, rastreando completamente essa cadeia do HTTP/CLI até o EngineCore.

CHAPTER 03

Capítulo 3: Entrada de requisições: a cadeia completa do HTTP/CLI ao EngineCore

Projeto: vllm-project/vllm · Progresso do livro: Capítulo 3 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, analisamos as duas estruturas de dados centrais internas do engine: Request e KVCacheSpec, entendendo como a sequência lógica e os blocos físicos de memória de vídeo são desacoplados. Mas como um corpo de requisição HTTP ou uma string Python atravessa o API Server, o chat template e o processamento multimodal até finalmente se tornar um EngineCoreRequest? Este capítulo rastreará completamente essa cadeia e revelará como os três caminhos de entrada — CLI síncrona, API assíncrona e classe LLM offline — convergem para o mesmo núcleo do engine.

3.1 O ponto de convergência dos três caminhos de entrada: AsyncLLMEngine e LLMEngine

Antes de aprofundar na análise de requisições, é necessário visualizar claramente a topologia dos três caminhos de entrada. O vLLM oferece três formas de uso:vllm serveserviço HTTP compatível com OpenAI iniciado por , ferramenta de linha de comandovllm, e instanciação direta em Python da classeLLMpara inferência offline. Eles parecem independentes, mas na verdade compartilham o mesmo núcleo de engine.

Vejamos primeiro o mecanismo de alias do caminho da API assíncrona.

📎 vllm/engine/async_llm_engine.py:7-7

Este arquivo é tão curto que quase não parece um módulo — ele faz apenas uma coisa: apontar o aliasAsyncLLMEngineparavllm.v1.engine.async_llm.AsyncLLM. Esta é uma marca típica de migração arquitetural. Na era do vLLM v0,AsyncLLMEngineera uma classe enorme e complexa; após a reescrita da arquitetura v1, a novaAsyncLLMassumiu as mesmas responsabilidades. Para não quebrar o código existente dos usuários, o vLLM manteve o caminho do módulo antigo como camada de compatibilidade.

〔Inferência de design e trade-offs arquiteturais〕

Esse padrão de "alias do caminho antigo apontando para a nova implementação" aparece repetidamente no vLLM (como o deprecation warning deapi_server.py), indicando que o projeto adotou uma estratégia gradual na migração de v0 para v1: código novo usa o novo caminho, código antigo não gera erro mas recebe aviso, dando ao usuário janela suficiente para migração.

Vejamos agora a entrada do caminho offline.

📎 vllm/entrypoints/llm.py:344-346

LLM.__init__eventualmente chamaLLMEngine.from_engine_args, passandoUsageContext.LLM_CLASS. Este enumUsageContexté a chave para distinguir os caminhos de entrada — ele permite que o engine saiba se está rodando em modo de processamento em lote offline ou modo de serviço online, ajustando assim estratégias de log, métricas e gerenciamento de recursos.

📎 vllm/entrypoints/llm.py:357-359

Observe aqui a atribuição deself.renderer = self.llm_engine.renderereself.input_processor = self.llm_engine.input_processor. A classeLLMoffline não implementa ela mesma a renderização do chat template, mas reutiliza orendererinterno do engine. Isso significa que a lógica de parsing do chat template é o mesmo código nos caminhos offline e online, apenas com momentos de chamada diferentes.

A relação de convergência dos três caminhos pode ser representada pelo seguinte diagrama de fluxo de dados.

mermaid
flowchart LR
    subgraph entry["入口层"]
        http["HTTP 请求体<br/>ChatCompletionRequest"]
        cli["CLI 参数<br/>vllm serve / vllm chat"]
        offline["Python 调用<br/>LLM.chat(messages)"]
    end

    subgraph parse["解析层"]
        chat_utils["chat_utils.parse_chat_messages<br/>-> ConversationMessage + mm_data"]
        renderer["renderer<br/>apply_chat_template -> token_ids"]
    end

    subgraph engine["引擎层"]
        async_llm["AsyncLLM<br/>add_request()"]
        llm_engine["LLMEngine<br/>add_request()"]
        core["EngineCore<br/>input_queue"]
    end

    http --> chat_utils
    cli --> chat_utils
    offline --> chat_utils
    chat_utils --> renderer
    renderer --> async_llm
    renderer --> llm_engine
    async_llm --> core
    llm_engine --> core

Este diagrama revela um design fundamental: independentemente de a requisição vir de HTTP, CLI ou Python,chat_utilsé a única entrada para processamento multimodal e chat template. Ele unifica formatos de entrada heterogêneos emConversationMessagelista maisMultiModalDataDict, e então entrega ao renderer para gerar a sequência de tokens.

3.2 chat_utils: de mensagens heterogêneas a uma estrutura de diálogo unificada

chat_utils.pyé o módulo mais complexo de toda a camada de entrada de requisições; suas 2264 linhas de código tratam todos os formatos de entrada: formato compatível com OpenAI, extensões personalizadas, embeddings multimodais, chamadas de ferramentas, etc. Sua responsabilidade central pode ser resumida em uma frase: normalizar qualquer lista de mensagens enviada pelo usuário para uma listaConversationMessagecompreensível pelo chat template, enquanto extrai dados multimodais para umMultiModalDataDictseparado.

Modelo intuitivo: tradutor e triador de bagagens

Imaginechat_utilscomo um tradutor e triador de bagagens de aeroporto. Os passageiros (usuários) vêm de países diferentes (formato OpenAI, formato personalizado, formato Harmony), falando línguas diferentes. O tradutor primeiro traduz as falas de todos para uma língua de trabalho unificada (ConversationMessage), ao mesmo tempo, classificar a bagagem despachada dos passageiros (imagens, áudio, vídeo) em esteiras transportadoras independentes (MultiModalDataDict), colar etiquetas (UUID), e finalmente enviar pessoas e bagagem separadamente para o mesmo avião (motor).

Sem essa camada, o motor teria que entender os detalhes de cada formato de entrada, a lógica de extração de dados multimodais ficaria dispersa em cada ponto de entrada, e qualquer adição de novo formato exigiria alterar o núcleo do motor.

Estrutura de dados: colaboração de duas classes entre rastreador e analisador

chat_utilsO núcleo de é a colaboração de dois grupos de classes:BaseMultiModalItemTrackere suas subclasses são responsáveis por "rastrear" itens multimodais,BaseMultiModalContentParsere suas subclasses são responsáveis por "analisar" as partes de conteúdo.

Primeiro, vejamos o layout dos campos do rastreador.

📎 vllm/entrypoints/chat_utils.py:598-601

_items_by_modalityé umdefaultdict[str, list[_T]], armazenando itens pendentes agrupados por modalidade (image, audio, video, etc.)._modality_orderé dedicado avision_chunkregistrar a modalidade original de cada chunk (image ou video), porque o modelo unificado de chunk visual mapeia ambos paravision_chunk, mas o processamento subsequente precisa saber o tipo original.

📎 vllm/entrypoints/chat_utils.py:613-615

use_unified_vision_chunk_modalityé umcached_property, lendo a flaguse_unified_vision_chunkda configuração do HuggingFace. Usarcached_propertyem vez de um atributo comum é porque essa verificação é acionada a cada chamada deadd, e o cache evita a sobrecarga repetitiva degetattr.

O métodoadddo rastreador é o ponto de entrada principal.

📎 vllm/entrypoints/chat_utils.py:656-684

addO método primeiro chama_validate_addpara validação, depois armazena os itens sob chaves diferentes dependendo de se a modalidade unificada de chunk visual é usada. Note o tratamento especial deprompt_embeds: ele anexa diretamente a_items_by_modality["prompt_embeds"]e retornaNone, porque embeddings pré-computados não passam pelo HF processor e não têm string de placeholder.

_validate_addA lógica de validação em merece uma análise detalhada.

📎 vllm/entrypoints/chat_utils.py:686-721

Há um ramo sutil aqui: quandoenable_mm_embeds=Truee o limite por prompt dessa modalidade é 0 e a modalidade original termina com_embeds, a validação de quantidade é ignorada. Isso é para permitir que entradas de embedding contornem o limite de quantidade da modalidade original — embeddings são pré-computados e não ocupam recursos de processamento da modalidade original.

Orientado a cenário: como uma requisição de chat com imagem é analisada

Suponha que o usuário envie uma requisição de chat contendo uma URL de imagem e texto.parse_chat_messagesé o ponto de entrada do caminho síncrono.

📎 vllm/entrypoints/chat_utils.py:2161-2197

parse_chat_messagescriaMultiModalItemTracker, itera sobre cada mensagem chamando_parse_chat_message_content, e finalmente chama_postprocess_messagespara processar os parâmetros de chamada de ferramenta, depois materializa os dados multimodais através demm_tracker.resolve_items().

_parse_chat_message_contenté responsável pela análise de uma única mensagem.

📎 vllm/entrypoints/chat_utils.py:2007-2029

Ele primeiro normaliza o content:Nonetorna-se uma lista vazia, strings tornam-se uma única parte de texto. Depois chama_parse_chat_message_content_parts, onde o parâmetrowrap_dictsé determinado porcontent_format == "openai"— isso decide se a saída é uma lista de dicionários estruturados ou uma string concatenada.

_parse_chat_message_content_partsitera sobre cada part.

📎 vllm/entrypoints/chat_utils.py:1814-1853

Cada part passa por_parse_chat_message_content_part. Sewrap_dicts=False, finalmente concatena texto e placeholders em uma única string; sewrap_dicts=True, retorna uma lista de dicionários estruturados.

_parse_chat_message_content_parté o núcleo do despacho.

📎 vllm/entrypoints/chat_utils.py:1875-1884

Para parts de texto puro, primeiro faz a verificação de placeholder de preservação, depois decide o formato de retorno com base emwrap_dicts. Para parts estruturados, chama_parse_chat_message_content_mm_partpara extrair tipo e conteúdo.

📎 vllm/entrypoints/chat_utils.py:1690-1723

_parse_chat_message_content_mm_partprocura a função de análise correspondente através deMM_PARSER_MAP. Note a condição deuuid is None— se o usuário forneceu um UUID, isso indica que os dados de mídia podem não estar no corpo da requisição (já enviados por outro meio), então segue para o ramo abaixo de campos de URL diretos.

📎 vllm/entrypoints/chat_utils.py:1731-1733

Quandopart_type is Noneouuuid is not None, o código tenta extrair o campo URL diretamente do part. Essa "análise permissiva" é para compatibilidade com clientes que não seguem estritamente o formato OpenAI.

Voltando a_parse_chat_message_content_part, parts de tipo mídia são despachados para os métodosmm_parsercorrespondentes.

📎 vllm/entrypoints/chat_utils.py:1923-1968

Cada tipo de mídia chama o métodoparse_*correspondente, que internamente chamatracker.addpara adicionar o item ao rastreador e retorna uma string de placeholder. Finalmente, com base eminterleave_strings, decide se retorna o placeholder ouNone。

📎 vllm/entrypoints/chat_utils.py:1984-1999

prompt_embedsé tratado de forma especial: independentemente deinterleave_strings, retornaPROMPT_EMBEDS_PLACEHOLDER_TOKEN. O comentário explica o motivo — prompt_embeds são concatenados no deslocamento de token, a posição é importante, e se passar pela lógica de preenchimento frontal demissing_placeholdersa ordem seria bagunçada.

Diferenças do caminho assíncrono

O caminho assíncrono usaAsyncMultiModalItemTrackereAsyncMultiModalContentParser. A diferença principal está emresolve_items。

📎 vllm/entrypoints/chat_utils.py:906-952

A versão assíncrona usaasyncio.gatherpara aguardar concorrentemente todos os itens de modalidade. O comentário aponta explicitamente: cada item rastreado já é um awaitable independente, o conector assíncrono descarrega o trabalho de decodificação bloqueante para o pool de threads, então aguardar serialmente uma modalidade e depois outra aumentaria desnecessariamente a latência.return_exceptions=Truefaz com que todas as tarefas sejam concluídas ou falhem antes de lançar a exceção unificada, evitando que a primeira falha abandone requisições de rede ainda em andamento.

Reflexão de design: por que rastreador e analisador são separados

〔Inferência de design e trade-offs arquiteturais〕

A separação entre rastreador e analisador é um design interessante. O rastreador é responsável pelo "gerenciamento de estado" — registrar quantos itens cada modalidade tem, validar limites de quantidade, manter a ordem da modalidade original do vision_chunk. O analisador é responsável pela "extração de conteúdo" — obter imagens de URLs, decodificar embeddings de base64, processar conversão de formato de áudio. Essa separação permite que os caminhos síncrono e assíncrono compartilhem a lógica de rastreamento (BaseMultiModalItemTrackeré uma classe base abstrata), divergindo apenas na camada do analisador. Se fossem fundidos em uma única classe, as diferenças entre síncrono e assíncrono vazariam para a lógica de rastreamento, causando duplicação de código e complexidade no gerenciamento de estado.

3.3 De mensagem a token: a transição entre renderer e EngineCore

chat_utilsA listaConversationMessageeMultiModalDataDictproduzidas ainda precisam passar pela renderização do chat template para se tornarem sequências de tokens. Esse passo é feito pelo renderer, e só então a requisição realmente entra no motor.

Orientado a cenário: renderização de chat template e entrega de requisição

parse_chat_messagesApós retornar, o chamador (comoOpenAIServingChat) passaráconversationemm_datapara o renderer. O renderer aplica o chat template, renderiza a listaConversationMessagecomo texto e então tokeniza em uma sequência de token IDs. Placeholders multimodais (como<##IMAGE##>) são substituídos por tokens placeholder específicos do modelo após a tokenização.

Após a renderização, a requisição é encapsulada comoEngineCoreRequest, e entregue à fila de entrada do EngineCore através deAsyncLLM.add_request()ouLLMEngine.add_request().

📎 vllm/entrypoints/llm.py:420-484

O método offlineLLM.generatedemonstra essa cadeia: ele primeiro validarunner_type, obtém os parâmetros de amostragem padrão, e então chama_run_completion。_run_completion. Internamente, ele chama o renderer para renderizar o prompt, e então entrega a requisição através dellm_engine.

📎 vllm/entrypoints/llm.py:615-708

LLM.chatO métodomessagesdemonstra o caminho de chat: ele recebe a lista_run_chat, chamaparse_chat_messages, que internamente chama

e o renderer.

Considerações de design: por que o renderer está dentro do engine

LLM.__init__〔Inferência de design e trade-offs arquiteturais〕self.renderer = self.llm_engine.rendererEmself.renderer.warmup(ChatParams(...)), a linhaLLMrevela uma decisão de design importante: o renderer pertence ao engine, não à camada de entrada. Isso significa que o carregamento, cache e pré-aquecimento do chat template (AsyncLLM) são concluídos na inicialização do engine, e a camada de entrada é apenas o chamador. A vantagem disso é: offline

e online

_postprocess_messagescompartilham a mesma implementação de renderer e cache, evitando recarregar o tokenizer e o chat template repetidamente. Ao mesmo tempo, o pré-aquecimento do renderer pode ser concluído na inicialização do engine, evitando a latência de cold start da primeira requisição.

📎 vllm/entrypoints/chat_utils.py:2118-2158

Recuperação de erros e armadilhas em produçãotool_callsO tratamento de parâmetros de chamada de ferramentas emargumentsé uma armadilha típica de ambiente de produção.argumentsQuando a mensagem do assistant contém

, o campo

📎 vllm/entrypoints/chat_utils.py:1856-1872

pode ser uma string JSON, um dicionário ou JSON inválido. O código tenta fazer parse da string JSON; se falhar, registra um aviso e força a conversão para um objeto vazio. O comentário explica o motivo: dadosenable_prompt_embedsmal formatados existem no histórico da conversa, e se a requisição falhar aqui, cada rodada subsequente falhará, tornando a conversa irrecuperável. Este é um design de tolerância a falhas bem pensado — é preferível que o modelo veja parâmetros de ferramenta vazios do que travar toda a conversa.PROMPT_EMBEDS_PLACEHOLDER_TOKENOutra armadilha é a proteção contra injeção de placeholders reservados._reject_reserved_placeholder_in_textQuando

📎 vllm/entrypoints/chat_utils.py:1889-1892

está habilitado,isinstance(part, str)é registrado como um token especial indivisível. Se o texto do usuário contiver exatamente essa sequência literal, o tokenizer a codificará como o mesmo token ID, e o renderer erroneamente a considerará como ponto de concatenação, permitindo que o chamador mova ou injete a posição de concatenação através de conteúdo de texto puro.

O

rejeita essa entrada durante o parse de partes de texto, fechando essa vulnerabilidade de segurança.LLMNote que essa verificação é chamada tanto no branchchat_utilsquanto no branch de texto estruturado, garantindo que todos os caminhos de texto passem pela proteção.BaseMultiModalItemTrackerResumo do capítuloBaseMultiModalContentParserEste capítulo rastreou o primeiro segmento do caminho de uma requisição entrando no sistema a partir do exterior. Três caminhos de entrada — HTTP API, CLI e a classe offlineparse_chat_messages— eventualmente convergem para a camada de parsing multimodal deConversationMessage.MultiModalDataDicté responsável pelo gerenciamento de estado,EngineCoreRequesté responsável pela extração de conteúdo, e a separação entre os dois permite que os caminhos síncrono e assíncrono compartilhem a lógica de rastreamento.

normaliza mensagens heterogêneas em uma lista

e_parse_chat_message_content_mm_part, e então as entrega ao renderer interno do engine para completar a renderização do chat template e a tokenização. Finalmente, a requisição é encapsulada comouuid is Nonee entregue à fila de entrada do EngineCore.if isinstance(part_type, str) and part_type in MM_PARSER_MAP:Reflexões e autoavaliação do capítulo

Q1: Em:uuid is None, se removermos a condiçãoMM_PARSER_MAP[part_type](part)(ou seja, mudando paraimage_url), em quais cenários isso causaria problemas?NoneAnálise de referênciaparse_image(None, uuid)A condição_connector.fetch_image(None)existe para lidar com o cenário em que "o usuário forneceu um UUID mas os dados de mídia não estão no corpo da requisição". Quando o usuário fornece um UUID, os dados de mídia podem já ter sido enviados por outros meios (como pré-envio para o cache de mídia), e nesse caso a part no corpo da requisição pode conter apenas o UUID sem a URL ou dados reais. Se removermos essa condição, o código tentará fazer parse através deuuid is not None, mas a part pode não ter os campos de dados correspondentes (como📎 vllm/entrypoints/chat_utils.py:1713-1723vazio), resultando em conteúdo📎 vllm/entrypoints/chat_utils.py:1731-1733。

Q2: AsyncMultiModalItemTracker.resolve_items. Mais grave ainda, oasyncio.gather(..., return_exceptions=True)subsequente chamaráreturn_exceptions=False, podendo disparar requisições de rede desnecessárias ou exceções.FalseO branch

segue o caminho de extração direta de campos, tratando corretamente o caso de "UUID presente sem dados". Veja:return_exceptions=Falseeasyncio.gather.return_exceptions=TrueFaça com que todas as tarefas sejam concluídas ou falhem antes de verificar de forma unificada, garantindo que nenhuma tarefa seja abandonada. O comentário explica isso claramente: 「Gathering with return_exceptions=True lets every task finish (or itself fail) before we raise, instead of abandoning still-in-flight fetches (real network/thread-pool work) the moment the first one fails.」 Consulte📎 vllm/entrypoints/chat_utils.py:924-931。

Q3: _postprocess_messagesquandoargumentsé um JSON inválido, o código opta por forçar a conversão para um objeto vazio em vez de lançar uma exceção. Se fosse alterado para lançar uma exceção, em qual cenário de produção isso causaria um estado de conversa irrecuperável?

Análise de referência:argumentsO campo existe no histórico da conversa (da mensagem do assistanttool_calls). Se em algum turno da conversa o modelo gerar umargumentscom formato incorreto, esse erro será salvo no histórico da conversa. Se_postprocess_messageslançar uma exceção ao analisar o histórico, então cada turno subsequente falhará por causa desse erro no histórico — mesmo que a entrada do turno atual esteja completamente correta. O usuário não conseguirá continuar essa conversa, tendo que abandonar toda a sessão e recomeçar. Forçar a conversão para um objeto vazio permite que a conversa continue, e o modelo, ao ver os parâmetros de ferramenta vazios, gerará novamente a chamada correta. O comentário explica isso: 「A malformed arguments string lives in conversation history, so failing the request here would fail every subsequent turn too and leave the conversation unrecoverable.」 Consulte📎 vllm/entrypoints/chat_utils.py:2124-2139。

O próximo capítulo entrará no agendador, para ver como o EngineCore orquestra essas requisições com processamento em lote contínuo e estratégias cientes da memória de vídeo.

Até aqui, a requisição completou a transformação normalizada da entrada externa para EngineCoreRequest e chegou à entrada do núcleo do motor. Mas, após a requisição entrar, ela não será executada imediatamente — o motor precisa decidir quais requisições processar em cada passo e como alocar os recursos limitados de memória de vídeo. O próximo capítulo aprofundará o loop de agendamento do EngineCore, analisando como o Scheduler equilibra throughput e latência no processamento em lote contínuo, e como chunked prefill, prefix caching e alocação de KV block trabalham em conjunto.

CHAPTER 04

Capítulo 4: Agendador: processamento em lote contínuo e orquestração de requisições ciente da memória de vídeo

Projeto pertencente: vllm-project/vllm · Progresso do livro: Capítulo 4 / 14 · Status de verificação: Linhas FACT com ancoragem real

Após a requisição entrar na fila de entrada do EngineCore, ela não será executada imediatamente. Quais requisições processar em cada passo, quantos tokens de orçamento alocar para cada requisição, e quem sacrificar primeiro quando a memória de vídeo for insuficiente — essas decisões estão concentradas no métodoScheduler.schedule(). Este capítulo começa pelas estruturas de dados do agendador e rastreia como uma chamadaschedule()organiza a fila waiting, a lista running e o pool de KV cache em um lote executável.

4.1 Estruturas de dados do agendador: três filas e um pool de memória de vídeo

A pergunta central que o agendador precisa responder é:Sob um orçamento limitado de tokens e de KV blocks, quais requisições devem avançar quantos tokens neste passo?Para entendê-lo, primeiro é preciso ver claramente quais estados ele tem em mãos.

O agendador mantém três tipos de contêineres de requisições.self.requestsé um dicionário global,req_id -> Request, a única fonte de verdade para todas as requisições ativas📎 vllm/v1/core/sched/scheduler.py:208-209。self.waitingeself.skipped_waitingsão duas filas de prioridade; a primeira contém requisições aguardando agendamento normal, e a segunda contém requisições temporariamente não agendáveis por dependências assíncronas ou restrições (como aguardar KV remoto, aguardar compilação da gramática de saída estruturada)📎 vllm/v1/core/sched/scheduler.py:208-209。self.runningé uma lista comum, armazenando requisições que já entraram no estado de execução e possuem KV blocks📎 vllm/v1/core/sched/scheduler.py:208-209。

Há aqui um design facilmente ignorado:max_num_running_reqsemax_num_active_reqssão dois limites diferentes. O primeiro vem demax_num_seqs, determinando o número de slots do model runner; o segundo vem demax_num_active_seqs, limitando apenas o número de requisições que podem entrar em RUNNING, por padrão igual ao primeiro📎 vllm/v1/core/sched/scheduler.py:123-131. Essa separação permite reduzir o tamanho efetivo do lote de decodificação concorrente sem diminuir a capacidade de captura do CUDA graph.

O lado da memória de vídeo é gerenciado de forma unificada porKVCacheManager, que internamente mantémBlockPool。BlockPoolO núcleo deself.blockséKVCacheBlock(uma lista de todos osfree_block_queue) e📎 vllm/v1/core/block_pool.py:171-177(uma lista duplamente ligada de blocos livres ordenada por ordem de evicção)null_block. Observe a existência deis_null=True: é o primeiro bloco retirado da cabeça da fila de livres,📎 vllm/v1/core/block_pool.py:183-187, a contagem de referências não participa da manutenção regular, sendo usada exclusivamente como placeholder

. Quando uma posição de token de uma requisição não precisa de um KV block real (por exemplo, uma posição ignorada pela janela deslizante), preenche-se esse null block na block table.BlockHashToBlockMapA estrutura de índice do cache de prefixo éBlockHashWithGroupId, que mapeiaKVCacheBlockpara um{block_id: KVCacheBlock}ou um dicionário📎 vllm/v1/core/block_pool.py:56-59. Por que usar tipos união? O comentário dá a resposta: a maioria dos hashes corresponde a apenas um bloco, e usar um dicionário geraria sobrecarga desnecessária de GC; somente quando o mesmo hash é compartilhado por múltiplos blocos é que se promove para dicionário📎 vllm/v1/core/block_pool.py:56-59. Este é um trade-off típico de trocar complexidade de tipos por sobrecarga em tempo de execução.

KVCacheBlocksé o objeto de interface entre o escalonador e o gerenciador de KV cache, que oculta as estruturas de dados internas. Seublockscampo étuple[Sequence[KVCacheBlock], ...], a dimensão externa é o KV cache group, a interna é a sequência de blocos📎 vllm/v1/core/kv_cache_manager.py:41-54. O comentário explica explicitamente por que não usar blocos como dimensão externa: isso assumiria que todos os groups têm o mesmo número de blocos, mas no futuro pode-se configurar block sizes diferentes para groups diferentes📎 vllm/v1/core/kv_cache_manager.py:43-48。

mermaid
flowchart LR
    subgraph Sched["Scheduler 状态"]
        W["waiting<br/>RequestQueue"]
        SW["skipped_waiting<br/>RequestQueue"]
        R["running<br/>list[Request]"]
        REQ["requests<br/>dict[str, Request]"]
    end
    subgraph KV["KVCacheManager"]
        BP["BlockPool.blocks<br/>list[KVCacheBlock]"]
        FQ["free_block_queue<br/>FreeKVCacheBlockQueue"]
        MAP["cached_block_hash_to_block<br/>BlockHashToBlockMap"]
    end
    W -->|"admit + allocate_slots"| R
    R -->|"preempt"| W
    R -->|"free / pop_blocks_for_free"| FQ
    FQ -->|"get_new_blocks"| BP
    BP -->|"cache_full_blocks"| MAP
    MAP -->|"get_cached_block"| W

Este diagrama ancora o fluxo de dados entre o escalonador e o pool de memória de vídeo: as requisições da fila waiting entram em running através deallocate_slots, as requisições em running retornam para waiting quando são preemptadas, os blocos liberados retornam para a fila de ociosos, e a tabela hash de cache de prefixo é a porta de entrada para requisições em waiting acertarem o cache.

4.2 Fluxo principal de schedule(): running prioritário, waiting complementar, preempção como fallback

schedule()é o método central de todo o escalonador, ele retorna umSchedulerOutput, descrevendo o que deve ser executado neste passo. O comentário no início do método aponta a filosofia de design: no escalonador não há distinção entre "fase de decodificação" e "fase de pré-preenchimento", cada requisição tem apenasnum_computed_tokensenum_tokens_with_spec, e a tarefa do escalonador é fazer o primeiro alcançar o segundo📎 vllm/v1/core/sched/scheduler.py:559-568. Essa visão unificada é a base para que chunked prefill, prefix caching e decodificação especulativa possam coexistir.

4.2.1 Inicialização de orçamento e cálculo de limiares

Antes de entrar no loop principal, o escalonador define dois orçamentos:token_budgetinicializado comomax_num_scheduled_tokens,input_budgetinicializado comomax_num_batched_tokens 📎 vllm/v1/core/sched/scheduler.py:577-580. Ambos geralmente são iguais, mas quando o modelo pode adicionar tokens no lote (como na decodificação especulativa),max_num_scheduled_tokensserá menor quemax_num_batched_tokens, e a diferença é o espaço reservado para draft tokens.

long_prefill_token_thresholdO tratamento de📎 vllm/v1/core/sched/scheduler.py:606-616merece atenção separada. Sua função é evitar que um prefill longo mate de fome outras requisições, mas se houver apenas uma requisição no momento, ninguém passará fome, então o limiar é zeradoadaptive_long_prefill_threshold. Quandoinput_budget // num_eligible_reqsestá ativado, o limiar também é elevado para📎 vllm/v1/core/sched/scheduler.py:617-622。

, garantindo que o orçamento de uma única requisição não seja comprimido abaixo da cota justa

4.2.2 Loop de escalonamento de requisições runningself.runningO loop principal percorre a partir do início dereq_index, sendo📎 vllm/v1/core/sched/scheduler.py:624-627o cursor

  • . Para cada requisição, primeiro faz uma série de verificações de salto:max_tokensSob escalonamento assíncrono, se o placeholder de saída da requisição indicar que ela já atingiu📎 vllm/v1/core/sched/scheduler.py:631-645。
  • , pula para evitar executar um passo a maisnext_decode_eligible_stepNo cenário V2 + PP + assíncrono, se o passo atual ainda não chegou a📎 vllm/v1/core/sched/scheduler.py:647-651。
  • , pula para corresponder ao ritmo de broadcast de tokens de amostragem do lado do worker📎 vllm/v1/core/sched/scheduler.py:653-657。

Quando o balanceamento de prefill DP está ativado, chunks de prefill em passos não alinhados ao ritmo são adiados

code
num_new_tokens = request.num_tokens_with_spec
               + request.num_output_placeholders
               - request.num_computed_tokens

Copiarlong_prefill_token_threshold、token_budget、input_budget - draft_slotsEm seguida, é restringido sucessivamente pormax_model_lene📎 vllm/v1/core/sched/scheduler.py:670-688. Se a requisição tiver entrada de encoder, ainda passa pelo ajuste de_try_schedule_encoder_inputsA seguir vem o passo mais crítico: alocar KV block.📎 vllm/v1/core/sched/scheduler.py:700-712。

é envolvido em um loopallocate_slotswhile True. Se retornar📎 vllm/v1/core/sched/scheduler.py:742-747, significa memória de vídeo insuficiente, e o escalonador inicia a preempção: seleciona a vítima de acordo com a política (a estratégia PRIORITY escolhe a de menor prioridade, a estratégia FCFS escolhe a do final da lista running)None, chama📎 vllm/v1/core/sched/scheduler.py:761-767para expulsá-la de volta à fila waiting, e então tenta a alocação novamente_preempt_request. Se a vítima for a própria requisição atual, significa que não há mais objetos para preemptar, sai do loop, e a requisição atual também não pode ser escalonada📎 vllm/v1/core/sched/scheduler.py:801-806Há um detalhe refinado na lógica de preempção: sob a estratégia PRIORITY, se a requisição preemptada já está em📎 vllm/v1/core/sched/scheduler.py:807-813。

(ou seja, os recursos já foram alocados para ela neste passo), é necessário devolver todo o seu orçamento de tokens, blocks, tokens especulativos e orçamento de encoderscheduled_running_reqs. Isso garante a consistência do livro-razão de orçamento.📎 vllm/v1/core/sched/scheduler.py:779-797Após a alocação bem-sucedida, a requisição é adicionada a

, registrando blocks e número de tokens, deduzindo o orçamentoscheduled_running_reqs. Tokens relacionados à decodificação especulativa são cortados e registrados aqui📎 vllm/v1/core/sched/scheduler.py:815-8234.2.3 Admissão de requisições waiting📎 vllm/v1/core/sched/scheduler.py:825-841。

Após o término do loop running, se não houve preempção neste passo e o escalonador não está pausado, começa-se a processar a fila waiting

. Antes da admissão, verificam-se dois limites:📎 vllm/v1/core/sched/scheduler.py:868-872emax_num_active_reqsO escalonamento de requisições waiting tem um passo a mais de busca no cache de prefixo em relação a running. Quandoinput_budget 📎 vllm/v1/core/sched/scheduler.py:873-879。

, chama-serequest.num_computed_tokens == 0para buscar acerto no cache local_get_local_prefix_cache_hit. Se um KV connector estiver configurado, também se consulta o acerto no cache remoto📎 vllm/v1/core/sched/scheduler.py:932-939Aqui há uma lógica refinada para tratar conflitos entre acertos locais e remotos. O acerto local pode não estar alinhado a blocos (📎 vllm/v1/core/sched/scheduler.py:942-954。

), e se o acerto remoto exceder estritamente o acerto local completo, descarta-se a cauda do sub-bloco local, deixando o carregamento remoto sobrescrevê-la, evitando copy-on-writepartial_tail. Caso contrário, mantém-se a cauda local e não se carrega o externo📎 vllm/v1/core/sched/scheduler.py:977-988Após a admissão bem-sucedida, a requisição é removida da fila waiting, o estado é definido como RUNNING, e ela é adicionada à lista running📎 vllm/v1/core/sched/scheduler.py:989-995。

. Se após este passo ela ainda estiver em prefill (📎 vllm/v1/core/sched/scheduler.py:1263-1319), adiciona-se ao conjuntonum_computed_tokens + num_new_tokens < request.num_tokens_inflight_prefillsCopiar📎 vllm/v1/core/sched/scheduler.py:1326-1328。

mermaid
flowchart TD
    start["schedule() 开始"] --> init["初始化 token_budget / input_budget"]
    init --> run_loop{"running 循环<br/>req_index < len(running)<br/>且 token_budget > 0?"}
    run_loop -->|是| skip_check{"跳过条件?<br/>max_tokens 已达 /<br/>decode_eligible / defer_prefills"}
    skip_check -->|跳过| run_inc["req_index += 1"]
    run_inc --> run_loop
    skip_check -->|不跳过| calc["计算 num_new_tokens<br/>受多约束裁剪"]
    calc --> alloc{"allocate_slots<br/>返回 None?"}
    alloc -->|成功| admit_run["加入 scheduled_running_reqs<br/>扣减预算"]
    admit_run --> run_inc
    alloc -->|失败| can_preempt{"有可抢占请求?<br/>_request_blocks_can_be_freed"}
    can_preempt -->|否| break_run["跳出 running 循环"]
    can_preempt -->|是| preempt["_preempt_request<br/>踢回 waiting"]
    preempt --> alloc
    break_run --> wait_loop{"无抢占且未暂停?<br/>waiting 非空且 token_budget > 0?"}
    run_loop -->|否| wait_loop
    wait_loop -->|是| blocked{"blocked 状态?<br/>_is_blocked_waiting_status"}
    blocked -->|是且无法提升| skip_wait["移入 skipped_waiting"]
    skip_wait --> wait_loop
    blocked -->|否| prefix{"num_computed_tokens == 0?<br/>查找前缀缓存"}
    prefix -->|命中| alloc_wait["allocate_slots<br/>带 new_computed_blocks"]
    prefix -->|未命中| alloc_wait
    alloc_wait --> wait_ok{"分配成功?"}
    wait_ok -->|是| admit_wait["加入 running<br/>状态设为 RUNNING"]
    admit_wait --> wait_loop
    wait_ok -->|否| break_wait["跳出 waiting 循环"]
    wait_loop -->|否| build["构建 SchedulerOutput"]
    break_wait --> build

os dois grandes loops e o ramo de preempção deschedule(). Observe o caminho de retentativa de preempção após falha deallocate_slotsno loop running, e a movimentação de requisições em estado blocked no loop waiting paraskipped_waitingbypass de.

4.3 O núcleo da percepção de memória de vídeo: allocate_slots e preempção

allocate_slotsé o portão entre o agendador e a memória de vídeo. Sua lista de parâmetros é, por si só, um registro contábil da memória de vídeo:num_new_tokensé o número de tokens a serem recalculados,num_new_computed_tokensé o número de tokens recém-acertados no cache de prefixo,num_external_computed_tokensé o número de acertos externos fornecidos pelo connector,num_lookahead_tokenssão os slots reservados para decodificação especulativa📎 vllm/v1/core/kv_cache_manager.py:371-383。

O comentário no início do método descreve com precisão o layout dos blocos usando um diagrama ASCII📎 vllm/v1/core/kv_cache_manager.py:417-438:

code
| < comp > | < new_comp > | < ext_comp >  | < new >  | < lookahead > |
                                          |   < to be computed >     |
                        |            < to be allocated >           |

compsão tokens já calculados,new_compé acerto no cache de prefixo,ext_compé acerto externo,newé o novo cálculo deste passo,lookaheadé a reserva especulativa. A alocação é dividida em três fases: primeiro liberar blocos desnecessários e verificar se há blocos livres suficientes, depois processar os tokens de prefixo e, por fim, alocar blocos para os tokens recém-calculados📎 vllm/v1/core/kv_cache_manager.py:458-461。

4.3.1 Linha de nível d'água e controle de admissão

allocate_slotshá dois portões de admissão. O primeiro éfull_sequence_must_fit: quando ativado, primeiro verifica se toda a sequência da requisição (e não apenas o primeiro chunk) cabe; se não couber, retorna diretamenteNone 📎 vllm/v1/core/kv_cache_manager.py:515-531. Isso evita que, sob chunked prefill, a admissão excessiva cause oscilação no KV cache.

O segundo é a linha de nível d'água.watermark_blockssó entra em vigor quando o estado da requisição é WAITING ou PREEMPTED e já há requisições agendadas📎 vllm/v1/core/kv_cache_manager.py:506-513. Ela exige que, após a alocação, seja mantida ao menos uma certa proporção de blocos livres, evitando evicções e preempções frequentes.reserved_blocksé usada em cenários de carregamento assíncrono de KV, garantindo que os blocos reservados para prefill em andamento não sejam consumidos por novas requisições📎 vllm/v1/core/kv_cache_manager.py:564-570。

4.3.2 O custo e a recuperação da preempção

〔Inferência de design e trade-offs arquiteturais〕

_preempt_requestfez algo aparentemente brutal, mas necessário: redefinir onum_computed_tokensda requisição para 0📎 vllm/v1/core/sched/scheduler.py:1560-1561. Isso significa que uma requisição preemptada precisará refazer o prefill do zero na próxima vez que for agendada. Por que foi projetado assim? Porque o KV block do vLLM é privado da requisição; na preempção, todos os blocos devem ser liberados e, após a liberação, não há garantia de obter os mesmos blocos na realocação, então só resta recalcular do início. A existência do cache de prefixo compensa parcialmente esse custo: se o prefixo da requisição preemptada já estiver em cache, ao reagendar será possível acertar o cache, sem precisar realmente recalcular.

A preempção também trata o problema de "saída obsoleta" sob agendamento assíncrono.num_stale_output_tokensé definido comonum_in_flight_tokens, marcando todas as saídas em andamento como obsoletas📎 vllm/v1/core/sched/scheduler.py:1571-1574. Esses tokens ainda serão entregues (descartá-los perturbaria a taxa de aceitação da decodificação especulativa), mas não modificarão os contadores após a redefinição.drop_stale_outputo flag determina se deve descartar ou entregar📎 vllm/v1/core/sched/scheduler.py:1539-1547。

4.3.3 Liberação atrasada: o risco de write-after-read em connectors assíncronos

Ao usar um KV connector e havendo múltiplos lotes em andamento,defer_block_freeé definido comoTrue 📎 vllm/v1/core/sched/scheduler.py:175-181. O motivo é: um passo ainda pode estar gravando nos blocos KV de uma requisição já liberada, enquanto o connector consumidor pode realocar e preencher esses blocos por meio de um carregamento não ordenado em relação a essa gravação.

A liberação atrasada é implementada por meio dedeferred_freesuma deque dupla, em que cada entrada é(fence_seq, blocks) 📎 vllm/v1/core/sched/scheduler.py:388-390。_free_request_blocksverifica_request_blocks_can_be_freed, se o último passo de agendamento da requisição ainda não tiver sido processado, coloca os blocos na fila de atraso📎 vllm/v1/core/sched/scheduler.py:2679-2688。_drain_deferred_freesemupdate_from_outputavançaprocessed_step_seqe depois chama, liberando os blocos cujo fence já foi satisfeito📎 vllm/v1/core/sched/scheduler.py:2701-2706。

4.4 Determinação de acerto no cache de prefixo e ciclo de vida dos blocos

A entrada de busca do cache de prefixo éKVCacheManager.get_computed_blocks. Primeiro verifica se o cache está habilitado e se a requisição não está marcada para pular a leitura📎 vllm/v1/core/kv_cache_manager.py:286-287. Em seguida, chamacoordinator.find_longest_cache_hit, passandorequest.block_hashesemax_cache_hit_length = request.num_tokens - 1 📎 vllm/v1/core/kv_cache_manager.py:295-300。

Por quenum_tokens - 1? O comentário explica: quando todos os tokens acertam o cache, é necessário recalcular o último token para obter os logits📎 vllm/v1/core/kv_cache_manager.py:289-294. Esse é um limite fácil de ignorar: mesmo que o prefixo acerte completamente, pelo menos um token deve ser calculado.

O ciclo de vida dos blocos é gerenciado porBlockPool.get_new_blocksremove um bloco da cabeça da fila de livres; se o cache estiver habilitado, primeiro chama_maybe_evict_cached_blockpara limpar seus metadados de hash e, em seguida, incrementa a contagem de referências📎 vllm/v1/core/block_pool.py:683-702。free_blocksdecide, com base em o bloco ter hash ou não, se o devolve à cabeça ou ao fim da fila: blocos sem hash são reutilizados em LIFO (melhor localidade de GPU), blocos com hash são reutilizados em FIFO (comportamento de evicção LRU)📎 vllm/v1/core/block_pool.py:785-805。

cache_full_blocksé o momento em que um bloco é escrito na tabela hash do cache de prefixo. Ele percorre os blocos recém-completados, ignora blocos null e blocos mascarados, calcula o hash de cada bloco e insere emcached_block_hash_to_block 📎 vllm/v1/core/block_pool.py:272-300. Se o bloco já tiver hash (cenário em que um bloco parcial é promovido a bloco cheio), primeiro remove o hash antigo e depois insere o novo hash📎 vllm/v1/core/block_pool.py:285-293。

toucho método trata a contagem de referências em caso de acerto no cache: se o bloco estiver na fila de livres (ref_cnt == 0), primeiro o remove da fila e depois incrementa a contagem de referências📎 vllm/v1/core/block_pool.py:754-770. Isso garante que blocos acertados não sejam evictados.

Reflexões de design

〔Inferência de design e trade-offs arquiteturais〕

Por que a preempção escolhe "recalcular do zero" em vez de "retenção parcial"?A retenção parcial exigiria registrar a posição física dos blocos de cada requisição no momento da preempção e tentar restaurar o mapeamento ao reagendar. Mas o pool de blocos é compartilhado globalmente, e outras requisições podem já ter ocupado esses blocos. A complexidade e o custo de memória de manter esse mapeamento superam o custo do recálculo, especialmente quando o cache de prefixo consegue acertar a maior parte do prefixo.

〔Inferência de design e trade-offs arquiteturais〕

Por que a linha de nível d'água é 0 por padrão?A linha de nível d'água é um seguro contra preempções frequentes, mas ao custo de sacrificar a utilização da memória de vídeo. Desativá-la por padrão significa que o vLLM prioriza throughput em vez de estabilidade, e o usuário precisa ativá-la conforme as características da carga.

〔Inferência de design e trade-offs arquiteturais〕

skipped_waitingo significado da existência da fila.Sem essa fila, as requisições bloqueadas ocupariam permanentemente a cabeça da fila waiting, impedindo que as requisições seguintes fossem escalonadas (sob a política FCFS). Ao separá-la, o escalonador pode pular as requisições bloqueadas e continuar processando as seguintes, enquanto preserva o estado das requisições bloqueadas para posterior promoção.

Resumo do capítulo

O núcleo do escalonador é oschedule()método com dois loops: o loop running prioriza garantir o avanço das requisições já em execução, e o loop waiting admite novas requisições quando o orçamento permite. Quando a memória de vídeo é insuficiente, abre-se espaço por meio da preempção da requisição de menor prioridade na lista running; a requisição preemptada tem seunum_computed_tokensredefinido para 0, mas o cache de prefixo pode compensar parte do custo de recálculo.allocate_slotsé o portão da memória de vídeo, através defull_sequence_must_fit, linha d'água ereserved_blockstrês camadas de controle de admissão para evitar superalocação. O cache de prefixo é compartilhado entre requisições por meio de índice de hash de bloco, e a determinação de acerto tem como limite superiornum_tokens - 1para garantir que pelo menos um token seja calculado e obtenha logits.

Reflexão e autoavaliação do capítulo

Q1: Noschedule()loop running deallocate_slots, seNoneretornar_request_blocks_can_be_freedeFalseretornar para a vítimabreak, o código_preempt_requestsai do loop. Se essa verificação for removida e

for chamado diretamente, em qual cenário isso causaria inconsistência de estado?:_request_blocks_can_be_freedAnálise de referênciarequest.last_sched_seq <= self.processed_step_seq 📎 vllm/v1/core/sched/scheduler.py:2672-2677verificadefer_block_free. Quando_free_request_blocksestá habilitado, se o último passo de escalonamento da vítima ainda não tiver sido processado, seus blocos podem ainda estar sendo escritos por passos de GPU em trânsito. A preempção direta chamaria_request_blocks_can_be_freed, e este, quandoFalseédeferred_frees, colocaria os blocos em📎 vllm/v1/core/sched/scheduler.py:2679-2688em vez de liberá-los imediatamenteallocate_slots. Mas a semântica da preempção é "liberar blocos imediatamente para a requisição atual", e a liberação atrasada não satisfaz essa necessidade,

Q2: get_computed_blocksfalharia novamente, formando um loop infinito. Mais grave ainda, se os blocos da vítima forem liberados com atraso e depois alocados pela requisição atual, enquanto a GPU ainda estiver escrevendo nos blocos da vítima, ocorreria uma condição de corrida de dados.max_cache_hit_length = request.num_tokens - 1emrequest.num_tokens. Se for alterado para

, em quais circunstâncias isso causaria saída incorreta?Análise de referêncianum_computed_tokens: quando todos os tokens da requisição acertam o cache,num_tokensserá igual a📎 vllm/v1/core/kv_cache_manager.py:289-294. Nesse momento, o escalonador considera que nenhum novo token precisa ser calculado, mas a amostragem de logits requer o estado oculto da última posição, e o estado oculto vem da propagação direta. Se nenhum token for calculado, não haverá logits para amostrar, e a requisição ficará travada ou produzirá saída incorreta. O comentário explica isso claramenteallocate_slots. Além disso,num_computed_tokensexige que

Q3: _preempt_requestesteja alinhado ao tamanho do bloco; recalcular o último token pode disparar o recálculo de todo o bloco, o que é uma limitação conhecida da implementação atual.num_computed_tokensredefinerequest.num_tokenspara 0, mas preserva

(prompt + tokens já gerados). Se, ao reescalonar uma requisição preemptada, o cache de prefixo não acertar, quantos tokens ela precisa recalcular? Se acertar, quantos podem ser economizados?:num_computed_tokens = 0Análise de referência📎 vllm/v1/core/sched/scheduler.py:1561。request.num_tokenssignifica que, ao reescalonar, começa-se do primeiro tokennum_tokenspermanece inalterado, incluindo o prompt original e os tokens de saída já gerados. Se o cache de prefixo não acertar, é preciso recalcular o prefill de todos osget_computed_blockstokens. Se acertar,num_computed_tokensretornará os blocos que acertaram,📎 vllm/v1/core/kv_cache_manager.py:296-300começa a partir da posição de acertonum_tokens. Observe que os tokens de saída da requisição preemptada também estão emmax_cache_hit_length = num_tokens - 1; seus hashes de prefixo já foram armazenados em cache no momento da geração (se habilitado), então, ao reescalonar, os prefixos desses tokens de saída também podem acertar. Mas

significa que o último token sempre precisa ser recalculado.SchedulerOutputA saída do escalonadorSchedulerOutputespecifica claramente o conteúdo de execução deste passo: IDs de bloco da nova requisição, número de tokens da requisição em cache, tokens especulativos, entrada do codificador etc. O próximo capítulo rastreará como essa saída é consumida pelo ModelRunner, desde

CHAPTER 05

Próximo capítulo: Capítulo 5 →

Capítulo 5: Tronco de execução do modelo: de SchedulerOutput à propagação direta na GPU · Projeto: vllm-project/vllm · Progresso do livro: Capítulo 5 / 14

Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, vimos que o Scheduler, em cada passo do loop de escalonamento, decide quais requisições entram na fila running, quais são preemptadas, quais aguardam por falta de memória de vídeo e, por fim, produz um SchedulerOutput — que descreve o que deve ser calculado neste passo: quais requisições, quantos tokens cada uma, quais blocos KV usar. Mas essa lista é apenas intenção lógica; a GPU precisa de tensores físicos. Este capítulo rastreia como o SchedulerOutput é distribuído pelo Executor aos Workers e então traduzido pelo GPUModelRunner em entradas executáveis pela GPU, como input_ids, positions, slot_mapping e block table, e finalmente, por meio do forward_context, injeta a descrição de lote compartilhada entre camadas em cada camada do modelo, completando a travessia da decisão de escalonamento até a propagação direta.

5.1 Executor: enviar o resultado do escalonamento para cada placa

ExecutorModelo intuitivoSchedulerOutputSerializar o passado — a lógica de agendamento ficaria entrelaçada com a topologia distribuída.ExecutorExtrair essa responsabilidade: o EngineCore apenas chamaexecute_model(scheduler_output), e o restante — «para quem enviar, como enviar, quantos resultados receber» — é decidido pelo Executor.

Hierarquia de classes e campos

Executoré uma classe base abstrata cujos campos de nível de classe codificam diretamente as capacidades do backend📎 vllm/v1/executor/abstract.py:48-49:

python
uses_ray: bool = False  # whether the executor uses Ray for orchestration.
supports_pp: bool = False  # whether the executor supports PP

Estes dois sinalizadores não são decorativos — o código das camadas superiores lê-os para decidir se ativa determinados caminhos de otimização.__init__Em são inicializadossleeping_tags、kv_output_aggregator、ec_output_aggregatortrês campos de estado📎 vllm/v1/executor/abstract.py:119-120, usados respetivamente para rastreamento de etiquetas do modo de suspensão, agregação de saída do conector KV e agregação de saída do conector do codificador.

Seleção de backend:get_classO encaminhamento de ramos de

get_classé uma fábrica estática que, com base na configuraçãodistributed_executor_backend, devolve a classe Executor concreta📎 vllm/v1/executor/abstract.py:51-96. A sua estrutura de ramos merece uma análise detalhada:

  • Se a própria configuração for umtype, valida se é uma subclasse deExecutore usa-a diretamente📎 vllm/v1/executor/abstract.py:52-61;
  • "ray"Sob o ramo existem ainda sub-ramos:VLLM_USE_RAY_V2_EXECUTOR_BACKENDquando é verdadeiro usa-seRayExecutorV2, caso contrário usa-seRayDistributedExecutor 📎 vllm/v1/executor/abstract.py:64-72;
  • "mp"mapeia paraMultiprocExecutor,"uni"mapeia paraUniProcExecutor 📎 vllm/v1/executor/abstract.py:73-80;
  • Backends personalizados em forma de string são resolvidos dinamicamente através deresolve_obj_by_qualnameresolução dinâmica📎 vllm/v1/executor/abstract.py:85-90。
mermaid
flowchart TD
    start["Executor.get_class(vllm_config)"] --> check_type{"backend 是 type?"}
    check_type -->|是| verify_sub{"issubclass(Executor)?"}
    verify_sub -->|否| err_type["raise TypeError"]
    verify_sub -->|是| use_direct["executor_class = backend"]
    check_type -->|否| check_ray{"backend == 'ray'?"}
    check_ray -->|是| ray_v2{"VLLM_USE_RAY_V2?"}
    ray_v2 -->|是| use_rayv2["RayExecutorV2"]
    ray_v2 -->|否| use_ray["RayDistributedExecutor"]
    check_ray -->|否| check_mp{"backend == 'mp'?"}
    check_mp -->|是| use_mp["MultiprocExecutor"]
    check_mp -->|否| check_uni{"backend == 'uni'?"}
    check_uni -->|是| use_uni["UniProcExecutor"]
    check_uni -->|否| check_ext{"backend == 'external_launcher'?"}
    check_ext -->|是| use_ext["ExecutorWithExternalLauncher"]
    check_ext -->|否| check_str{"backend 是 str?"}
    check_str -->|是| resolve["resolve_obj_by_qualname"]
    check_str -->|否| err_unknown["raise ValueError"]

Passo a passo: o fluxo de chamada de umexecute_modelCenário: o EngineCore conclui um passo de agendamento, obtém

, chamaSchedulerOutputA implementação de é extremamente simplesexecutor.execute_model(scheduler_output)。

Executor.execute_modelCopiar📎 vllm/v1/executor/abstract.py:237-238:

python
def execute_model(
    self, scheduler_output: SchedulerOutput, non_block: bool = False
) -> ModelRunnerOutput | None | Future[ModelRunnerOutput | None]:
    output = self.collective_rpc(
        "execute_model", args=(scheduler_output,), non_block=non_block
    )
    return output[0]
O ponto-chave está em

— este difunde o nome do método e os parâmetros para todos os Workers, recolhe a lista de valores de retorno de cada Worker e depoiscollective_rpcapenas recolhe o primeiro. Porquê apenas o primeiro? Porque sob paralelismo tensorial todos os Workers executam a mesma passagem forward lógica, e as saídas são semanticamente equivalentes; o resultado de amostragem é determinado pelo último estágio PP ou pelo rank 0, e recolheroutput[0]evita agregação duplicada.output[0]A documentação de recomenda explicitamente «enviar apenas mensagens de controlo; a comunicação do plano de dados é estabelecida separadamente»collective_rpc, e é precisamente esse o posicionamento de📎 vllm/v1/executor/abstract.py:220-221— é uma mensagem de controlo; os dados reais de tokens circulam dentro dos Workers através de tensores GPU.SchedulerOutputsegue o mesmo padrão

sample_tokens, mas o tipo de retorno não inclui📎 vllm/v1/executor/abstract.py:257-258— a amostragem produz necessariamente um resultado. A divisão de trabalho entre estes dois métodos corresponde ao design de «separação execução-amostragem» do vLLM v1:Nonepode devolverexecute_model(indicando que o forward foi submetido mas a amostragem foi adiada), e nesse caso o estado é temporariamente armazenado emNone.ExecuteModelStateReflexão de design

é declarado como

collective_rpc, o que significa que cada backend deve implementar por si «como enviar o RPC ao Worker».@abstractmethod 📎 vllm/v1/executor/abstract.py:186-192usa filas de memória partilhada,MultiprocExecutorusa chamadas a atores Ray,RayDistributedExecutorfaz chamadas locais diretas. Esta abstração faz com que o código das camadas superiores não precise de se preocupar minimamente com detalhes distribuídos.UniProcExecutorUm detalhe fácil de ignorar:

está marcado comosupported_tasks, e o comentário diz explicitamente «evitar chamadas RPC desnecessárias». Porque@cached_property 📎 vllm/v1/executor/abstract.py:306-309requer comunicação entre processos, e a lista de tarefas não muda durante o ciclo de vida do modelo, a cache é uma otimização correta e necessária.get_supported_tasks5.2 GPUModelRunner: de SchedulerOutput para tensores de entrada

Modelo intuitivo

é o «tradutor»: traduz a descrição lógica em

GPUModelRunner(IDs de pedido, número de tokens, IDs de blocos) para tensores físicos que a GPU pode consumir diretamente. Sem ele, a camada do modelo teria de lidar sozinha com questões como «em que slot KV está o 7.º token do 3.º pedido» — o que seria uma fuga de responsabilidades catastrófica.SchedulerOutputEstado central e disposição de memória

herda de três Mixins

GPUModelRunner, que fornecem respetivamente capacidades de adaptação LoRA, conector KV e conector do codificador.📎 vllm/v1/worker/gpu_model_runner.py:479-480:LoRAModelRunnerMixin、KVConnectorModelRunnerMixin、ECConnectorModelRunnerMixinEm são armazenados em cache todos os objetos de configuração

__init__, e são inicializados vários sinalizadores-chave:📎 vllm/v1/worker/gpu_model_runner.py:488-498: apenas quando o paralelismo de dados > 1 e o modelo é MoE, consulta se o gestor EP all2all suporta tolerância a falhas

  • check_ep_fault: determinado por📎 vllm/v1/worker/gpu_model_runner.py:507-509;
  • is_pooling_model: se a entrada de prompt embedding está ativadarunner_type == "pooling"é um📎 vllm/v1/worker/gpu_model_runner.py:515;
  • enable_prompt_embeds, que transporta o estado temporário entre📎 vllm/v1/worker/gpu_model_runner.py:516。

ExecuteModelStateeNamedTuple. O design dos seus campos revela a essência da separação execução-amostragem:execute_model()é o produto do forward,sample_tokens()são os metadados ainda necessários na fase de amostragem. O comentário afirma explicitamente que este é «o estado de cache temporário transmitido após execute_model() devolver None»📎 vllm/v1/worker/gpu_model_runner.py:463-476Como sincronizar o estado da cachelogits、hidden_states、sample_hidden_statesCenário: o agendador decide que neste passo serão processados os pedidos A (novo pedido), B (continuação do decode do passo anterior) e C (recuperado após preempção), enquanto o pedido D já foi concluído.spec_decode_metadata、slot_mappingsPrimeiro passo: limpar pedidos concluídos.📎 vllm/v1/worker/gpu_model_runner.py:464-464。

Step-by-Step:_update_statespercorre

, remove o estado do dicionário

, remove de. Note o caso-limite indicado no comentário:finished_req_idseself.requestspodem sobrepor-se — quando um pedido é abortado e depois reenviado com o mesmo ID, são tratados como dois pedidos distintosinput_batchSegundo passo: zerar os blocos KV recém-alocados.📎 vllm/v1/worker/gpu_model_runner.py:1202-1217Sefinished_req_idsnão estiver vazio, chamascheduled_req_idspara zerar a memória da GPU, evitando que NaN obsoletos contaminem os cálculos de atenção ou SSM📎 vllm/v1/worker/gpu_model_runner.py:1211-1215。

. Este é o pré-requisito de segurança para a reutilização de blocos do PagedAttention.Terceiro passo: calcular o conjunto de pedidos não agendados.new_block_ids_to_zeroEste é o passo mais propenso a erros_zero_block_idsCopiar📎 vllm/v1/worker/gpu_model_runner.py:1219-1222O comentário explica porque é

e não diretamente: normalmente📎 vllm/v1/worker/gpu_model_runner.py:1238-1247:

python
scheduled_req_ids = scheduler_output.num_scheduled_tokens.keys()
cached_req_ids = self.input_batch.req_id_to_index.keys()
resumed_req_ids = scheduler_output.scheduled_cached_reqs.resumed_req_ids
unscheduled_req_ids = cached_req_ids - (scheduled_req_ids - resumed_req_ids)

não se intersectam, mas no cenário de preempção forçada desencadeado porscheduled_req_ids - resumed_req_ids, os pedidos recuperados precisam primeiro de ser removidos do lote persistente e depois readicionadosscheduled_req_idsQuarto passo: processar novos pedidos.cached_req_idsPara cadaresumed_req_ids, constróireset_prefix_cache. Se o tipo de amostragem for📎 vllm/v1/worker/gpu_model_runner.py:1241-1246。

, criacom seed. Se o modelo usar M-RoPE, chamascheduled_new_reqspara pré-calcular as posiçõesCachedRequestState 📎 vllm/v1/worker/gpu_model_runner.py:1295-1308Quinto passo: atualizar pedidos em execução.RANDOM_SEEDPara cadatorch.Generator 📎 vllm/v1/worker/gpu_model_runner.py:1277-1284, atualiza_init_mrope_positions, trata da adição ou substituição de IDs de blocos📎 vllm/v1/worker/gpu_model_runner.py:1319-1321。

. Se o pedido não estiver no lote persistente (), adiciona ascheduled_cached_reqsSexto passo: compressão e reordenação.num_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1402,处理块 ID 追加或替换 📎 vllm/v1/worker/gpu_model_runner.py:1437-1448。若请求不在持久批中(req_index is None),加入 reqs_to_add 📎 vllm/v1/worker/gpu_model_runner.py:1450-1465。

第六步:压缩与重排。 condense()Preencher os vazios deixados pelas requisições de remoção📎 vllm/v1/worker/gpu_model_runner.py:1511-1512,_may_reorder_batchPermitir que o backend de atenção reorganize sob demanda📎 vllm/v1/worker/gpu_model_runner.py:1513-1514,refresh_metadata()Atualizar os metadados do lote📎 vllm/v1/worker/gpu_model_runner.py:1515-1516。

Preparação dos tensores de entrada:_prepare_input_idsCaminho rápido assíncrono de

_prepare_input_idsLidar com um problema sutil: sob agendamento assíncrono, o token de amostragem da etapa anterior ainda está na GPU, e osinput_idsdesta etapa precisam preenchê-los📎 vllm/v1/worker/gpu_model_runner.py:1767-1772。

Caminho normal (prev_sampled_token_ids is None) copia diretamente o tensor da CPU para a GPU📎 vllm/v1/worker/gpu_model_runner.py:1788-1794. O caminho assíncrono percorre as requisições, calculando o índice do último token de cada requisição noinput_idsachatado📎 vllm/v1/worker/gpu_model_runner.py:1809-1836. Os comentários fornecem exemplos concretos:cu_num_tokens = [2, 5, 8]、draft_tokens = [1, 2, 2]quando,sample_flattened_indices = [0, 2, 5],spec_flattened_indices = [1, 3, 4, 6, 7] 📎 vllm/v1/worker/gpu_model_runner.py:1820-1822。

há uma otimização crucial📎 vllm/v1/worker/gpu_model_runner.py:1859-1868:

python
if common_indices_match and max_flattened_index == (num_common_tokens - 1):
    self.input_ids.gpu[:num_common_tokens].copy_(
        self.input_batch.prev_sampled_token_ids[:num_common_tokens, 0],
        non_blocking=True,
    )
    return

Quando o lote não muda e não há reorganização, os índices são0..N-1a mesma permutação, permitindo cópia direta com um único slice, evitando a sobrecarga de scatter. Esta é uma manifestação direta da otimização de lote persistente.

slot_mappinge block table

_get_slot_mappingsretorna dois formatos📎 vllm/v1/worker/gpu_model_runner.py:4078-4078: indexado por KV cache groupdict[int, torch.Tensor]para uso em metadados de atenção, indexado por nome de camadadict[str, torch.Tensor]para uso emForwardContext. Para KV cache groups encoder-only, o slot mapping é um tensor totalmente zerado📎 vllm/v1/worker/gpu_model_runner.py:4096-4115; caso contrário, é fatiado deblock_table.slot_mapping.gpu📎 vllm/v1/worker/gpu_model_runner.py:4107-4109. Preenchimento de cauda não utilizado-1, os comentários explicam que isso é necessário parareshape_and_cacheno modo CUDA graph completo📎 vllm/v1/worker/gpu_model_runner.py:4118-4122。

_get_block_tableObtém o tensor do dispositivo para cada KV cache group📎 vllm/v1/worker/gpu_model_runner.py:2319-2335, e preenche comNULL_BLOCK_IDas linhas de padding do CUDAGraph — o bloco 0 é reservado para padding📎 vllm/v1/worker/gpu_model_runner.py:2332-2334。

5.3 forward_context: descrição de lote compartilhada entre camadas

Modelo intuitivo

forward_contexté como um "quadro de avisos unificado" afixado na frente da sala de aula: cada camada do modelo pode levantar a cabeça e ver o arranjo de assentos (attention metadata) e as regras (slot mapping) deste exame, sem precisar perguntar individualmente. Sem ele, cada camada de atenção teria que receber essas informações dos parâmetros — mas a assinaturaforwarddas camadas do modelo é fixa, impossibilitando passar parâmetros individualmente por camada.

Estrutura de dados

ForwardContexté um@dataclass 📎 vllm/forward_context.py:141-202, campos principais:

  • no_compile_layers: copiado destatic_forward_context, marca camadas que não participam da compilação📎 vllm/forward_context.py:132-137;
  • attn_metadata: mapeamento de nome de camada para metadados de atenção, no modo DBO é uma lista de comprimento 2 (um por microbatch)📎 vllm/forward_context.py:144-152;
  • slot_mapping: mapeamento de nome de camada para tensor de slot mapping📎 vllm/forward_context.py:145;
  • cudagraph_runtime_mode: modo CUDA graph em tempo de execução, padrãoNONE 📎 vllm/forward_context.py:155-157;
  • batch_descriptor: descritor de lote, usado para despacho de CUDA graph📎 vllm/forward_context.py:158;
  • is_padding: máscara booleana no eixo de tokens,Trueindica linhas de padding📎 vllm/forward_context.py:162-165。

BatchDescriptoré outro@dataclass(frozen=True) 📎 vllm/forward_context.py:30-57, o design dos campos segue o princípio de "minimizar itens de descrição":num_tokens、num_reqs(pode ser None no modo PIECEWISE),uniform(todos os tokens de requisição têm o mesmo número),has_lora、num_active_loras. Os comentários explicam a razão da existência denum_active_loras: quandocudagraph_specialize_lora_counté habilitado, cada valor de quantidade de LoRA captura um CUDA graph independente, pois o grid size de kernels comofused_moe_loradepende deste valor📎 vllm/forward_context.py:60-64。

Singleton global e gerenciamento de contexto

_forward_contexté uma variável global de nível de módulo📎 vllm/forward_context.py:199-201, através do gerenciador de contextooverride_forward_contextsalva o valor antigo ao entrar e restaura ao sair📎 vllm/forward_context.py:263-274。set_forward_contexté um encapsulamento de nível superior📎 vllm/forward_context.py:277-394, que adicionalmente lida com construção de metadados DP, criação automática de batch descriptor, injeção de kwargs específicos da plataforma.

Passo a Passo: deexecute_modelaté o forward do modelo

Cenário:GPUModelRunner.execute_modeltodos os tensores de entrada estão prontos, prestes a chamar o modelo.

Emexecute_model,set_forward_contexté chamado📎 vllm/v1/worker/gpu_model_runner.py:4408-4420:

python
with (
    set_forward_context(
        attn_metadata,
        self.vllm_config,
        num_tokens=num_tokens_padded,
        num_tokens_across_dp=num_tokens_across_dp,
        cudagraph_runtime_mode=cudagraph_mode,
        batch_descriptor=batch_desc,
        ubatch_slices=ubatch_slices_padded,
        slot_mapping=slot_mappings,
        skip_compiled=has_encoder_input,
        is_padding=is_padding,
    ),
    ...
):
    model_output = self._model_forward(...)

set_forward_contextInternamente, primeiro constróiDPMetadata(se DP ou MoE de paralelismo de sequência estiver habilitado)📎 vllm/forward_context.py:299-328, depois chamacreate_forward_contextpara construir a instânciaForwardContext, e finalmente define a variável global via📎 vllm/forward_context.py:347-358override_forward_contextAs camadas do modelo leem📎 vllm/forward_context.py:361-362。

através deget_forward_context(). Se não definido, a asserção falha e sugere usar📎 vllm/forward_context.py:208-214set_forward_context。

mermaid
sequenceDiagram
    participant EC as EngineCore
    participant EX as Executor
    participant W as Worker
    participant MR as GPUModelRunner
    participant FC as ForwardContext
    participant M as Model Layers

    EC->>EX: execute_model(SchedulerOutput)
    EX->>W: collective_rpc("execute_model", args)
    W->>MR: execute_model(scheduler_output)
    MR->>MR: _update_states(scheduler_output)
    MR->>MR: _prepare_inputs(...)
    MR->>MR: _get_slot_mappings(...)
    MR->>FC: set_forward_context(attn_metadata, slot_mapping, ...)
    FC-->>MR: context manager entered
    MR->>M: _model_forward(input_ids, positions, ...)
    M->>FC: get_forward_context()
    FC-->>M: ForwardContext
    M-->>MR: hidden_states
    MR->>MR: compute_logits(sample_hidden_states)
    MR-->>W: ExecuteModelState / None
    W-->>EX: ModelRunnerOutput
    EX-->>EC: output[0]

Reflexões de design

〔Inferência de design e trade-offs arquiteturais〕

Por que usar variável global em vez de passagem explícita de parâmetros? Porque a assinaturaforwarddas camadas do modelo é fixada pela convenção do HuggingFace, impossibilitando injetar parâmetros extras por camada. Variável global + gerenciador de contexto é a única solução para injeção entre camadas sem modificar o código do modelo. O custo é a dependência implícita —get_forward_context()o chamador deset_forward_contextdeve garantir que está dentro do escopo de

is_padding. O design do campo📎 vllm/forward_context.py:162-165merece atenção: os comentários dizem "consumidores podem usá-lo para pular o trabalho de tokens de padding". Esta é uma otimização no cenário de CUDA graph — linhas de padding participam da captura do grafo mas não devem produzir computação real.

all_moe_layersemoe_layer_indexsão um par engenhoso de workarounds📎 vllm/forward_context.py:170-195. Os comentários explicam detalhadamente o problema:vllm.moe_forwardoperadores personalizados codificam strings de nomes de camadas no grafo, causando tempo de inicialização a frio excessivo do torch.compile. A solução é armazenar a lista de nomes de camadas emForwardContext, e os operadores personalizados retiram strings em ordem e incrementam um contador. Os comentários também admitem que isso depende da suposição de que "operadores personalizados executam em ordem e torch.compile não reordena"📎 vllm/forward_context.py:182-184。

Reflexões de design e armadilhas em produção

Consistência de estado no agendamento assíncrono. _update_statesadota uma estratégia de "suposição otimista" sob decodificação especulativa assíncrona: assume que todos os draft tokens da etapa anterior foram aceitos, primeiro expandeoutput_token_ids, depois registra uma função de correção diferida📎 vllm/v1/worker/gpu_model_runner.py:1376-1384. A função de correção é chamada após o início do forward do modelo📎 vllm/v1/worker/gpu_model_runner.py:1509-1510, lê o número real de aceitos da GPU e revertenum_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1547-1558. A elegância deste design está em: a correção ocorre após "o lote já ter sido iniciado", não bloqueia o forward, mantendo a continuidade do pipeline assíncrono.

_may_reorder_batchCondição de disparo de. Este método primeiro verificakv_cache_groupsse está vazio📎 vllm/v1/worker/gpu_model_runner.py:1131-1132. Os comentários explicam por que não se pode simplesmente verificaris_attention_free:O modelo Mamba também é attention-free, mas usa KV cache para armazenar o estado interno📎 vllm/v1/worker/gpu_model_runner.py:1116-1139。Apenas modelos que realmente não possuem KV cache group é que pulam o reordenamento.

_prepare_input_idsarmadilha de cálculo de índice.Quando o lote contém tanto requisições de decode do passo anterior quanto novas requisições,num_common_tokens < total_without_spec, é necessário primeiro copiar o tensor da CPU e depois fazer scatter📎 vllm/v1/worker/gpu_model_runner.py:1849-1854。Senum_common_tokens == 0, significa que nenhuma requisição se sobrepõe ao passo anterior, retornando diretamente📎 vllm/v1/worker/gpu_model_runner.py:1855-1858。A distinção entre esses dois ramos é crucial — omitir qualquer um deles resultará eminput_idsparcialmente não inicializado.

AsyncGPUModelRunnerOutputsincronização de stream.A cópia de saída é realizada em uma CUDA stream independente📎 vllm/v1/worker/gpu_model_runner.py:308-328, usandoblocking=TrueEvent para evitar busy-polling do lock do driver CUDA📎 vllm/v1/worker/gpu_model_runner.py:296-298。get_output()primeiro synchronize e depois liberar a referência do tensor do dispositivo📎 vllm/v1/worker/gpu_model_runner.py:336-340, a ordem não pode ser invertida — caso contrário, o tensor pode ser reciclado antes da conclusão da cópia.

Resumo do capítulo

Este capítulo rastreouSchedulerOutputo caminho completo do EngineCore até o forward pass na GPU.ExecutorAtravés decollective_rpctransmitir os resultados do agendamento para todos os Workers,GPUModelRunnero_update_statessincroniza o estado do cache,_prepare_inputsconstrói os tensores de entrada,_get_slot_mappingsgera o mapeamento de slots KV, e finalmenteset_forward_contextinjeta a descrição do lote no contexto global para consumo pelas camadas do modelo. O caminho de agendamento assíncrono mantém a continuidade do pipeline através de suposição otimista + correção tardia, enquantoForwardContexto design de singleton global resolve a contradição entre a assinatura fixa das camadas do modelo e a injeção de metadados entre camadas.

Reflexão e autoavaliação do capítulo

Q1: _update_statesEmunscheduled_req_ids = cached_req_ids - (scheduled_req_ids - resumed_req_ids)esta expressão, se removermosresumed_req_idsda subtração, tornando-secached_req_ids - scheduled_req_ids, em qual cenário isso causaria inconsistência de estado?

Análise de referência:O comentário indica explicitamente que📎 vllm/v1/worker/gpu_model_runner.py:1241-1246,cached_req_idseresumed_req_idsgeralmente não se intersectam, mas em cenários de preempção forçada acionados porreset_prefix_cache, uma requisição pode aparecer simultaneamente emcached_req_idseresumed_req_ids。Nesse caso,scheduled_req_ids - resumed_req_idsexcluirá esta requisição do conjunto "já agendado", fazendo com que ela caia emunscheduled_req_ids, sendo assim primeiro removida do lote persistente e depois readicionada através do caminho normal de resumed. Se removermosresumed_req_ids, a requisição será considerada "já agendada" e mantida no lote, mas seu block ID já foi substituído (req_state.block_ids = new_block_ids 📎 vllm/v1/worker/gpu_model_runner.py:1448), causando incompatibilidade entre a linha antiga na block table e o novo block ID, e o cálculo de atenção lerá posições KV incorretas.

Q2: _prepare_input_idscaminho rápido de📎 vllm/v1/worker/gpu_model_runner.py:1859-1868usacommon_indices_match and max_flattened_index == (num_common_tokens - 1)como condição. Se a ordem das requisições no lote mudar (por exemplo, o backend de atenção reordenou o lote), mascommon_indices_matchainda for True, o que acontecerá?

Análise de referência:common_indices_matchNo loop, através deprev_index == flattened_indexacumula📎 vllm/v1/worker/gpu_model_runner.py:1835。prev_indexproveniente deprev_positions, mapeando a posição atual do lote para a posição do lote do passo anterior;flattened_indexé o índice flat do último token desta requisição no lote atual. Se o lote for reordenado,prev_indexeflattened_indexa correspondência mudará,common_indices_matchse tornará False e o caminho rápido não será acionado. Mas se o reordenamento fizer com queprev_index == flattened_indexseja válido para todas as requisições (por exemplo, trocando duas requisições com o mesmo número de tokens), o caminho rápido erroneamente usaráprev_sampled_token_ids[:num_common_tokens, 0]para cópia direta por slicing — isso preencherá o token amostrado da requisição A na posição da requisição B.max_flattened_index == num_common_tokens - 1Esta condição adicional serve exatamente para prevenir este caso degenerado: ela exige que os índices flat sejam exatamente uma permutação de0..N-1, excluindo qualquer reordenamento não trivial.

Q3: ForwardContextusa variável global de nível de módulo_forward_contextem vez de variável thread-local. No agendamento assíncrono comexecute_modelesample_tokensseparados, sesample_tokensfor chamado antes da conclusão do forward,get_forward_context()retornará o quê? Isso causaria qual problema?

Análise de referência:set_forward_contexté um context manager📎 vllm/forward_context.py:278-288, que ao sair do blocowithrestaura o valor antigo através deoverride_forward_contextdofinally📎 vllm/forward_context.py:263-274。Emexecute_model,set_forward_contexto blocowithenvolve apenas a chamada_model_forward, e o contexto é restaurado após o retorno do forward. Se📎 vllm/v1/worker/gpu_model_runner.py:4408-4433for chamado após a conclusão do forward,sample_tokensfalhará na asserçãoget_forward_context(), porque📎 vllm/forward_context.py:208-214já foi redefinido para_forward_context(ou valor externo). Esta é exatamente a razão da existência deNone: o estado necessário para amostragem (ExecuteModelState) é explicitamente armazenado em NamedTuple, em vez de depender da passagem implícita de📎 vllm/v1/worker/gpu_model_runner.py:463-476。Se erroneamente assumirmos quelogits、hidden_states、slot_mappingsainda está disponível emForwardContext, isso acionará erro de asserção ou leitura de metadados incorretos.ForwardContextAté aqui, percorremos o caminho completo do SchedulerOutput até a propagação forward na GPU: Executor distribui, Worker executa, GPUModelRunner traduz a lista lógica em tensores físicos, e através do forward_context injeta a descrição do lote em cada camada. No entanto, a parte mais demorada da propagação forward do modelo — o cálculo de atenção — ainda não foi explorada. O próximo capítulo mergulhará nos backends de atenção, vendo como a block table e o slot mapping em attn_metadata são consumidos pelos kernels do PagedAttention, e como diferentes backends como FlashAttention, FlashInfer, Triton são selecionados e agendados através de uma interface unificada.sample_tokens← Capítulo anterior: Capítulo 4

Voltar ao topo ↑

CHAPTER 06

Progresso do livro: Capítulo 6 / 14

Status de verificação: Linhas FACT com ancoragem real · 全书进度:第 6 / 14 章 · 核验状态:FACT 行号真实锚定

No capítulo anterior vimos como o GPUModelRunner traduz os resultados do agendamento em tensores físicos como input_ids, slot_mapping e block_table, e os injeta em cada camada através do forward_context. Mas o grande consumidor de tempo de GPU — o cálculo de atenção — ainda está no ar. Quem consome exatamente aqueles tensores em attn_metadata? Por que FlashAttention, FlashInfer e Triton podem ser intercambiáveis sob o mesmo código de modelo? A resposta está na camada de abstração AttentionBackend. Ela desacopla "como a atenção é calculada" de "como o modelo a invoca": a camada do modelo mantém apenas uma referência a AttentionImpl e chama a interface unificada forward(query, key, value, kv_cache, attn_metadata, output); enquanto o backend concreto é responsável por traduzir block_table, slot_mapping, seq_lens em parâmetros que seu próprio kernel consegue consumir. Este capítulo usa o FlashAttentionBackend como linha principal, porque ele cobre simultaneamente a semântica de gather do PagedAttention, compatibilidade com CUDA Graph, atenção em cascata, contexto distribuído DCP e os ramos mais ricos. Entendendo-o a fundo, os outros backends são apenas variantes de mapeamento de parâmetros. A motivação desse design de "registro de backend + interface unificada" é direta: os kernels de atenção evoluem extremamente rápido (FA2→FA3→FA4, iterações do FlashInfer, Triton próprio), e se a camada do modelo dependesse diretamente de um kernel concreto, cada atualização de kernel exigiria alterar o código do modelo. A camada de abstração isola as mudanças atrás de um único método de fábrica get_impl_cls().

Seleção de backend: declaração de capacidades e construção de metadados

Modelo intuitivo

Pense emAttentionBackendcomo um anúncio de vaga: ele não executa trabalho, apenas declara "quais dtypes, quais head_size, quais formatos de quantização de KV cache, quais tipos de atenção eu consigo processar". O agendador usa a configuração do modelo para fazer o matching, e se falhar, passa para o próximo candidato. Sem essa camada de declaração, o sistema só descobriria em tempo de execução que "este head_size não é suportado pelo kernel", resultando em crash imediato.

Matriz de capacidades: campos como contrato

FlashAttentionBackendOs atributos de classe de são seus limites de capacidade.supported_dtypesrestringe a fp16/bf16📎 vllm/v1/attention/backends/flash_attn.py:287-287;supported_kv_cache_dtypespermite adicionalmente a série fp8📎 vllm/v1/attention/backends/flash_attn.py:298-299. Mas "declarar suporte" não é o mesmo que "suporte incondicional" —supports_kv_cache_dtypepara KV quantizado delega ainda mais paraflash_attn_supports_kv_cache_dtypefazer julgamentos dependentes de dispositivo📎 vllm/v1/attention/backends/flash_attn.py:431-438。

Ainda mais refinado ésupports_combination: ele recebe um conjunto completo de parâmetros combinados como head_size, dtype, block_size, use_mla, has_sink, e retornaNoneindicando disponibilidade, ou uma string indicando o motivo da rejeição📎 vllm/v1/attention/backends/flash_attn.py:454-507. Por exemplo, sink é rejeitado em capacidade de computação < 9.0📎 vllm/v1/attention/backends/flash_attn.py:467-468, e em SM90 FP8 KV com mm_prefix deve obrigatoriamente usar Triton📎 vllm/v1/attention/backends/flash_attn.py:472-472. Esse design de "retornar string de motivo" permite que as camadas superiores forneçam erros diagnosticáveis, em vez de fallback silencioso.

A escolha do block_size também é guiada por capacidades. Por padrão retornaMultipleOf(16), mas SM90 FP8-KV força 64📎 vllm/v1/attention/backends/flash_attn.py:297-324, e o kernel FA4 com head_size=256 forçaFA4_HD256_PAGE_SIZE 📎 vllm/v1/attention/backends/flash_attn.py:326-352. Isso explica por que o tamanho de bloco do KV cache não é definido arbitrariamente — ele é restringido inversamente pelo tamanho do tile TMA do kernel.

Estrutura de metadados: layout de campos do FlashAttentionMetadata

FlashAttentionMetadataé uma dataclass, com campos divididos em quatro grupos📎 vllm/v1/attention/backends/flash_attn.py:511-566:

O primeiro grupo é a descrição básica do lote:num_actual_tokens(número real de tokens sem padding),max_query_len、query_start_loc(soma de prefixos, usada por kernels varlen para localizar início e fim de cada sequência),seq_lens、block_table、slot_mapping 📎 vllm/v1/attention/backends/flash_attn.py:520-526. Observe o diagrama ASCII nos comentários do código-fonte📎 vllm/v1/attention/backends/flash_attn.py:512-518, que distingue precisamentecontext_len(KV histórico),query_len(adicionado nesta rodada),seq_len(soma dos dois) — esta é a chave para entender os parâmetros do kernel varlen.

O segundo grupo são campos de atenção em cascata:use_cascade、common_prefix_len、cu_prefix_query_lensetc.📎 vllm/v1/attention/backends/flash_attn.py:528-533。

O terceiro grupo são campos DCP (Decode Context Parallel):max_dcp_context_kv_len、dcp_context_kv_lens, além de contadores que distinguem o número de requisições decode/prefill📎 vllm/v1/attention/backends/flash_attn.py:535-544。

O quarto grupo são agendamento opcional e máscaras especiais:scheduler_metadata(usado pelo agendamento AOT do FA3),causal(pode ser bool ou tensor, suporta causalidade por sequência),mm_prefix_query_range_tensor(intervalo bidirecional multimodal), campos relacionados a R-SWA📎 vllm/v1/attention/backends/flash_attn.py:546-566。

〔Inferência de design e trade-offs arquiteturais〕

causalO tipo do campo ébool | torch.Tensorem vez de bool puro, isso é para suportar o cenário de "parte das sequências no mesmo lote é causal, parte não é" (como PrefixLM). Quando é um tensor, o parâmetrodynamic_causaldo FA4 assume o controle, e FA2/FA3 lançam diretamente NotImplementedError📎 vllm/v1/attention/backends/flash_attn.py:1429-1433。

Passo a passo do build()

Cenário: um lote misto, 3 sequências decode + 2 sequências prefill, sem cascata, sem DCP.

Primeiro passo, a partir decommon_attn_metadatadesempacotar os tensores básicos📎 vllm/v1/attention/backends/flash_attn.py:824-832. Segundo passo, decidir se ativar o agendamento AOT:aot_schedule = self.aot_schedule and not fast_build and not envs.VLLM_BATCH_INVARIANT 📎 vllm/v1/attention/backends/flash_attn.py:836-838。self.aot_scheduleem__init__é decidido porget_flash_attn_version() == 3— apenas FA3 suporta pré-cálculo de metadados de agendamento. Terceiro passo, no primeiro build preencher lazily📎 vllm/v1/attention/backends/flash_attn.py:709-709: percorrer todas asaot_sliding_windowcamadas para coletar configurações de janela deslizante; se a configuração for única, adotá-la; se houver mais de uma, desativar AOTFlashAttentionImplQuarto passo, calcular📎 vllm/v1/attention/backends/flash_attn.py:848-851。

. Padrão 0 (deixar FA3 usar heurística), definir comomax_num_splitsapenas quando full CUDA graph estiver ativado e o número de tokens estiver dentro do intervalo de capturaself.max_num_splits 📎 vllm/v1/attention/backends/flash_attn.py:856-866. O comentário explica o motivo:num_splits > 1aloca[num_splits, num_heads, num_tokens, head_size]buffer intermediário, com alto custo de memória de vídeo, só vale a pena em cenários de CUDA graph📎 vllm/v1/attention/backends/flash_attn.py:862-865。

Quinto passo, seguir o ramo não-cascata e não-DCP, chamar_get_scheduler_metadatapara gerar os metadados de agendamento do FA3📎 vllm/v1/attention/backends/flash_attn.py:976-986. Sexto passo,_store_scheduler_metadatatrata o cenário de CUDA graph: copiar os novos metadados para o buffer pré-alocado e zerar a parte restante📎 vllm/v1/attention/backends/flash_attn.py:671-684. O passo de zerar é crucial — o comentário indica claramente que, caso contrário, alguns thread blocks lerão metadados inválidos e sobrescreverão o buffer de saída📎 vllm/v1/attention/backends/flash_attn.py:671-672。

Sétimo passo, construirFlashAttentionMetadatae retornar📎 vllm/v1/attention/backends/flash_attn.py:992-1015。

mermaid
flowchart TD
    start["build(common_prefix_len, common_attn_metadata)"] --> unpack["解包 query_start_loc / seq_lens / block_table / slot_mapping"]
    unpack --> aot{"aot_schedule 且非 fast_build 且非 BATCH_INVARIANT?"}
    aot -->|是| sw_check{"aot_sliding_window 已初始化?"}
    aot -->|否| maxsplit
    sw_check -->|否, 首次| collect["_get_sliding_window_configs 收集层滑窗"]
    collect --> sw_unique{"配置数量 == 1?"}
    sw_unique -->|是| set_sw["设置 aot_sliding_window"]
    sw_unique -->|否, >1| disable_aot["self.aot_schedule = False"]
    set_sw --> maxsplit
    disable_aot --> maxsplit
    sw_check -->|是| maxsplit["计算 max_num_splits"]
    maxsplit --> cg_check{"use_full_cuda_graph 且 tokens <= max_cudagraph_size?"}
    cg_check -->|是| set_splits["max_num_splits = self.max_num_splits"]
    cg_check -->|否| zero_splits["max_num_splits = 0"]
    set_splits --> branch
    zero_splits --> branch
    branch{"dcp_world_size > 1?"}
    branch -->|是| dcp_path["计算 dcp_context_kv_lens, 可能 skip"]
    branch -->|否| cascade_check{"common_prefix_len > 0?"}
    cascade_check -->|是| cascade_path["构造 prefix/suffix 双份 scheduler_metadata"]
    cascade_check -->|否| normal_path["_get_scheduler_metadata 单份"]
    dcp_path --> store
    cascade_path --> store
    normal_path --> store
    store["_store_scheduler_metadata: CUDA graph 时拷入预分配缓冲并清零尾部"] --> build_meta["构造 FlashAttentionMetadata"]
    build_meta --> mm_check{"mm_req_doc_ranges 非空?"}
    mm_check -->|是| fill_mm["fill_mm_prefix_query_ranges + 拷贝到 GPU"]
    mm_check -->|否| rswa_check
    fill_mm --> rswa_check{"rswa_window 非空?"}
    rswa_check -->|是| copy_rswa["拷贝 prefix_lens 到持久缓冲"]
    rswa_check -->|否| done
    copy_rswa --> done["返回 attn_metadata"]

---

forward(): a cadeia completa dos metadados até a chamada do kernel

Modelo intuitivo

forward()é a "oficina de montagem final" do backend: ela recebe os Q/K/V calculados pelas camadas do modelo, os tensores de KV cache e os metadados construídos no passo anterior, ajusta o layout físico do KV cache para o formato esperado pelo kernel e então despacha para o kernel específico. Sem esse passo, o kernel leria um layout de memória incorreto e produziria erros silenciosos na saída — mais difíceis de depurar do que um crash.

Transformação do layout de memória do KV cache

A forma física do KV cache do vLLM é[num_blocks, num_kv_heads, block_size, 2 * head_size]— K e V concatenados na última dimensão📎 vllm/v1/attention/backends/flash_attn.py:1246-1247. Mas os kernels do FlashAttention esperam K e V separados, com layout[num_blocks, block_size, num_kv_heads, head_size]。

A transformação ocorre no início deforward():kv_cache.transpose(1, 2).split(self.head_size, dim=-1) 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310。transpose(1,2)transforma[blocks, heads, block_size, 2D]em[blocks, block_size, heads, 2D],splitdividindo ao longo da última dimensão em K e V. Note quetransposeapenas altera o stride sem mover dados, então os kernels subsequentes devem suportar acesso não contíguo.

Em seguida vemcanonicalize_singleton_dim_strides 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310. O comentário aponta a motivação: quandonum_kv_heads=1(comum em cenários TP), o stride de dimensões de tamanho 1 é degenerado, e FA3/FA4 em H100+ usam TMA, que exige que o stride tenha pelo menos 16 bytes de alinhamento📎 vllm/v1/attention/backends/flash_attn.py:1310-1310. Esta é uma armadilha típica de "logicamente equivalente, fisicamente inválido".

Fluxo de parâmetros do caminho não-cascata

Após entrar no ramoif not attn_metadata.use_cascade, os parâmetros são mapeados um a um📎 vllm/v1/attention/backends/flash_attn.py:1326-1342:cu_seqlens_q = query_start_loc,seqused_k = seq_lens,block_table = attn_metadata.block_table。descale_shapepega(batch_size, num_kv_heads), usado para broadcast de scale na quantização FP8 — o comentário explica que flash-attn espera que a forma de descale seja(num_sequences, num_kv_heads), usando.expand()para evitar cópia📎 vllm/v1/attention/backends/flash_attn.py:1258-1258。

Em seguida vem o tratamento de simetrização da janela deslizante._maybe_symmetrize_windowA lógica: janela deslizante causal(w, 0)em cenário não-causal deve se tornar(w, w), permitindo que queries bidirecionais olhem em ambas as direções📎 vllm/v1/attention/backends/flash_attn.py:587-589. O comentário também enfatiza que "a window da própria camada tem prioridade sobre a window do group", pois um KV cache group pode conter simultaneamente camadas com janela e camadas globais (como no Gemma-3 quando o hybrid KV cache manager está desativado)📎 vllm/v1/attention/backends/flash_attn.py:1362-1365。

Ramo de máscara: mm_prefix e R-SWA

Quandomm_prefix_query_rangesnão é vazio e satisfaz as condições de FA4 + causal estático, o código constrói omask_mod 📎 vllm/v1/attention/backends/flash_attn.py:1374-1407do CuTE-DSL. As ações-chave sãocausal = Falseesliding_window_size = None 📎 vllm/v1/attention/backends/flash_attn.py:1406-1407. O comentário explica o motivo: a semântica de mm_prefix é(causal ∧ window) ∨ bidirectional-range, não um subconjunto de causal; após FA #155, definir mask_mod não limpa mais automaticamente causal/local, e o chamador deve desativá-los explicitamente, caso contrário o caminho causal embutido fará curto-circuito em mask_mod📎 vllm/v1/attention/backends/flash_attn.py:1402-1405。

_make_mm_prefix_mask_modusafunctools.cachepara cachear📎 vllm/v1/attention/backends/flash_attn.py:1793-1802. O comentário apresenta a razão técnica: ohash_callabledo FA4 mistura orepr()da unidade de closure na chave de compilação; o_load_q_rangeaninhado tem endereço diferente a cada chamada, fazendo com que cada forward dispare uma recompilação JIT completa📎 vllm/v1/attention/backends/flash_attn.py:1793-1802. Este é um exemplo típico de armadilha de desempenho em ambiente de produção.

Dentro da máscara há um detalhe de conversão de coordenadas: o FA4 passaq_idxlocal (0-based dentro do chunk de prefill atual), enquantokv_idxé a posição absoluta. O código usaq_abs = q_idx + seqlen_k - seqlen_qpara restaurar a posição absoluta📎 vllm/v1/attention/backends/flash_attn.py:1859-1865。__vec_size__ = 1A configuração de_load_q_rangetambém tem suas particularidades: lê lane 0, uma chamada não pode atravessar linhas de query📎 vllm/v1/attention/backends/flash_attn.py:1897-1897。

O mask_mod do R-SWA é semelhante, mas a semântica écausal & (in_prefix | in_window) 📎 vllm/v1/attention/backends/flash_attn.py:1945-1948, euse_fast_sampling = Truefaz o FA4 pular blocos de KV totalmente mascarados, sem carregar seus dados📎 vllm/v1/attention/backends/flash_attn.py:1950-1950。

Tratamento especial do FA4 hd256

Quandoself.fa4_hd256é verdadeiro, o código força alinhamento de página:num_pages = cdiv(max_seqlen_k, FA4_HD256_PAGE_SIZE),max_seqlen_karredondado para cima até a fronteira de página,block_tabletruncado para o número exato de páginas,num_splits = 1 📎 vllm/v1/attention/backends/flash_attn.py:1442-1448. O comentário explica que o kernel hd256 exige comprimento alinhado à página, block table de largura exata e não suporta SplitKV.

Chamada final a_FA4_DENSE_ATTENTION_KERNEL(...), passando q, k, v, out, cu_seqlens_q, seqused_k, block_table, softcap, mask_mod, aux_tensors etc. para📎 vllm/v1/attention/backends/flash_attn.py:1450-1475。

Escrita no KV cache: do_kv_cache_update

forward()apenas lê o KV cache; a escrita é feita pordo_kv_cache_update. Ele chamareshape_and_cache_flash, usandoslot_mappingpara espalhar os K/V recém-calculados no cache📎 vllm/v1/attention/backends/flash_attn.py:1532-1541. O comentário observa:key/valueé padded enquantoslot_mappingNão, mas não é necessário fatiar manualmente, porque o op usaslot_mappingo shape de📎 vllm/v1/attention/backends/flash_attn.py:1527-1531para determinar o número real de tokens📎 vllm/v1/attention/backends/flash_attn.py:1520-1521。

mermaid
sequenceDiagram
    participant Model as 模型层 Attention
    participant Impl as FlashAttentionImpl
    participant KVC as kv_cache 张量
    participant Kernel as flash_attn_varlen_func
    Model->>Impl: forward(query, key, value, kv_cache, attn_metadata, output)
    Impl->>Impl: output_scale 非空? 抛 NotImplementedError
    Impl->>Impl: attn_metadata is None? 返回 output.fill_(0)
    Impl->>KVC: transpose(1,2).split(head_size)
    KVC-->>Impl: key_cache, value_cache
    Impl->>Impl: canonicalize_singleton_dim_strides(key_cache)
    Impl->>Impl: use_cascade?
    alt 非级联
        Impl->>Impl: 映射 cu_seqlens_q / seqused_k / block_table
        Impl->>Impl: _maybe_symmetrize_window
        Impl->>Impl: mm_prefix 或 R-SWA? 构造 mask_mod
        Impl->>Kernel: _FA4_DENSE_ATTENTION_KERNEL(q, k, v, out, ...)
        Kernel-->>Impl: output 就地写入
    else 级联
        Impl->>Kernel: cascade_attention(prefix + suffix 两次调用)
        Kernel-->>Impl: merge_attn_states 合并
    end
    Impl-->>Model: output

---

da cópia

Considerações de design: por que está escrito assim

〔Inferência de design e trade-offs arquiteturais〕。supports_combinationSeparação entre declaração de capacidade e implementação

Retorna uma string de motivo em vez de bool; isso permite que a camada superior, ao fazer fallback para outros backends, registre "por que FA não foi usado", reduzindo enormemente o custo de diagnóstico em produção. Em comparação com fallback silencioso, esse design torna explícita a base da decisão.。_store_scheduler_metadataA compatibilidade com CUDA Graph é uma restrição invisível do design de metadados📎 vllm/v1/attention/backends/flash_attn.py:671-684O padrão de "copiar para dentro + zerar a cauda"📎 vllm/v1/attention/backends/flash_attn.py:787-798aparece repetidamente no buffer persistente de R-SWA📎 vllm/v1/attention/backends/flash_attn.py:800-813e na área de staging de mm_prefix__init__. O padrão comum é: pré-alocar nobuild()um buffer persistente do tamanho máximo,📎 vllm/v1/attention/backends/flash_attn.py:1044-1046。

e no。supports_draft_decode_metadata_update = self.dcp_world_size == 1 📎 vllm/v1/attention/backends/flash_attn.py:742-742apenas copiar, sem alocar. O motivo é apontado nos comentários — durante a captura de CUDA graph não pode haver operações de alocaçãoskip_dcp_context_attention()Exclusão mútua entre DCP e fused draft decode📎 vllm/v1/attention/backends/flash_attn.py:736-741. O comentário explica: fused draft decode reutiliza entre etapas de draft o objeto de metadados capturado, mas as decisões do lado do host em build-time do DCP (como

) alteram o shape dos metadados, e esses campos Python não são atualizados in-place entre replays do graph。use_cascade_attention. Este é um trade-off típico de "escolher corretude quando otimização de desempenho entra em conflito com corretude".📎 vllm/v1/attention/backends/flash_attn.py:1967-1967Limiar heurístico da atenção em cascata📎 vllm/v1/attention/backends/flash_attn.py:1978-1979usa uma série de limiares para filtrar: common_prefix_len < 256 é rejeitado diretamente📎 vllm/v1/attention/backends/flash_attn.py:1982-1984, alibi/sliding_window/local_attention não são suportados📎 vllm/v1/attention/backends/flash_attn.py:1985-1987, número de requisições < 8 é rejeitado📎 vllm/v1/attention/backends/flash_attn.py:2011-2029, cenário DCP é desabilitado📎 vllm/v1/attention/backends/flash_attn.py:2009-2010。

. Depois de passar, ainda é preciso usar um modelo de desempenho aproximado para comparar o número de CTAs e waves entre cascade e FlashDecoding:forward(). O comentário admite que esse modelo é "very rough"view/slicePontos de armadilha em produção📎 vllm/v1/attention/backends/flash_attn.py:1277-1284Há um comentário destacado em[:num_actual_tokens], alertando que sob piece-wise CUDA graph esse método é executado em modo eager,

---

e métodos que aparentam não ter operações de GPU, como

, são na verdade muito lentos; alterações devem ser benchmarkadasFlashAttentionBackend. Isso explica por que o código usa amplamentesupports_*slicing em vez de formas mais "elegantes" — cada ponto é resultado de um trade-off de desempenho.build()Resumo do capítuloCommonAttentionMetadataEste capítulo percorreuFlashAttentionMetadatatodo o ciclo de vida do backend de atenção: declaração de capacidade (forward()série) → construção de metadados (transpose+splittraduz

para

) → chamada de kernel (logitstransforma o layout do KV cache, constrói máscaras, despacha para o kernel FA). Os mecanismos centrais incluem: a transformação de layout

do KV cache, a normalização de strides degenerados, o padrão de buffer persistente sob CUDA graph, a construção de máscaras CuTE-DSL de mm_prefix/R-SWA, e a decisão heurística da atenção em cascata.

Princípios-chave de design: separação entre declaração de capacidade e implementação, pré-alocação de metadados impulsionada pela compatibilidade com CUDA graph, e prioridade à corretude quando otimização de desempenho entra em conflito com corretude (DCP desabilita fused draft decode)._store_scheduler_metadataO próximo capítulo voltará-se para amostragem e saída:self.scheduler_metadata[n:] = 0como

se transforma em token via cadeia de processadores (temperatura, top-p, penalidades), como a saída estruturada restringe a decodificação, e como o retorno em streaming coopera com o scheduler.:_store_scheduler_metadataReflexões e autoavaliação deste capítulo📎 vllm/v1/attention/backends/flash_attn.py:671-684Q1: Se a operação de📎 vllm/v1/attention/backends/flash_attn.py:671-672de zerar

Q2: _make_mm_prefix_mask_modemfunctools.cachefor removida, em quais cenários isso causaria saída incorreta? Por que o comentário enfatiza especialmente esse ponto?

Análise de referênciaNo cenário de CUDA graph, copia os novos metadados para as primeiras n posições do buffer pré-alocadohash_callable. Se a cauda não for zerada, metadados de agendamento remanescentes da build anterior serão lidos pelo kernel atual. O comentário aponta explicitamente que "some thread blocks may use the invalid scheduler metadata and overwrite the output buffer"repr(). Cenário de disparo: o tamanho do batch diminui de grande para pequeno (por exemplo, de 8 sequências para 3), as primeiras 3 posições do buffer são dados novos, mas as posições 4-8 ainda são dados do batch antigo. Os metadados de agendamento do FA3 incluem informações de alocação de tiles; quando o kernel lê conforme batch_size, se o cálculo de batch_size tiver desvio ou o kernel escanear com stride fixo, ele lerá dados sujos e corromperá a saída. Esta é a armadilha clássica da reutilização de buffers em CUDA graph: o ciclo de vida do buffer atravessa múltiplos replays e deve ser limpo explicitamente.📎 vllm/v1/attention/backends/flash_attn.py:1793-1802。_make_mm_prefix_mask_modusa_load_q_rangecache; o comentário diz que, caso contrário, isso "force a full JIT recompile every forward". Se esse decorador de cache for removido, quanto o desempenho degradaria? Por que a chave de compilação do FA4 é afetada pelo endereço da closure?repr()Contém endereços de memória, e o endereço muda a cada vez → a chave de compilação muda a cada vez → o FA4 considera que é necessário recompilar via JIT. Após o cache, torna-se igual(sliding_window, sliding_window_left)Os parâmetros reutilizam o mesmo objeto de função, e a chave de compilação é estável. O grau de degradação de desempenho depende do tempo de compilação do FA4, mas é certo que "cada forward dispara uma compilação completa", compilando uma vez a cada passo no loop de decode, e a latência degrada de milissegundos para segundos. Este é um caso típico de invalidação do cache JIT causada por uma "closure Python aparentemente inofensiva".

Q3: supports_draft_decode_metadata_update = self.dcp_world_size == 1Esta linha de código desabilita o fused draft decode no cenário DCP. Suponha que você force a alteração paraTrue, que erro específico ocorreria na combinação de decodificação especulativa + DCP?

Análise de referência: O comentário explica que o fused draft decode reutiliza entre passos de draft o objeto de metadados capturado, enquanto as decisões do lado do host em tempo de build do DCP (comoskip_dcp_context_attention()) alteram a forma dos metadados/caminho de controle, por exemplomax_dcp_context_kv_len 📎 vllm/v1/attention/backends/flash_attn.py:736-741. Esses campos Python não são atualizados in-place entre replays do CUDA graph. Erro específico: o comprimento da sequência cresce entre passos de draft,skip_dcp_context_attentiona determinação pode mudar de True para False (ou vice-versa), mas o objeto de metadados reutilizado ainda mantém o valor antigo. Se o valor antigo formax_dcp_context_kv_len = 0, o kernel seguirá o caminho "sem contexto DCP"📎 vllm/v1/attention/backends/flash_attn.py:1565-1589, pulando a atenção de contexto entre ranks, resultando em saída sem informações de contexto — erro silencioso, sem crash. Isso é exatamente a manifestação de "escolher a correção quando otimização de desempenho e correção entram em conflito".

Até aqui, a cadeia completa do backend de atenção, da interface abstrata à implementação do kernel, já está conectada: a camada do modelo chama uniformemente através de AttentionImpl, o backend é responsável por traduzir metadados como block_table e slot_mapping em parâmetros concretos de kernel, e a implementação PagedAttention do FlashAttentionBackend demonstra a semântica de gather sob KV Cache paginado e a estratégia de compatibilidade com CUDA Graph. Mas o cálculo de atenção produz apenas estados ocultos, e o que o modelo finalmente deve emitir é o próximo token. Como esses estados ocultos se tornam logits, e como os logits passam por amostragem e pós-processamento, retornando finalmente texto em streaming ao cliente? O próximo capítulo rastreará esse último quilômetro.

CHAPTER 07

Capítulo 7: Amostragem e saída: processamento de Logits, saída estruturada e retorno em streaming

Projeto pertencente: vllm-project/vllm · Progresso do livro: Capítulo 7 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, rastreamos como o backend de atenção traduz a block table em parâmetros de kernel, realizando o cálculo de atenção do tipo gather em memória de vídeo não contígua. Mas a atenção produz apenas estados ocultos — o que o modelo realmente precisa entregar ao usuário é o texto do próximo token. Este capítulo rastreia esse último quilômetro: após os estados ocultos serem projetados em logits pelo lm_head, como eles atravessam uma cadeia de processadores cuidadosamente ordenada (temperatura, penalidades, top-k/top-p, restrições estruturais), são amostrados em token ids, e então restaurados para texto pelo detokenizer e enviados em streaming. Qualquer passo fora de ordem ou vazamento de estado nessa cadeia fará a qualidade da saída degradar silenciosamente.

Sampler: a ordem da cadeia de processadores é a própria correção

Modelo intuitivo: O Sampler é como uma linha de montagem, e os logits são a matéria-prima a ser processada. Cada estação (processor) na linha modifica a matéria-prima, e a ordem das estações determina diretamente o produto final — primeiro cortar e depois polir e primeiro polir e depois cortar resultam em duas coisas diferentes. Sem essa cadeia, o modelo só poderia emitir a distribuição de probabilidade bruta, e o usuário receberia uma "amostragem nua" sem controle de temperatura, sem supressão de repetição e sem restrição de formato.

Estrutura de dados e layout de memória

O próprio Sampler énn.Module, mas seu estado central é extremamente fino: ele mantém apenas o submódulotopk_topp_sampler,logprobs_modee a flaguse_fp64_gumbel.📎 vllm/v1/sample/sampler.py:61-64Todo o estado real em nível de batch está encapsulado emSamplingMetadata, passado como parâmetro do forward. Esse design de "Sampler sem estado + metadados externos" é intencional: a instância do Sampler é criada apenas uma vez durante o ciclo de vida da engine, enquanto a composição do batch muda a cada decode step; externalizar o estado é o que permite que o Sampler seja capturado pelo CUDA Graph e reproduzido com segurança.

A constante chave é_SAMPLING_EPS = 1e-5 📎 vllm/v1/sample/sampler.py:18. Ela serve simultaneamente a duas semânticas: temperatura abaixo desse valor é tratada como greedy, eapply_temperatureé o fallback para evitar divisão por zero em

Step-by-Step Walkthrough

.

Cenário: um batch mistura requisições greedy e requisições de amostragem aleatória, e algumas requisições também habilitaram logprobs.Primeiro passo, tirar um snapshot dos logprobs originais.logprobs_modeAntes de aplicar qualquer penalidade ou temperatura, se a requisição precisar de logprobs, primeiro determine o conteúdo do snapshot conforme📎 vllm/v1/sample/sampler.py:84-93. Observe que o comentário aponta explicitamente a diferença em relação ao V0: o V1 usalogits originais(antes de penalidades e temperatura) para calcular top-k logprobs📎 vllm/v1/sample/sampler.py:72-77. Este é o contrato semântico — o logprob que o usuário vê deve refletir a distribuição real do modelo, e não a distribuição distorcida pelas penalidades.

Segundo passo, unificar para float32. 📎 vllm/v1/sample/sampler.py:95-96Independentemente de a entrada ser bf16 ou fp16, converter para float32. O motivo é que o log_softmax, top-k e probabilidade acumulada subsequentes acumulam erros em baixa precisão, especialmente quando o vocabulário chega a 150 mil.

Terceiro passo, cadeia de processadores que não alteram o argmax. apply_logits_processorsAplicar em sequência: máscara de whitelist de allowed token, exclusão de bad words,non_argmax_invariantprocessadores, termos de penalidade📎 vllm/v1/sample/sampler.py:391-404. A classificação aqui é o design central —non_argmax_invariantrefere-se àquelesque alteram o resultado gulosoprocessadores (como min_tokens, logit_bias), que devem entrar em vigor antes da amostragem gulosa; e osargmax_invariantprocessadores (como min_p) não alteram o argmax, podendo ser adiados para depois da temperatura.

Quarto passo, amostragem. sampleO método primeiro verifica se é totalmente aleatório📎 vllm/v1/sample/sampler.py:256-271: seall_greedy, retorna diretamente o argmax; caso contrário, calcula primeiro o resultado guloso para uso posterior, depois aplica temperatura, processadores que não alteram o argmax, top-k/top-p📎 vllm/v1/sample/sampler.py:275-291. Por fim, usatorch.wherepara escolher entre o resultado guloso e o aleatório de acordo com o limiar de temperatura📎 vllm/v1/sample/sampler.py:305-306, e reutilizagreedy_sampledo tensor como buffer de saída, evitando alocação extra.

Quinto passo, coletar logprobs e encapsular a saída.De acordo comnum_logprobs, há três casos: None retorna apenas os logprobs do token especificado; -1 retorna logprobs completos não ordenados; caso contrário, top-k📎 vllm/v1/sample/sampler.py:120-131. Por fim, o token id é convertido para int32 para comprimir o volume, expandido para[num_requests, 1]o tensor bidimensional📎 vllm/v1/sample/sampler.py:138-148。

mermaid
flowchart TD
    in_logits["logits (bf16/fp16)"] --> snap{"需要 logprobs?"}
    snap -->|是| raw["compute_logprobs / clone<br/>raw_logprobs 快照"]
    snap -->|否| f32
    raw --> f32["logits.to(float32)"]
    f32 --> proc["apply_logits_processors"]
    proc --> mask{"allowed_token_ids_mask?"}
    mask -->|是| fill["masked_fill_(-inf)"]
    mask -->|否| bad
    fill --> bad{"bad_words_token_ids?"}
    bad -->|是| apply_bad["apply_bad_words"]
    bad -->|否| noninv
    apply_bad --> noninv["non_argmax_invariant 处理器"]
    noninv --> pen["apply_penalties"]
    pen --> sample["sample()"]
    sample --> allg{"all_greedy?"}
    allg -->|是| greedy["greedy_sample (argmax)"]
    allg -->|否| temp["apply_temperature"]
    temp --> arginv["argmax_invariant 处理器"]
    arginv --> topp["topk_topp_sampler"]
    topp --> where["torch.where(temp < EPS)"]
    greedy --> out
    where --> out["SamplerOutput<br/>sampled_token_ids"]

Reflexões de design e armadilhas

Por que os termos de penalidade devem vir antes da temperatura?A temperatura é uma escala sobre a distribuição, a penalidade é uma adição/subtração de pontos para tokens específicos. Se primeiro escalar e depois penalizar, a magnitude absoluta da penalidade será ampliada ou reduzida pela temperatura, fazendo com que o mesmo conjunto de parâmetros de penalidade se comporte de forma inconsistente em temperaturas diferentes. A V1 fixa a penalidade antes da temperatura, garantindo a estabilidade semântica dos parâmetros.

mark_unbackedA armadilha de compilação doEmgather_logprobs,batched_count_greater_thané compilado, e quando a dimensão de batch muda de 1 para ≥2, isso dispara a recompilação de especialização 0/1 do dynamo📎 vllm/v1/sample/sampler.py:345-348。mark_unbackedmarca essa dimensão como totalmente simbólica, evitando essa recompilação. Em ambiente de produção, se você vir uma travada súbita após a primeira requisição de decode, provavelmente é esse tipo de recompilação.

gpu_sync_allowedA fronteira de sincronização do batched_count_greater_thanpode disparar sincronização de GPU internamente, o vLLM usagpu_sync_allowed(first_only=True)contexto para declarar explicitamente "aqui é permitida sincronização, mas apenas na primeira vez"📎 vllm/v1/sample/sampler.py:345-348. Se houver sincronização inesperada dentro da região de captura do CUDA Graph, isso causará falha na captura — esta é a pista chave para investigar problemas de captura de grafo.

Saída estruturada: máquina de estados de trilha dupla com bitmask e gramática

Modelo intuitivo: a saída estruturada é como colocar um par de "óculos de gramática" no amostrador — a cada passo só se podem ver tokens que obedecem ao JSON schema ou à gramática. Sem isso, o modelo pode gerar JSON com erro de sintaxe, e o parser downstream quebra diretamente. A essência da implementação do vLLM está em: a máquina de estados da gramática avança no lado da CPU, enquanto a restrição é passada ao lado da GPU em forma de bitmask para amostragem.

Estruturas de dados e layout de memória

StructuredOutputManageré um singleton no nível do engine, que mantémbackend(um entre xgrammar/guidance/outlines/lm-format-enforcer),reasoner_clse dois pools de threads📎 vllm/v1/structured_output/__init__.py:39-98。

O bitmask é a estrutura de dados central:_grammar_bitmaské um tensor int32 com formato[max_batch_size * (1 + max_num_spec_tokens), vocab_size/32]📎 vllm/v1/structured_output/__init__.py:327-336. Cada bit corresponde a se um token é legal._full_mask = torch.tensor(-1, dtype=torch.int32)representa "todos 1" — todos os tokens legais📎 vllm/v1/structured_output/__init__.py:59。

Os dois pools de threads têm divisão clara:executorresponsável pela compilação da gramática (intensivo em CPU, número de workers é metade do número de CPUs)📎 vllm/v1/structured_output/__init__.py:71-78;executor_for_fillmaskresponsável pelo preenchimento paralelo de bitmasks em batch grande, habilitado apenas quando o batch excede 128📎 vllm/v1/structured_output/__init__.py:62-69。

Step-by-Step Walkthrough

Inicialização da gramática.Quando a requisição entra pela primeira vez,grammar_inité chamado📎 vllm/v1/structured_output/__init__.py:115-176. Se o backend não estiver inicializado, escolhe a implementação conforme a configuração📎 vllm/v1/structured_output/__init__.py:130-165. Em seguida, submete a tarefa de compilação: por padrão segue o caminho assíncronoexecutor.submit, mas no modoexternal_launcherdeve ser síncrono📎 vllm/v1/structured_output/__init__.py:167-176。

Geração de bitmask.A cada decode step,grammar_bitmaskgera máscaras para todas as requisições estruturadas no batch📎 vllm/v1/structured_output/__init__.py:314-442. Batch grande segue o caminho paralelo: submete em lotes de 16 para o pool de threads📎 vllm/v1/structured_output/__init__.py:346-373. Batch pequeno segue o caminho serial, avançando o estado da gramática token a token📎 vllm/v1/structured_output/__init__.py:374-433。

Alinhamento de máscaras sob decodificação especulativa.Esta é a parte mais engenhosa. Quando há draft tokens, cada requisição precisa de1 + max_num_spec_tokenslinhas de máscara. O caminho serial processa token a token: se algum draft token for rejeitado pela gramática, registrafailed_index, e as linhas subsequentes copiam diretamente a máscara dessa linha📎 vllm/v1/structured_output/__init__.py:396-418. Isso garante que "após o draft ser rejeitado, o estado de restrição das posições subsequentes retrocede ao ponto de rejeição".

Rollback de estado.Durante o preenchimento do bitmask, o estado da gramática avançoustate_advancementspassos, mas o draft token ainda não foi realmente aceito, portanto é necessáriogrammar.rollback(state_advancements)reverter📎 vllm/v1/structured_output/__init__.py:422-430. A aceitação real ocorre emaccept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466。

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Mgr as StructuredOutputManager
    participant Pool as executor_for_fillmask
    participant Gram as StructuredOutputGrammar
    participant GPU as GPU Runner

    Sched->>Mgr: grammar_bitmask(requests, ids, spec_tokens)
    Mgr->>Mgr: allocate_token_bitmask(max_batch*(1+spec))
    alt batch > 128 且无投机
        Mgr->>Pool: _async_submit_fill_bitmask(batch)
        Pool->>Gram: fill_bitmask(bitmask, index)
        Gram-->>Pool: 写入合法 token 位
        Pool-->>Mgr: Future.result()
    else 小 batch 或含投机
        loop 每个 req 的每个 spec token
            Mgr->>Gram: fill_bitmask(bitmask, cumulative_index)
            Mgr->>Gram: accept_tokens(req_id, [token])
            Gram-->>Mgr: True/False
            Note over Mgr: 失败则记录 failed_index<br/>后续行复制该行
        end
        Mgr->>Gram: rollback(state_advancements)
    end
    Mgr-->>Sched: bitmask.numpy() (NDArray int32)
    Sched->>GPU: 传入采样内核

Reflexões de design e armadilhas

Por que external_launcher deve compilar de forma síncrona?O comentário dá a razão precisa: a compilação assíncrona faria com queWAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR → WAITINGa transição de estado ocorresse em momentos diferentes em ranks de TP diferentes, quebrando a suposição de determinismo da qual o external_launcher depende📎 vllm/v1/structured_output/__init__.py:47-56. Este é um caso típico do conflito entre determinismo distribuído e otimização assíncrona.

Ponto de partida da restrição no modelo de raciocínio. _get_constraint_startDetermina a partir de qual token começar a aplicar a restrição gramatical📎 vllm/v1/structured_output/__init__.py:220-292. Para modelos com cadeia de pensamento, a fase de reasoning não deve estar sujeita à restrição JSON; ela só é iniciada após o término do reasoning.enable_in_reasoningQuando True, retorna diretamente 0 (restrição em todo o percurso)📎 vllm/v1/structured_output/__init__.py:235-236. Se o reasoner suportarfind_reasoning_end_offset, use-o para localizar com precisão📎 vllm/v1/structured_output/__init__.py:261-267; caso contrário, recorra à busca regressiva token a token📎 vllm/v1/structured_output/__init__.py:287-291。

validate_tokensda semântica de prefixo.Na decodificação especulativa, os draft tokens podem violar a gramática,validate_tokensretorna o "prefixo legal mais longo"📎 vllm/v1/structured_output/__init__.py:294-312. Observe que ele primeiro remove o preenchimento especulativo (-1), depois calcula o ponto de início da restrição e, por fim, realiza a validação gramatical apenas nos tokens dentro do intervalo de restrição.

Detokenizer: o jogo de fronteiras entre decodificação incremental e stop string

Modelo intuitivo: o detokenizer é como um escriba que transcreve caractere por caractere, traduzindo token ids em texto legível por humanos. A dificuldade está em que: tokens e caracteres não têm correspondência um-a-um (um token pode corresponder apenas a meio caractere UTF-8), e a stop string pode abranger múltiplos tokens. Sem decodificação incremental, seria necessário decodificar a sequência inteira do zero a cada passo, e o custo O(n²) prejudicaria a vazão.

Estruturas de dados e layout de memória

IncrementalDetokenizerA classe base mantém apenastoken_idsa lista📎 vllm/v1/engine/detokenizer.py:32-33。BaseIncrementalDetokenizeradiciona campos relacionados a stop:stoplista,min_tokens、include_stop_str_in_output、stop_buffer_lengthe_last_output_text_offset 📎 vllm/v1/engine/detokenizer.py:70-94。

stop_buffer_lengthsão cruciais: quando a stop string não está incluída na saída, ele é igual ao comprimento da stop string mais longa menos um📎 vllm/v1/engine/detokenizer.py:87-90. Esse "buffer de recuo" garante que a saída em streaming não emita antecipadamente caracteres que possam ser prefixo de uma stop string.

Dois caminhos de implementação:FastIncrementalDetokenizerusa a biblioteca tokenizersDecodeStream 📎 vllm/v1/engine/detokenizer.py:166-246;SlowIncrementalDetokenizerusa o lado Pythondetokenize_incrementally 📎 vllm/v1/engine/detokenizer.py:249-305. O critério de escolha é a versão do tokenizers ≥ 0.22.0 e o tipo de tokenizer correspondente📎 vllm/v1/engine/detokenizer.py:32-33📎 vllm/v1/engine/detokenizer.py:61-63。

Step-by-Step Walkthrough

decodificação incremental. updaterecebe novos token ids estop_terminatedflag📎 vllm/v1/engine/detokenizer.py:96-142. Se o stop terminar e não incluir a stop string, o último token é excluído da decodificação📎 vllm/v1/engine/detokenizer.py:107-111. Em seguida, chama token a tokendecode_nextacumula texto📎 vllm/v1/engine/detokenizer.py:117-122。

detecção de stop string. check_stop_stringsbusca apenas dentro do intervalo de caracteres recém-adicionados📎 vllm/v1/engine/detokenizer.py:308-360. O ponto de início da busca é1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338, esse deslocamento garante que stop strings que cruzam fronteiras de tokens também sejam capturadas. Quando múltiplas stop strings correspondem simultaneamente, escolhe-sea que completaprimeiro📎 vllm/v1/engine/detokenizer.py:342-347。

fatiamento da saída em streaming. get_next_output_textconformedeltao parâmetro decide retornar o total ou o incremental📎 vllm/v1/engine/detokenizer.py:148-163. Quando não concluído, mantémstop_buffer_lengthcaracteres sem emitir📎 vllm/v1/engine/detokenizer.py:145-146, usa_last_output_text_offsetregistra a posição já enviada📎 vllm/v1/engine/detokenizer.py:148-163。

recuperação de exceções. FastIncrementalDetokenizer._protected_steptrata dois tipos de exceção: OverflowError/TypeError registra log e retorna None📎 vllm/v1/engine/detokenizer.py:225-229; erro "Invalid prefix" entãoreconstrói DecodeStreame tenta novamente📎 vllm/v1/engine/detokenizer.py:222-246. O último lida com casos de fronteira em que o tokenizer produz saída UTF-8 não monotônica.

Reflexões de design e armadilhas

O trade-off de stop_buffer_length.Quanto maior o buffer, maior a latência do streaming (o momento em que o usuário vê o texto é adiado), mas menor a chance de perder stop strings que cruzam tokens. Tomar "comprimento da stop string mais longa menos um" é o limite inferior exato: o prefixo de qualquer stop string tem no máximo esse comprimento.

min_tokens e stop_check_offset.Quando o número de tokens de saída não atingemin_tokens,stop_check_offseté continuamente empurrado para o fim do texto📎 vllm/v1/engine/detokenizer.py:120-122, o que significa que esse trecho de texto não será submetido à detecção de stop. Isso evita que o modelo encontre uma stop string logo no início e produza saída vazia.

Cache de added_token_ids no caminho Fast.Quandospaces_between_special_tokensé False, é necessário suprimir espaços entre tokens especiais📎 vllm/v1/engine/detokenizer.py:192-207. O código armazenaadded_token_idsem cache no objeto tokenizer📎 vllm/v1/engine/detokenizer.py:195-200, evitando reconstruir o dicionário a cada decode.

Reflexões de design

Os três módulos compartilham uma filosofia de design:separar o avanço de estado da verificação de restrições, deixando o lado da GPU apenas com operações tensoriais sem estado. O Sampler é sem estado, o estado está emSamplingMetadata; a máquina de estados gramatical avança no lado da CPU, e a GPU apenas consome a máscara de bits; o_last_output_text_offsetdo detokenizer é o único cursor de streaming. Essa separação permite que cada componente do lado da GPU seja capturado pelo CUDA Graph.

Outra linha principal éordem é semântica. A ordem da cadeia de processadores do Sampler, o ponto de início da restrição da saída estruturada, o deslocamento da detecção de stop do detokenizer — qualquer erro de ordem em qualquer um desses pontos não causa crash, apenas produz resultados incorretos silenciosamente — e é exatamente isso que torna esse tipo de código tão difícil de depurar.

Resumo do capítulo

  • A cadeia de processadores do Sampler é estritamente ordenada: snapshot dos logprobs originais → float32 → whitelist/bad words → non-argmax-invariant → penalidades → temperatura → argmax-invariant → top-k/top-p.
  • A saída estruturada usa bitmask para passar o estado sintático do lado da CPU para a GPU; sob decodificação especulativa, através defailed_indexcópia erollbackgarante consistência de estado.
  • O Detokenizer usastop_buffer_lengthbuffer de fallback para equilibrar a latência de streaming e a detecção de stop string entre tokens; o caminho Fast depende de tokenizers ≥ 0.22.0DecodeStream。

Reflexões e autoavaliação deste capítulo

Q1: Se moverapply_logits_processorso termo de penalidade em (apply_penalties) para ser executado após a temperatura, que desvio concreto ocorrerá no cenário de amostragem em alta temperatura com temperature=2.0? Por quê?

Análise de referência: A temperatura é uma escala de todo o vetor de logits (logits.div_(temp))📎 vllm/v1/sample/sampler.py:241-242. O termo de penalidade (como repetition penalty) é um ajuste multiplicativo/aditivo para tokens específicos. Se escalar primeiro e penalizar depois, a magnitude absoluta da penalidade será amplificada 2 vezes pela temperatura, fazendo com que o mesmo conjunto derepetition_penaltyparâmetros tenha efeito inibitório em alta temperatura muito mais forte do que em baixa temperatura, e a semântica dos parâmetros varia com a temperatura. A V1 fixa a penalidade antes da temperatura📎 vllm/v1/sample/sampler.py:403-404, garantindo que a magnitude da penalidade seja desacoplada da temperatura. Além disso, a penalidade pertence ànon_argmax_invariantcategoria (afeta o resultado guloso), e o caminho guloso já retorna antes da temperatura📎 vllm/v1/sample/sampler.py:261-271; se for movida para depois da temperatura, as requisições gulosas ignorarão completamente a penalidade, causando comportamento inconsistente.

Q2: Nogrammar_bitmaskcaminho serial de , se a linhagrammar.rollback(state_advancements) 📎 vllm/v1/structured_output/__init__.py:422-430for removida, o que acontecerá na combinação de decodificação especulativa + saída estruturada? Analise combinando comaccept_tokenso momento de chamada de .

Análise de referência: Ao preencher o bitmask, o código chamagrammar.accept_tokenspara cada draft token para avançar o estado sintático e gerar a máscara da próxima posição📎 vllm/v1/structured_output/__init__.py:396-418, mas isso é apenas um "avanço exploratório" — o draft token ainda não foi verificado e aceito pelo modelo alvo. Serollbackfor removido, o estado sintático permanecerá permanentemente na posição de "todos os drafts aceitos". Quando o modelo alvo realmente rejeitar parte dos draft tokens, a sequência de tokens realmente aceita não corresponderá ao estado sintático:accept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466fará a validação com base em um estado sintático incorreto, fazendo com que tokens legais sejam rejeitados ou tokens ilegais sejam permitidos. O resultado é corrupção silenciosa da saída JSON, sem crash, mas com falha na análise downstream.

Q3: check_stop_stringsO ponto de início da busca de1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338é . Se for alterado para busca completa começando de 0, isso é funcionalmente correto? Que problemas de desempenho isso trará em cenários de streaming com sequências longas?

Análise de referência: Funcionalmente correto — buscar desde 0 encontra todas as correspondências, incluindo aquelas que cruzam limites de tokens. Mas em desempenho, a cada passo fazoutput_textem todo ofind, e a complexidade degrada de O(new_char_count) para O(total_length), sendo O(n²) em sequências longas. Mais grave ainda, buscar desde 0 pode corresponder asubstrings de stop string no texto históricojá enviado ao usuário, causando disparo repetido de stop ou truncamento incorreto. O deslocamento1 - new_char_count - stop_string_lendo design original cobre precisamente a janela mínima necessária de "caracteres novos + prefixo de stop string que pode cruzar limites", garantindo que não haja omissão de detecção e evitando falsas correspondências no histórico.

Até aqui, toda a cadeia de inferência em uma única máquina está conectada: do cálculo de atenção à saída de amostragem, cada etapa afeta diretamente a qualidade do texto final entregue. Mas quando a escala do modelo excede a capacidade de um único cartão, essa cadeia precisa ser concluída de forma colaborativa entre vários dispositivos. No próximo capítulo, deixaremos a máquina única e entraremos no paralelismo distribuído: como TP, PP e EP dividem o modelo, e como as primitivas de comunicação sincronizam esses resultados de amostragem entre ranks.

CHAPTER 08

Capítulo 8: Paralelismo distribuído: TP, PP, EP e primitivas de comunicação

Projeto pertencente: vllm-project/vllm · Progresso do livro: Capítulo 8 / 14 · Status de verificação: linhas FACT com âncoras reais

No capítulo anterior, percorremos o último trecho do ciclo de vida de uma única inferência, da amostragem de logits à saída em streaming. Mas quando o modelo é grande demais para caber em um único cartão, esse pipeline precisa ser dividido entre vários dispositivos para execução colaborativa. A primeira questão do raciocínio distribuído não é "como dividir o modelo", mas "depois de dividir, quem fala com quem e de que forma". O vLLM delega essas duas questões, respectivamente, à topologia de grupos de processos em parallel_state.py e à implementação do comunicador em custom_all_reduce.py. Este capítulo segue a cadeia "criar grupo → dividir → comunicar → reequilibrar carga", desmontando camada por camada as estratégias de paralelismo de TP, PP e EP e as primitivas de comunicação de baixo nível.

8.1 Topologia de grupos de processos: como uma grade de ranks recorta TP/PP/DP/EP

Modelo intuitivo

Pense em 8 GPUs como uma mesa comprida com 8 assentos. O paralelismo de tensor (Tensor Parallelism, TP) exige que "as pessoas da mesma mesa levantem o copo ao mesmo tempo", o paralelismo de pipeline (Pipeline Parallelism, PP) exige que "assentos adjacentes passem o prato em revezamento", o paralelismo de dados (Data Parallelism, DP) exige que "mesas diferentes comam por conta própria, mas no final conciliem as contas", e o paralelismo de especialistas (Expert Parallelism, EP) exige que "os tokens sejam triados por setor". Se não houver uma organização unificada de assentos, cada módulo individualmentenew_group, surge um desalinhamento de comunicação do tipo "pensei que você estava no grupo TP, mas na verdade você está no grupo DP" — uma vez que falte um rank na comunicação coletiva, o NCCL simplesmente trava em vez de reportar erro.

Estrutura de dados e layout de memória

GroupCoordinatoré o veículo de tudo isso. O design de seus campos corresponde diretamente à "múltipla identidade de um processo em várias dimensões paralelas":

  • ranké o rank global,ranksé a lista de ranks globais dos membros do grupo,world_sizeé o tamanho do grupo📎 vllm/distributed/parallel_state.py:434-436。
  • local_rankusado para vincular o dispositivo,rank_in_groupé o índice dentro do grupo — o código-fonte usa uma tabela para distinguir precisamente os dois: em um grupo de 4 GPUs distribuído em dois nós, olocal_rankdo rank 2 é 0 (ele é a primeira GPU no nó 1), masrank_in_groupé 2📎 vllm/distributed/parallel_state.py:437-445。
  • cpu_groupedevice_groupexistem em pares: o primeiro usa gloo para comunicação de metadados/objetos, o segundo usa NCCL para comunicação de tensores📎 vllm/distributed/parallel_state.py:446-447。

Aqui há um design crucial:Por que cada grupo precisa manter um grupo de CPU?Porquebroadcast_object、send_objectoperações desse tipo transmitem objetos Python (bytes serializados), e usar NCCL desperdiça memória de GPU e pode poluir o dispositivo CUDA atual.barrier()Os comentários de deixam isso bem claro: o barrier do NCCL é internamente um broadcast, que cria tensores de GPU sorrateiramente e pode bagunçar o dispositivo atual, então é obrigatório usar o grupo de CPU📎 vllm/distributed/parallel_state.py:1355-1362。

Step-by-Step:initialize_model_parallelComo dividir a grade

Vamos a um cenário concreto: 8 GPUs, TP=2, PP=4, DP=1. O núcleo é fazer reshape de uma sequência unidimensional de ranks em uma grade multidimensional e então dividir ao longo de cada dimensão.

Primeiro passo, construir a grade de ranks. A ordem de layout é explicitamente definida comoExternalDP x DP x PP x PCP x TP 📎 vllm/distributed/parallel_state.py:2045-2060:

python
all_ranks = torch.arange(world_size).reshape(
    -1, data_parallel_size, pipeline_model_parallel_size,
    prefill_context_model_parallel_size, tensor_model_parallel_size,
)

Segundo passo, dividir o grupo TP: fazer view da grade como(-1, tp_size)e então unbind, obtendo[g0,g1],[g2,g3],... 📎 vllm/distributed/parallel_state.py:2065-2077. Note que o grupo TP passa adicionalmenteuse_message_queue_broadcaster=True, porque o grupo TP precisa de broadcast de memória compartilhada para distribuir metadados.

Terceiro passo, dividir o grupo PP:all_ranks.transpose(2, 4)mover a dimensão PP para a última dimensão e então dividir, obtendo[g0,g2,g4,g6],[g1,g3,g5,g7] 📎 vllm/distributed/parallel_state.py:2175-2188. Este é exatamente o exemplo dado na docstring📎 vllm/distributed/parallel_state.py:1997-1997。

Quarto passo, dividir o grupo DP:transpose(1, 4)e então dividir📎 vllm/distributed/parallel_state.py:2195-2202。

Quinto passo, dividir o grupo EP — aqui há um detalhe fácil de ignorar: o grupo EP só é criado em modelos MoE, modelos dense simplesmente pulam📎 vllm/distributed/parallel_state.py:2210-2241. O conjunto de ranks do grupo EP é o produto deDP x PCP x TP, o que significa que o EP reutiliza as GPUs físicas do DP e do TP, em vez de ser uma dimensão independente.

mermaid
flowchart TD
    start["initialize_model_parallel()"] --> grid["all_ranks = arange(world_size).reshape(-1, DP, PP, PCP, TP)"]
    grid --> tp["TP: view(-1, tp_size).unbind(0)"]
    grid --> pp["PP: transpose(2,4).reshape(-1, pp_size)"]
    grid --> dp["DP: transpose(1,4).reshape(-1, dp_size)"]
    grid --> ep_check{"model_config.is_moe?"}
    ep_check -->|是| ep["EP: transpose(1,2).reshape(-1, DP*PCP*TP)"]
    ep_check -->|否| skip["_EP 保持 None"]
    ep --> eplb_check{"enable_eplb?"}
    eplb_check -->|是| eplb["EPLB: 与 EP 同 rank 集,独立 PG"]
    eplb_check -->|否| no_eplb["_EPLB 保持 None"]
    tp --> done["logger.info_once 打印各维度 rank"]
    pp --> done
    dp --> done
    ep --> done
    skip --> done
    eplb --> done
    no_eplb --> done

Reflexões de design e armadilhas

Por que o EPLB precisa de um grupo de processos independente?Os comentários dão a resposta: isolar a comunicação do EPLB da comunicação coletiva do forward do MoE, evitando que "o torch.distributed da fase de execução" e "o torch.distributed do EPLB" causem deadlock mútuo📎 vllm/distributed/parallel_state.py:2243-2246. Este é um trade-off típico de "trocar um domínio de comunicação independente por determinismo" — o custo de memória de GPU de um PG a mais é trocado pela garantia de não travar o forward durante a movimentação de pesos.

Restrição de sincronização do grupo DPé a armadilha mais comum em produção: todos os ranks dentro do mesmo grupo DP devem chamargeneratesimultaneamente, caso contrário há deadlock📎 vllm/distributed/parallel_state.py:2048-2051. Porque dentro do grupo DP é feito all-reduce de gradientes/resultados de amostragem, e qualquer rank ausente fará a comunicação coletiva bloquear permanentemente.

Ordem de destruiçãotambém tem suas particularidades.destroy()Primeiro destrói o device communicator, depois o device_group e o cpu_group📎 vllm/distributed/parallel_state.py:1380-1393. Os comentários explicam o motivo: o device communicator pode manter áreas de trabalho de comunicação coletiva que dependem desses PGs (como a FlashInfer PCIe IPC barrier), então deve ser liberado primeiro📎 vllm/distributed/parallel_state.py:1377-1377。

8.2 Primitivas de comunicação: como o all-reduce customizado contorna o NCCL

Modelo intuitivo

O all-reduce do NCCL é um "caminhão de carga geral", capaz de transportar qualquer carga por qualquer rota, mas com overhead fixo de inicialização e protocolo. Quando você precisa fazer repetidamente all-reduce de tensores pequenos em uma máquina de 8 GPUs totalmente interconectadas por NVLink (cada camada de attention/MLP do TP precisa fazer isso), o "pedágio" do caminhão de carga geral torna-se não desprezível. O all-reduce customizado é um "carrinho dedicado": habilitado apenas em cenários intra-nó, com NVLink totalmente interconectado e tamanho de tensor adequado, trocando uma vezcudaMemcpypelo handshake e overhead de protocolo do NCCL.

Estrutura de dados e layout de memória

CustomAllreduceA inicialização de é uma combinação de "detecção de capacidades + pré-alocação de recursos". Campos-chave:

  • _SUPPORTED_WORLD_SIZES = [2, 4, 6, 8, 16]: só suporta esses tamanhos de grupo📎 vllm/distributed/device_communicators/custom_all_reduce.py:113-129。
  • meta_ptrs: metadados de sincronização + buffer de resultados intermediários, tamanhoops.meta_size() + max_size 📎 vllm/distributed/device_communicators/custom_all_reduce.py:291-294。
  • buffer_ptrs: buffer IPC pré-registrado, no modo eager o tensor de entrada é copiado para cá antes do cálculo📎 vllm/distributed/device_communicators/custom_all_reduce.py:298-305。
  • rank_data: tensor uint8 de 8MB, armazena as tuplas de ponteiros de buffer IPC de todos os ranks📎 vllm/distributed/device_communicators/custom_all_reduce.py:309-315。

Por que os buffers precisam ser pré-registrados?Porque a captura de CUDA Graph exige que todos os endereços estejam fixos no momento da captura.register_graph_buffersNo final da captura, faz broadcast de todos os endereços de buffer usados para todos os ranks e os registra📎 vllm/distributed/device_communicators/custom_all_reduce.py:474-491。

Passo a passo: o fluxo de decisão de um all-reduce

Cenário: a saída de uma camada MLP dentro do grupo TP precisa de all-reduce, a entrada é um tensor bf16 de 4MB.

Primeiro passo,custom_all_reduceverifica se está desabilitado, se satisfazshould_custom_ar 📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-533。

Segundo passo,should_custom_arFiltragem item a item: world_size > 8 é rejeitado; dtype deve ser fp32/fp16/bf16; o número de bytes deve ser múltiplo de 16; deve ser fracamente contíguo; só continua se world_size==2 ou totalmente interconectado📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508。

Terceiro passo, ramificar conforme se está em captura de CUDA Graph: durante a captura usa-seregistered=True(endereço já fixado), caso contrárioregistered=False(é necessário primeiro memcpy para o buffer pré-registrado)📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-545。

Quarto passo, chamar efetivamenteops.all_reduce, passandobuffer_ptrs[rank]emax_size 📎 vllm/distributed/device_communicators/custom_all_reduce.py:519-527。

mermaid
flowchart TD
    call["custom_all_reduce(input)"] --> disabled{"self.disabled?"}
    disabled -->|是| ret_none["return None → 回退 NCCL"]
    disabled -->|否| should{"should_custom_ar(input)?"}
    should -->|否| ret_none
    should -->|是| capturing{"self._IS_CAPTURING?"}
    capturing -->|是| stream_cap{"is_current_stream_capturing()?"}
    stream_cap -->|是| reg["all_reduce(registered=True)"]
    stream_cap -->|否| mimic["return empty_like(input) 模拟分配"]
    capturing -->|否| eager["all_reduce(registered=False) 先 memcpy"]
    reg --> out["返回 out 张量"]
    eager --> out

Reflexões de design e armadilhas

O caminho de degradação para cenários multi-máquinaé a parte mais engenhosa deste código.same_nodeQuando é falso,mnnvl_onlydefine como verdadeiro📎 vllm/distributed/device_communicators/custom_all_reduce.py:198-199, em seguida verifica a capacidade MNNVL (Multi-Node NVLink). Se nem todas as GPUs do grupo suportarem MNNVL, desabilita diretamente a comunicação coletiva personalizada📎 vllm/distributed/device_communicators/custom_all_reduce.py:228-233。_group_can_attempt_mnnvlusa um all-reduce de CPU (operação MIN) para garantir que todos os ranks sigam o mesmo fluxo de controle📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73——esta é a proteção crucial em clusters heterogêneos para evitar que "parte dos ranks entre no caminho MNNVL e parte vá para NCCL" causando travamento.

O custo da verificação P2P:_can_p2ppercorre todos os peers fazendogpu_p2p_access_check, o comentário diz que o primeiro cálculo é caro mas será cacheado📎 vllm/distributed/device_communicators/custom_all_reduce.py:278-278. Em ambiente de produção, se notar inicialização lenta, pode definirVLLM_SKIP_P2P_CHECKpara pular, confiando diretamente no relatório P2P do driver📎 vllm/distributed/device_communicators/custom_all_reduce.py:86-100。

A seleção de backend em três níveis do reduce-scattermerece ser vista separadamente:_select_reduce_scatter_backendretorna por prioridademnnvl_multimem > mnnvl_lamport > legacy 📎 vllm/distributed/device_communicators/custom_all_reduce.py:601-636. O caminho multimem exige que world_size esteja em(2,4,8)e que a capacidade do dispositivo seja (10,0) ou (10,3) (nível Blackwell)📎 vllm/distributed/device_communicators/custom_all_reduce.py:103-104. Note queVLLM_BATCH_INVARIANTdesabilita o caminho multimem📎 vllm/distributed/device_communicators/custom_all_reduce.py:628——porque a ordem de redução do multimem é indeterminada, o que quebraria a invariância de batch.

8.3 EPLB: lógica de agendamento para rebalanceamento de carga de especialistas

Modelo intuitivo

Em modelos MoE, 256 especialistas lógicos são distribuídos em 32 GPUs, 8 por GPU. Mas sob tráfego real, alguns "especialistas populares" (por exemplo, os que processam estruturas gramaticais comuns) recebem roteamento de uma grande quantidade de tokens, fazendo com que a GPU que os contém se torne gargalo, enquanto as outras ficam ociosas. O EPLB (Expert Parallel Load Balancer) consiste em "adicionar réplicas aos especialistas populares": copiar os pesos dos especialistas populares para GPUs ociosas, permitindo que os tokens sejam desviados para lá. Sem ele, a vazão real do MoE ficaria travada pela GPU mais lenta.

Estruturas de dados e layout de memória

EplbModelStateusa três tabelas de mapeamento para descrever a relação "especialista lógico ↔ especialista físico":

  • physical_to_logical_map: formato(num_moe_layers, num_physical_experts), cada slot físico armazena o id do especialista lógico que ele carrega📎 vllm/distributed/eplb/eplb_state.py:105-120。
  • logical_to_physical_map: formato(num_moe_layers, num_logical_experts, max_replicas+1), matriz esparsa, -1 indica ausência de mapeamento📎 vllm/distributed/eplb/eplb_state.py:123-146。
  • logical_replica_count: quantas réplicas cada especialista lógico possui📎 vllm/distributed/eplb/eplb_state.py:147-161。

expert_load_windowé a janela deslizante, formato(window_size, num_moe_layers, num_physical_experts) 📎 vllm/distributed/eplb/eplb_state.py:180-187. O comentário destaca especialmente: agora registra-se a carga de todos os especialistas físicos, não apenas dos locais, para garantir que diferentes métodos de dispatch (naive all-to-all, DeepEP) tenham estatísticas consistentes; sob naive all-to-all, cada rank de DP contribui com o mesmo conjunto de tokens, e a carga é multiplicada por dp_size📎 vllm/distributed/eplb/eplb_state.py:180-187。

Passo a passo: a cadeia completa de um rearranjo

Cenário de exemplo:expert_rearrangement_stepatinge o limiar, dispararearrange()。

Primeiro passo, mapear a carga física de volta para os especialistas lógicos. Usascatter_add_para agregar porphysical_to_logical_map, slots inválidos (<0) são preenchidos noinvalid_idxbucket e descartados no final📎 vllm/distributed/eplb/eplb_state.py:794-816。

Segundo passo, all-reduce entre ranks para obter a carga lógica global._allreduce_listconcatena as cargas de múltiplos modelos e faz um único all-reduce e depois separa, evitando múltiplas comunicações📎 vllm/distributed/eplb/eplb_state.py:1045-1068。

Terceiro passo, chamar a estratégia para calcular o novo mapeamento.policy.rebalance_expertsroda no host, então a janela de carga e o mapeamento atual precisam ser copiados de volta para a CPU📎 vllm/distributed/eplb/eplb_state.py:859-867。

Quarto passo, julgamento de "pular rearranjo" especializado para ROCm: se a melhoria no desequilíbrio de carga entre ranks trazida pelo novo mapeamento for menor que 5%, pula este rearranjo📎 vllm/distributed/eplb/eplb_state.py:869-923. Esta é uma otimização pragmática——o rearranjo em si tem custo de comunicação, se o ganho não for suficiente, não se faz.

Quinto passo, executar a movimentação de pesos e submeter o novo mapeamento📎 vllm/distributed/eplb/eplb_state.py:925-942。

mermaid
sequenceDiagram
    participant Main as 主线程 step()
    participant Policy as DefaultEplbPolicy
    participant Comm as EplbCommunicator
    participant Async as async_worker 线程
    Main->>Main: expert_rearrangement_step >= interval
    Main->>Main: scatter_add_ 物理负载→逻辑负载
    Main->>Main: _allreduce_list 跨 rank 聚合
    Main->>Policy: rebalance_experts(load, replicas, groups, nodes, gpus, map)
    Policy-->>Main: new_physical_to_logical_map
    alt 同步模式
        Main->>Comm: rearrange_expert_weights_inplace()
        Comm-->>Main: 权重搬运完成
        Main->>Main: _commit_eplb_maps()
    else 异步模式
        Main->>Main: eplb_stats = EplbStats(...); rebalanced = True
        Main->>Async: rearrange_event.record()
        Async->>Comm: 后台搬运权重到 expert_buffer
        Async-->>Main: pending_result 就绪
        Main->>Main: _move_to_workspace() 提交
    end

Reflexões de design e armadilhas

Primitivas de sincronização no modo assíncronoé o ponto mais sutil deste código.rebalancedA flag depende do GIL para sincronizar entre a thread principal e o async worker📎 vllm/distributed/eplb/eplb_state.py:194-203. Mas o comentário alerta:rebalanceddeve permanecer consistente em todos os ranks, caso contrário o all-reduce dentro de_all_ranks_result_readytravará📎 vllm/distributed/eplb/eplb_state.py:664-665。_all_ranks_result_readyprioriza usar o grupo de CPU para o all-reduce, porque o grupo de CPU é mais confiável📎 vllm/distributed/eplb/eplb_state.py:1024-1043。

Otimização de "gravação antecipada" da janela deslizante:_should_record_current_stepsó ativa a gravação quando faltam no máximowindow_sizepassos para o próximo rearranjo📎 vllm/distributed/eplb/eplb_state.py:689-709. O comentário explica: os dados dosstep_interval - window_sizepassos antes de cada ciclo de rearranjo serão sobrescritos pela janela deslizante, gravar seria inútil, desperdiçando computação de GPU📎 vllm/distributed/eplb/eplb_state.py:1196-1199。should_record_tensoré o mesmo tensor escalar compartilhado por todas as camadas, uma únicafill_atualiza todas as camadas📎 vllm/distributed/eplb/eplb_state.py:272-278。

Reserva de capacidade do EP elástico:enable_elastic_epquando,physical_expert_capacityreserva conformeelastic_ep_max_dp_size, a tabela de mapeamento preenche os slots extras com -1📎 vllm/distributed/eplb/eplb_state.py:375-386. Assim, ao expandir não é necessário realocar memória de vídeo, basta preencher os slots -1 com especialistas reais.reconfigure_physical_expert_slotsé responsável por atualizar a visão ao expandir/contrair📎 vllm/distributed/eplb/eplb_state.py:1135-1160。

_commit_eplb_mapsTratamento de pin memory de: quandoPIN_MEMORYestá ativado e a origem está na CPU, primeiro copia para memória pinned e depoisnon_blocking=Truecópia assíncrona para a GPU📎 vllm/distributed/eplb/eplb_state.py:1392-1400. Isso evita que a cópia H2D bloqueie a thread principal——a tabela de mapeamento é atualizada a cada camada e a cada rodada, cópias síncronas se tornariam gargalo.

Reflexões de design

Três blocos de código compartilham uma filosofia de design:Trocar capacidade de detecção por degradação determinística。GroupCoordinatorEmworld_size == 1fazer bypass direto de toda comunicação coletiva📎 vllm/distributed/parallel_state.py:736-738;CustomAllreduceretornar quando qualquer condição não for satisfeitaNonepermitir que o chamador faça fallback para NCCL📎 vllm/distributed/device_communicators/custom_all_reduce.py:532-533; EPLB pula o rearranjo quando a melhoria é inferior a 5%📎 vllm/distributed/eplb/eplb_state.py:916. Esse padrão de "falha rápida + degradação graciosa" permite que o mesmo código rode em toda a gama de hardware, de uma única GPU a MNNVL multi-nó, sem precisar escrever ramificações para cada configuração.

Outra característica comum éConsistência de fluxo de controle tem prioridade sobre desempenho。_group_can_attempt_mnnvlusar CPU all-reduce para forçar todos os ranks a seguir o mesmo ramo📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73,_all_ranks_result_readyDa mesma forma📎 vllm/distributed/eplb/eplb_state.py:1024-1043. Em sistemas distribuídos, "alguns ranks seguem o caminho rápido, outros o caminho lento" é muito mais perigoso do que "todos os ranks seguem o caminho lento" — o primeiro trava, o segundo apenas fica lento.

Resumo do capítulo

  • GroupCoordinatorreshape da sequência unidimensional de ranks emExternalDP x DP x PP x PCP x TPgrade, dividindo ao longo de cada dimensão os grupos de processos TP/PP/DP/EP/EPLB; cada grupo mantém simultaneamente dois PGs: CPU (gloo) e device (NCCL).
  • CustomAllreduceAtravés da detecção de capacidade (mesma máquina, NVLink totalmente interconectado, tamanho do tensor, dtype, alinhamento de 16 bytes) decide se assume o all-reduce, degradando para MNNVL ou NCCL em cenários multi-nó.
  • EPLB usa três tabelas de mapeamento para descrever a relação entre especialistas lógicos/físicos, através de janela deslizante estatística de carga, estratégia de cálculo de novo mapeamento, comunicador para transportar pesos, suportando modos síncrono e assíncrono.
  • Princípio de design comum aos três: detecção de capacidade + degradação determinística + consistência de fluxo de controle prioritária.

Reflexões e autoavaliação do capítulo

Q1: GroupCoordinator.destroy()Destruir primeiro o device communicator e depois o process group📎 vllm/distributed/parallel_state.py:1380-1393. Se invertermos a ordem, destruindo primeiro o PG e depois o communicator, em qual cenário ocorreria crash?

Análise de referência: O comentário indica explicitamente que o device communicator pode manter áreas de trabalho de comunicação coletiva que dependem desses PGs, como FlashInfer PCIe IPC barrier📎 vllm/distributed/parallel_state.py:1377-1377. Se destruirmos primeiro o PG, o communicatordestroy()internamente, se ainda precisar usar esses PGs para uma barrier ou limpeza de comunicação, acessará um ProcessGroup já destruído, disparando use-after-free ou falha de asserção interna do NCCL. A ordem correta é "o dependente morre primeiro": o communicator depende do PG, então o communicator é destruído primeiro.

Q2: should_custom_arRequerinp_size % 16 == 0 📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508. Se removermos essa verificação, o que aconteceria com um tensor bf16 de 15 bytes (por exemplo, 7,5 elementos, o que na prática é impossível, mas suponha 8 elementos = caso limite de 16 bytes)? Por que o kernel customizado precisa desse alinhamento?

Análise de referência: O kernel all-reduce customizado usa internamente carregamento vetorizado (como load de 128 bits), exigindo que endereço e tamanho estejam alinhados a 16 bytes para usarfloat4instruções de carregamento largo como essa. O desalinhamento faria o kernel ler fora dos limites ou disparar exceção de endereço desalinhado. Mais sutil ainda,buffer_ptrso buffer pré-registrado é alocado pormax_size; se o tamanho de entrada não for múltiplo de 16, após copiar para o buffer pode haver dados residuais na cauda sendo reduzidos junto, gerando erro silencioso. Portanto, essa verificação é tanto proteção de correção quanto pré-requisito de desempenho.

Q3: No modo assíncrono do EPLB,rebalanceda flag depende de sincronização do GIL📎 vllm/distributed/eplb/eplb_state.py:194-203, e o comentário alerta que todos os ranks devem permanecer consistentes, caso contrário o all-reduce trava📎 vllm/distributed/eplb/eplb_state.py:664-665. Suponha que um rank, devido a instabilidade de rede, tenha o async worker definidorebalancedcomo False antecipadamente, enquanto os outros ranks ainda estão True,_all_ranks_result_readyo que aconteceria?

Análise de referência:_all_ranks_result_readyFaz all-reduce de soma sobrehas_resulte então verifica se é igual ao tamanho do grupo📎 vllm/distributed/eplb/eplb_state.py:1030-1032. Se orebalancedde algum rank virar False antecipadamente, seupending_resultpode já ter sido consumido,has_resultsendo 0, fazendo o resultado da soma ser menor que o tamanho do grupo, e os outros ranks ficarão esperando. Pior ainda, se esse rank já saiu dowhile ms.rebalancedloop, ele não participará mais dos all-reduces subsequentes, e os all-reduces dos outros ranks bloquearão permanentemente — é isso que o comentário chama de "hang at collective communication calls". A medida de proteção é_all_ranks_result_readyusar o grupo CPU em vez do grupo device, edrain_asyncdrenar explicitamente todos os pending results antes do rearranjo📎 vllm/distributed/eplb/eplb_state.py:985-1022。

Até aqui, esclarecemos os mecanismos de criação de grupos, divisão e rebalanceamento de carga para comunicação entre placas. Mas o desafio de comunicação da inferência distribuída não se limita ao interior de uma única instância — quando prefill e decode são separados em instâncias diferentes, o KV Cache precisa ser transferido entre nós. No próximo capítulo, deixaremos a "comunicação entre placas" e entraremos na "comunicação entre instâncias": como o KV Cache é transferido entre instâncias de prefill e decode em implantação separada, e como a abstração KV Connector unifica backends de transferência como NIXL, Mooncake, etc.

CHAPTER 09

Capítulo 9: Transferência de KV Cache e Implantação Separada (PD Disaggregation)

Projeto: vllm-project/vllm · Progresso do livro: Capítulo 9 / 14 · Status de verificação: Linhas FACT com ancoragem real

No capítulo anterior, restringimos nossa visão ao interior de uma única instância de inferência: como os grupos de processos TP/PP/DP/EP são criados, como os tensores são divididos entre placas, e como o EPLB faz o rebalanceamento de especialistas na camada MoE. Mas todos esses mecanismos partem da mesma premissa — prefill e decode rodam na mesma instância, e o KV Cache permanece na memória local do início ao fim. A implantação separada (Prefill-Decode Disaggregation, abreviada como PD Disaggregation) quebra essa premissa. Ela divide prefill e decode em duas instâncias vLLM independentes: a instância de prefill faz apenas o cálculo forward do prompt, produz o KV Cache e o entrega à instância de decode; a instância de decode usa esse KV Cache para continuar a geração autorregressiva. A vantagem é que os recursos podem ser configurados independentemente de acordo com as características de cada fase — prefill é intensivo em computação, adequado para TP grande e batch grande; decode é intensivo em acesso à memória, adequado para batch pequeno e agendamento de baixa latência. Os dois não se atrapalham mais. O custo é: o KV Cache precisa ser transferido entre instâncias. Esse é o protagonista deste capítulo — o KV Connector. O comentário de cabeçalho do arquivo vllm/distributed/kv_transfer/kv_connector/v1/base.py já lista as primitivas centrais de toda a abstração: o lado do Scheduler é responsável por vincular metadados, consultar acertos de cache remoto e decidir se libera blocos de forma assíncrona; o lado do Worker é responsável pela carga e salvamento reais do KV. O objetivo de design dessa interface é desacoplar completamente a lógica de agendamento superior dos backends de transferência subjacentes (NIXL, Mooncake, MoRIIO). Do ponto de vista de engenharia, o maior risco da PD Disaggregation não é a lentidão da transferência, mas a inconsistência de estado: a instância de prefill acha que o KV já foi enviado, mas a instância de decode não o recebeu; ou a instância de decode libera o bloco antecipadamente, enquanto o prefill ainda está escrevendo nele. O que este capítulo pretende esclarecer é exatamente como esse sistema de conectores usa protocolos de handshake, leases, heartbeats e mecanismos de recuperação de falhas para cobrir essas bordas.

I. KVConnectorBase_V1: Abstração de Papel Duplo e Contrato de Metadados

Modelo Intuitivo

O KV Connector é como um sistema de entrega entre duas filiais. A filial de Prefill calcula o produto semiacabado (KV Cache), embala e envia para a filial de Decode continuar o processamento. Mas o sistema de entrega não pode ter apenas a ação de "enviar" — ele precisa de uma guia de transporte (metadata) indicando o que enviar e para onde; precisa de um mecanismo de confirmação de recebimento; e também precisa de um conjunto de regras de timeout para evitar que pacotes fiquem presos na estrada ocupando prateleiras.

Sem essa abstração, cada backend de transferência (NIXL, Mooncake) teria que implementar sua própria lógica de agendamento, e o Scheduler do vLLM teria que escrever um conjunto de código de adaptação para cada backend. O valor do KVConnectorBase_V1 é fixar esse contrato.

Papel Duplo: Lado do Scheduler e Lado do Worker

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:137-142define os dois papéis do conector:

python
class KVConnectorRole(enum.Enum):
    # Connector running in the scheduler process
    SCHEDULER = 0
    # Connector running in the worker process
    WORKER = 1

Essa divisão não é arbitrária. O processo do Scheduler é responsável pelas decisões globais de agendamento — quais requisições precisam de transferência, quando os blocos podem ser liberados; o processo do Worker é responsável pela movimentação real dos dados. Ambos se comunicam através deKVConnectorMetadatacomunicação.

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:153-158define a classe base de metadados na direção Scheduler para Worker:

python
class KVConnectorMetadata(ABC):  # noqa: B024
    """Abstract Metadata used to communicate
    Scheduler KVConnector -> Worker KVConnector.
    """
    pass

Na direção inversa, Worker para Scheduler,📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:161-176defineKVConnectorWorkerMetadata, que exige a implementação do métodoaggregate— porque em um engine step pode haver múltiplos workers retornando metadados cada um, que precisam ser agregados antes de serem entregues ao Scheduler.

Estrutura de Dados Central: KVConnectorTransferResults

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:87-96define a estrutura de snapshot dos resultados de transferência:

python
@dataclass
class KVConnectorTransferResults:
    finished_sending: set[str] = field(default_factory=set)
    finished_recving: set[str] = field(default_factory=set)
    failed_recving: set[str] = field(default_factory=set)

Observe o design-chave nos comentários:Recebimentos falhos também aparecem emfinished_recving. Isso é para permitir que o Scheduler libere a requisição do estado de "aguardando transferência" — mesmo que a transferência falhe, a requisição não pode ficar travada para sempre. A informação de falha é transmitida separadamente através defailed_recving, e o Scheduler decide com base nisso se deve tentar novamente ou fazer downgrade.

Hooks de ciclo de vida: da requisição à liberação

Todo o ciclo de vida do conector gira em torno de alguns hooks principais. No lado do Scheduler:

  • get_num_new_matched_tokens 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:485-518: consulta quantos tokens o cache remoto pode acertar. O comentário enfatiza especialmente que "deve-se considerar apenas o prefixo máximo realmente disponível"; se alguns tokens não puderem ser obtidos devido a problemas de conexão ou eviction, eles não podem ser contabilizados.
  • update_state_after_alloc 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:520-544: atualiza o estado após a alocação de block. Há uma armadilha fácil de cometer nos comentários — para determinar se deve carregar, é preciso verificarnum_external_tokens, e não seblocksestá vazio, porque os subconectores não selecionados do MultiConnector também recebem blocks reais.
  • request_finished 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:579-598: chamado quando a requisição é concluída, retornaTrueindicando que o conector assume a responsabilidade de liberação assíncrona do block.

No lado do Worker:

  • start_load_kv / wait_for_layer_load: carrega camada por camada, com suporte a pipeline.
  • save_kv_layer / wait_for_save: salva camada por camada.
  • get_transfer_results 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:396-397: retorna o status de conclusão da transferência assíncrona.

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:192-201Há também um design fácil de ignorar, mas crucial —requires_kv_deliveryatributo:

python
@property
def requires_kv_delivery(self) -> bool:
    """Whether this connector hands off KV that must be reliably delivered.
    ...
    """
    return self._kv_transfer_config.is_kv_producer

O comentário explica a motivação: se a requisição for preempted antes que a transferência de KV seja concluída, deve-se recalcular em vez de deixá-la completar e transferir blocks que já foram liberados pela preempção. Apenas o papel de producer precisa de entrega confiável; se o cache best-effort for perdido, será apenas um cache miss futuro.

Metadados de handshake

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:145-150define a classe base dos metadados de handshake:

python
class KVConnectorHandshakeMetadata(ABC):  # noqa: B024
    """Metadata used for out of band connector handshake between
    P/D workers. This needs to serializable.
    """
    pass

"out of band" significa que o handshake não segue o caminho normal da requisição, mas sim a comunicação direta entre os workers P/D. Isso prepara o terreno para o protocolo de handshake ZMQ do NIXL.

---

II. Conector NIXL: handshake, registro e construção de descritores

Modelo intuitivo

NIXL (NVIDIA Inference Xfer Library) é a biblioteca de transporte de baixo nível fornecida pela NVIDIA, com suporte a diversos backends como UCX, GDS, etc. O papel do NixlBaseConnectorWorker é como o centro de triagem de uma transportadora — ele precisa primeiro estabelecer uma linha dedicada com o centro de triagem da outra parte (handshake), registrar o layout de suas próprias prateleiras (registrar as regiões de memória do KV Cache), e só então pode buscar e enviar mercadorias de forma eficiente por endereço.

Sem esse mecanismo, cada transferência precisaria renegociar endereços e restabelecer conexões, e a latência seria inaceitavelmente alta.

Layout de memória: Region e Descriptor

O conceito central do NIXL éregion(região de memória) edescriptor(descritor). Cada camada do KV Cache é registrada no NIXL como uma ou mais regions, e cada region tem endereço base, comprimento de bloco e stride de bloco.

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:740-751lista os campos principais relacionados a region:

python
# Number of NIXL regions. Currently one region per cache
# (so 1 per layer for MLA, otherwise 2 per layer)
self.num_regions = 0
self.region_mem_types: list[str] = []
self.region_group_ids: list[int] = []
self._uses_region_group_mapping = False
self.region_names: list[str] = []
self.region_num_blocks: list[int] = []
self._mixed_mem_types = False

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:897-900explica ainda a origem do stride de bloco:

python
# Per-region block stride in bytes. Taken from the registered tensor's
# stride(0) so it stays correct under layouts that interleave layers
# within a block (BLHNC/BHLNC), where stride > block_len.
self.block_stride_per_layer = list[int]()

A percepção-chave aqui é:block_stride não é igual a block_len. Em layouts com intercalação entre camadas como BLHNC/BHLNC, a extensão real de um block pode ser maior que o comprimento de seus dados efetivos. Se block_len for usado diretamente como stride, endereços incorretos serão lidos.

Protocolo de handshake: ZMQ + hash de compatibilidade

O handshake é a parte mais complexa do conector NIXL.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:974-1128O método_nixl_handshakede

demonstra completamente esse processo.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:988-998O primeiro passo é configurar o contexto do dispositivo CUDA.

python
# the first time we connect to a remote agent.
# be careful, the handshake happens in a background thread.
# it does not have an active cuda context until any cuda runtime
# call is made. when UCX fails to find a valid cuda context, it will
# disable any cuda ipc communication, essentially disabling any NVLink
# communication.
if not self.use_host_buffer:
    current_platform.set_device(self.device_id)

explica o motivo:

Cópia📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1029-1036:

python
msg = msgspec.msgpack.encode(
    (GET_META_MSG, remote_pp_rank, remote_rank)
)
# Set receive timeout to 5 seconds to avoid hanging on dead server
sock.setsockopt(zmq.RCVTIMEO, 5000)  # milliseconds
start_time = time.perf_counter()
sock.send(msg)
reply_parts = sock.recv_multipart()

O segundo passo é enviar a consulta de metadados via ZMQ.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1042-1045Cópia

O timeout de 5 segundos serve para evitar espera infinita caso a outra parte morra. Ao mesmo tempo, o código usa RTT para estimar o desvio de clock📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1063-1080:

python
assert self.compat_hash is not None
if (
    self.enforce_compat_hash
    and handshake_payload.compatibility_hash != self.compat_hash
):
    raise RuntimeError(
        f"NIXL compatibility hash mismatch. "
        ...
    )

O terceiro passo é a verificação de compatibilidade.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1372-1376Cópia

python
self.compat_hash = compute_nixl_compatibility_hash(
    self.vllm_config,
    self.backend_name,
    transfer_mode=self._TRANSFER_MODE,
)

:transfer_modeCópia📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:163-166Observe que

também participa do hash —

O comentário de📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:824-835:

python
self._handshake_initiation_executor = ThreadPoolExecutor(
    # NIXL is not guaranteed to be thread-safe, limit 1 worker.
    max_workers=1,
    thread_name_prefix="vllm-nixl-handshake-initiator",
)
self._ready_requests = queue.Queue[tuple[ReqId, ReqMeta]]()
self._handshake_futures: dict[
    EngineId, Future[tuple[dict[tuple[int, int], str], float]]
] = {}
# Protects _handshake_futures and _remote_agents.
self._handshake_lock = threading.RLock()

max_workers=1Agendamento assíncrono de handshake_handshake_lockO handshake é assíncrono, executado através de um pool de threads._handshake_futuresCópia_remote_agentsé porque o NIXL não garante thread safety.

_ensure_handshake 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1257-1317protege os dois dicionários

e

.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:172-310implementa o início idempotente de handshake: se já houve handshake bem-sucedido, retorna None diretamente; se está em processo de handshake, retorna o Future existente; caso contrário, submete uma nova tarefa e registra o callback._compute_desc_idsConstrução de descritores: de block ID para NIXL descriptor

Após a conclusão do handshake, é preciso construir descritores para cada requisição.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:226-262O

python
# NOTE (NickLucche) With HMA, every kv group has the same number of layers
# and layers from different groups share the same kv tensor.
# eg block_ids=[[1, 2], [3]]->blocks [1, 2] need to be
# read across all regions, same for [3], but group0-group1 blocks will
# always differ (different areas). Therefore we can just flatten the
# block_ids and compute the descs ids for all groups at once.

é o núcleo.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:285-304:

python
elif _is_ssm_spec(spec_type):
    # NOTE (NickLucche) SSM and Attention block regions can
    # be exchanged arbitrarily by manager.  Therefore, descs
    # are laid out as:
    #   [descs_fa (all regions) | descs_ssm (all regions)].
    # num_fa_descs offset must be computed per-engine since
    # P and D can have different num_blocks (and thus
    # different FA desc counts).

. O comentário explica o tratamento no cenário HMA:

Cópia📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2130-2178Para modelos híbridos com SSM, o layout de descritores é mais complexoadd_remote_agentCópia

Quando D.world_size > P.world_size, vários workers D leem fragmentos diferentes de KV head do mesmo worker P. A documentação fornece um exemplo concreto: D TP=4, P TP=2, tp_ratio=2. D-Worker0 lê a primeira metade dos KV heads de P-Worker0, D-Worker1 lê a segunda metade.

Para modelos MLA, o KV Cache é replicado entre os workers TP, então rank_offset é sempre 0.

Lease e heartbeat: evitando a liberação prematura de blocks

Este é um dos designs mais engenhosos do conector NIXL. Após a instância Prefill enviar o KV, ela não pode liberar o block imediatamente — porque a instância decode pode ainda estar lendo. Mas se nunca liberar, a memória de vídeo vazará.

A solução é o lease.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:528-528:

python
kv_lease_duration: int = vllm_config.kv_transfer_config.get_from_extra_config(
    "kv_lease_duration", 30
)
# NOTE (NickLucche): For now we use a hardcoded value for a simpler interface.
self._lease_extension = kv_lease_duration * 2 // 3

O lease padrão é de 30 segundos, estendido em 20 segundos a cada heartbeat (2/3).

O tratamento do heartbeat está em📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034:

python
def _handle_heartbeat(self, payload: str) -> None:
    new_expiry = time.perf_counter() + self._lease_extension
    for req_id in payload.split(","):
        if req_id in self._reqs_to_send:
            old = self._reqs_to_send[req_id]
            self._reqs_to_send[req_id] = max(old, new_expiry)

Atençãomax(old, new_expiry)— o heartbeat só pode estender o lease, não encurtá-lo.

A recuperação após a expiração do lease está em📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2986-3012:

python
def _reap_expired_send_leases(self, done_sending: set[str]) -> None:
    """Reclaim expired send-side KV leases into ``done_sending``.

    ``_reqs_to_send`` is not ordered by expiry: heartbeats update the
    deadline in place, and mixed TTLs share the map, so a live head
    entry can sit in front of already-expired ones. Scan every entry
    rather than stopping at the first still-live request.
    """

O comentário aponta um erro fácil de cometer: não se pode parar a varredura ao encontrar a primeira requisição não expirada, porque o heartbeat atualiza o tempo de expiração no local, fazendo com que o map não esteja ordenado por tempo de expiração.

Máquina de estados de transferência e recuperação de falhas

O ciclo de vida da transferência é gerenciado através de_pop_done_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3036-3086:

python
for handle in handles:
    try:
        xfer_state = self.nixl_wrapper.check_xfer_state(handle)
        if xfer_state == "DONE":
            res = self.nixl_wrapper.get_xfer_telemetry(handle)
            self.xfer_stats.record_transfer(res)
            self.nixl_wrapper.release_xfer_handle(handle)
        elif xfer_state == "PROC":
            in_progress.append(handle)
        else:
            self._log_failure(
                failure_type="transfer_failed",
                req_id=req_id,
                xfer_state=xfer_state,
            )

A transferência NIXL tem três estados:DONE(concluído),PROC(em andamento), outros (falha).

O tratamento de falhas está em📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3103-3127:

python
def _handle_failed_transfer(
    self,
    req_id: str,
    handle: int | None,
    failed_req_ids: set[str] | None = None,
    record_failed_transfer: bool = True,
) -> bool:
    if record_failed_transfer:
        self.xfer_stats.record_failed_transfer()
    if failed_req_ids is not None:
        failed_req_ids.add(req_id)
    return handle is None or self._try_release_xfer_handle(req_id, handle)

_try_release_xfer_handle 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101O comentário em é crucial:

python
except Exception as e:
    # A status error does not guarantee that the backend stopped DMA.
    self._log_failure(
        failure_type="transfer_release_failed",
        msg="Retaining handle and blocks until release succeeds",
        ...
    )
    return False

Erros de estado não garantem que o backend parou o DMA. Se a liberação falhar, o handle e o block devem ser mantidos até que a liberação seja bem-sucedida. Este é um design típico de "prefiro vazar a usar incorretamente".

Tratamento de block para requisições com falha

Quando a recepção falha,📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2876-2891mostra a lógica de tratamento:

python
for req_id in done_recving:
    meta = self._recving_metadata.pop(req_id, None)
    assert meta is not None, f"{req_id} not found in recving_metadata list"

    # Skip KV sync and post-processing for failed requests
    if req_id in failed_recv_reqs:
        self._pending_recv_notifs.pop(req_id, None)
        # TODO (NickLucche) handle failed transfer for HMA.
        if not self._is_hma_required:
            self._invalid_block_ids.put(set(meta.local_block_ids[0]))
        logger.warning(
            "Skipping KV post-processing for failed request %s",
            req_id,
        )
        continue

O ID do block com falha é colocado na fila_invalid_block_ids, e o Scheduler o retira através deget_block_ids_with_load_errors 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3491-3504, decidindo se deve tentar novamente.

Expulsão por TTL de engines remotas

Instâncias de longa duração encontram continuamente novas engines remotas; se não forem limpas, a memória crescerá indefinidamente.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3506-3532O_evict_stale_enginesde implementa a expulsão por TTL:

python
def _evict_stale_engines(self) -> None:
    """Scan for and evict remote engines that have exceeded their TTL.

    Called from the main thread in when a new remote engine appears.
    We can only go OOM as we discover and register a new remote, therefore we make
    sure we clean up stale engine data structures before then.
    """
    if self._engine_ttl <= 0:
        return

    now = time.perf_counter()
    busy = self._engines_with_inflight_transfers()
    for eid, last_active in list(self._engine_last_active.items()):
        if now - last_active > self._engine_ttl and eid not in busy:
            self._cleanup_remote_engine(eid)

A restrição chave é o conjuntobusy— engines com transferências em andamento não podem ser expulsas.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546O comentário em explica o motivo:

python
"""Remote engines a transfer is still reading from.

The timestamp is stamped when a read is issued and not refreshed while
it runs, so a transfer that outlives the TTL leaves its engine looking
idle. A peer that has lost its NIC holds one indefinitely.
"""

Se a placa de rede do par estiver quebrada, a transferência pode ficar pendurada para sempre, o timestamp não será atualizado e a engine parecerá ociosa.busyO conjunto protege explicitamente essa situação.

Temporização do handshake e da transferência

O diagrama de sequência abaixo mostra a interação central desde a requisição até a conclusão da transferência:

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Worker as NixlWorker
    participant BgThread as 握手后台线程
    participant Remote as 远程 NIXL Agent

    Sched->>Worker: build_connector_meta()
    Worker->>Worker: _ensure_handshake(engine_id)
    alt 已握手
        Worker->>Worker: 直接返回 None
    else 握手中
        Worker->>BgThread: 返回已有 Future
    else 新握手
        Worker->>BgThread: submit(_nixl_handshake)
        BgThread->>Remote: ZMQ GET_META_MSG
        Remote-->>BgThread: NixlHandshakePayload
        BgThread->>BgThread: 校验 compat_hash
        BgThread->>Remote: add_remote_agent()
        BgThread-->>Worker: done_callback 注册 _remote_agents
    end
    Worker->>Remote: prep_xfer_dlist + make_xfer_req
    Worker->>Worker: _recving_transfers[req_id] = handles
    Sched->>Worker: get_transfer_results()
    Worker->>Worker: _pop_done_transfers()
    alt xfer_state == DONE
        Worker->>Remote: release_xfer_handle
        Worker-->>Sched: finished_recving
    else xfer_state == PROC
        Worker->>Worker: 保留 handle 等待下一轮
    else 失败
        Worker->>Worker: _handle_failed_transfer
        Worker-->>Sched: failed_recving + invalid_block_ids
    end

---

III. Reflexões de design: por que foi projetado assim

Por que o handshake é assíncrono?

O handshake envolve ida e volta pela rede, podendo levar dezenas de milissegundos. Se executado de forma síncrona, bloquearia o loop principal do Scheduler, afetando o agendamento de todas as requisições. O handshake assíncrono permite que o Scheduler processe outras requisições primeiro, notificando via callback quando o handshake for concluído.

Mas a assincronia também traz complexidade:_handshake_futuresO dicionário precisa de proteção por lock, o callback deve tratar tanto sucesso quanto falha, e ainda é preciso evitar handshakes duplicados.

Por que usar lease em vez de contagem de referências?

A contagem de referências exige que a instância decode notifique explicitamente o prefill "terminei de ler". Mas se a instância decode falhar, a notificação nunca chegará, e o block do prefill vazará para sempre.

O lease é uma solução mais robusta: mesmo que o decode falhe, após a expiração do lease o prefill recupera automaticamente. O mecanismo de heartbeat garante a renovação do lease em condições normais.

Por que manter o handle em caso de falha?

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101O comentário em deixa claro: erros de estado não garantem que o DMA parou. Se o handle for liberado nesse momento, o DMA pode ainda estar escrevendo dados na memória já liberada, causando corrupção de dados ou crash. Melhor vazar temporariamente do que correr esse risco.

Por que a expulsão por TTL verifica o busy?

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546O comentário em revela um cenário de bug oculto: o timestamp é registrado no início da leitura e não é atualizado durante a leitura. Se o tempo de transferência exceder o TTL, a engine parecerá ociosa, mas na verdade ainda está sendo lida. Se for expulsa nesse momento, a transferência em andamento falhará.

Armadilhas em ambiente de produção

1. Problema de contexto CUDA: o handshake é executado em thread de background, sendo necessário explicitamenteset_device, caso contrário o UCX desabilitará silenciosamente o NVLink.

2. Incompatibilidade de hash de compatibilidade: a versão do vLLM, modelo, dtype, layout de KV e backend de attention das instâncias P/D devem ser completamente idênticos. Em caso de incompatibilidade, o handshake falhará, e a mensagem de erro indicará como desabilitar a verificação (mas não é recomendado).

3. Expiração do lease: se a instância decode estiver com carga muito alta, o heartbeat pode atrasar, causando a expiração do lease. Aparecerá um aviso "Releasing expired KV blocks" no log. Pode-se aumentarkv_lease_duration。

4. Incompatibilidade de TP: TP heterogêneo requer layout block-contiguous (como LBHNC). Se for usado um layout não contíguo, o TP heterogêneo falhará.

5. Esgotamento de UAR do NIXL:📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:631-636Aviso de comentário: cada thread UCX aloca UAR (doorbell pages) via DevX; uso excessivo de UAR pelo NIXL esgota o espaço de UAR da NIC, fazendo com que o NVSHMEM (usado pelo kernel DeepEP) falhe na inicialização do RDMA.

---

Resumo do capítulo

Este capítulo aprofundou os mecanismos centrais do sistema KV Connector:

1. KVConnectorBase_V1Define a abstração de papéis duplos no lado do Scheduler e no lado do Worker, através deKVConnectorMetadataeKVConnectorTransferResultspara realizar troca de metadados e feedback de resultados de transferência.

2. Conector NIXLÉ a implementação mais madura; estabelece conexões entre instâncias P/D através do protocolo de handshake ZMQ, usa hash de compatibilidade para evitar incompatibilidade de configuração e usa pool de threads assíncrono para evitar bloquear o loop principal.

3. Lease e heartbeatO mecanismo resolve o problema de timing da liberação de block: o prefill não libera imediatamente após enviar o KV, mas espera a renovação do heartbeat do decode ou a expiração do lease.

4. Recuperação de falhasSegue o princípio de "prefira vazar a usar errado": em caso de falha na liberação, mantém o handle; o block ID com falha é reportado ao Scheduler para decidir sobre retry.

5. Expulsão por TTLEvita crescimento ilimitado do estado de engines remotos em execuções de longa duração, mas deve proteger engines com transferências em andamento.

No próximo capítulo, voltamo-nos para outra direção de eliminação de overhead: aceleração de compilação e CUDA Graph. Quando a separação PD resolveu o problema de utilização de recursos, o overhead de inicialização de um único forward torna-se o novo gargalo — como usar CUDA Graph para comprimir centenas ou milhares de lançamentos de kernels em uma única reprodução.

Reflexões e autoavaliação deste capítulo

Q1: Se removermos o tratamento de exceções em_try_release_xfer_handlee chamarmos diretamenterelease_xfer_handle, em quais cenários isso causaria corrupção de dados? Por quê?

Análise de referência:_try_release_xfer_handle 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101O comentário em deixa claro: "A status error does not guarantee that the backend stopped DMA." Se removermos o tratamento de exceções, quandorelease_xfer_handlelançar uma exceção, o chamador pensará que a liberação foi bem-sucedida e continuará liberando o block. Mas, na verdade, o DMA do backend NIXL pode ainda estar em andamento, escrevendo dados nessa memória. Uma vez que o block seja realocado para outra requisição, a escrita do DMA contaminará o KV Cache da nova requisição, causando saída ilegível ou NaN. Pior ainda, se o block for liberado de volta ao pool de memória de vídeo e reutilizado por outro tensor, o DMA pode escrever em endereços inválidos e causar crash. A abordagem correta é manter o handle e o block, e tentar liberar novamente na próxima rodada de_pop_done_transfers.

Q2: _reap_expired_send_leasesO comentário em diz "não se pode parar a varredura só porque se encontrou a primeira requisição não expirada". Se mudarmos para break ao encontrar uma não expirada, em quais cenários isso dispararia vazamento de block?

Análise de referência:_reqs_to_sendÉ um dict comum, não uma fila de prioridade ordenada por tempo de expiração. O tratamento de heartbeat_handle_heartbeat 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034atualiza o tempo de expiração in-place:self._reqs_to_send[req_id] = max(old, new_expiry). Isso significa que uma requisição que entrou antes pode ter um tempo de expiração muito posterior por receber heartbeats continuamente, enquanto requisições atrás dela podem já ter expirado. Se pararmos no primeiro não expirado, as requisições já expiradas atrás nunca serão recuperadas, e seus blocks ocuparão memória de vídeo indefinidamente. Em cenários de longa execução com padrões de requisição mistos (algumas requisições renovadas frequentemente por heartbeat, outras cujas instâncias de decode já falharam), isso se acumula em vazamento grave de memória de vídeo.

Q3: _evict_stale_enginesUsa_engines_with_inflight_transferspara proteger engines com transferências em andamento. Se removermos essa proteção, em quais cenários de falha de rede isso causaria falha na transferência?

Análise de referência:_engines_with_inflight_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546O comentário em explica um cenário crítico: "The timestamp is stamped when a read is issued and not refreshed while it runs, so a transfer that outlives the TTL leaves its engine looking idle. A peer that has lost its NIC holds one indefinitely." Suponha que a NIC do par falhe e uma operação de leitura NIXL fique pendurada além do TTL (padrão 3600 segundos)._engine_last_activeO timestamp é marcado no momento em que a leitura é emitida e não é atualizado durante a leitura, então o engine parece ocioso. Se nesse momento_evict_stale_enginesexpulsar esse engine, chamará_cleanup_remote_enginepara liberardst_xfer_side_handlese remover o remote agent. Mas o DMA em andamento ainda está usando esses recursos; após a liberação, isso causará falha na transferência ou até crash.busyO conjunto protege explicitamente esse caso, garantindo que engines com transferências em andamento não sejam expulsos.

Até aqui, vimos como o KV Connector estabelece um canal de dados confiável entre as instâncias de prefill e decode, e como ele usa leases, heartbeats e mecanismos de recuperação de falhas para manter a consistência de estado. Mas a transferência entre instâncias é apenas metade da história da separação PD — depois que o KV Cache chega à instância de decode, o motor de inferência ainda precisa executar eficientemente cada passo de forward computation dentro de uma única instância. E o overhead de agendamento do Python e de lançamento de kernels é exatamente o próximo gargalo que limita a latência de um único passo. O próximo capítulo voltará-se para aceleração por compilação e CUDA Graph, para ver como o vLLM usa torch.compile e piecewise backend para eliminar esses overheads, e como faz o CUDA Graph coexistir de forma coordenada com formas de batching dinâmico.

CHAPTER 10

Capítulo 10: Aceleração por compilação e CUDA Graph: eliminando overhead de inicialização e agendamento

Projeto pertencente: vllm-project/vllm · Progresso do livro: Capítulo 10 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, vimos que o KV Connector, por meio de conectores como NIXL e Mooncake, transporta eficientemente o KV cache entre os motores de Prefill e Decode, permitindo que a arquitetura desagregada reduza o TTFT enquanto melhora a utilização de recursos. Mas mesmo que a transferência seja rápida, na decodificação autorregressiva ainda existem dois custos fixos que não podem ser eliminados por algoritmos: o overhead de agendamento do interpretador Python e o overhead de lançamento de kernels da GPU. Quando o forward do modelo é dividido em centenas de operadores, e cada operador precisa passar por uma chamada de função Python e um lançamento de kernel CUDA, o overhead do lado da CPU é suficiente para deixar a GPU ociosa entre dois cálculos. Este capítulo analisa como o vLLM usa torch.compile para fundir operadores em um grafo estático, e depois usa CUDA Graph para gravar toda a sequência de lançamento de kernels como uma única reprodução, reduzindo esses dois tipos de overhead a quase zero.

Cache de compilação e camada de adaptação do compilador: permitindo reutilização de resultados de compilação entre processos

Modelo intuitivo

O benefício da aceleração por compilação é "compilar uma vez, executar muitas vezes", mas o custo é que a primeira compilação pode levar vários minutos. Sem cache, cada reinício do serviço exigiria recompilação, e o tempo de cold start seria inaceitável.CompilerInterfaceEsta camada resolve exatamente o problema de "como serializar o artefato de compilação, como identificá-lo por hash e como acertá-lo com precisão no próximo início". Sem ela, o desastre enfrentado pelo sistema não é uma falha, mas sim a degradação de cada reinício para "primeira execução" — em ambientes de produção com auto scaling, isso significa que as instâncias escaladas não conseguirão fornecer serviço de baixa latência por vários minutos.

Estruturas de dados e contrato de interface

CompilerInterfaceDefine o contrato abstrato do adaptador do compilador, cujo núcleo são quatro métodos:initialize_cacheResponsável por redirecionar o diretório de cache do próprio compilador para o diretório de cache do vLLM📎 vllm/compilation/compiler_interface.py:36-51;compute_hashColeta informações de configuração relacionadas ao compilador para gerar um hash📎 vllm/compilation/compiler_interface.py:53-62;compileExecuta a compilação e retorna um objeto chamável e um handle📎 vllm/compilation/compiler_interface.py:64-95;loadRestaura o artefato de compilação a partir do handle📎 vllm/compilation/compiler_interface.py:97-103。

O design-chave aqui écompileRetorna uma tupla de dois elementos(callable, handle)。callableé o resultado de compilação diretamente chamável dentro deste processo;handleé a credencial usada para "restaurar no próximo início", e a documentação exige explicitamente que ele seja "plain Python object, preferably a string or a file path"📎 vllm/compilation/compiler_interface.py:81-81. Essa separação permite que o caminho de acerto de cache e o caminho de primeira compilação sigam códigos completamente diferentes — no acerto, não é necessáriocompile, apenasload。

compile_rangeO parâmetro carrega a semântica de formas dinâmicas. O comentário explica que ele "could be concrete size (if compile_sizes is provided), e.g. [4, 4] or a range [5, 8]", e que "Right now we only support one variable in ranges for all inputs, which is the batchsize (number of tokens) during inference"📎 vllm/compilation/compiler_interface.py:74-74. Esta é a restrição central da estratégia de compilação do vLLM: todas as formas dinâmicas são reduzidas a uma única variável — o número de tokens.

Orientado por cenário: o fluxo completo de uma requisição de compilação

Suponha que o serviço seja iniciado pela primeira vez,InductorAdaptor.compileé chamado. Ele primeiro incrementa o contador de compilação📎 vllm/compilation/compiler_interface.py:477-489, e então entra em uma pilha de patches cuidadosamente construída.

O primeiro passo é fazer deep copy do grafo. O comentário observa que "inductor can inplace modify the graph, so we need to copy it"📎 vllm/compilation/compiler_interface.py:500-502, o que é um design defensivo — após uma falha de compilação, o grafo original ainda pode ser usado para nova tentativa.

O segundo passo é instalar uma série de monkey-patches.hijacked_compile_fx_innerenvolve a função interna de compilação do Inductor, e após a compilação terminar, extrai o hash deinductor_compiled_graph._fx_graph_cache_keycaptura o hash📎 vllm/compilation/compiler_interface.py:512-536。hijack_compiled_fx_graph_hashentão intercepta a própria função de cálculo de hash📎 vllm/compilation/compiler_interface.py:538-542. Por que "sequestrar" o hash? Porque o vLLM precisa compilar separadamente fora do contexto de tracing do Dynamo, e o cálculo de hash do Inductor depende desse contexto.

O terceiro passo é_check_can_cachepatch, ele retorna diretamente, sem fazer nenhuma verificação📎 vllm/compilation/compiler_interface.py:544-551. O comentário explica a motivação: "Inductor refuses to cache the graph outside of Dynamo tracing context, and also disables caching for graphs with high-order ops. For vLLM, in either case, we want to cache the graph"📎 vllm/compilation/compiler_interface.py:544-551。

O quarto passo é limpar o contexto de rastreamento. Este é o ponto mais sutil: o vLLM chamaPiecewiseCompileInterpreterinternamentecompile_fx, neste momento oFakeTensorModedo Dynamo e oFakeTensorModeda entrada do subgrafo são inconsistentes,detect_fake_mode()fará com que a asserção falhe📎 vllm/compilation/compiler_interface.py:615-622. O código salvaTracingContexte depois o define como vazio, e registra um callback para restaurá-lo na saída📎 vllm/compilation/compiler_interface.py:623-630。

mermaid
flowchart TD
    start["InductorAdaptor.compile()"] --> deepcopy["copy.deepcopy(graph)"]
    deepcopy --> patch_stack["ExitStack 安装补丁"]
    patch_stack --> p1["patch compiled_fx_graph_hash"]
    patch_stack --> p2["patch FxGraphCache._get_shape_env"]
    patch_stack --> p3["patch _check_can_cache"]
    patch_stack --> p4["清空 TracingContext"]
    p4 --> call_fx["compile_fx(graph, example_inputs)"]
    call_fx --> check{"hash_str is None?"}
    check -->|"是"| err["RuntimeError: 编译失败<br/>建议删除 torch_compile_cache"]
    check -->|"否"| check2{"file_path is None?"}
    check2 -->|"是"| assert_err["AssertionError"]
    check2 -->|"否"| ret["return (compiled_graph, (hash_str, file_path))"]
    err --> cleanup["ExitStack 退出<br/>恢复 TracingContext"]
    assert_err --> cleanup
    ret --> cleanup

Reflexão de design: AlwaysHitShapeEnv e consistência de cache

AlwaysHitShapeEnvEsta classe merece uma análise separada. Sua docstring declara diretamente a motivação: o vLLM executa a compilação do bytecode do Dynamo apenas uma vez, mas precisa executar a compilação do Inductor várias vezes com diferentes shapes mais um shape genérico; a compilação para shapes específicos ocorre fora do contexto do Dynamo, momento em que não há shape environment disponível para o Inductor, causando falha na busca do cache de código do Inductor📎 vllm/compilation/compiler_interface.py:114-131。

A solução é fornecer um shape environment falso que "sempre acerta":evaluate_guards_expressionsempre retornaTrue 📎 vllm/compilation/compiler_interface.py:144-145,get_pruned_guardsretorna lista vazia📎 vllm/compilation/compiler_interface.py:144-145,produce_guards_expressionretorna string vazia📎 vllm/compilation/compiler_interface.py:147-159. O comentário admite que esses métodos foram "obtained by trial-and-error until it works"📎 vllm/compilation/compiler_interface.py:137-142——este é um ponto frágil acoplado à implementação interna do PyTorch, e também o local mais propenso a problemas ao atualizar o PyTorch.

A composição do hash de cache também é crucial.get_inductor_factorscoleta três tipos de fatores: estado do sistemaCacheBase.get_system(), estado do PyTorchtorch_key(), e configurações do Inductor e functorch📎 vllm/compilation/compiler_interface.py:165-185. Note que a configuração do functorch é coletada no contexto depatch(_get_vllm_functorch_config()), o que garante que "a configuração no momento da compilação e a chave de cache sejam sempre consistentes"——o comentário afirma explicitamente que isso é para manter📎 vllm/compilation/compiler_interface.py:188-189eset_functorch_config()consistentesget_inductor_factors(). Se esses dois locais forem inconsistentes, ocorrerá um descasamento de "configuração A usada na compilação, chave de cache calculada com base na configuração B", levando a um acerto de cache que carrega o artefato errado.📎 vllm/compilation/compiler_interface.py:147-159Armadilhas em produção:

é um backport para torch < 2.10.0_patch_standalone_compile_atomic_save. Ele altera📎 vllm/compilation/compiler_interface.py:205-243para usarCompiledArtifact.save()para escrever em formato binário, e o comentário explica que o objetivo é "preventing corrupt cache files when multiple processes compile concurrently"write_atomic. No cenário de inicialização a frio simultânea de múltiplas réplicas, vários processos escrevem concorrentemente no mesmo arquivo de cache; escrita não atômica produz arquivos truncados, e processos subsequentes que leem artefatos corrompidos têm comportamento imprevisível.📎 vllm/compilation/compiler_interface.py:208-210PiecewiseBackend: compilação por faixas de shape e despacho em tempo de execução

Modelo intuitivo

é o centro de agendamento entre compilação e execução. Ele compila "um subgrafo FX" em "objetos chamáveis para múltiplas faixas de shape", e em tempo de execução seleciona o mais adequado com base no número real de tokens. Sem ele, ou todos os shapes usariam a mesma compilação genérica (desempenho subótimo), ou cada shape seria compilado separadamente (explosão no tempo de compilação).

PiecewiseBackendEstrutura de dados: RangeEntry e intervalo de compilação

A estrutura de dados central é

, que vincula a flagRangeEntryecompile_range、compiledjuntosrunnablemantém um📎 vllm/compilation/piecewise_backend.py:80-83。PiecewiseBackendA construção do intervalo de compilação é feita em duas etapas. Primeiro processarange_entries: dict[Range, RangeEntry] 📎 vllm/compilation/piecewise_backend.py:166-171。

(tamanhos exatos), cada tamanho gera umcompile_sizesde intervalo pontualRange(start=size, end=size). Note que aqui para a string📎 vllm/compilation/piecewise_backend.py:166-171lança diretamente"cudagraph_capture_sizes", e explica que "should be handled inNotImplementedError——esta é uma declaração explícita de fronteira de responsabilidade. Depois processapost_init_cudagraph_sizes" 📎 vllm/compilation/piecewise_backend.py:166-171(intervalos), cada intervalo gera um entrycompile_rangessuporta dois modos mutuamente exclusivos, e o construtor força isso com uma asserção XOR📎 vllm/compilation/piecewise_backend.py:173-173。

PiecewiseBackend: modo de compilação (com graph, sem compiled_runnables) usa📎 vllm/compilation/piecewise_backend.py:117-119; modo pré-compilado (sem graph, com compiled_runnables) usacompile_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194. Este design permite que inicialização a frio e a quente compartilhem a mesma classe, apenas com fontes de dados diferentes.load_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194Orientado a cenários: da compilação ao despacho em tempo de execução

Fase de compilação

percorre todos os range entries, e para cada entry não compilado chama:compile_all_rangesregistra evento de rastreamento_log_compile_start. O branch crítico está na construção de parâmetros: se for tamanho pontual, chama📎 vllm/compilation/piecewise_backend.py:252-256para gerar FakeTensor com shape específicocreate_concrete_args; caso contrário, chama📎 vllm/compilation/piecewise_backend.py:258-261para reutilizar diretamente os metadados de placeholder do grafoget_fake_args_from_graphA implementação de📎 vllm/compilation/piecewise_backend.py:262-263。

create_concrete_argsrevela detalhes da concretização de shapes simbólicos. Ele constrói umShapeEnvcomFakeTensorMode 📎 vllm/compilation/piecewise_backend.py:54, e então percorre os nós placeholder. Para entradas do tipoSymInt, usaconcretizepara substituir todos os símbolos livres porsize 📎 vllm/compilation/piecewise_backend.py:47-52; para o tipoTensor, é necessário concretizar simultaneamente shape, stride, storage_offset, e usarcompute_required_storage_lengthpara calcular o comprimento de armazenamento necessário, e então reconstruir o tensor através deas_strided 重建张量 📎 vllm/compilation/piecewise_backend.py:64-73. Por que não é possível alterar apenas o shape? Porque stride e storage_offset também podem conter símbolos, e os três devem ser consistentes entre si, caso contrárioas_stridedcausará acesso fora dos limites.

Despacho em tempo de execução:__call__é o caminho crítico. Se existirsym_shape_indices, extrair o shape de tempo de execução deargs, e então chamar📎 vllm/compilation/piecewise_backend.py:357-362para buscar. A lógica de busca tem prioridade: primeiro verifica se há correspondência exata de_find_range_for_shape, se houver, retorna esse intervalo de ponto únicocompile_sizes; caso contrário, percorre📎 vllm/compilation/piecewise_backend.py:342-355para encontrar o intervalo que contém esse shapecompile_rangesCópia📎 vllm/compilation/piecewise_backend.py:342-355。

mermaid
flowchart TD
    call["PiecewiseBackend.__call__(*args)"] --> has_sym{"sym_shape_indices 非空?"}
    has_sym -->|"是"| get_shape["runtime_shape = args[sym_shape_indices[0]]"]
    get_shape --> find["_find_range_for_shape(runtime_shape)"]
    find --> exact{"runtime_shape in compile_sizes?"}
    exact -->|"是"| exact_entry["返回 Range(start=shape, end=shape) 的 entry"]
    exact -->|"否"| scan["遍历 compile_ranges 找包含区间"]
    scan --> found{"找到?"}
    found -->|"否"| assert_fail["AssertionError: 形状超出编译范围"]
    found -->|"是"| entry_ok["返回对应 entry"]
    has_sym -->|"否"| static["取唯一已编译 entry"]
    static --> check_count{"compiled_entries 数量 == 1?"}
    check_count -->|"否"| count_err["AssertionError"]
    check_count -->|"是"| entry_ok
    exact_entry --> run["range_entry.runnable(*args)"]
    entry_ok --> run

〔Inferência de design e trade-offs de arquitetura〕

O método é responsável por serializar os artefatos compilados, para uso no cache AOT. Aqui há um

to_bytesengenhoso: quando o pickle encontrareducer_override, primeiro chamaCachingAutotunere depois serializaobj.prepare_for_pickle(). Por que esse hook é necessário?📎 vllm/compilation/piecewise_backend.py:209-218mantém internamente artefatos de compilação Triton e estado de tempo de execução; fazer pickle diretamente pode falhar ou produzir objetos não reutilizáveis;CachingAutotunerobviamente converte o objeto em uma forma pura e serializável.prepare_for_pickleDurante a serialização, também ativa temporariamente

, o que ecoa a lógica embundled_autograd_cache 📎 vllm/compilation/piecewise_backend.py:222— quando_get_vllm_functorch_confignão está habilitado, essa configuração éVLLM_USE_MEGA_AOT_ARTIFACT, e durante a serialização é forçada paraFalse 📎 vllm/compilation/compiler_interface.py:160-161, garantindo que os artefatos sejam empacotados.Trueé o caminho de inicialização a quente; ele afirma que cada range pode ser encontrado em

load_all_rangescom a chave correspondente, caso contrário lança um erro contendo a lista de chaves disponíveiscompiled_runnables. Essa mensagem de erro foi projetada de forma muito prática — lista diretamente as chaves disponíveis, facilitando a investigação de incompatibilidade de versão de cache.📎 vllm/compilation/piecewise_backend.py:329-339Wrapper de CUDA Graph: captura, replay e despacho aninhado

Modelo intuitivo

CUDA Graph grava "uma sequência de lançamentos de kernels" como um grafo estático, e depois cada replay requer apenas uma chamada de API.

é o executor da gravação e do replay. O desafio central que enfrenta é: o tamanho de batch do vLLM é dinâmico, enquanto o CUDA Graph exige endereços de entrada fixos. A solução é "capturar por faixas de batch descriptor" — gravar um grafo para cada faixa de shape, e em tempo de execução consultar a tabela por descriptor para replay.CUDAGraphWrapperEstrutura de dados: CUDAGraphEntry e contrato de despacho

mantém três campos principais:

CUDAGraphEntrycomo chave de despachobatch_descriptoré o objeto de grafo capturado📎 vllm/compilation/cuda_graph.py:128-135、cudagraphé a saída no momento da captura (armazenada como referência fraca para economizar memória)📎 vllm/compilation/cuda_graph.py:128-135、outputusado apenas em modo de depuração para validar a consistência dos endereços de entrada no replay📎 vllm/compilation/cuda_graph.py:128-135。input_addressesA documentação da classe descreve com precisão o contrato de despacho: na inicialização, aloca um runtime mode (FULL ou PIECEWISE)📎 vllm/compilation/cuda_graph.py:128-135。

CUDAGraphWrapper; em tempo de execução, recebe runtime_mode e batch_descriptor do forward context e "blindly trust them"📎 vllm/compilation/cuda_graph.py:158-158; se runtime_mode for NONE ou não corresponder, chama diretamente📎 vllm/compilation/cuda_graph.py:158-158; caso contrário, executa captura ou replay📎 vllm/compilation/cuda_graph.py:158-158A documentação também declara explicitamente uma fronteira: "CUDAGraphWrapper does not store persistent buffers or copy any runtime inputs into that buffers for replay"📎 vllm/compilation/cuda_graph.py:158-158。

. Isso significa que o gerenciamento dos buffers de entrada é responsabilidade do chamador — o wrapper é responsável apenas pelo grafo em si.📎 vllm/compilation/cuda_graph.py:164-164Orientado a cenários: uma captura e um replay

Caminho de captura

: quandoé acionado e o runtime_mode corresponde, primeiro verifica se o forward context está disponível. Se não estiver (como no forward do codificador visual), chama diretamente a função subjacente__call__. Este é o branch crítico do cenário multimodal — o forward do ViT não passa pelo CUDA Graph.📎 vllm/compilation/cuda_graph.py:232-233Em seguida, obtém

ebatch_descriptor. Se o mode for NONE ou não corresponder, chama diretamentecudagraph_runtime_mode 📎 vllm/compilation/cuda_graph.py:242-244. Esse design de "se não corresponder, passa direto" permite a coexistência de wrappers aninhados: o wrapper FULL na camada externa, o wrapper PIECEWISE na camada interna, e apenas um será ativado em tempo de execução.📎 vllm/compilation/cuda_graph.py:246-256Se o

da entry for None, entra na captura. Primeiro chamacudagraphpara validar a legalidadevalidate_cudagraph_capturing_enabled(), depois registra os endereços de entrada📎 vllm/compilation/cuda_graph.py:279, cria📎 vllm/compilation/cuda_graph.py:281-284Há várias operações críticas no contexto de captura. Setorch.cuda.CUDAGraph() 📎 vllm/compilation/cuda_graph.py:285。

estiver habilitado, faz patch degc_disableegc.collect. O comentário explica o motivo: no modo piecewise, cada camada precisa capturar um grafo, e o GC repetido tornaria a captura extremamente lenta, então "only run gc for the first graph, and disable gc for the rest"torch.accelerator.empty_cache 📎 vllm/compilation/cuda_graph.py:288-303. Em seguida, define o graph pool id📎 vllm/compilation/cuda_graph.py:289-294, e sincroniza o stream de cópia do offloader📎 vllm/compilation/cuda_graph.py:305-308A captura real é executada no contexto📎 vllm/compilation/cuda_graph.py:310-312。

torch.cuda.graph(cudagraph, pool=..., stream=...). Após a captura, chamaself.runnable(*args, **kwargs) 📎 vllm/compilation/cuda_graph.py:315-321para evitar erros de streams não sincronizadosget_offloader().join_after_forward(). Se📎 vllm/compilation/cuda_graph.py:322-326estiver habilitado, converte o output em referência fraca para economizar memóriaweak_ref_output. Por fim, a entry salva a referência fraca do output e o objeto de grafo📎 vllm/compilation/cuda_graph.py:327-334, mas📎 vllm/compilation/cuda_graph.py:338-339retorna o output original em vez da referência fraca— o comentário enfatiza que isso é para permitir que o PyTorch gerencie corretamente a memória durante a capturaCaminho de replay📎 vllm/compilation/cuda_graph.py:343-346。

: se a entry já tiver um grafo, em modo de depuração valida a consistência dos endereços de entrada, depois sincroniza o offloader📎 vllm/compilation/cuda_graph.py:348-357, chama📎 vllm/compilation/cuda_graph.py:359-361e retornaentry.cudagraph.replay() 并返回 entry.output 📎 vllm/compilation/cuda_graph.py:362-363。

Considerações de design: por que a saída deve ser uma referência fraca, enquanto o retorno deve ser uma referência forte

Este éCUDAGraphWrappero ponto mais contraintuitivo emoutputNo momento da captura,📎 vllm/compilation/cuda_graph.py:320é gerenciado pelo cudagraph pool do PyTorch. Se a entry mantiver uma referência forte ao output, a memória de vídeo ocupada por este grafo nunca poderá ser liberada; mas se for convertido em referência fraca durante a captura, o PyTorch pode recuperar a memória antes da conclusão da captura, causando falha na captura. Por isso, o código usa referência fraca dentro do bloco de captura📎 vllm/compilation/cuda_graph.py:334, armazena uma referência fraca na entry📎 vllm/compilation/cuda_graph.py:338, mas o valor de retorno da função é uma referência forte📎 vllm/compilation/cuda_graph.py:346. Este "estado de referência triplo" é um equilíbrio preciso entre segurança de memória e eficiência de memória de vídeo.

Outro design digno de nota é_all_instancesesteWeakSet 📎 vllm/compilation/cuda_graph.py:173-176. Ele permite queclear_all_graphslimpe de uma só vez os grafos de todos os wrappers📎 vllm/compilation/cuda_graph.py:173-176, usado para recuperação de emergência quando a memória de vídeo está escassa. UsarWeakSetem vez de um conjunto comum é para não impedir que o wrapper seja coletado pelo GC — caso contrário, o próprio wrapper vazaria.

Armadilhas em produção:__getattr__A implementação de lança, em modo de depuração, um erro com contexto para atributos inexistentes📎 vllm/compilation/cuda_graph.py:211-217. Isso parece trivial, mas ao investigar "por que uma determinada chamada de método falhou", poder ver a descrição em string do runnable encapsulado pelo wrapper é muito mais útil do que umAttributeErrorcru.

Considerações de design: desacoplamento entre compilação e CUDA Graph

O documento de design registra explicitamente a motivação desta refatoração. A compilação piecewise inicial existia para suportar a captura piecewise de CUDA Graph, excluindo operadores que não suportam CUDA Graph (principalmente attention)📎 docs/design/cuda_graphs.md:25. Posteriormente foi adicionado suporte a full CUDA Graph, mas "this tight coupling between compilation and cudagraph capture led to an all-or-nothing experience with little flexibility"📎 docs/design/cuda_graphs.md:25。

Após a refatoração, os objetivos são quatro: distinguir explicitamente lotes prefill/mixed de uniform-decode e capturá-los separadamente📎 docs/design/cuda_graphs.md:25-25; desacoplar a lógica de captura de CUDA Graph da compilação, permitindo "capturing piecewise and full cudagraphs using the same compiled graph"📎 docs/design/cuda_graphs.md:25-25; despachar em tempo de execução conforme a composição do lote📎 docs/design/cuda_graphs.md:25-25; controle centralizado para reduzir complexidade📎 docs/design/cuda_graphs.md:25-25。

BatchDescriptoré a estrutura central da chave de despacho, contendonum_tokens、num_reqs、uniform、has_loraos quatro campos📎 docs/design/cuda_graphs.md:86-93。uniformO flag é especialmente crítico — muitos backends de attention só suportam full CUDA Graph quando o lote é uniform📎 docs/design/cuda_graphs.md:95-95. O documento também antecipa que esta estrutura pode ser estendida, por exemplo adicionandouniform_query_lensuporte a múltiplos comprimentos de uniform decode📎 docs/design/cuda_graphs.md:95-95。

A prioridade de despacho éFULL > PIECEWISE > None, e se a chave de despacho não existir, faz fallback para o modo NONE com execução eager📎 docs/design/cuda_graphs.md:112-115. Esta estratégia de "degradar em vez de reportar erro" garante que qualquer combinação de lotes possa ser executada, apenas com desempenho diferente.

AttentionCGSupportO enum quantifica a capacidade de CUDA Graph do backend, com valoresALWAYS=3 > UNIFORM_BATCH=2 > UNIFORM_SINGLE_TOKEN_DECODE=1 > NEVER=0 📎 docs/design/cuda_graphs.md:153-162. Modelos de attention híbrida (como mamba mixer) tomam o mínimo das capacidades de todos os backends e degradam o modo CUDA Graph com base nisso📎 docs/design/cuda_graphs.md:173-175. Este design desacopla "declaração de capacidade" de "seleção de modo" — novos backends só precisam declarar capacidade, e a estratégia de degradação entra em vigor automaticamente.

Resumo do capítulo

Reflexões e autoavaliação do capítulo

Q1: Se removermos o_check_can_cachepatch (📎 vllm/compilation/compiler_interface.py:544-551), deixando o Inductor decidir por conta própria se deve fazer cache, em quais cenários o cache de compilação seria invalidado? Por que o comentário diz "Inductor refuses to cache the graph outside of Dynamo tracing context"?

Análise de referência:_check_can_cacheretorna diretamente, sem fazer nenhuma verificação, e o comentário explica que o Inductor recusa o cache em duas situações: fora do contexto de tracing do Dynamo, e quando o grafo contém operadores de alta ordem📎 vllm/compilation/compiler_interface.py:544-551. O fluxo de compilação do vLLM está justamente fora do contexto do Dynamo (compile_fxé chamado porPiecewiseCompileInterpreter, e o código limpa explicitamenteTracingContext 📎 vllm/compilation/compiler_interface.py:623-625). Se o patch for removido, o Inductor determinará "não cacheável", recompilando a cada inicialização, e o tempo de cold start degradaria de segundos para minutos. Mais sutil ainda é que, como o vLLM depende dehijacked_compile_fx_innerpara capturarhash_str, se o caminho de cache for ignorado,hash_strpode ser None, disparando o RuntimeError de📎 vllm/compilation/compiler_interface.py:640-652. Isso explica por que o comentário enfatiza "vLLM today assumes and requires the monkey-patched functions to get hit"📎 vllm/compilation/compiler_interface.py:596-598。

Q2: CUDAGraphWrapperconverte o output em referência fraca ao armazená-lo na entry durante a captura (📎 vllm/compilation/cuda_graph.py:338), mas retorna uma referência forte (📎 vllm/compilation/cuda_graph.py:346). Se o valor de retorno também fosse alterado para referência fraca, em quais cenários ocorreria crash?

Análise de referência: durante a captura,outputé gerenciado pelo cudagraph pool do PyTorch📎 vllm/compilation/cuda_graph.py:320。Se o valor de retorno for uma referência fraca, o objeto obtido pelo chamador pode ser imediatamente coletado pelo GC após a saída do bloco de captura — porque nesse momento nenhuma referência forte o mantém vivo. O PyTorch precisa que o output permaneça vivo durante a captura para estabelecer corretamente o mapeamento do pool de memória; uma vez coletado, na reprodução subsequenteentry.outputa referência fraca apontada já se tornou inválida,replay()o objeto retornado depois pode já ter sido sobrescrito ou liberado. O comentário afirma explicitamente "we need to return the output, rather than the weak ref of the output, so that pytorch can correctly manage the memory during cuda graph capture"📎 vllm/compilation/cuda_graph.py:343-345. Este design é um equilíbrio preciso de "referência forte durante a captura, referência fraca durante o armazenamento".

Q3: EmPiecewiseBackend._find_range_for_shape(📎 vllm/compilation/piecewise_backend.py:342-355), a busca por tamanho exato tem prioridade sobre a busca por intervalo. Suponhacompile_sizes=[8]、compile_ranges=[Range(1,16)], com shape=8 em tempo de execução, qual entry será atingido? Se a prioridade fosse invertida, quais seriam as consequências?

Análise de referência: A lógica atual verifica primeiroruntime_shape in self.compile_sizes, e se houver correspondência retornaRange(start=8, end=8)o entry de ponto único📎 vllm/compilation/piecewise_backend.py:342-355. Este entry foi compilado comcreate_concrete_args, com a forma totalmente concretizada, permitindo que o kernel Triton faça a especialização máxima (comoset_inductor_configem que tamanhos de ponto único ativammax_autotune 📎 vllm/compilation/compiler_interface.py:747-754). Se a prioridade fosse invertida, shape=8 atingiria o intervaloRange(1,16)do entry — que é uma versão genérica compilada com formas simbólicas, com desempenho subótimo. Mais grave ainda,compile_sizesgeralmente vem decudagraph_capture_sizes, e esses tamanhos são exatamente os níveis que o CUDA Graph precisa capturar; se em tempo de execução for despachado para o entry genérico, o grafo capturado pelo CUDA Graph será inconsistente com o runnable despachado, podendo causar incompatibilidade de forma na reprodução. Portanto, a prioridade exata não é apenas uma escolha de desempenho, mas um requisito de correção.

O próximo capítulo abordará quantização e kernels personalizados, vendo como o vLLM intervém no controle de precisão desde a fase de carregamento de pesos, e usa operadores altamente especializados para converter os ganhos de quantização em aumento real de throughput.

Este capítulo analisou os dois níveis de mecanismos de aceleração de compilação do vLLM. O primeiro nível é CompilerInterface e PiecewiseBackend: o primeiro define o contrato de adaptação do compilador e a estratégia de hash de cache, usando AlwaysHitShapeEnv para contornar o problema de contexto ausente do Dynamo; o segundo compila um único subgrafo FX em múltiplos níveis de forma, despachando em tempo de execução pelo número de tokens. O segundo nível é CUDAGraphWrapper: ele captura CUDA Graphs por níveis de BatchDescriptor, implementando despacho aninhado através de correspondência de runtime mode, permitindo que os modos FULL e PIECEWISE coexistam no mesmo grafo compilado. O desacoplamento entre os dois é o núcleo desta refatoração — os artefatos de compilação podem ser reutilizados por ambos os modos de CUDA Graph, e o CUDA Graph também pode funcionar independentemente da compilação. No entanto, compilação e captura de grafo resolvem a sobrecarga de agendamento; a precisão dos pesos do modelo em si e a eficiência dos operadores ainda são outra linha principal de otimização. O próximo capítulo abordará quantização e kernels personalizados, vendo como o vLLM analisa configurações de quantização, realiza conversão de formatos como FP8/INT4/AWQ/GPTQ durante o carregamento de pesos, e usa _custom_ops e kernels Triton para extrair ainda mais o desempenho do hardware.

CHAPTER 11

Capítulo 11: Quantização e Kernels Personalizados: Do Carregamento de Pesos a Operadores de Alto Desempenho

Projeto pertencente: vllm-project/vllm · Progresso do livro: Capítulo 11 / 14 · Status de verificação: Linhas FACT com ancoragem real

No capítulo anterior vimos que torch.compile e CUDA Graph reduziram ao extremo a sobrecarga de agendamento Python e inicialização de kernels. Mas por mais rápido que seja o agendamento, se os pesos em si são FP16 e a multiplicação de matrizes usa GEMM genérico, o poder computacional do hardware ainda é limitado pela largura de banda de memória e operadores ineficientes. Quantização e kernels personalizados são outra linha ortogonal de otimização: a primeira reduz a precisão já na fase de carregamento de pesos, a segunda converte os ganhos de quantização em throughput real. Este capítulo parte do ponto de entrada de análise de configuração de quantização, percorrendo até o registro de operadores em _custom_ops e o agendamento de kernels Triton.

11.1 Configuração de Quantização: Da String CLI ao QuantKey

Modelo intuitivo

O papel do módulo de configuração de quantização é como um tradutor de menu de restaurante. O usuário no balcão diz "quero fp8_per_tensor" (string CLI), e a cozinha precisa do número exato da receita (QuantKey). O tradutor deve lidar com três tipos de entrada: abreviação pura de CLI, metadados de quantização do próprio checkpoint, e cenários combinados dos dois. Sem essa camada de tradução, a cozinha receberia um monte de strings ambíguas, incapaz de decidir qual kernel chamar.

Estrutura de dados e layout de memória

A estrutura de dados central éQuantSpeceQuantizationConfigArgs. A primeira descreve as chaves de quantização de pesos e ativações de um tipo de camada (linear ou MoE), a segunda é a configuração de nível superior visível ao usuário.

📎 vllm/config/quantization.py:73-99

python
@config
class QuantSpec:
    weight: QuantKeyField = None
    activation: QuantKeyField = None

    def __str__(self) -> str:
        def quant_key_str(quant_key: QuantKey | None) -> str:
            if quant_key is None:
                return "None"
            return next(
                (
                    name
                    for name, known_quant_key in QUANT_KEY_NAMES.items()
                    if known_quant_key == quant_key
                ),
                str(quant_key),
            )
        return quant_key_str(self.weight)

weighteactivationsão ambos opcionaisQuantKey。NoneA semântica de é "recuar para o valor padrão da própria classe do método" — normalmente herdado do checkpoint; em cenários de quantização online, significa não quantizar📎 vllm/config/quantization.py:74-74。QuantKeyé em si um tipo complexo que contémNamedTupleeClassVar[GroupShape]declarações; o pydantic não consegue introspeccioná-lo diretamente, por isso o autor usouGetPydanticSchemapara injectar um validador personalizado_coerce_quant_key, normalizando uniformemente strings ouQuantKeyO layout dos campos de merece atenção📎 vllm/config/quantization.py:60-69。

QuantizationConfigArgs📎 vllm/config/quantization.py:102-126:

  • linear / moe: aplicam-se respectivamente às camadasLinearBaseeFusedMoEFactory;
  • ignore: lista de nomes de camadas a saltar na quantização; a quantização online também suporta wildcards fnmatch;
  • targets: sobreposição de quantização online camada a camada; a chave pode ser um nome exacto de camada,re:regex com prefixo, ou padrão fnmatch; o valor é mutuamente exclusivo comlinear/moe.

targetselinear/moeA exclusão mútua entre e é imposta pormodel_validator📎 vllm/config/quantization.py:172-179. Esta restrição não é formalismo:targetssegue o caminho de sobreposição camada a camada,linear/moesegue o caminho padrão global; a coexistência de ambos tornaria indeterminável "qual spec é realmente usado por uma dada camada".

Passo-a-passo: uma resolução de--quantization fp8_per_tensor

Cenário: o utilizador passa na linha de comandos--quantization fp8_per_tensor, e ao mesmo tempo especifica a quantização de activação da camada MoE através de--quantization-config.

Primeiro passo,resolve_quantization_configé chamado, com argumentos a string CLI e o dicionário de configuração📎 vllm/config/quantization.py:233-235. Primeiro verifica sequantizationestá emONLINE_QUANT_SHORTHAND_NAMES— esta tupla contém todos os nomes abreviados mais um"online" 📎 vllm/config/quantization.py:216-222。

Segundo passo,fp8_per_tensorcorresponde à tabela de abreviaturas,baseé resolvido para_ONLINE_SHORTHANDS["fp8_per_tensor"], ou seja, tanto linear como moe usamkFp8StaticTensorSym 📎 vllm/config/quantization.py:188-190。

Terceiro passo,quantization_confignão vazio, é construído como objectoQuantizationConfigArgs. Segue-se a lógica de fusão📎 vllm/config/quantization.py:267-268: cada campo é decidido porquantization_config.xxx or base.xxx— campos explicitamente definidos pelo utilizador têm prioridade; os não definidos herdam o valor padrão da abreviatura. Aqui usa-seorem vez deif is not Noneintencionalmente:QuantSpece lista vazia são ambos falsy; semanticamente "não definido" e "vazio" são equivalentes.

Quarto passo, sequantizationnão estiver na tabela de abreviaturas (por exemplo, é oawqpróprio do checkpoint), equantization_configforNone, a função retorna directamenteNone 📎 vllm/config/quantization.py:256-257. Isto significa "não sobrepor quantização online"; o método de quantização do checkpoint mantém-se dominante.

Há um ramo fácil de ignorar:_DEFERRED_ONLINE_SHORTHANDScontémmxfp4emxfp8 📎 vllm/config/quantization.py:233-235. Estes dois nomes são simultaneamente abreviaturas CLI e nomes de métodos de quantização de checkpoint. Quando o utilizador passa apenas--quantization mxfp4semquantization_config, a função retornaNoneem vez debase 📎 vllm/config/quantization.py:267-268, adiando a decisão para os metadados do checkpoint — só quando o checkpoint não tem informação de quantização é que se recua para a abreviatura online.

mermaid
flowchart TD
    start["resolve_quantization_config(quantization, quantization_config)"]
    check_shorthand{"quantization in ONLINE_QUANT_SHORTHAND_NAMES?"}
    checkpoint_path{"quantization_config is None?"}
    return_none1["return None (checkpoint 主导)"]
    build_args["QuantizationConfigArgs(**quantization_config)"]
    get_base["base = _ONLINE_SHORTHANDS.get(quantization)"]
    cfg_none{"quantization_config is None?"}
    deferred{"quantization in _DEFERRED_ONLINE_SHORTHANDS?"}
    return_none2["return None (推迟到 checkpoint)"]
    return_base["return base"]
    merge["逐字段合并: cfg.xxx or base.xxx"]
    return_merged["return 合并后的 QuantizationConfigArgs"]

    start --> check_shorthand
    check_shorthand -->|否| checkpoint_path
    checkpoint_path -->|是| return_none1
    checkpoint_path -->|否| build_args
    check_shorthand -->|是| get_base
    get_base --> cfg_none
    cfg_none -->|是| deferred
    deferred -->|是| return_none2
    deferred -->|否| return_base
    cfg_none -->|否| merge
    merge --> return_merged

Reflexões de design e armadilhas

_coerce_specO validador trata um cenário subtil: quandolinearoumoerecebem uma string, primeiro consulta_ONLINE_SHORTHANDS; se corresponder, extrai o spec do campo correspondente; se não, trata como um único nomeQuantKey📎 vllm/config/quantization.py:130-139. Isto significa quelinear="fp8_per_tensor"elinear="fp8_per_tensor_static"seguem dois caminhos diferentes — o primeiro é uma abreviatura de configuração completa, o segundo é uma chave de quantização única. Se na abreviatura esse campo forNone(por exemplo,int8_per_channel_weight_onlynão tem campolinear), lança umValueErrorexplícito em vez de retornar silenciosamenteNone 📎 vllm/config/quantization.py:130-139。

Uma armadilha comum em produção:targetsAs chaves regex de são pré-compiladas e validadas em_validate_targets📎 vllm/config/quantization.py:166-167, mas as chaves de padrão fnmatch não são validadas. Se o utilizador escrever um padrão fnmatch que nunca corresponde a nenhuma camada, não há erro; simplesmente essa camada permanece não quantizada — na depuração é preciso verificar se o nome da camada realmente corresponde.

11.2 _custom_ops: Registo de operadores e implementação fake

Modelo intuitivo

_custom_ops.pyé a camada de adaptação entre o vLLM e os operadores CUDA/C++ subjacentes, como uma alfândega. No namespacetorch.ops._Cdo PyTorch estão registados operadores C++ compilados, mas chamá-los directamente tem três problemas: o conjunto de operadores difere entre plataformas (CUDA/ROCm/CPU/XPU),torch.compileprecisa de implementações fake para inferir formas de saída, e alguns operadores precisam de pré-processamento de parâmetros do lado Python._custom_opsencapsula uniformemente estes problemas.

Estruturas de dados e mecanismo de registo

No carregamento do módulo, chama-se primeirocurrent_platform.import_kernels() 📎 vllm/_custom_ops.py:25-26, dando à camada de plataforma a oportunidade de importar a sua própria biblioteca de operadores. Depois define-seregister_fake— sobTYPE_CHECKINGé um decorador vazio; em runtime importa-se detorch.library📎 vllm/_custom_ops.py:25-26。

O papel central da implementação fake é permitir quetorch.compilesaiba a forma de saída e o dtype do operador durante a fase de tracing, sem o executar realmente. Tomandoscaled_fp4_quantcomo exemplo:

📎 vllm/_custom_ops.py:90-100

python
if hasattr(torch.ops, "_C") and hasattr(torch.ops._C, "scaled_fp4_quant"):

    @register_fake("_C::scaled_fp4_quant")
    def _scaled_fp4_quant_fake(
        input: torch.Tensor,
        input_scale: torch.Tensor,
        is_sf_swizzled_layout: bool,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        n = input.shape[-1]
        m = input.numel() // n
        return create_fp4_output_tensors(m, n, input.device, is_sf_swizzled_layout)

Atenção à guardahasattr: só quando a plataforma realmente registou_C::scaled_fp4_quanté que a implementação fake é definida. Isto garante que importar o módulo em CPU ou GPUs antigas não rebente por falta de operadores.

create_fp4_output_tensorsmostra detalhes do layout de memória da saída de quantização FP4📎 vllm/_custom_ops.py:69-87. Quandois_sf_swizzled_layout=True, o tensor de scale precisa de ser disposto em tiles 128x4 conforme exigido pelos Tensor Cores: o número de linhas arredondado para cima a múltiplo de 128, o número de colunas (n // 16) arredondado para cima a múltiplo de 4, e cada 4 float8_e4m3 empacotados num int32📎 vllm/_custom_ops.py:55-64. O comentário indica explicitamente que o kernel de quantização NVFP4 limpa explicitamente todas as entradas de scale de padding, pelo que não é necessário um kernel separado de inicialização a zero📎 vllm/_custom_ops.py:60-61。

Passo-a-passo: fluxo de chamada de um AWQ GEMM

Cenário: o modelo carregou pesos quantizados AWQ; na propagação forward é preciso fazer multiplicação matricial entre activações e pesos quantizados.

Primeiro passo, chama-seawq_gemm 📎 vllm/_custom_ops.py:587-592. A função verifica primeiro a variável de ambienteVLLM_USE_TRITON_AWQ. Se verdadeira, importa tardiamenteawq_gemm_tritone chama — este é um caminho de implementação puramente Triton, para plataformas que não suportam operadores CUDA ou para cenários de depuração.

Segundo passo, o caminho padrão chamatorch.ops._C.awq_gemm, passando input, qweight, scales, qzeros esplit_k_iters 📎 vllm/_custom_ops.py:598-598。

Terceiro passo, setorch.ops._C.awq_gemmExiste, a implementação fake está registrada📎 vllm/_custom_ops.py:601-616. A forma retornada pelo fake é(split_k_iters, num_in_feats, qweight.size(1) * 8)e então.sum(0)—isto simula com precisão a forma dos resultados intermédios do split-K e a forma final após a redução.qweight.size(1) * 8Vem do modo de empacotamento do AWQ: cada int32 armazena 8 pesos de 4 bits.

Quarto passo,awq_dequantizesegue um caminho semelhante📎 vllm/_custom_ops.py:553-559, mas a derivação de forma da implementação fake é diferente:out_c = qout_c * 8, porque após a desquantização o número de colunas expande 8 vezes📎 vllm/_custom_ops.py:587-592。

A função repack da série Marlin mostra outro padrão.gptq_marlin_repackA implementação fake de calculapack_factor = 32 // num_bits, a forma de saída é(size_k // 16, size_n * 16 // pack_factor) 📎 vllm/_custom_ops.py:1103-1119. Aqui16é o Marlin tile size,size_k // 16indica que a dimensão K é dividida por tile. A versão MoE degptq_marlin_moe_repackno nível Python percorre cada expert e chama o repack de expert único📎 vllm/_custom_ops.py:1154-1172, e afirmasize_k % 16 == 0—esta é uma restrição rígida do formato Marlin.

mermaid
flowchart LR
    input["input: torch.Tensor (FP16/BF16)"]
    qweight["qweight: torch.Tensor (INT32 packed)"]
    scales["scales: torch.Tensor"]
    qzeros["qzeros: torch.Tensor"]
    check_env{"VLLM_USE_TRITON_AWQ?"}
    triton_path["awq_gemm_triton(input, qweight, scales, qzeros, split_k_iters)"]
    cuda_path["torch.ops._C.awq_gemm(...)"]
    output["output: torch.Tensor (FP16/BF16)"]

    input --> check_env
    qweight --> check_env
    scales --> check_env
    qzeros --> check_env
    check_env -->|是| triton_path
    check_env -->|否| cuda_path
    triton_path --> output
    cuda_path --> output

Reflexões de design e armadilhas

A implementação fake deve ser completamente consistente com a forma de saída do operador real, caso contráriotorch.compileo grafo traçado por terá incompatibilidade de forma em tempo de execução.create_fp4_output_tensorsO comentário de enfatiza especialmente "Must match the C++ scaled_fp4_quant_func allocation exactly when padded_n is None"📎 vllm/_custom_ops.py:69-74. Este é um ponto propenso a erros: se o lado C++ alterar a lógica de alocação e o fake não sincronizar, o grafo compilado irá falhar durante a reprodução do CUDA Graph.

Outra armadilha étorch.library.custom_opa regra de alias de .safeFusedQuantizeNvO comentário de indica que torch 2.12+ não permite que a saída de operadores personalizados faça alias de qualquer entrada, portanto o autor alterou o tensor de retorno para um parâmetro in-place📎 vllm/_custom_ops.py:4650-4655. Esta abordagem de "alterar a forma da API para contornar limitações do framework" é muito comum na camada de adaptação de operadores, e ao investigar é necessário estar atento semutates_argsa declaração é consistente com o comportamento real.

CPUDNNLGEMMHandlermostra outro modo de gestão de recursos: o ponteiro do handler é armazenado num tensor int64,__del__ao chamarrelease_dnnl_matmul_handlerliberta📎 vllm/_custom_ops.py:3708-3717. Armazenar o ponteiro num tensor serve para evitar que seja eliminado pela otimização de inlining de inteiros do Python—esta é uma técnica clássica de bindings de baixo nível.

11.3 Agendamento de kernels Triton:KernelOverridee re-vinculação entre módulos

Modelo intuitivo

O papel do agendador de kernels Triton é como um sistema de substituição de funções numa empresa. Quando uma plataforma (por exemplo, ROCm) precisa de substituir os kernels Triton no núcleo do vLLM pela sua própria implementação, não pode alterar diretamente o código do núcleo—isso poluiria o upstream.dispatcherpermite que a plataforma registe um substituto e depois substitua silenciosamente todas as referências ao kernel original pelo substituto. Sem este mecanismo, cada plataforma teria de manter um fork, com conflitos constantes ao fazer merge das alterações do upstream.

Estruturas de dados e layout de memória

A estrutura de dados central é_registryo dicionário eKernelOverridea classe📎 vllm/triton_utils/dispatcher.py:29-36。

KernelOverrideos campos-chave de📎 vllm/triton_utils/dispatcher.py:50-61:

  • _impl: função de implementação da plataforma;
  • arg_names: tupla de nomes de parâmetros que espelha o kernel original, usada para vinculação por palavra-chave no launch;
  • constexprs: declarações constexpr herdadas do kernel original;
  • func: aponta para a função de implementação, para introspeção no warmup;
  • _forward_by_name: flag booleana que determina se no launch os parâmetros são encaminhados por palavra-chave ou por posição.

_forward_by_nameA lógica de cálculo de é: compararinspect.signature(impl).parameterscom o do kernel originalarg_namesse são completamente iguais📎 vllm/triton_utils/dispatcher.py:50-61. Se forem iguais, significa que os nomes de parâmetros da implementação são consistentes com o kernel, podendo encaminhar com segurança por palavra-chave; caso contrário, deve encaminhar por posição segundo a ordem de parâmetros do kernel original.

Step-by-Step: umaregister_kernelsre-vinculação de

Cenário: a plataforma ROCm ao inicializar chamaregister_kernels({"vllm.v1.sample.rejection_sampler.expand_kernel": my_expand_impl})。

Primeiro passo,register_kernelspercorre os overrides, para cada nome chama_resolve_kernel 📎 vllm/triton_utils/dispatcher.py:162-166。_resolve_kerneldivide o nome pelo último.em nome de módulo e nome de atributo📎 vllm/triton_utils/dispatcher.py:83-94. Se a primeira letra da última parte do nome do módulo for maiúscula, significa que o kernel pertence a uma classe (JIT warmup owner), sendo necessário importar primeiro o módulo pai e depoisgetattrobter a classe, retornando(类, 属性名); caso contrário, importa o próprio módulo, retornando(模块, 属性名)。

Segundo passo, após obter o objeto do kernel original, constróiKernelOverrideo wrapper, e regista em_registry 📎 vllm/triton_utils/dispatcher.py:167-169。

Terceiro passo,_rebind_kernelsexecuta uma varredura de todos os módulos📎 vllm/triton_utils/dispatcher.py:97-144. Percorresys.modulesde todos os módulos em__dict__, faz comparação de identidade para cada valor de atributo—atenção, éise não==, porque alguns valores de atributo (comoPlaceholderModulesentinela) ao fazer hash/eq disparam importações ou exceções📎 vllm/triton_utils/dispatcher.py:116-123。

Quarto passo, para atributos que correspondem ao kernel original, diretamentesetattrsubstitui por wrapper📎 vllm/triton_utils/dispatcher.py:125-135. Para JIT warmup owner (objetos cujo atributo de instânciakernelaponta para o kernel original), substituivalue.kernele limpa o cache de_kernel_arg_names, fazendo com que a vinculação do launch seja novamente derivada do wrapper📎 vllm/triton_utils/dispatcher.py:138-139。

Quinto passo,_rebind_kernelsapós concluir, só então substitui também o atributo no local de definição por wrapper📎 vllm/triton_utils/dispatcher.py:170-174. O comentário explica a importância da ordem: se substituir primeiro no local de definição, durante a varredura já não encontrará o kernel original📎 vllm/triton_utils/dispatcher.py:170-171。

mermaid
sequenceDiagram
    participant Platform as "ROCm 平台"
    participant Dispatcher as "register_kernels"
    participant Resolver as "_resolve_kernel"
    participant Scanner as "_rebind_kernels"
    participant Modules as "sys.modules"

    Platform->>Dispatcher: register_kernels({"vllm...expand_kernel": my_impl})
    Dispatcher->>Resolver: _resolve_kernel("vllm...expand_kernel")
    Resolver-->>Dispatcher: (module, "expand_kernel")
    Dispatcher->>Dispatcher: KernelOverride(original, my_impl)
    Dispatcher->>Scanner: _rebind_kernels([(original, wrapper)])
    Scanner->>Modules: 遍历所有模块 __dict__
    Modules-->>Scanner: 属性值列表
    Scanner->>Scanner: lookup(value) 身份比较
    Scanner->>Modules: setattr(module, attr, wrapper)
    Scanner->>Modules: value.kernel = wrapper (JIT owner)
    Scanner-->>Dispatcher: 重绑定完成
    Dispatcher->>Modules: setattr(host, attr, wrapper)
    Dispatcher-->>Platform: 注册完成

Reflexões de design e armadilhas

KernelOverride.__getitem__retornaself._launch, tornandokernel[grid](**kwargs)esta sintaxe padrão de launch Triton transparente para o wrapper📎 vllm/triton_utils/dispatcher.py:63-74。_launchA lógica de encaminhamento de divide-se em três casos📎 vllm/triton_utils/dispatcher.py:63-74: quando há argumentos posicionais, passa diretamente;_forward_by_namequando é verdadeiro, encaminha por palavra-chave; caso contrário, verifica se em kwargs há nomes de parâmetros que o kernel original não reconhece, se houver lançaRuntimeError, se não houver extrai os valores por ordem de parâmetros do kernel original e encaminha por posição.

EsteRuntimeErrorÉ uma defesa importante: se o nome do parâmetro implementado pela plataforma for inconsistente com o kernel, e o chamador passar um parâmetro que a implementação não reconhece, ignorá-lo silenciosamente levará a resultados errôneos difíceis de diagnosticar. O erro explícito expõe o problema já na fase de registro.

Uma armadilha em ambiente de produção:_rebind_kernelsA varredura de é O(número de módulos × número de atributos × número de kernels). Para modelos grandes,sys.modulespode haver milhares de módulos, cada um com centenas de atributos. Embora seja executado apenas uma vez na inicialização, se houver muitos kernels registrados, o tempo de inicialização aumentará significativamente.lookupA função usa varredura linear em vez de busca por hash, e o comentário explica o motivo — alguns valores de atributos não são hashable📎 vllm/triton_utils/dispatcher.py:116-123. Este é um trade-off típico de "correção antes de desempenho".

Outra armadilha:_resolve_kernelDetermina se é um atributo de classe pela "primeira letra do último segmento do nome do módulo em maiúscula"📎 vllm/triton_utils/dispatcher.py:83-94. Se o nome de um módulo começar com letra maiúscula (o que não segue a convenção de nomenclatura Python, mas é sintaticamente válido), será erroneamente classificado como classe. Este é um design de convenção sobre configuração, que depende das normas de nomenclatura internas do vLLM.

Reflexões de design

Os dois mecanismos de configuração de quantização e registro de operadores juntos formam a superfície de ajuste "precisão-desempenho" do vLLM.QuantizationConfigArgsO design de reflete a separação entre "intenção do usuário" e "valor padrão do método":NoneNão é "não quantizar", mas "deixar a própria classe do método decidir". Essa decisão adiada permite que a mesma configuração se adapte tanto à quantização de checkpoint quanto à quantização online.

_custom_opsO padrão de implementação fake de é otorch.compilepadrão do ecossistema, mas a singularidade do vLLM está nohasattruso generalizado de guardas. Isso permite que o mesmo módulo seja importado em CUDA, ROCm, CPU, XPU sem quebrar, ao custo de que cada operador precisa de três partes de código: wrapper Python, implementação fake e guarda de plataforma.

A religação cross-module do dispatcher Triton é uma solução agressiva. Ela não depende de import hooks do Python ou de__getattr__, mas escaneia e substitui diretamente todas as referências. A vantagem dessa abordagem é ser completa — não importa para quantos lugares o kernel sejafrom mod import kernelcopiado, ele será substituído; a desvantagem é ser frágil — qualquer nova forma de manter uma referência ao kernel (como captura por closure) pode escapar da varredura.

Resumo do capítulo

Reflexões e autoavaliação do capítulo

Q1: Emresolve_quantization_config, se removermos o_DEFERRED_ONLINE_SHORTHANDSbranch (ou seja, quandoquantization in _DEFERRED_ONLINE_SHORTHANDSretornabaseem vez deNone), o que acontece ao carregar um modelo que trazquant_method: "mxfp4"no checkpoint e o usuário passa apenas--quantization mxfp4?

Análise de referência:_DEFERRED_ONLINE_SHORTHANDSA intenção de design de é dar prioridade ao método de quantização do checkpoint📎 vllm/config/quantization.py:233-235. Se removermos esse branch,mxfp4cairá em_ONLINE_SHORTHANDSe retornarábase(ou seja,QuantSpec(weight=kMxfp4Static))📎 vllm/config/quantization.py:198-210. Nesse caso, a configuração de quantização online sobrescreverá o método de quantização do checkpoint, mas os pesos do checkpoint estão armazenados no formatomxfp4— se okMxfp4Staticda configuração online não for completamente consistente com o formato real do checkpoint (por exemplo, layout de scale diferente), o carregamento dos pesos falhará ou produzirá resultados errados. Um caso mais sutil: omxfp4do checkpoint pode usar um group size ou scale dtype diferente, e os valores padrão da configuração online não corresponderem, causando degradação da precisão de inferência sem erro.

Q2: KernelOverride._launchEm, se_forward_by_nameforFalsee o chamador passar kwargs contendo um nome de parâmetro que o kernel original não reconhece, o código lançaráRuntimeError. Se removermos essa verificação e passarmos a ignorar silenciosamente parâmetros desconhecidos, em que cenários isso causaria problemas difíceis de diagnosticar?

Análise de referência:_forward_by_nameSerFalsesignifica que os nomes de parâmetros da implementação da plataforma são inconsistentes com o kernel original, e é necessário encaminhar por posição📎 vllm/triton_utils/dispatcher.py:50-61. Se o chamador passar um parâmetro que o kernel original não reconhece (por exemplo, um novo parâmetro opcional adicionado upstream), ignorá-lo silenciosamente fará com que o valor desse parâmetro seja perdido. No cenário de kernel Triton, isso geralmente significa que algum constexpr ou dimensão de grid não foi passado, e o kernel pode iniciar com valores padrão — o resultado pode ser um cálculo errado em vez de um crash. Como resultados errados de kernels Triton geralmente se manifestam como desvios numéricos e não como exceções, o diagnóstico é extremamente difícil. ORuntimeErrorexplícito expõe o problema já no primeiro launch📎 vllm/triton_utils/dispatcher.py:63-74。

Q3: _rebind_kernelsApós substituir a propriedadekerneldo owner de JIT warmup, será executadovalue.__dict__.pop("_kernel_arg_names", None). Se removermos esta linha, em que circunstâncias ocorrerá erro de binding no launch?

Análise de referência: O owner de JIT warmup armazena em cache_kernel_arg_names, usado no launch para vincular kwargs aos parâmetros do kernel📎 vllm/triton_utils/dispatcher.py:138-139. Após substituirkernelpelo wrapper, oarg_namesdo wrapper pode ser diferente do kernel original (se os nomes de parâmetros da implementação da plataforma forem diferentes, oarg_namesdo wrapper ainda espelha o kernel original, mas_forward_by_namepode serFalse). Se o cache não for limpo, o mecanismo de warmup continuará usando a lista antiga de nomes de parâmetros para binding, enquanto a lógica de launch do wrapper pode esperar um modo de binding diferente. Especificamente,KernelOverride._launchquando_forward_by_nameéFalse, extrai valores na ordem deself.arg_names, e se o📎 vllm/triton_utils/dispatcher.py:79-80em cache for inconsistente com o_kernel_arg_namesdo wrapper, a ordem dos parâmetros extraídos ficará incorreta, fazendo o kernel receber valores de parâmetros errados.arg_namesO próximo capítulo abordará recursos avançados de inferência, vendo como o prefix caching reutiliza KV blocks, como a decodificação especulativa acelera modelos grandes com modelos pequenos, e como o LoRA alterna adaptadores dinamicamente sem alterar os pesos da base.

下一章将转向高级推理特性,看前缀缓存如何复用 KV block、投机解码如何用小模型加速大模型、以及 LoRA 如何在不改基座权重的前提下动态切换适配器。

Este capítulo analisa a infraestrutura de duas camadas do vLLM para quantização e kernels personalizados. A primeira camada é a análise da configuração de quantização: QuantSpec e QuantizationConfigArgs unificam e normalizam strings de CLI, metadados de checkpoint e sobrescritas por camada em QuantKey; resolve_quantization_config lida com expansão de abreviações e mesclagem de campos; _DEFERRED_ONLINE_SHORTHANDS resolve cenários de conflito de nomes. A segunda camada é a adaptação de operadores: _custom_ops implementa o registro de operadores multiplataforma por meio de guardas hasattr e register_fake; a implementação fake espelha com precisão as formas de saída dos operadores reais para suportar torch.compile; o dispatcher implementa a substituição de plataforma de kernels Triton por meio de KernelOverride e varredura de módulo completo. Juntas, ambas sustentam a concretização dos ganhos de quantização desde o carregamento de pesos até a computação forward. A seguir, passaremos aos recursos avançados de inferência que aumentam a vazão e reduzem a latência: como o cache automático de prefixos reutiliza KVs entre requisições, como a decodificação especulativa acelera a geração com um modelo draft e como o LoRA alterna adaptadores dinamicamente.

CHAPTER 12

Capítulo 12: Recursos avançados de inferência: cache de prefixos, decodificação especulativa e LoRA

Projeto: vllm-project/vllm · Progresso do livro: Capítulo 12 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, aprofundamo-nos no sistema de quantização e na infraestrutura de operadores personalizados do vLLM, vimos como as configurações de quantização são analisadas e como o kernel correspondente é selecionado, e como esquemas como FP8, INT4, AWQ e GPTQ concluem a conversão no carregamento de pesos. Ao mesmo tempo, investigamos como _custom_ops registra operadores CUDA, o mecanismo de despacho de kernels Triton e como kernels fundidos de MoE reduzem o tráfego de memória de vídeo. Essas capacidades de baixo nível abriram caminho para otimizações de inferência mais avançadas. Este capítulo focará em três recursos avançados de inferência do vLLM: cache automático de prefixos (APC), decodificação especulativa e LoRA. Embora pareçam independentes, na prática compartilham o mesmo conjunto de infraestrutura de baixo nível — o hash de blocos KV, a alocação de slots pelo scheduler e a injeção dinâmica de pesos na execução do modelo. A chave para entendê-los é compreender como eles levam a "reutilização" ao extremo sem quebrar a semântica de paginação do PagedAttention.

12.1 Cache de prefixos: como o block hash fingerprinta um prefixo

Modelo intuitivo

O cache de prefixos é como um "caderno de trechos públicos" de uma biblioteca: dois alunos escrevem redações e ambos citam o mesmo trecho de texto clássico no início; o professor só precisa corrigir esse trecho uma vez, e depois avalia separadamente as partes diferentes de cada um. Sem ele, cada requisição precisaria fazer prefill de todo o prompt desde o início, e em cenários de perguntas e respostas sobre documentos longos a capacidade computacional seria consumida repetidamente várias vezes.

Estrutura de dados: mapeamento de tokens para block hash

O núcleo do cache de prefixos é "como determinar que os prefixos de duas requisições são iguais". A resposta do vLLM é: dividir a sequência de tokens em blocos e calcular um hash encadeado para cada bloco. Encadeado significa que o hash do N-ésimo bloco contém o hash dos N-1 blocos anteriores; portanto, um block hash fingerprinta de forma única todo o prefixo "do início da sequência até o fim desse bloco".

O portador do hash éBlockHash, que é definido comobytesdeNewType, e não comobytespuro, com o objetivo de evitar no nível de tipo o uso indevido de📎 vllm/v1/core/kv_cache_utils.py:59-62. Quando é necessário combinar o block hash com o KV cache group id para formar uma chave de dicionário, o vLLM não usa tuplas, mas concatena diretamente o group id de 4 bytes em big-endian ao final dos bytes do hash📎 vllm/v1/core/kv_cache_utils.py:75-76:

python
def make_block_hash_with_group_id(block_hash, group_id):
    return BlockHashWithGroupId(block_hash + group_id.to_bytes(4, "big", signed=False))
〔Inferência de design e trade-offs arquiteturais〕

Esta é uma otimização típica para "evitar alocação de tuplas": no caminho quente, cada busca de bloco precisa construir uma chave; tuplas trazem alocação extra de objetos Python e custo de hash, enquanto a concatenação de byte strings é feita na camada C, e a própria byte string já é hashable. Na recuperação, usa-se slicingkey[:-4]eint.from_bytes(key[-4:])para restaurar📎 vllm/v1/core/kv_cache_utils.py:87-89。

A própria função de hash é assumida porhash_block_tokens, que alimenta a função de hash com o hash do bloco pai, a tupla de token ids do bloco atual e chaves adicionais📎 vllm/v1/core/kv_cache_utils.py:650-680. Observe que o hash pai do primeiro bloco não éNone, mas o globalNONE_HASH:

python
if not parent_block_hash:
    parent_block_hash = NONE_HASH

📎 vllm/v1/core/kv_cache_utils.py:674-675。NONE_HASHA escolha da semente de esconde um design de segurança: para hashes criptográficos como SHA-256, a semente é fixa"vllm-none-hash", fazendo com que diferentes processos do vLLM calculem o mesmo hash para o mesmo conteúdo, permitindo assim compartilhar o cache de prefixos entre nós; já para hashes não criptográficos como xxhash, a semente é aleatória por processo, porque uma semente previsível permitiria a um atacante pré-calcular offline blocos de colisão📎 vllm/v1/core/kv_cache_utils.py:105-126。resolve_none_hash_seedimplementa essa bifurcação:PYTHONHASHSEEDvariável de ambiente tem prioridade; caso contrário, hashes criptográficos usam semente fixa e hashes não criptográficos usamos.urandom(32) 📎 vllm/v1/core/kv_cache_utils.py:132-145。

Orientado por cenário: cálculo de block hash de uma requisição

Suponha que uma requisição chegue com 128 tokens e o block size seja 16.get_request_block_hasherA closure retornada é responsável pelo cálculo incremental📎 vllm/v1/core/kv_cache_utils.py:802-861:

Primeiro passo: determinar de onde começar o cálculo.start_token_idx = len(request.block_hashes) * hash_block_size 📎 vllm/v1/core/kv_cache_utils.py:812-812, ou seja, o número de blocks já calculados multiplicado pelo tamanho do block. Se os tokens restantes não forem suficientes para um block, retorna vazio diretamente📎 vllm/v1/core/kv_cache_utils.py:812-812。

Segundo passo: tratar o deslocamento multimodal. Se a posição inicial cair dentro de alguma entrada multimodal, é preciso usarget_mm_features_in_windowpara reposicionarcurr_mm_idx 📎 vllm/v1/core/kv_cache_utils.py:823-832. Isso porque o placeholder token da entrada multimodal em si não carrega semântica; é necessário incorporar o identificador de feature mm e seu deslocamento dentro do block como chaves extras no hash.

Terceiro passo: calcular cada block em loop.generate_block_hash_extra_keysColetar todas as chaves extras📎 vllm/v1/core/kv_cache_utils.py:611-647, incluindo nome do LoRA, chave multimodal, cache salt, hash de prompt embeds. O cache salt só tem efeito no primeiro block📎 vllm/v1/core/kv_cache_utils.py:633-635, e isso é intencional: o papel do salt é isolar todo o namespace de cache, bastando injetá-lo uma vez no início da cadeia.

Quarto passo:hash_block_tokensfazer o hash do parent hash, da tupla de tokens e das chaves extras juntos, e o resultado é usado como parent hash do próximo block📎 vllm/v1/core/kv_cache_utils.py:851-857. A estrutura encadeada se forma assim.

Conversão de granularidade entre múltiplos block sizes

Quando o modelo tem múltiplos KV cache groups com block sizes diferentes, a granularidade do hash e a granularidade do block do group podem ser inconsistentes.BlockHashListWithBlockSizeresolve esse problema: ele não recalcula o hash, mas aproveita a propriedade do hash encadeado — o hash de um target block é o hash do último hash block dentro dele📎 vllm/v1/core/kv_cache_utils.py:2781-2851. Por exemplo, quando o hash block é 16 e o target block é 32, o hash dos tokens 0-31 é o segundo hash de tamanho 16 (ele já cobre 0-31 de forma encadeada)📎 vllm/v1/core/kv_cache_utils.py:2794-2806。_get_value_atA implementação éself.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851。

mermaid
flowchart TD
    req["Request 到达"] --> check{"剩余 token >= hash_block_size?"}
    check -->|否| empty["返回空列表"]
    check -->|是| mm{"起始位置在多模态窗口内?"}
    mm -->|是| reloc["get_mm_features_in_window 重定位 curr_mm_idx"]
    mm -->|否| extra
    reloc --> extra["generate_block_hash_extra_keys 收集 LoRA/MM/salt/embeds 键"]
    extra --> hash["hash_block_tokens 链式哈希"]
    hash --> append["追加到 new_block_hashes"]
    append --> advance["start_token_idx += hash_block_size"]
    advance --> check

Reflexões de design e armadilhas

Por que usar hash encadeado em vez de hash independente?O hash independente não consegue distinguir o caso em que "o mesmo block aparece em posições de prefixo diferentes". O hash encadeado faz com que o block hash seja uma impressão digital única de todo o prefixo, e é exatamente isso quefind_longest_cache_hitpermite reutilizar KV com segurança.

Armadilha entre processos de hash não criptográfico.Se usar xxhash e não definirPYTHONHASHSEED, oNONE_HASHde cada processo será diferente, fazendo com que o cache de prefixo entre instâncias falhe completamente.init_none_hashimprimirá um aviso📎 vllm/v1/core/kv_cache_utils.py:161-169. Em ambiente de produção, se forem implantadas múltiplas instâncias compartilhando cache, é obrigatório definir explicitamentePYTHONHASHSEEDou trocar para sha256.

As sutilezas do deslocamento multimodal. _gen_mm_extra_hash_keysUsar(mm_identifier, offset - start_token_idx)como chave extra📎 vllm/v1/core/kv_cache_utils.py:552. O deslocamento é relativo ao início do block, de modo que o mesmo item mm, ao aparecer em posições de block diferentes, gera hashes diferentes, evitando falsos acertos.

12.2 Decodificação especulativa: a colaboração entre rascunho e verificação

Modelo intuitivo

A decodificação especulativa é como uma secretária que primeiro redige algumas versões de resposta para o chefe, e o chefe só precisa marcar rapidamente qual versão serve. O modelo de rascunho (drafter) prevê múltiplos tokens candidatos com custo extremamente baixo, e o modelo alvo (target) verifica esses candidatos em paralelo em uma única passada forward, aceitando a parte que coincide. Sem isso, o modelo alvo só poderia gerar token por token em série, e a utilização da GPU na fase de decode seria extremamente baixa.

Estrutura de dados: anotação do EAGLE group

O problema central da decodificação especulativa no gerenciamento de KV cache é: como as camadas KV do modelo de rascunho e as camadas KV do modelo alvo são agrupadas?_annotate_eagle_groupsUsa duas regras para identificar o grupo de rascunho📎 vllm/v1/core/kv_cache_utils.py:2134-2189:

Regra um, orientada por spec:non_causal_multi_token_decodeA flag é declarada emMLAAttentionSpec, definida pela camada de atenção de rascunho que executa decode multi-token não causal, e consegue sobreviver à operaçãomerge📎 vllm/v1/core/kv_cache_utils.py:2175-2177。

Regra dois, fallback por posição: drafters MTP (como DeepseekV4/V4.1 DSpark) reutilizam as próprias camadas decoder do modelo alvo, sem marcação em spec, mas suas camadas de atenção de rascunho sempre são registradas depois de todas as camadas alvo; portanto, anota o group que contém a última camada registrada📎 vllm/v1/core/kv_cache_utils.py:2183-2184. Essa regra só tem efeito quando o group divide exatamentekv_cache_spectodas as camadas📎 vllm/v1/core/kv_cache_utils.py:2183-2184。

Orientado por cenário: alocação de KV na decodificação especulativa

Quandospeculative_configestá habilitado euse_eagle_block_drop()é verdadeiro,_annotate_eagle_groupsé chamado📎 vllm/v1/core/kv_cache_utils.py:2175-2177. O resultado da anotaçãois_eagle_groupafeta a estratégia subsequente de alocação de blocks — os blocks do grupo de rascunho podem ser descartados após a verificação.

No caminho principal deget_kv_cache_groups, a anotação ocorre após o agrupamento📎 vllm/v1/core/kv_cache_utils.py:2364-2365. Se nenhum group for anotado como grupo de rascunho,_warn_if_unannotated_eagle_mambaemitirá um aviso📎 vllm/v1/core/kv_cache_utils.py:2192-2222。

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Drafter as 草稿模型
    participant Target as 目标模型
    participant KV as KV Cache Manager
    Sched->>Drafter: 请求生成 k 个候选 token
    Drafter->>KV: 分配草稿组 block (is_eagle_group=True)
    Drafter-->>Sched: 返回候选 token 序列
    Sched->>Target: 并行验证候选 (一次前向)
    Target->>KV: 读取目标组 block
    Target-->>Sched: 返回接受/拒绝掩码
    Sched->>KV: 丢弃被拒绝的草稿 block

Reflexões de design e armadilhas

Por que o grupo de rascunho precisa de anotação separada?Os tokens gerados pelo modelo de rascunho podem ser rejeitados após a verificação, e o KV correspondente precisa ser descartado. Se o KV de rascunho e o KV alvo estiverem misturados no mesmo group, a operação de descarte afetaria erroneamente o KV alvo. A anotação permite que o scheduler faça a recuperação com precisão.

A fragilidade da regra de fallback por posição.A regra dois depende da convenção de que "a camada de rascunho é registrada por último"; o comentário marca explicitamente isso como hacky check e deixa um FIXME📎 vllm/v1/core/kv_cache_utils.py:2158-2159. Quando o cache final do rascunho abrange múltiplos groups, essa regra só anota o group que contém a última camada, e precisa ser generalizada.

Restrições adicionais do modelo Mamba.Se a decodificação especulativa estiver ativada mas nenhum grupo for reconhecido como grupo de rascunho, e existir um grupo Mamba, será disparado um aviso📎 vllm/v1/core/kv_cache_utils.py:2211-2213. Isso geralmente significa que o spec da camada de rascunho não pode ser distinguido da camada alvo, sendo necessário verificar a ordem de registro do modelo.

12.3 LoRA: adaptadores dinâmicos sem recarregar a base

Modelo intuitivo

LoRA é como trocar a capinha de um mesmo celular: o corpo do celular (modelo base) permanece o mesmo, e ao trocar a capinha (adaptador) ele se torna um estilo diferente. Sem isso, cada tarefa de fine-tuning precisaria carregar um conjunto completo de pesos, e a memória de vídeo não suportaria.

Estrutura de dados: cache LRU duplo e array de slots

LoRAModelManagerUsa dois caches LRU para gerenciar o ciclo de vida dos adaptadores📎 vllm/lora/model_manager.py:115-120:

python
self._registered_adapters: AdapterLRUCache[LoRAModel] = AdapterLRUCache(
    self.capacity, self.deactivate_adapter
)
self._active_adapters: AdapterLRUCache[None] = AdapterLRUCache(
    self.lora_slots, self._deactivate_adapter
)

capacityé o número total de adaptadores que podem ser cacheados no lado da CPU (max_cpu_loras)📎 vllm/lora/model_manager.py:340-342,lora_slotsé o número de adaptadores que podem ser ativados simultaneamente no lado da GPU (max_loras)📎 vllm/lora/model_manager.py:345-346。_registered_adaptersquando removido dispara o callbackdeactivate_adaptercallback📎 vllm/lora/model_manager.py:71-74, garantindo que, quando o cache da CPU é eliminado, a cópia na GPU também seja limpa.

lora_index_to_idé um array de comprimentolora_slotsque mapeia índices de slot da GPU para ids de adaptador📎 vllm/lora/model_manager.py:122. Este array é o índice central usado pelo punica wrapper ao fazer cálculo em lote de LoRA.

Orientado a cenários: ativação de adaptador

Quando uma requisição chega com um adaptador LoRA,activate_adapteré chamado📎 vllm/lora/model_manager.py:352-409:

Primeiro passo, verifica se já está ativado; se sim, retorna diretamente📎 vllm/lora/model_manager.py:352-354。

Segundo passo, procura um slot livre. Percorrelora_index_to_idencontra o primeiroNone 📎 vllm/lora/model_manager.py:362-362. Se não houver slot livre, lançaValueError("No free lora slots") 📎 vllm/lora/model_manager.py:368-368。

Terceiro passo, atualiza o estado e percorre todos os módulos já empacotados, chamandomodule.set_lora(index, lora_a, lora_b)para copiar os pesos para o stacked buffer da GPU📎 vllm/lora/model_manager.py:377-401. Se algum módulo não tiver pesos LoRA correspondentes, chamareset_lora(index)para zerar📎 vllm/lora/model_manager.py:378-385。

Quarto passo, se nenhum peso foi aplicado, imprime um log de depuração único📎 vllm/lora/model_manager.py:411-416. Isso é comportamento esperado sob paralelismo de pipeline ou paralelismo de especialistas — alguns ranks não possuem as camadas adaptadas.

Empacotamento de módulos: de nn.Linear para BaseLayerWithLoRA

_create_lora_modulesPercorre todos os módulos nomeados do modelo📎 vllm/lora/model_manager.py:462-606. Lógica principal:

  • PulaPPMissingLayer 📎 vllm/lora/model_manager.py:473-474。
  • Filtra com base emtarget_modules: se não especificado, usais_supported_lora_modulepara julgar; caso contrário, usa_match_target_modules 📎 vllm/lora/model_manager.py:479-493。
  • Trata módulos alias: o mesmo módulo subjacente pode ser acessado por múltiplos caminhos (por exemplo, o gate do MoE está tanto no block quanto no runner). Nesse caso, redireciona o atributo alias para o mesmo wrapper, mas não registra novamente, caso contrárioactivate_adapterchamaráreset_lorano alias, limpando os pesos recém-definidos📎 vllm/lora/model_manager.py:512-527。
  • Usafrom_layerpara criar o wrapper e substituir o módulo original📎 vllm/lora/model_manager.py:546-553。

Reflexões de design e armadilhas

Mudanças no layout de slots disparam atualização de mapeamento. set_adapter_mappingnão apenas compara se o mapping mudou, mas também comparalora_index_to_ido snapshot da tupla📎 vllm/lora/model_manager.py:1323-1331. O motivo está claramente comentado: umadd_lora()fora de banda pode disparar eliminação LRU e realocar slots, enquanto o batch em execução e seu mapping não mudaram📎 vllm/lora/model_manager.py:1323-1331. Se olhar apenas o mapping, o punica metadata usará um layout de slot desatualizado.

Fatiamento EP do MoE.Quando o paralelismo de especialistas está ativado, o checkpoint contém os pesos de todos os especialistas globais, mas cada rank possui apenaslocal_num_experts._stack_moe_lora_weightsprimeiro fazglobal_num_expertsreshape, depois fatia[expert_start:expert_end] 📎 vllm/lora/model_manager.py:966-977. Quando não é EP, o fatiamento é no-op.

Momento do pin_memory.O empacotamento de pesos (comopack_moe) pode invalidar a alocação de pin_memory, portanto pin_memory é executado após a fusão de todos os pesos📎 vllm/lora/model_manager.py:916-934. O comentário aponta explicitamente dois motivos: modelos MoE têm grande quantidade de pesos LoRA, e fazer pin cedo tem custo significativo; o empacotamento pode invalidar a alocação📎 vllm/lora/model_manager.py:916-921。

Reflexão de design: o ponto de sinergia dos três

As três características convergem na camada de gerenciamento do KV cache. O cache de prefixo reutiliza KV via block hash; a decodificação especulativa usais_eagle_grouppara marcar e distinguir KV de rascunho; LoRA usa_gen_lora_extra_hash_keyspara misturar o nome do adaptador no block hash📎 vllm/v1/core/kv_cache_utils.py:568-581, garantindo que sequências de tokens idênticas com adaptadores diferentes não colidam erroneamente com o KV um do outro.

generate_block_hash_extra_keyscoloca a chave LoRA no início da lista de chaves extras📎 vllm/v1/core/kv_cache_utils.py:640-642, junto com chaves multimodais, cache salt e prompt embeds, formando a entrada completa de hash. Isso garante que: mesmo que dois requests tenham tokens completamente idênticos, desde que os adaptadores LoRA sejam diferentes, seus block hashes serão diferentes, e o KV não será compartilhado indevidamente.

Resumo do capítulo

Reflexões e autoavaliação do capítulo

Q1: Se removerinit_none_hasha lógica de semente aleatória do hash não criptográfico, mudando para sempre usar semente fixa, em quais cenários isso introduziria risco de segurança? Por que o comentário do código-fonte enfatiza especialmente que xxhash precisa de semente secreta?

Análise de referência: O código-fonte em_NON_CRYPTO_HASH_FUNCTIONSlista explicitamente xxhash e xxhash_cbor como algoritmos não resistentes a colisão📎 vllm/v1/core/kv_cache_utils.py:125-126。resolve_none_hash_seedpara tais algoritmos retornaos.urandom(32).hex() 📎 vllm/v1/core/kv_cache_utils.py:143-144. Se mudar para semente fixa, um atacante pode pré-calcular offline blocos que colidem com o prefixo alvo, construir requests com o mesmo hash mas conteúdo diferente, e assim acessar e ler o KV cache de outros — isso é vazamento de informação entre requests. A resistência a colisão do SHA-256 não depende do sigilo da semente, então semente fixa afeta apenas a reprodutibilidade, não a segurança📎 vllm/v1/core/kv_cache_utils.py:97-111。

Q2: _create_lora_modulesao tratar módulos alias, se remover a lógica de "não registrar novamente" e chamar diretamenteregister_moduletambém no alias, emactivate_adaptero que acontece? Por favor, analise em conjunto comreset_lorao caminho de chamada de

Análise de referência:activate_adapterpercorreself.modulese para cada módulo chamaset_loraoureset_lora 📎 vllm/lora/model_manager.py:377-401. Se tanto o alias quanto o nome canônico estiverem registrados, o mesmo wrapper subjacente será acessado duas vezes. No caminho do nome canônico,_get_lora_layer_weightsconsegue encontrar os pesos e chamaset_lorapara gravar; no caminho do alias, devido à incompatibilidade de nomes,_get_lora_layer_weightsretorna None, disparandoreset_lora(index) 📎 vllm/lora/model_manager.py:378-385, que zera os pesos recém-gravados. Os comentários do código-fonte apontam explicitamente essa armadilha📎 vllm/lora/model_manager.py:519-523. A abordagem correta é redirecionar o atributo de alias para o mesmo wrapper, mas sem registrar novamente📎 vllm/lora/model_manager.py:531-537。

Q3: BlockHashListWithBlockSizedepende da propriedade de que «o hash do target block é igual ao hash do seu último hash block interno». Se a função de hash não for encadeada (ou seja, cada block é hasheado independentemente), essa classe ainda funcionaria corretamente? Em que circunstâncias ocorreriam falsos acertos de cache?

Análise de referência: Não._get_value_atretorna diretamenteself.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851, e a premissa dessa implementação é que o hash do último hash block já cobre encadeadamente todos os tokens anteriores a ele. Se o hash for independente, esse valor apenas fingerprinta o conteúdo do último hash block, e não o target block inteiro. Dois target blocks podem diferir na primeira metade, mas ter o mesmo último hash block, causando colisão de hash,find_longest_cache_hitreutilizaria incorretamente KVs incompatíveis. Os comentários do código-fonte afirmam explicitamente que «Each hash_block_size hash is already chained over its entire prefix»📎 vllm/v1/core/kv_cache_utils.py:2787-2792。

O próximo capítulo abordará o sistema de plugins e a extensibilidade, mostrando como o vLLM oferece suporte a formas diversificadas de implantação por meio de abstração de plataforma, processadores de IO e extensões de endpoints.

Este capítulo analisou os mecanismos subjacentes das três principais características avançadas de inferência do vLLM. O núcleo do cache de prefixo é o hash encadeado de blocks: hash_block_tokens faz o hash conjunto do hash pai, da tupla de tokens e de chaves extras, e a estratégia de seed do NONE_HASH equilibra compartilhamento entre processos e segurança contra colisões. A decodificação especulativa distingue grupos de KV de rascunho por meio da anotação is_eagle_group. O LoRA gerencia o ciclo de vida dos adaptadores com cache LRU duplo e array de slots, e mistura o nome do adaptador no block hash para isolar o cache. Essas características, em conjunto, demonstram a profundidade e a flexibilidade do vLLM na otimização de inferência. A seguir, voltamo-nos ao sistema de plugins e à extensibilidade do vLLM, para ver como plugins de plataforma se adaptam a novos hardwares, como plugins de IO processor intervêm no processamento de entradas multimodais e como plugins de endpoint injetam rotas de API personalizadas. Entender a ordem de carregamento do registro e da descoberta de plugins revelará como estender as capacidades do vLLM sem modificar o código central.

CHAPTER 13

Capítulo 13: Sistema de plugins e extensibilidade: plataformas, processadores de IO e extensões de endpoints

Projeto: vllm-project/vllm · Progresso do livro: Capítulo 13 / 14 · Status de verificação: linhas FACT ancoradas em localizações reais

No capítulo anterior, vimos que recursos avançados como cache de prefixo, decodificação especulativa e LoRA estão profundamente acoplados ao caminho central do escalonador, do gerenciamento de KV e da execução do modelo. Mas, para um motor de inferência realmente chegar à produção, desempenho por si só não basta — ele precisa responder a uma pergunta mais espinhosa: quando a comunidade quer integrar um novo hardware, um novo formato de entrada multimodal ou uma rota HTTP personalizada, como fazer isso sem fork do código central? É exatamente aí que reside o sentido do sistema de plugins. A arquitetura do vLLM é naturalmente multiprocesso: o processo frontend do API Server, o processo EngineCore e o processo Worker correspondente a cada rank de TP/PP. Se o mecanismo de plugins simplesmente «executasse um trecho de código no import», ele ou seria executado repetidamente em cada processo, acumulando efeitos colaterais, ou seria executado apenas no processo principal, fazendo com que os Workers não recebessem a extensão. O que este capítulo disseca é como o vLLM usa o mecanismo padrão de entry_points do Python, combinado com a tripla restrição de grupo (group) + fronteira de processo + momento de carregamento, para construir um sistema de plugins capaz de cobrir todos os processos e, ao mesmo tempo, controlar com precisão a superfície exposta. Focamos em três linhas principais: plugins de plataforma (adaptação a novo hardware), plugins de IO processor (intervenção no processamento de entrada multimodal) e plugins de endpoint (injeção de rotas de API personalizadas). As estratégias de carregamento dos três são completamente diferentes; entender essa diferença significa entender a filosofia de trade-off do vLLM entre «capacidade de extensão» e «fronteira de segurança».

I. Descoberta e carregamento de plugins: o contrato de agrupamento de entry_points

Modelo intuitivo: os «canais de broadcast» dos plugins

Imagine o sistema de plugins do vLLM como um conjunto de canais de broadcast. Cada pacote de plugin, ao ser instalado, por meio desetup.pydoentry_points«registra» em algum canal seu indicativo (plugin name) e sua função de resposta (plugin value). O vLLM escaneia esses canais na inicialização e decide quais canais serão «sintonizados» em quais processos.

Sem esse mecanismo, estender o vLLM só seria possível alterando o código-fonte — a cada novo hardware adicionado pela comunidade, seria necessário manter um fork, resultando em fragmentação de versões. O valor do mecanismo de agrupamento está em:O mesmo pacote de plugin pode ser registrado apenas em um canal específico, ficando restrito ao carregamento em processos específicos。

Estrutura de dados: cinco constantes de agrupamento e flag global

O vLLM emvllm/plugins/__init__.pydefine no topo cinco constantes de entry point group, cada constante correspondendo a uma estratégia de carregamento:

📎 vllm/plugins/__init__.py:16-30

python
DEFAULT_PLUGINS_GROUP = "vllm.general_plugins"
IO_PROCESSOR_PLUGINS_GROUP = "vllm.io_processor_plugins"
PLATFORM_PLUGINS_GROUP = "vllm.platform_plugins"
STAT_LOGGER_PLUGINS_GROUP = "vllm.stat_logger_plugins"
ENDPOINT_PLUGINS_GROUP = "vllm.endpoint_plugins"

Os comentários escondem informações cruciais:DEFAULT_PLUGINS_GROUPemTodos os processoscarregam (process0, engine core, worker);IO_PROCESSOR_PLUGINS_GROUP apenas no process0;PLATFORM_PLUGINS_GROUPcarrega em todos os processos, mas o momento de disparo écurrent_platformna primeira vez que é acessado;STAT_LOGGER_PLUGINS_GROUPapenas no process0 e em modo assíncrono;ENDPOINT_PLUGINS_GROUPapenas no processo frontend do API Server.

Em seguida há uma variável global de nível de móduloplugins_loaded = False 📎 vllm/plugins/__init__.py:32-33, que é a guarda de carregamento idempotente — o comentário afirma explicitamente "make sure one process only loads plugins once".

Step-by-Step: umaload_plugins_by_groupfluxo completo de chamada

Cenário: o usuário registrou emsetup.pyvllm.general_pluginssobregister_dummy_model, agora o vLLM inicia, algum processo chamaload_general_plugins()。

Primeiro passo: guarda de idempotência. load_general_pluginsprimeiro verificaplugins_loaded, se já forTrueretorna diretamente📎 vllm/plugins/__init__.py:77-90. Note uma sutileza aqui: a guarda é ativadaantesdo carregamento, o que significa que mesmo se o carregamento subsequente lançar exceção, não haverá retentativa. Isso é intencional — falhas no carregamento de plugins não devem fazer o processo tentar repetidamente.

Segundo passo: descoberta.entra emload_plugins_by_group, através deimportlib.metadata.entry_points(group=group)obtém todos os entry points instalados sob esse grupo📎 vllm/plugins/__init__.py:36-45. Se vazio, registra log de debug e retorna dicionário vazio.

Terceiro passo: nivelamento de logs.O código-fonte diferencia o nível de log entre grupos padrão e não padrão:is_default_groupquando verdadeiro usalogger.debug, caso contrário usalogger.info 📎 vllm/plugins/__init__.py:47-54. A motivação é bem prática —vllm.general_pluginsgeralmente contém muitos plugins de registro de modelos, usar INFO causaria poluição; já plugins de plataforma/endpoint são poucos e importantes, merecendo visibilidade INFO.

Quarto passo: filtragem por whitelist.lêenvs.VLLM_PLUGINS, se forNonecarrega todos, caso contrário carrega apenas plugins cujos nomes estão na lista📎 vllm/plugins/__init__.py:62-70. Note queplugin.load()está envolvido em try/except, falha no carregamento de um único plugin apenas registra log de exception, sem afetar outros plugins📎 vllm/plugins/__init__.py:68-72。

Quinto passo: execução.retorna aload_general_plugins, para cada função carregada chama diretamentefunc() 📎 vllm/plugins/__init__.py:77-90. É por isso que a documentação enfatiza que funções de plugin devem serreentrantes (re-entrant)— podem ser chamadas múltiplas vezes em múltiplos processos.

O fluxograma abaixo descreveload_plugins_by_groupo caminho completo de decisão de

mermaid
flowchart TD
    start["load_plugins_by_group(group)"] --> discover["entry_points(group=group)"]
    discover --> empty{"len(discovered) == 0?"}
    empty -->|是| ret_empty["返回 {}"]
    empty -->|否| log["按 is_default_group 选 log_level"]
    log --> loop["遍历 discovered_plugins"]
    loop --> check{"allowed_plugins is None<br/>或 plugin.name in allowed?"}
    check -->|否| skip["跳过该插件"]
    check -->|是| load["func = plugin.load()"]
    load --> load_ok{"加载成功?"}
    load_ok -->|否| log_exc["logger.exception 记录"]
    load_ok -->|是| add["plugins[name] = func"]
    skip --> next["下一个插件"]
    log_exc --> next
    add --> next
    next --> loop
    loop --> ret["返回 plugins 字典"]

Reflexão de design: por que usar entry_points em vez de arquivo de configuração

〔Inferência de design e trade-offs arquiteturais〕

Escolherentry_pointsem vez de arquivo de configuração personalizado, a motivação central épermitir que plugins sejam distribuídos junto com o pacote Python. Após o usuáriopip install vllm-add-dummy-platform, o plugin aparece automaticamente no grupo correspondente, sem necessidade de editar manualmente a configuração do vLLM. Isso segue a mesma linhagem do ecossistema de plugins de ferramentas como pytest e flake8. O custo é que a descoberta de plugins depende dos metadados do pacote; se o pacote de plugin não estiver completamente instalado (por exemplo, apenas o diretório de código-fonte copiado sem passar pelo pip), o entry_points não será encontrado.

---

II. Plugins de plataforma: camada de abstração para adaptação de hardware

Modelo intuitivo: a plataforma é o "tradutor de dialetos de hardware"

PlatformA classeé oúnico tradutorcurrent_platform.get_attn_backend_cls()、current_platform.is_cuda_alike()de toda a conversa entre o vLLM e o hardware. O código do modelo apenas chamaimport torch.cudamétodos abstratos comoif device == "xpu", nunca diretamente

. Sem essa camada de abstração, cada novo hardware suportado exigiria adicionar

Platformbranches no código do modelo, virando espaguete.vllm/platforms/interface.pyEstrutura de dados: layout de campos da classe base Platform📎 vllm/platforms/interface.py:135-179:

python
class Platform:
    _enum: PlatformEnum
    device_name: str
    device_type: str
    dispatch_key: str = "CPU"
    ray_device_key: str = ""
    device_control_env_var: str = "VLLM_DEVICE_CONTROL_ENV_VAR_PLACEHOLDER"
    ray_noset_device_env_vars: list[str] = []
    simple_compile_backend: str = "inductor"
    dist_backend: str = ""
    supported_quantization: list[str] = []
    additional_env_vars: list[str] = []
    _global_graph_pool: Any | None = None

_enuminícioPlatformEnumCopiaris_cuda()、is_rocm()é📎 vllm/platforms/interface.py:69-78。device_control_env_varvalor de enum, determinaCUDA_VISIBLE_DEVICESe outras verificações📎 vllm/platforms/interface.py:151-152。_global_graph_poolé a abstração de "variável de ambiente de visibilidade de dispositivo" independente de plataforma — CUDA éget_global_graph_pool, outras plataformas definem cada uma📎 vllm/platforms/interface.py:1210-1215。

é o cache de pool de memória de CUDA graph em nível de classe, inicializado preguiçosamente via__getattr__Vale notar📎 vllm/platforms/interface.py:1189-1208a lógica de fallback detorch.<device_type>: ao acessar um atributo inexistente em Platform, ele tenta encaminhar do namespacecurrent_platform.memory_allocated(). Isso permite que o código da plataforma escrevatorch.cuda.memory_allocated()mas na prática chame__getstate__. Porém o código-fonte exclui deliberadamente métodos dunder — caso contrário, a verificação de pickleNoneobteria📎 vllm/platforms/interface.py:1182-1185。

e tentaria chamá-lo

Step-by-Step: conversão de device ID entre três namespacesO ponto mais propenso a erros na abstração de plataforma éo namespace de device ID📎 vllm/platforms/interface.py:275-283:

  • logical. Os comentários do código-fonte listam explicitamente três_assigned_physical_gpu_ids
  • visible: local rank interno do vLLM, indexaCUDA_VISIBLE_DEVICES: número torch/CUDA do processo atual após remapeamento via
  • physical: ID global de GPU usado por APIs de topologia como NVML, não afetado por variáveis de ambiente

Cenário: um processo Worker recebeu a GPU física[4, 5], variável de ambienteCUDA_VISIBLE_DEVICES=4,5, agora é preciso converter local rank 0 paratorch.device("cuda:0")。

Primeiro passo: logical → physical. device_id_to_physical_device_id(0)primeiro consulta_assigned_physical_gpu_ids, se já definido, indexa e retorna diretamente4 📎 vllm/platforms/interface.py:296-297. Se não definido, extrai dodevice_control_env_vara lista separada por vírgulas e pega o item 0📎 vllm/platforms/interface.py:305-311. Note que o código-fonte deliberadamente tratastring vaziacomo não definida — esta é uma configuração legítima quando o Ray inicia o engine em um placement group puramente CPU📎 vllm/platforms/interface.py:296-297。

Passo 2: physical → visible. logical_device_id_to_visible_device_id(0)Após obter o physical4, decompõe-se a variável de ambiente em[4, 5], encontra-se o índice4de0e retorna-se📎 vllm/platforms/interface.py:316-339. Se o physical ID não estiver na lista visível, lança-seRuntimeError— esta é uma proteção rígida contra o uso indevido de dispositivos não visíveis entre processos.

set_assigned_physical_gpu_idsO design idempotente deRuntimeError 📎 vllm/platforms/interface.py:38-56também merece atenção: definir repetidamente o mesmo valor é uma no-op, mas definir um valor diferente lança

. Isto evita que o mapeamento de dispositivos seja sobrescrito acidentalmente em ambientes multithread.

Registo e injeção de configuração de plugins de plataformavllm.platform_pluginsOs plugins de plataforma são registados através do grupoNone, e a função do plugin retorna o nome totalmente qualificado da classe de plataforma (ou📎 docs/design/plugin_system.md:50-50indica que o ambiente atual não é suportado)📎 docs/design/plugin_system.md:100-100:

  • _enum. A implementação mínima apresentada na documentação exigePlatformEnum.OOT(out-of-tree)
  • device_typenormalmente definido como
  • check_and_update_configretorna a string do tipo de dispositivo que o PyTorch reconheceé chamado no início da inicialização do vLLM,worker_cls
  • get_attn_backend_clsé obrigatório definir aqui
  • get_device_communicator_clsretorna o nome da classe do backend de atenção

check_and_update_configretorna o nome da classe do comunicador📎 vllm/platforms/interface.py:583-592é o hook mais crítico do plugin de plataformaVllmConfig. Recebe a referência📎 docs/design/plugin_system.md:105-105e modifica-a in-place, podendo ajustar block size, graph mode, etc. A documentação enfatiza que "o mais importante é que worker_cls deve ser definido aqui"

— porque o vLLM precisa saber qual classe Worker usar para instanciar o processo de trabalho.

Reflexão de design: estratégia de três fases para alinhamento do block sizeupdate_block_size_for_backend 📎 vllm/platforms/interface.py:666-708A lógica mais complexa na interface de plataforma é

Phase 1. Divide-se em três fases para garantir que o block size seja compatível com o backend de atenção:--block-size: se o utilizador não especificou explicitamente_preferred_block_size_for_backends, chama-se📎 vllm/platforms/interface.py:687-697para selecionar o menor block size suportado por todos os backends📎 vllm/platforms/interface.py:622-663。

Phase 2. Esta função usa LCM (mínimo múltiplo comum) para enumerar valores candidatos, porque alguns backends (como CPU_MLA) só aceitam tamanhos exatos e não múltiplos📎 vllm/platforms/interface.py:699-702。

Phase 3: modelos híbridos (attention + mamba) precisam de alinhar o block com o mamba page size📎 vllm/platforms/interface.py:704-708。

: quando múltiplos KV dtypes partilham o block pool (por exemplo, nvfp4 principal + camadas skip não quantizadas), é necessário expandir o block principal para cobrir a maior padded spec page

〔Inferência de design e trade-offs arquiteturais〕

---

Este design faseado reflete a realidade que o vLLM enfrenta: diferentes hardwares, diferentes esquemas de quantização e diferentes arquiteturas de modelo impõem restrições ao block size que entram em conflito entre si, não sendo possível resolvê-las com uma única fórmula. O faseamento permite tratar cada restrição independentemente e, no final, obter a solução que satisfaz todas as restrições.

III. IO Processor e plugins de endpoint: processamento de entrada e extensão da API

Modelo intuitivo: o IO Processor é uma "camada de tradução multimodal"

A entrada de modelos multimodais (como o LLaVA) não é texto puro, mas uma mistura de texto + imagem. O plugin IO Processor é responsável por converter os dados multimodais brutos em tensores que o modelo consegue consumir, e depois converter a saída do modelo de volta para um formato legível por humanos. É como um tradutor na alfândega: a língua estrangeira que entra (imagem/áudio) é traduzida para a língua materna do modelo, e a língua materna do modelo que sai é traduzida de volta para a língua estrangeira.

Passo a passo: descoberta e instanciação do IO Processorio_processor_pluginCenário: carregar um modelo com um HF config que contém o campo

. get_io_processorPasso 1: determinar o nome do plugin.plugin_from_initUsa-se prioritariamente ohf_configpassado explicitamente; caso contrário, lê-se o campoio_processor_pluginde📎 vllm/plugins/io_processors/__init__.py:42-50e obtém-seNone. Se ambos estiverem vazios, retorna-se📎 vllm/plugins/io_processors/__init__.py:52-54。

— indicando que o modelo não precisa de IO processorPasso 2: carregar todos os plugins instalados.load_plugins_by_group(IO_PROCESSOR_PLUGINS_GROUP)Chama-se📎 vllm/plugins/io_processors/__init__.py:59-61。

para obter todos os plugins desse grupoPasso 3: construir o mapeamento carregável.processor_cls_qualnameItera-se sobre cada plugin, chama-se a sua função para obterNone, e se não forloadable_plugins 📎 vllm/plugins/io_processors/__init__.py:66-76regista-se em

. Note-se que a chamada de função de cada plugin também está envolvida em try/except, pelo que uma falha individual não afeta as outras.Passo 4: validação e instanciação.ValueErrorSe o número de plugins carregáveis for 0, lança-se📎 vllm/plugins/io_processors/__init__.py:66-76com a mensagem "é necessário um plugin IOProcessor mas nenhum está instalado"ValueError. Se o nome do plugin exigido pelo modelo não estiver na lista de carregáveis, lança-se📎 vllm/plugins/io_processors/__init__.py:80-81e listam-se todos os nomes de plugins disponíveisresolve_obj_by_qualname. Por fim, resolve-se o nome da classe através de📎 vllm/plugins/io_processors/__init__.py:80-81。

e instancia-se

Plugins de endpoint: postura de segurança de negação por omissãoOs plugins de endpoint são a categoria mais especial deste capítulo, porque。load_endpoint_pluginsnão são carregados por omissãoload_plugins_by_group. A docstring de📎 vllm/plugins/__init__.py:93-94。

explica claramente a razão: os plugins de endpoint adicionam rotas HTTP ao API Server, ampliando a superfície de exposição de rede, pelo que se adota uma postura de "negação por omissão" mais rigorosa do queVLLM_PLUGINSA regra concreta é: só quando o nome do pluginaparece explicitamente emrequired_tasks, e o seuNoneé📎 vllm/plugins/__init__.py:108-108。

ou tem interseção com as tasks suportadas pelo servidor, é que é carregadoVLLM_PLUGINS。

Cenário: o utilizador instalou um plugin de endpoint mas esqueceu-se de definirPasso 1: verificar se VLLM_PLUGINS não está definido.envs.VLLM_PLUGINS is NoneSe📎 vllm/plugins/__init__.py:126-126, primeiro descobrem-se os plugins desse grupo; se existirem, regista-se um warning a indicar "é obrigatório allowlist explícito"VLLM_PLUGINS="". Note-se que os comentários do código-fonte salientam especialmente:[""]é interpretado comoNonee não como📎 vllm/plugins/__init__.py:108-108, sendo portanto tratado como "uma allowlist que não corresponde a nenhum plugin", e não como "não definido"None. Esta distinção de fronteira é importante — a string vazia é um explícito "não carregar nada", enquanto

é "não configurado".Passo 2: carregar e instanciar.load_plugins_by_groupApós obter a função de fábrica através defactory(), chama-se📎 vllm/plugins/__init__.py:133-141individualmente para instanciar

. Se a instanciação falhar, regista-se exception e faz-se continue.Passo 3: gating por task.plugin.required_tasksVerifica-seNone, e se não forsupported_tasksSem interseção, ignorar este plugin📎 vllm/plugins/__init__.py:144-145. Isso permite que o mesmo pacote de plugin registre endpoints diferentes para tarefas distintas (como embedding vs generation).

O diagrama de sequência abaixo descreve a interação completa do plugin de endpoint, desde a descoberta até o carregamento:

mermaid
sequenceDiagram
    participant App as "API Server 前端进程"
    participant Loader as "load_endpoint_plugins()"
    participant Env as "envs.VLLM_PLUGINS"
    participant EP as "entry_points(ENDPOINT_PLUGINS_GROUP)"
    participant Factory as "plugin factory()"

    App->>Loader: load_endpoint_plugins(supported_tasks)
    Loader->>Env: 读取 VLLM_PLUGINS
    alt VLLM_PLUGINS is None
        Loader->>EP: entry_points(group)
        EP-->>Loader: discovered plugins
        Loader-->>App: 返回 [] (记 warning)
    else VLLM_PLUGINS 已设置
        Loader->>EP: load_plugins_by_group(group)
        EP-->>Loader: factories 字典
        loop 每个 factory
            Loader->>Factory: factory()
            Factory-->>Loader: EndpointPlugin 实例
            Loader->>Loader: 检查 required_tasks 交集
            alt tasks 不匹配
                Loader->>Loader: 跳过 (记 info)
            else tasks 匹配
                Loader->>Loader: append 到结果列表
            end
        end
        Loader-->>App: 返回 endpoint_plugins 列表
    end

Reflexão de design: a fronteira de processo determina a estratégia de carregamento

A diferença nas estratégias de carregamento dos três tipos de plugins é, em essência, um mapeamento dafronteira de processo:

Tipo de pluginProcesso de carregamentoComportamento padrãoMotivação
generalTodos os processosCarregar tudoO registro do modelo precisa ser visível em cada Worker
platformTodos os processosCarregar tudoA abstração de hardware é dependida por todos os processos
io_processorApenas process0Carregar tudoO processamento de entrada ocorre apenas no frontend
stat_loggerApenas process0 (assíncrono)Carregar tudoOs logs são coletados apenas no processo principal
endpointApenas API ServerNegar por padrãoAmplia a superfície de exposição de rede, requer autorização explícita
〔Inferência de design e trade-offs arquiteturais〕

A "negação por padrão" dos plugins de endpoint é uma prática padrão de engenharia de segurança: qualquer extensão que amplie a superfície de ataque deve ser opt-in. Já os outros plugins são carregados por padrão porque não expõem diretamente interfaces de rede, e o ecossistema da comunidade precisa de uma experiência de integração com baixo atrito.

Armadilhas em produção: degradação silenciosa quando o carregamento de plugin falha

load_plugins_by_groupPara cada plugin, oplugin.load()é envolvido em try/except, e em caso de falha apenas registra a exception📎 vllm/plugins/__init__.py:68-72. Isso significa queum plugin corrompido não impedirá o vLLM de iniciar, mas também não fornecerá um erro explícito — o usuário pode ficar confuso sobre "por que meu plugin não está funcionando".

Sugestão de diagnóstico: ajuste o nível de log para DEBUG e pesquise por"Failed to load plugin". Se o plugin estiver sob o grupovllm.general_plugins, o nível de log padrão é DEBUG, sendo necessário habilitá-lo explicitamente para ver os detalhes do carregamento📎 vllm/plugins/__init__.py:49-50。

Outra armadilha é o momento em que a guardaplugins_loadedé ativada📎 vllm/plugins/__init__.py:77-90: ela é definida antes do carregamentoTrue. Se o primeiro carregamento falhar por algum motivo (como uma exceção na varredura de entry_points), as chamadas subsequentes retornarão diretamente sem tentar novamente. Isso pode causar o fenômeno estranho de "plugin que funciona às vezes" em ambientes de teste.

---

Resumo do capítulo

O sistema de plugins do vLLM é construído sobre o Pythonentry_points, através decinco constantes de grupoque dividem os tipos de extensão, através dafronteira de processoque determina o escopo de carregamento, e através daVLLM_PLUGINSallowlistque controla o conjunto de carregamento. Os plugins de plataforma usam a classe basePlatformpara abstrair diferenças de hardware, e sua conversão de três namespaces de device ID (logical/visible/physical) é o núcleo do gerenciamento de dispositivos entre processos; os plugins de IO processor são acionados pelo campoio_processor_plugindo HF config, responsáveis pela tradução de entradas multimodais; os plugins de endpoint adotam a postura de "negação por padrão", sendo carregados apenas quando explicitamente na allowlist e com task correspondente, para controlar a superfície de exposição de rede.

As três linhas principais compartilham o mesmo mecanismo de descoberta, mas as diferenças nas estratégias de carregamento refletem o trade-off do vLLM entre "conveniência de extensão" e "fronteira de segurança": plugins que não expõem rede são carregados por padrão, plugins que expõem rede devem ser opt-in.

Reflexões e autoavaliação do capítulo

Q1: Se removermos o try/except deload_plugins_by_groupemplugin.load(), deixando a falha de carregamento ser lançada diretamente, qual seria o impacto na inicialização multiprocesso do vLLM? Em quais cenários isso seria, na verdade, um design melhor?
〔Inferência de design e trade-offs arquiteturais〕

Análise de referência: A implementação atual📎 vllm/plugins/__init__.py:68-72faz com que a falha de carregamento de um único plugin seja silenciosamente engolida, registrando apenas um log de exception. Se removermos o try/except, a falha de carregamento se propagará paraload_general_plugins, interrompendo a inicialização do processo. Em cenários multiprocesso, isso causaria: se o carregamento de plugin de algum processo Worker falhar, todo o engine não poderá iniciar — o que pode ser bom (falha rápida, evitando que processos parciais rodem doentes causando inconsistência de estado), ou ruim (um bug em um plugin opcional derruba todo o serviço). Um design melhor poderia introduzir a variável de ambienteVLLM_PLUGINS_STRICT: padrão permissivo (comportamento atual), e em modo estrito a falha de carregamento lança exceção. Assim, ambientes de produção podem exigir que "todos os plugins declarados sejam carregados com sucesso", enquanto ambientes de desenvolvimento mantêm a tolerância a falhas.

Q2: load_endpoint_pluginsEmVLLM_PLUGINS="", qual é a diferença de comportamento entreVLLM_PLUGINSeNonenão definido (
)? Por que o código-fonte distingue deliberadamente esses dois casos?

〔Inferência de design e trade-offs arquiteturais〕Análise de referênciaVLLM_PLUGINS="": Os comentários do código-fonte indicam explicitamente que[""]é interpretado comoNoneem vez de📎 vllm/plugins/__init__.py:108-108, sendo portanto tratado como "uma allowlist que não corresponde a nenhum plugin"VLLM_PLUGINS is None. Quandoload_endpoint_plugins, o código[]retorna diretamente📎 vllm/plugins/__init__.py:126-126e registra um warningVLLM_PLUGINS=""; já quandoload_plugins_by_group, o código continua até, mas como a string vazia não corresponde a nenhum nome de plugin, acaba também retornando uma lista vazia. Ambos têm omesmo resultado(nenhum plugin de endpoint é carregado), mas:Nonesemânticas diferentes"": significa "o usuário não configurou, nós ativamente negamos e avisamos",

Q3: device_id_to_physical_device_idsignifica "o usuário configurou explicitamente uma allowlist vazia, respeitamos sua intenção e não avisamos". Essa distinção permite que a operação "desabilite silenciosamente todos os plugins de endpoint" definindo uma string vazia, sem precisar tolerar o ruído de warning a cada inicialização.device_control_env_varEm📎 vllm/platforms/interface.py:302-308, por que o código-fonte trata um

vazio como não definido? Se removermos essa verificação de string vazia, o que aconteceria no cenário de placement group CPU-only do Ray?📎 vllm/platforms/interface.py:296-297Análise de referência!= "": Os comentários do código-fonte explicam que uma variável de ambiente vazia é uma configuração legítima do Ray ao iniciar um placement group CPU-only em nós GPUdevice_ids = "".split(","). Se removermos a verificação[""], o código entrará no branchdevice_ids[device_id], obteráint(""), entãoValueError。Isso faz com que o motor falhe ao iniciar sob uma configuração Ray legítima. Após manter a verificação, variáveis de ambiente vazias seguem para oelseramo e retornam diretamentedevice_id, ou seja, assume-se que o logical ID é igual ao physical ID — o que é seguro em cenários CPU-only, pois não há GPU a mapear. Este caso mostra que "não definida" e "definida como vazia" têm semânticas diferentes em sistemas de orquestração distribuída, e o código deve tratá-las explicitamente.

---

O próximo capítulo volta-se para trade-offs arquiteturais, armadilhas em produção e evolução futura; reuniremos os mecanismos dissecados nos treze capítulos anteriores para examinar os compromissos do vLLM entre desempenho, manutenibilidade e extensibilidade, e vislumbrar a direção evolutiva dos motores de inferência.

Até aqui, vimos como o vLLM, por meio do mecanismo de agrupamento de entry_points, do momento de carregamento ciente das fronteiras de processo e de estratégias diferenciadas para três tipos de plugins — plataforma, IO processor e endpoint —, abre superfície de extensão mantendo o núcleo estável. Esse sistema de plugins permite que novos hardwares, novos formatos de entrada e novas rotas de API sejam integrados de forma não invasiva, mas a extensibilidade em si também implica mais dimensões a ponderar. O próximo capítulo encerra o livro, organizando sistematicamente as tensões nas decisões-chave de design do vLLM — continuous batching versus fragmentação de VRAM, CUDA Graph versus formas dinâmicas, implantação desagregada versus overhead de rede — e apresenta uma lista de armadilhas em produção e um caminho de diagnóstico, além de vislumbrar tendências de evolução em frontend Rust, camada IR e hardware heterogêneo.

CHAPTER 14

Capítulo 14: Trade-offs arquiteturais, armadilhas em produção e evolução futura

Projeto: vllm-project/vllm · Progresso do livro: Capítulo 14 / 14 · Status de verificação: linhas FACT ancoradas em fontes reais

No capítulo anterior, dissecamos o mecanismo de extensão por plugins do vLLM e vimos como plugins de plataforma, de IO processor e de endpoint permitem que o motor se adapte a novos hardwares, novas modalidades e novas APIs sem modificar o código central. Essa extensibilidade permite que o vLLM abrace mudanças rapidamente, mas quanto mais pontos de extensão, mais complexos se tornam os caminhos de interação em produção. Quando problemas reais como fragmentação de VRAM, falha no handshake do NCCL, invalidação de cache de compilação e instabilidade de rede ocorrem simultaneamente, os mecanismos apresentados nos treze capítulos anteriores entram em conflito, expondo tensões que não apareciam em ambientes ideais. Este capítulo não introduz novos mecanismos centrais; em vez disso, coloca esses mecanismos lado a lado, usando a documentação oficial de troubleshooting como âncora e combinando com o design da ferramenta de bench do frontend Rust, para examinar os trade-offs entre desempenho e operabilidade e oferecer um caminho de diagnóstico acionável.

I. Níveis de otimização: contrato explícito entre tempo de inicialização e desempenho em execução

Modelo intuitivo

Os níveis de otimização são como os "modos de cena" de uma câmera: o modo automático (-O2) serve para a maioria dos cenários, mas quando você precisa de um disparo rápido (depuração), mudar para o modo manual (-O0) responde imediatamente, ao custo de pior qualidade de imagem (desempenho). O vLLM transforma esse trade-off em um contrato explícito de quatro níveis, em vez de escondê-lo em dezenas de flags booleanas para o usuário montar sozinho.

Layout de campos dos quatro níveis

O vLLM oferece-O0até-O3quatro níveis📎 docs/design/optimization_levels.md:5-5. O princípio central de design é:flags definidas explicitamente pelo usuário têm prioridade sobre os padrões do nível de otimização 📎 docs/design/optimization_levels.md:5-5. Isso significa que o nível de otimização é apenas um conjunto de valores padrão, não uma restrição rígida.

-O0desativa tudo: sem autotuning, sem compilação, sem cudagraph📎 docs/design/optimization_levels.md:32-33. Concretamente, isso se traduz em quatro chaves:cudagraph_mode=NONE、mode=NONE, todas as fusões desativadas,enable_flashinfer_autotune=False 📎 docs/design/optimization_levels.md:37-40。

-O1é o ponto de equilíbrio para cenários de desenvolvimento: habilitaPIECEWISEcudagraph eVLLM_COMPILEmodo📎 docs/design/optimization_levels.md:50-51. Note um detalhe sutil:fuse_norm_quantefuse_act_quantsó são habilitados quando um dos operadores usa kernel customizado; caso contrário, a fusão automática do Inductor tem melhor efeito📎 docs/design/optimization_levels.md:61. Essa é uma decisão de design típica de "não competir com o compilador".

-O2é o valor padrão, voltado para produção📎 docs/design/optimization_levels.md:66-67. Sobre-O1, acrescentaFULL_AND_PIECEWISEcudagraph efuse_allreduce_rms 📎 docs/design/optimization_levels.md:72-73。-O3atualmente equivale a-O2, reservando espaço para otimizações experimentais mais agressivas no futuro📎 docs/design/optimization_levels.md:80-81。

Fluxo de seleção orientado por cenário

Quando um usuário executavllm serve model -O1, o que acontece internamente? O fluxograma abaixo mostra como os níveis de otimização interagem com as flags do usuário:

mermaid
flowchart TD
    start["用户启动 vllm serve -O1"] --> parse["解析 optimization_level=1"]
    parse --> load_defaults["加载 O1 默认值集合"]
    load_defaults --> check_user{"用户是否显式设置了<br/>cudagraph_mode?"}
    check_user -->|是| user_wins["使用用户值<br/>覆盖 O1 默认"]
    check_user -->|否| use_default["使用 O1 默认<br/>PIECEWISE"]
    user_wins --> check_fusion{"fuse_norm_quant<br/>是否涉及自定义 kernel?"}
    use_default --> check_fusion
    check_fusion -->|是| enable_fuse["启用该 fusion"]
    check_fusion -->|否| skip_fuse["跳过,交给 Inductor"]
    enable_fuse --> done["配置完成,进入引擎初始化"]
    skip_fuse --> done

O ponto-chave desse fluxo está no ramocheck_user: a configuração explícita do usuário sempre tem prioridade📎 docs/design/optimization_levels.md:5-5. Isso evita problemas difíceis de diagnosticar, como "o nível de otimização sobrescreveu silenciosamente minha flag de depuração".

Reflexões de design e armadilhas

A armadilha de produção mais comum dos níveis de otimização étempo de inicialização excessivo. A documentação recomenda explicitamente: quando o tempo de inicialização estiver excessivo, use-O0ou-O1 📎 docs/design/optimization_levels.md:87. Mas há um custo oculto —-O0sem cudagraph, o overhead de lançamento de cada kernel na CPU fica exposto, e em cenários de alta concorrência a vazão pode cair várias vezes.

Outra armadilha éerro de compilação。-O2: oFULL_AND_PIECEWISEcudagraph faz suposições mais fortes sobre a estrutura do modelo; alguns modelos customizados falham na compilação em-O2mas funcionam em-O1. A documentação recomenda usardebug_dump_pathpara obter mais informações de depuração📎 docs/design/optimization_levels.md:88. O caminho de diagnóstico deve ser: primeiro usar-O0para confirmar a correção funcional, depois subir gradualmente até-O1、-O2, localizando qual nível introduziu o problema.

〔Inferência de design e trade-offs arquiteturais〕

Essa abordagem de diagnóstico por "degradação em níveis" é, em essência, semelhante à do CUDA Graph--enforce-eagerÉ a mesma metodologia: primeiro confirmar a correção com a configuração mais conservadora, depois habilitar otimizações gradualmente, isolando o problema na menor diferença de configuração possível.

---

II. Lista de armadilhas em produção: caminho de diagnóstico de sintomas a causas raiz

Modelo intuitivo

A solução de problemas em ambiente de produção é como triagem de emergência: você não pode fazer exames completos em todos os pacientes, deve primeiro reduzir rapidamente o escopo com base nos sintomas (OOM, hang, crash) e depois aprofundar de forma direcionada. A documentação de troubleshooting do vLLM é essencialmente um manual de triagem.

Classificação de sintomas e ferramentas de diagnóstico

A documentação divide os problemas comuns em várias categorias principais; vamos organizá-los em ordem crescente de dificuldade de diagnóstico.

Primeira categoria: download/carregamento do modelo travado.O sintoma é ausência de resposta por longo tempo após a inicialização. A causa raiz geralmente é rede lenta ou sistema de arquivos compartilhado lento📎 docs/usage/troubleshooting.md:11-11. O meio de diagnóstico é--load-format dummypular o carregamento de pesos, isolando se é o download ou o carregamento que está lento📎 docs/usage/troubleshooting.md:23-23. Esta é uma técnica típica de "isolamento por bisseção".

Segunda categoria: OOM de memória de vídeo.A documentação aponta diretamente para o documento de configuração conserving_memory📎 docs/usage/troubleshooting.md:23. Mas o OOM em produção geralmente não é porque o modelo é grande demais, e sim fragmentação do KV cache ou número de requisições concorrentes acima do esperado.

Terceira categoria: mudança na qualidade de geração.Esta é uma armadilha facilmente ignorada. A v0.8.0 mudou a origem dos parâmetros de amostragem padrão: de valores neutros padrão do vLLM para os do autor do modelogeneration_config.json 📎 docs/usage/troubleshooting.md:23-23. Na maioria dos casos isso melhora a qualidade, mas para certos modelos a configuração fica pior📎 docs/usage/troubleshooting.md:23-23. O método de diagnóstico é reverter para--generation-config vllmcomparar📎 docs/usage/troubleshooting.md:23-23。

Quarta categoria: travamento (hang).Esta é a categoria mais difícil de diagnosticar. A documentação fornece um conjunto progressivo de variáveis de ambiente de depuração📎 docs/usage/troubleshooting.md:41-41:

  • VLLM_LOGGING_LEVEL=DEBUG: ativar logs detalhados
  • VLLM_LOG_STATS_INTERVAL=1.: saída de alta frequência do estado da fila e de acertos de cache
  • CUDA_LAUNCH_BLOCKING=1: localizar qual kernel CUDA está com problema
  • NCCL_DEBUG=TRACE: ativar logs detalhados do NCCL
  • VLLM_TRACE_FUNCTION=1: registrar todas as chamadas de função, mas desacelera mais de 100 vezes📎 docs/usage/troubleshooting.md:41

Há uma disciplina operacional importante aqui: após a depuração, é obrigatório desativar essas variáveis de ambiente, ou abrir um novo shell diretamente, caso contrário a configuração de depuração residual continuará desacelerando o sistema📎 docs/usage/troubleshooting.md:11-11。

A armadilha da fronteira de processo na depuração com breakpoints

A arquitetura multiprocesso do vLLM faz com que breakpoints convencionaispdbpercam efeito — se o breakpoint for executado em um subprocesso, lançaráBdbQuit 📎 docs/usage/troubleshooting.md:45-54. Duas soluções: usarforked-pdb 📎 docs/usage/troubleshooting.md:57-61, ou definirVLLM_ENABLE_V1_MULTIPROCESSING=0para manter o scheduler no mesmo processo📎 docs/usage/troubleshooting.md:63-68。

〔Inferência de design e trade-offs de arquitetura〕

O segundo método, embora conveniente, altera o modelo de execução — no modo de processo único, o EngineCore e o API Server não se comunicam mais por fila, e certos bugs de concorrência podem não ser reproduzíveis. Portanto, ele serve para localizar erros de lógica, mas não para reproduzir problemas de concorrência.

Diagnóstico de comunicação distribuída

Implantação distribuída tem documentação de diagnóstico dedicada. A recomendação central é:definir variáveis de ambiente no momento da criação do cluster, porque as variáveis se propagam para todos os nós; enquanto defini-las no shell afeta apenas o nó local📎 docs/serving/distributed_troubleshooting.md:16-16。

Um problema frequente éNo available node types can fulfill resource request, que ocorre mesmo quando o cluster tem GPUs suficientes📎 docs/serving/distributed_troubleshooting.md:16-16. A causa raiz geralmente é que o nó tem múltiplos IPs e o vLLM escolheu o errado. A solução é usarVLLM_HOST_IPpara especificar explicitamente, eray statuspara validar📎 docs/serving/distributed_troubleshooting.md:16-16。

Script de diagnóstico de falha na inicialização do NCCL

A documentação fornece um script de diagnóstico completo, validando a pilha de comunicação camada por camada📎 docs/usage/troubleshooting.md:89-150. Seu design é bem estratificado:

mermaid
flowchart TD
    start["运行诊断脚本"] --> nccl_test["测试 PyTorch NCCL<br/>dist.all_reduce"]
    nccl_test --> nccl_ok{"value == world_size?"}
    nccl_ok -->|否| hw_broken["硬件/驱动故障<br/>联系系统管理员"]
    nccl_ok -->|是| gloo_test["测试 PyTorch GLOO<br/>CPU 通信"]
    gloo_test --> gloo_ok{"value == world_size?"}
    gloo_ok -->|否| gloo_fail["GLOO 配置问题<br/>检查网络接口"]
    gloo_ok -->|是| pynccl_test["测试 vLLM PyNcclCommunicator"]
    pynccl_test --> pynccl_ok{"all_reduce 正确?"}
    pynccl_ok -->|否| pynccl_fail["vLLM NCCL 封装问题"]
    pynccl_ok -->|是| graph_test["测试 CUDA Graph 内 all_reduce"]
    graph_test --> graph_ok{"g.replay() 后正确?"}
    graph_ok -->|否| graph_fail["CUDA Graph 捕获问题<br/>检查 stream 语义"]
    graph_ok -->|是| success["sanity check 成功"]

A sutileza deste script está em isolar camada por camada: primeiro validar o PyTorch NCCL mais baixo, depois o GLOO do lado da CPU, depois o encapsulamento PyNcclCommunicator do próprio vLLM, e por fim a comunicação dentro do CUDA Graph📎 docs/usage/troubleshooting.md:90-146. Cada falha de camada aponta para uma causa raiz diferente.

Um detalhe notável no script:pynccl.disabled = Falseé para compatibilidade retroativa com 0.6.4 e versões anteriores📎 docs/usage/troubleshooting.md:121-125. A partir da 0.6.5 vem habilitado por padrão, mas manter essa linha evita confusão para quem lê a documentação mais recente.

Em testes multinó, a documentação usa intencionalmente--rdzv_backend=staticem vez dec10d, porquec10dem ambiente multinó falha por erro de resolução DNS📎 docs/usage/troubleshooting.md:168-168. Esta é uma configuração típica de "só se sabe depois de pisar no buraco".

Reflexões de design e armadilhas

Falha na inicialização do NCCL(ncclCommInitRankreporta unhandled system error) geralmente aponta para duas causas raiz: falta deIPC_LOCKcapability ou/dev/shmnão montado📎 docs/usage/troubleshooting.md:311-311. Ambas são armadilhas clássicas de implantação em contêineres.

Incompatibilidade da toolchain CUDA PTX(the provided PTX was compiled with an unsupported toolchain) indica que o PTX dentro do wheel foi compilado com uma versão mais alta do CUDA toolkit📎 docs/usage/troubleshooting.md:325-327. A solução é habilitar a compatibilidade futura do CUDA: no Docker adicionar-e VLLM_ENABLE_CUDA_COMPATIBILITY=1 📎 docs/usage/troubleshooting.md:325-327, em bare metal instalar o pacotecuda-compate definirVLLM_CUDA_COMPATIBILITY_PATH 📎 docs/usage/troubleshooting.md:325-327。

Problema conhecido de consumo de memória do NCCL:vLLM >= 0.4.3, <= 0.10.1.1defineNCCL_CUMEM_ENABLE=0para contornar um bug do NCCL; processos externos que se conectam ao vLLM também precisam definir essa variável, caso contrário ocorrerá hang ou crash📎 docs/usage/troubleshooting.md:375. Após a correção no NCCL 2.22.3, versões novas removeram essa sobrescrita para permitir otimização de desempenho📎 docs/usage/troubleshooting.md:375. Este caso mostra que:o contrato de variáveis de ambiente entre processos é uma dependência implícita de sistemas distribuídos, e deve ser sincronizado em atualizações.

---

III. Frontend Rust: a filosofia de design zero-copy da ferramenta bench

Modelo intuitivo

Se o frontend Python é um canivete suíço "completo em funcionalidades, mas pesado", a ferramenta bench em Rust é um bisturi "feito apenas para teste de carga". Seu objetivo de design não é cobertura de funcionalidades, mas minimizar a sobrecarga do próprio cliente sob alta concorrência, fazendo com que os números medidos reflitam de forma real o desempenho do servidor.

Estruturas de dados e layout de memória

A estrutura de dados central da ferramenta bench éRequestFuncInput 📎 rust/src/bench/src/backends/mod.rs:59-89. Ela faz uso extensivo deArc<str>eArc<[u32]>em vez deString/Vec, o que é o núcleo do design de zero-copy.

Vejamos alguns campos-chave:prompt: Arc<str> 📎 rust/src/bench/src/backends/mod.rs:50-52——múltiplas requisições concorrentes podem compartilhar a mesma string de prompt, evitando que cada requisição clone uma cópia.prompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77——IDs de token pré-computados são enviados diretamente ao servidor, pulando a tokenização do lado do servidor📎 rust/src/bench/src/backends/mod.rs:74-76。

O mais engenhoso émulti_modal_content: Option<Arc<[Arc<str>]>> 📎 rust/src/bench/src/backends/mod.rs:81. O comentário explica: conteúdo multimodal como fragmentos JSON pré-serializados, o chat backend os concatena diretamente no fluxo de bytes do payload, evitando qualquer parsing ou deep copy dos dados de imagem em base64📎 rust/src/bench/src/backends/mod.rs:78-80. Esta é uma estrutura deArcem duas camadas: a camada externaArc<[...]>compartilha todo o array, a camada internaArc<str>compartilha um único fragmento.

chat_messages_json: Option<Arc<str>>tem a prioridade mais alta, sendo concatenado diretamente no payload sem alterações📎 rust/src/bench/src/backends/mod.rs:82-85。

Desserialização sem alocação

A análise de respostas em streaming SSE é outro ponto crítico de desempenho. O comentário aponta explicitamente: usar desserialização tipada para evitar construir a árvore completa deserde_json::Value, extraindo apenas os campos necessários📎 rust/src/bench/src/backends/mod.rs:20-24。

CompletionChunkmantém apenas os dois camposchoiceseusage📎 rust/src/bench/src/backends/mod.rs:20-24,ChatChunkDa mesma forma📎 rust/src/bench/src/backends/mod.rs:33-37。#[serde(default)]faz com que o campochoicesausente tenha como padrão um array vazio📎 rust/src/bench/src/backends/mod.rs:20-24, que é a situação comum em respostas de streaming.

Fluxo de requisições orientado a cenários

Quando uma requisição de teste de carga é enviada, como os dados fluem? O diagrama de fluxo de dados abaixo mostra a transformação da entrada para a saída:

mermaid
flowchart LR
    input["RequestFuncInput<br/>Arc&lt;str&gt; prompt"] --> build["build_headers<br/>+ payload 拼接"]
    build --> send["reqwest::Client<br/>send_request"]
    send --> sse["SSE 流式响应<br/>字节流"]
    sse --> parse["CompletionChunk<br/>类型化反序列化"]
    parse --> output["RequestFuncOutput<br/>ttft/itl/tpot"]

BackendA enumeração usa despacho estático para evitar o problema de async trait object📎 rust/src/bench/src/backends/mod.rs:150-154。send_requestAtravés dematchdespacha para a implementação concreta📎 rust/src/bench/src/backends/mod.rs:158-168。get_backendCom base emBackendKindretorna o backend correspondente📎 rust/src/bench/src/backends/mod.rs:172-181。

Um detalhe:API_KEYusaOnceLockpara cache, evitando fazer uma syscall de variável de ambiente a cada requisição📎 rust/src/bench/src/backends/mod.rs:186-188。build_headersInsere sequencialmente Content-Type, Authorization, extra headers, request-id📎 rust/src/bench/src/backends/mod.rs:191-215。

Reflexões de design e armadilhas

〔Inferência de design e trade-offs arquiteturais〕

O design de zero-copy da ferramenta bench em Rust reflete um julgamento importante:o overhead do cliente da ferramenta de teste de carga se torna uma fonte de erro de medição. Se cada requisição clona o prompt, faz parsing completo do JSON e deep copy de imagens base64, então a latência medida inclui o overhead do cliente, não refletindo com precisão o desempenho do servidor. UsarArcpara compartilhar dados imutáveis e desserialização tipada para pular campos irrelevantes é, essencialmente, reduzir o overhead do cliente a quase zero.

RequestFuncOutputO design dos campos dettft(time to first token)、itltambém merece atenção:tpot(time per output token)📎 rust/src/bench/src/backends/mod.rs:93-105(array de inter-token latency),

---

. Essas três métricas correspondem a diferentes dimensões de desempenho: TTFT reflete prefill e latência de fila, ITL reflete a estabilidade do decode, TPOT reflete a vazão geral. Se no teste de carga olharmos apenas a latência média, a variação do ITL será mascarada.

Reflexão de design: a lógica subjacente dos trade-offs arquiteturais

Colocando este capítulo junto com os mecanismos dos treze capítulos anteriores, é possível ver várias linhas centrais de trade-offs do vLLM.

〔Inferência de design e trade-offs arquiteturais〕Continuous batching vs fragmentação de memória de vídeo.

O continuous batching permite que o lote seja reorganizado a cada passo, aumentando muito a vazão, mas ao custo de alocação e liberação extremamente frequentes do KV cache. O mecanismo de block table do PagedAttention existe justamente para lidar com essa alocação de alta frequência — blocos de tamanho fixo eliminam a fragmentação externa, mas introduzem o overhead de indireção da block table e fragmentação interna (o último bloco pode não estar cheio). Este é um trade-off típico de "trocar taxa de fragmentação por uma camada de indireção", a mesma ideia da paginação de memória virtual dos sistemas operacionais.CUDA Graph vs formas dinâmicas.PIECEWISECUDA Graph exige formas estáticas, mas o tamanho do lote do continuous batching muda a cada passo. A solução do vLLM éFULL_AND_PIECEWISEe📎 docs/design/optimization_levels.md:50,72modo-O0——capturar como grafo a parte que pode ser estaticizada, mantendo a parte dinâmica em eager.-O2desligar completamente o cudagraph é para depuração,-O1totalmente ligado é para produção, o

no meio é o meio-termo.Implantação separada vs overhead de rede.IPC_LOCK、/dev/shm)📎 docs/usage/troubleshooting.md:311-311O KV Connector permite separar prefill e decode em instâncias diferentes, mas a transferência de KV cache entre instâncias introduz latência de rede. Os requisitos de configuração do GPUDirect RDMA na documentação (

) indicam que esse caminho tem exigências rígidas de infraestrutura. A variação da rede pode causar timeout na transferência de KV, disparando retentativas ou degradação.Operabilidade vs desempenho.VLLM_TRACE_FUNCTION=1Níveis de otimização, variáveis de ambiente de depuração, scripts de diagnóstico, tudo isso é o custo pago pela operabilidade.📎 docs/usage/troubleshooting.md:41pode deixar 100 vezes mais lento

---

, mas é o último recurso para localizar problemas de hang. Um motor maduro deve fornecer essas ferramentas "lentas mas que permitem enxergar".

Resumo deste capítulo

Este capítulo encerra o livro, reexaminando os mecanismos dos treze capítulos anteriores sob a perspectiva de produção.-O0Níveis de otimização (-O3até📎 docs/design/optimization_levels.md:5-5) são um contrato explícito entre tempo de inicialização e desempenho em execução, e as flags do usuário sempre têm prioridade sobre os valores padrão do nívelArc. A lista de armadilhas de produção cobre o caminho completo de diagnóstico, desde carregamento de modelo, OOM de memória de vídeo, mudanças na qualidade de geração até falhas de comunicação distribuída, com a metodologia central de "isolamento por bisseção" e "verificação camada por camada". A ferramenta bench em Rust usa

Três linhas centrais de trade-offs permeiam todo o livro: batching contínuo versus fragmentação de memória de vídeo, CUDA Graph versus formas dinâmicas, implantação desagregada versus sobrecarga de rede. Compreender essas tensões é mais importante do que memorizar qualquer mecanismo individual — porque cada ajuste em ambiente de produção é, essencialmente, encontrar um ponto de equilíbrio entre essas tensões.

Reflexões e autoavaliação deste capítulo

Q1: Se alterarmos o-O2doFULL_AND_PIECEWISEcudagraph para o-O1doPIECEWISE, em quais cenários ocorreria regressão de desempenho? Por quê?

Análise de referência:-O2Com base no-O1, adiciona-se oFULL_AND_PIECEWISEmodo cudagraph📎 docs/design/optimization_levels.md:72。FULLO modo captura toda a propagação direta em um único grafo, enquanto oPIECEWISEcaptura apenas os fragmentos que podem ser estatizados. Em cenários de produção com formas de lote estáveis,FULLo modo elimina mais sobrecarga de lançamento de kernels, proporcionando maior throughput. Porém, se o modelo contiver fluxo de controle dinâmico (como o roteamento de tokens do MoE),FULLo modo pode não conseguir capturar ou apresentar comportamento anômalo após a captura; nesse caso, oPIECEWISEé mais estável. A regressão de desempenho ocorrerá quando: mudanças frequentes no tamanho do lote impedirem oFULLgrafo de ser acionado, ou quando a estrutura do modelo disparar o caminho de fallback doFULLmodo. O método de diagnóstico é primeiro usar o-O1para confirmar a linha de base, depois escalar para o-O2para comparação, e usar oVLLM_LOG_STATS_INTERVAL=1.para observar o estado da fila📎 docs/usage/troubleshooting.md:41-41。

Q2: No script de diagnóstico, por que é necessário testar o PyTorch GLOO antes de testar o vLLM PyNcclCommunicator? Se pularmos o teste GLOO e formos direto para o PyNccl, o que deixaríamos de detectar?

Análise de referência: A ordem de execução do script é PyTorch NCCL → PyTorch GLOO → vLLM PyNccl → CUDA Graph📎 docs/usage/troubleshooting.md:90-146. O GLOO testa a comunicação do lado da CPU📎 docs/usage/troubleshooting.md:106-112, enquanto oPyNcclCommunicatordo vLLM precisa de um grupo GLOO como bootstrap📎 docs/usage/troubleshooting.md:120. Se pularmos o teste GLOO, quando a inicialização do PyNccl falhar, não será possível distinguir se o problema é do próprio NCCL ou do bootstrap GLOO. O GLOO depende da configuração da interface de rede (GLOO_SOCKET_IFNAME)📎 docs/usage/troubleshooting.md:81-81, que em ambientes de rede complexos é um ponto de falha frequente. O valor do teste camada por camada está em isolar a falha na menor diferença de configuração possível.

Q3: A ferramenta de bench em Rust usaArc<str>para compartilhar o prompt. Se o cenário de teste de carga exigir que cada requisição envie um prompt diferente, esse design se torna inválido? Por quê?

Análise de referência:Arc<str>O objetivo do design do📎 rust/src/bench/src/backends/mod.rs:50-52é permitir que múltiplas requisições concorrentes compartilhem a mesma string imutávelArc. Se o prompt de cada requisição for diferente,Arc<str>a vantagem de compartilhamento doArc<str>de fato desaparece — cada requisição precisa construir seu próprioString. Mas o design não se torna inválido:prompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77em comparação com oArcainda evita múltiplas clonagens durante o fluxo da requisição (como da fila de entrada para o backend e depois para a construção do payload). A verdadeira otimização de zero-copy está noArc<str>— mesmo que o texto do prompt seja diferente, o array pré-computado de token IDs ainda pode ser compartilhado através doArc<[u32]>durante o ciclo de vida da requisição, evitando alocações repetidas. A premissa de design da ferramenta de teste de carga é "mesmo prompt com alta concorrência" ou "token IDs pré-computados"; para o primeiro, usa-se o

---

para compartilhar o texto, para o segundo, usa-se o

para compartilhar a sequência de tokens.

⚡ Carregando todos os arquivos-fonte... · 🇺🇸 EN · Arquivos-fonte completos já montados · 🇰🇷 한국어 · 🇨🇳 中文 · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU