Incorporações Rápidas em GPUs

A pesquisa rápida e precisa é vital para toda a Perplexity, desde a Pesquisa e Computer até à nossa Plataforma API. Nos bastidores, o trabalho pesado é feito por modelos de incorporação e classificação, que ajudam os nossos sistemas a identificar os resultados mais relevantes para uma determinada consulta. Atingimos o estado da arte

AutoresPerplexity Engineering

Uma pesquisa rápida e precisa é vital para toda a Perplexity, desde a Pesquisa e Computer até à nossa Plataforma API. Nos bastidores, o trabalho mais pesado é feito por modelos de incorporação e classificação (ranking), que ajudam os nossos sistemas a identificar os resultados mais relevantes para uma determinada consulta. Atingimos qualidade e latência de última geração (state-of-the-art) através do treino e disponibilização dos Nossos próprios modelos, tais como pplx-embed.

Este artigo apresenta uma visão de bastidores da infraestrutura de atendimento (serving) da Perplexity para esta classe especial de modelos. Discutimos as nossas técnicas para responder de forma eficiente às necessidades de inferência da pesquisa nativa de IA, permitindo a prototipagem rápida e a avaliação de modelos enquanto impulsionamos o nosso índice de pesquisa à escala de exabites. Estas técnicas expandem coletivamente a fronteira de Pareto da qualidade e eficiência da pesquisa, permitindo-nos servir agentes e utilizadores com os melhores resultados possíveis ao menor custo e latência.

Incorporações para Pesquisa

Numa configuração de pesquisa típica, os documentos indexados são mapeados para um espaço vetorial de alta dimensão utilizando um modelo de incorporação e armazenados numa base de dados vetorial. Ao incorporar uma consulta utilizando o mesmo modelo, documentos semelhantes podem ser localizados encontrando os vetores mais próximos dos da consulta. Isto dá origem a dois padrões de tráfego diferentes para um motor de inferência servir:

  • Incorporação em Lote: ao construir, expandir ou reindexar a base de dados, documentos em massa devem ser incorporados no espaço vetorial, maximizando o débito para minimizar o custo.

Após a pesquisa vetorial, grandes lotes de documentos devem ser pontuados, encontrando um equilíbrio entre débito e latência.

  • Incorporação Online: ao consultar a base de dados, uma consulta curta deve ser incorporada para pesquisas, minimizando a latência.

Construímos a nossa infraestrutura de inferência para aproveitar o máximo de componentes comuns possível entre casos de uso. Como tipicamente utilizamos pequenos modelos Transformer para produzir incorporações, partilhamos a maior parte da implementação com o nosso código de inferência de LLMs: as incorporações em lote são semelhantes ao preenchimento (prefill) limitado por computação, enquanto as incorporações online, que frequentemente executam num pequeno número de tokens, são computacionalmente semelhantes à descodificação limitada por memória. Assim, reutilizamos os nossos kernels de preenchimento e descodificação otimizados para servir modelos de incorporação. Como resultado, conseguimos alcançar um enorme débito de inferência em lote com um trabalho de engenharia adicional mínimo, preservando ao mesmo tempo uma baixa latência para cargas de trabalho de incorporação online.

Tulips, Roses e algumas Ivy

Exponibilizamos a inferência através de APIs normalizadas, tanto internamente como externamente através da nossa Plataforma API. Nos bastidores, vários serviços estão envolvidos no processamento de um pedido de incorporação:

  • O Ivy é um gateway HTTP em Rust que os serviços da Perplexity chamam.

Lida com o trabalho do lado da CPU para pedidos como análise (parsing) de JSON, tokenização, criação de modelos de entrada (templating) e divisão de lotes, traduzindo os pedidos para um protocolo gRPC personalizado para servidores a jusante (downstream). Esta separação permite-nos configurar determinados parâmetros relacionados com a tokenização e a formatação de entradas sem ter de tocar nas instâncias de inferência mais pesadas.

  • Tulip é a interface do servidor de inferência.

É um servidor gRPC implementado com Rust, tokio e `tonic`. O Tulip recebe pedidos de inferência gRPC, lidando com o agendamento e o agrupamento em lotes (batching). De seguida, envia os lotes para o motor ROSE, devolvendo as respostas concluídas aos clientes.

  • **ROSE** (Runtime-Optimized Serving Engine) implementa a inferência de modelos.

É definido principalmente em Python, fornecendo kernels, camadas e definições para uma grande variedade de modelos. O ROSE implementa as passagens para a frente (forward passes) através dos modelos, fornecendo também gestão de grafos CUDA especializada para incorporações. É ligado ao Tulip através de uma função step(), que recebe um lote e devolve uma referência para a computação que executa no acelerador.

Arquitetura de atendimento desde um pedido de incorporações através do Ivy para servidores Tulip replicados

Prestando Atenção Além do Kernel

Tanto os modelos baseados em Transformer como as arquiteturas Hopper/Blackwell subjacentes são tecnologias maduras, pelo que a inferência de incorporação (embedding) do lado da GPU convergiu para uma implementação largamente ótima em vários motores de inferência. Ainda assim, descobrimos oportunidades adicionais de melhoria em tempos de execução e estruturas (harnesses) que expõem os modelos de ponta a ponta a um cliente. Em particular, descobrimos que podemos melhorar as latências gerindo cuidadosamente os grafos CUDA e construindo uma abstração LazyTensor para monitorizar assincronamente um resultado no lado da GPU no motor Rust nativo. Implementámos estas funcionalidades no Tulip, para que pudesse fazer a interface eficazmente com as implementações de modelos do ROSE.

Tulip

Concebimos o Tulip para ser uma interface o mais leve possível sobre a nossa disponibilização de modelos. Lida com pedidos recebidos em tarefas assíncronas do Tokio, mantendo um conjunto (pool) de pedidos que monitoriza e a partir do qual agenda lotes para despachar para o acelerador. O mecanismo de agendamento no Tulip é muito simples: os pedidos acumulam-se enquanto o Tulip está a despachar trabalho ou a aguardar resultados. A partir dos pedidos acumulados, as sequências são escolhidas por ordem de chegada para serem executadas através do modelo.

O mecanismo de agendamento simples é motivado por uma observação sobre o desempenho do modelo. Para pequenos modelos de incorporação, nos comprimentos de sequência que servimos, notar que o custo linear das camadas densas é dominante sobre o custo quadrático da atenção. Assim, a latência é maioritariamente proporcional ao número de tokens, e não ao número de sequências. Consequentemente, assim que um lote é grande o suficiente para saturar a GPU, o que ronda os 512 tokens num modelo com menos de mil milhões de parâmetros, juntar mais sequências não melhora a eficiência.

Para fazer a interface eficazmente com o modelo, o Tulip baseia-se em grafos CUDA e no rastreio de resultados com atraso (lazy) para sobrepor o trabalho da GPU e da CPU e utilizar plenamente os recursos disponíveis.

Gestão de Grafos CUDA

A execução da passagem para a frente de um modelo envolve trabalho tanto no lado da CPU como no lado da GPU. A CPU é responsável por agendar lotes e lançar kernels com os parâmetros adequados, enquanto a GPU executa os kernels de multiplicação de matrizes, atenção, normalização ou ativação relevantes. Para cargas de trabalho de alto débito, como treino e reindexação, as sobrecargas do lado da CPU são negligenciáveis porque os tamanhos dos lotes e a latência do lado da GPU são ambos elevados. No entanto, em tamanhos de lote menores, o trabalho do lado da CPU pode superar o trabalho do lado da GPU.

Passagem para a frente eager: invocações do anfitrião intercaladas com kernels do dispositivo

Para mitigar as sobrecargas, em vez de lançar kernels independentes, pode ser construído um grafo CUDA para capturar os metadados necessários para lançar todos os kernels de uma passagem para a frente com uma única chamada ao controlador CUDA. Isto elimina a necessidade de reexecutar código Python e PyTorch dispendioso para as configurações para as quais os grafos CUDA podem ser capturados.

Em cada modelo, monitorizamos um ponto de inflexão, determinando o número mínimo de tokens em que a execução na GPU é mais dispendiosa do que o lançamento de kernels no lado da CPU. Como os modelos de incorporação são pequenos, observamos que este ponto de inflexão ocorre em lotes de milhares de tokens e dezenas de sequências. Algumas implementações de atenção dependem de entradas dinâmicas no lado do anfitrião (host) para configurar os lançamentos de kernels, impedindo grafos CUDA densos/de prefill de modelo completo. Fizemos o upstream de alterações para os kernels relevantes, de modo a ativá-los no nosso motor de inferência.

Para resolver as sobrecargas, construímos grafos CUDA de modelo completo para todos os modelos de incorporação e sobrepomos o trabalho da CPU com o trabalho da GPU. Como os grafos CUDA minimizam as sobrecargas do lado da CPU, assim que um grafo é lançado, temos tempo livre para iniciar e colocar em fila a execução do lote seguinte sempre que este estiver disponível. Os resultados do lote pendente são monitorizados com um LazyTensor, o que permite que uma tarefa assíncrona em Rust bloqueie até que o lote anterior termine a execução. Os grafos CUDA ajudam o atendimento de baixa latência, garantindo que não somos retidos pelo custo de lançamentos de kernels e facilitando um agendamento melhorado no caso de alto débito, uma vez que libertam a CPU para trabalhar no lote seguinte mais rapidamente.

Passagem para a frente do Cudagraph

Os grafos CUDA devem ser capturados para cada configuração distinta, o que para incorporações significa um grafo por combinação de contagem de sequências e contagem de tokens. Como esta grelha é expansiva, preenchemos (pad) as contagens de tokens para baldes (buckets) que sejam múltiplos de 64 ou 256. Isto ainda resulta em milhares de plafonds ou grafos que podem demorar vários minutos a capturar para um modelo típico. O custo da captura provém de duas fontes: uma passagem para a frente eager que deve ser executada para compilar kernels e configurar buffers para vários kernels que deles necessitam, seguida da execução de captura que reexecuta o código Python.

Mitigamos os custos de arranque capturando grafos CUDA com atraso (lazily) à medida que o motor serve. Mantemos o registo de cada configuração e garantimos que esta passa por uma execução de aquecimento eager antes de acionar a captura e reprodução de grafos no segundo acerto. Todas as execuções subsequentes da mesma configuração de grafo passam então pela reprodução do grafo CUDA. A captura de grafos com atraso tem um impacto nas latências p99 durante o arranque; no entanto, é valiosa para distribuir vários minutos de trabalho eager por várias horas. Tempos de arranque mais rápidos permitem-nos dimensionar e gerir melhor as implementações de incorporação.

Tensores com Atraso (Lazy Tensors)

Através do CUDA, o trabalho da GPU é assíncrono. Uma vez que o lançamento de um kernel de forma assíncrona o coloca em fila num fluxo, o código do anfitrião deve sincronizar explicitamente para ler os vetores resultantes. Para facilitar um maior grau de paralelismo e poder iniciar lotes futuros enquanto se espera que o anterior seja concluído no dispositivo, contamos com uma abstração LazyTensor para monitorizar valores.

O LazyTensor monitoriza um buffer de anfitrião em memória bloqueada em página (page-locked) e uma operação cudaMemcpyAsync através de um evento que copia dados do dispositivo. É iniciado após o lançamento da passagem para a frente no mesmo fluxo (stream). Uma vez que a operação de cópia deve esperar que todos os kernels anteriores no fluxo sejam executados, o evento associado monitoriza tanto a conclusão da passagem para a frente como a disponibilidade do resultado na CPU.

Execução de LazyTensor

Aproveitamos LazyTensors no nosso motor de codificador ROSE para sobrepor o trabalho da GPU e da CPU. Em vez de cada chamada a step() executar o grafo CUDA e esperar que termine, step() devolve um LazyTensor para monitorizar assincronamente o seu resultado. Acoplado aos grafos CUDA, isto ajuda-nos a alcançar baixas latências e melhor débito (throughput).

Linha do tempo mostrando a preparação da CPU e a sincronização a sobrepor lotes sucessivos de GPU

ROSE

Adaptámos o nosso motor ROSE, originalmente construído para servir LLMs, para também lidar com a execução de modelos de incorporação. Para minimizar o esforço necessário para suportar modelos de incorporação, o ROSE reutiliza agressivamente o código entre LLMs e incorporações. Por exemplo, o atendimento pplx-embed e a descodificação de LLM Qwen3.5 passam todos pelos mesmos kernels. Esta partilha permite-nos servir facilmente um modelo de incorporação que foi originalmente ajustado (fine-tuned) a partir de um LLM para prototipagem, avaliação e inferência em produção.

Para camadas densas, a inferência de incorporação e de LLM é idêntica, uma vez que os vetores de tokens são processados de forma independente. Nas camadas de atenção, as diferenças são tratadas adicionando suporte para entradas irregulares (ragged), juntamente com as configurações de prefill e descodificação paginadas exigidas pelos LLMs. Ao servir um modelo de incorporação, não instanciamos uma cache KV e encaminhamos para variações de kernels de atenção que suportam o formato irregular para evitar o preenchimento (padding). As rotinas de conversão e calibração de suporte também são partilhadas com os LLMs.

Ivy

O Ivy, a nossa camada de proxy HTTP de inferência, também desempenha um papel importante no desempenho. Como as cargas úteis (payloads) dos pedidos variam em produção, o encaminhamento de pedidos individuais para réplicas individuais pode causar desequilíbrio de carga. O Ivy divide pedidos de grandes lotes em pedaços (chunks) e equilibra a carga entre réplicas, melhorando a utilização e suavizando a latência. O nosso trabalho recente na tokenização unigrama própria, totalmente implementada no Ivy, melhora drasticamente as latências em comparação com os tokenizadores disponíveis no mercado.

...mas os Kernels Continuam a Importar

O ROSE suporta uma variedade de backends de atenção. Diferentes kernels podem ser adequados para tamanhos de problemas específicos. Com o tempo, integrámos os kernels FlashInfer 2, FlashInfer 3 e FlashAttention 4 para implementar atenção irregular (ragged attention).

Desempenho do kernel de atenção por forma do modelo e tamanho do problema

Em geral, observamos que o FlashAttention 4 é mais rápido. No entanto, o FlashInfer 3 supera-o em modelos baseados em Qwen para comprimentos de sequência muito longos. Como o desempenho e o ajuste podem variar com o número e a dimensão das cabeças de atenção, mantemos suporte para várias configurações e tomamos uma decisão caso a caso durante a disponibilização.

Avaliações Comparativas (Benchmarks)

Fazemos uma avaliação comparativa (benchmark) em relação ao vLLM v0.22.0, executando a inferência em precisão BF16 em pesos de modelos reais e entradas derivadas de conjuntos de dados de avaliação. Todas as execuções de temporização foram precedidas por execuções de aquecimento (warmup) que verificaram se a divergência na similaridade de cosseno está dentro de 0,1%.

Incorporações de Baixa Latência (p50 / p90 / p99 / máx ms)

Relatamos tempos de execução para tamanho de lote de pedidos pré-tokenizado 1, pedidos totalmente sequenciais, comprimentos de sequência de 128, 512 e 4096 tokens.

Resultados de benchmark de incorporações de baixa latência

Pontuação de Baixa Latência (p50 / p90 / p99 / máx ms)

Tamanhos de lote de pedidos pré-tokenizados de 5, 25 e 50, comprimento de sequência de 512 tokens.

Resultados de benchmark de pontuação de baixa latência

Incorporações de Alto Débito (emb/s)

Tamanho de lote de pedidos de 100, quatro processos concorrentes a submeter pedidos, comprimentos de sequência de 512, 1024 e 4096 tokens.

Resultados de benchmark de incorporações de alto débito

Incorporações de Alta Concorrência (p50 / p90 / p99 / máx ms)

Comprimento de sequência 512, tamanho de lote 1, mas enviamos 1, 2, 4, 8 e 16 pedidos concorrentes. Esta avaliação comparativa (benchmark) inclui também os custos de tokenização através do Ivy, juntamente com a sobrecarga de rede entre o Ivy e o Tulip.

Resultados de benchmark de incorporações de alta concorrência

Conclusão e Trabalho Futuro

A infraestrutura de atendimento composta por Ivy, Tulip e ROSE permite-nos servir incorporações para a Perplexity com menor latência e melhor débito, resultando numa pesquisa mais precisa a um custo reduzido em comparação com soluções prontas a usar.

Ao focarmo-nos em modelos específicos e assumirmos a total responsabilidade por toda a pilha, obtemos a liberdade necessária para encontrar um equilíbrio eficaz entre desempenho e flexibilidade, misturando primitivas em Rust altamente reutilizáveis e de alto desempenho com código de modelação em Python mais genérico. Muitos motores de inferência de código aberto (open-source), como vLLM, SGLang e TokenSpeed, estão a integrar linguagens como Rust e C++ na sua pilha. Investimos em Rust nos últimos dois anos e colhemos grandes recompensas tanto no desempenho como na manutenção. Ao partilhar a maior parte da implementação de incorporação com a nossa pilha de servir LLMs, também obtemos ganhos no débito, sem exigir que seja gasto um esforço de engenharia significativo na manutenção de modelos de incorporação.

À medida que os modelos evoluem, continuaremos a melhorar cada camada da nossa pilha para reduzir as latências limitadas tanto pela CPU como pela GPU. Os nossos protocolos personalizados baseados em gRPC no Ivy e no Tulip permitem-nos ajustar a comunicação para reduzir as latências de rede, enquanto o ROSE fornece uma base para melhorar o débito computacional. Adicionalmente, à medida que o suporte para Python com threads livres (free-threaded) cresce em todo o ecossistema, poderemos melhorar ainda mais a interoperabilidade entre Python e Rust para reduzir as sobrecargas.

Referências