Raske innbygginger på GPU-er

Raskt og nøyaktig søk er avgjørende for hele Perplexity, fra Søk og Datamaskin til vår API-plattform. Bak kulissene gjøres det tunge løftet av innbyggings- og rangeringsmodeller, som hjelper systemene våre med å identifisere de mest relevante resultatene for en gitt spørring. Vi oppnår toppmoderne

ForfatterePerplexity Engineering

Raskt og nøyaktig søk er avgjørende for hele Perplexity, fra Søk og Datamaskin til vår API-plattform. Bak kulissene gjøres det tunge løftet av innbyggings- og rangeringsmodeller, som hjelper systemene våre med å identifisere de mest relevante resultatene for engitt spørring. Vi oppnår toppmoderne kvalitet og latens ved å trene og betjene våre egne modeller, som pplx-embed.

Denne artikkelen gir et innblikk under panseret i Perplexitys betjeningsinfrastruktur for denne spesielle klassen av modeller. Vi diskuterer våre teknikker for effektivt å møte inferensbehovene til AI-nativ søking, noe som muliggjør rask prototyputvikling og evaluering av modeller samtidig som det driver vår eksabyt-skala søkeindeks. Disse teknikkene utvider kollektivt Pareto-grensen for søkekvalitet og effektivitet, slik at vi kan betjene agenter og brukere med best mulige resultater til lavest mulig kostnad og latens.

Innbygginger for søk

I et typisk søkeoppsett kartlegges indiserte dokumenter til et høydimensjonalt vektorrom ved hjelp av en innbyggingsmodell og lagres i en vektorbase. Ved å innbygge en spørring ved hjelp av den samme modellen, kan lignende dokumenter lokaliseres ved å finne vektorene som er nektet kilden til spørringen. Dette gir opphav til to forskjellige trafikkmønstre som en inferensmotor må betjene:

  • Batch Embedding: ved bygging, utvidelse eller reindeksering av databasen må massevaredokumenter bygges inn i vektorrommet, noe som maksimerer gjennomstrømmingen for å minimere kostnadene.

Etter vektorsøk må store dokumentbunter scores, noe som skaper en balanse mellom gjennomstrømming og latens.

  • Online Embedding: ved spørring i databasen må en kort spørring innbygges for oppslag, noe som minimerer latensen.

Vi bygget ut vår inferensinfrastruktur for å utnytte så mange vanlige komponenter som mulig på tvers av brukstilfeller. Siden vi typisk bruker små Transformer-modeller til å produsere innbygginger, deler vi det meste av implementeringen med vår LLM-inferenskode: bunntetthetsinnbygginger (batch embeddings) ligner på beregningsbundet utfylling (prefill), mens online-innbygginger, som ofte kjører på noen få tokens, er beregningsmessig lik minnebundet avkoding (decode). Vi gjenbruker dermed våre optimaliserte prefill- og decode-kjerner til å betjene innbyggingsmodeller. Som et resultat kan vi oppnå massiv gjennomstrømming for buntinferens med minimalt ekstra ingeniørarbeid, samtidig som vi opprettholder lav latens for online innbyggingsarbeidslaster.

Tulips, Roses og litt Ivy

Vi eksponerer inferens gjennom standardiserte API-er, både internt og eksternt gjennom vår API-plattform. Under panseret er flere tjenester involvert i behandlingen av en innbyggingsforespørsel:

  • Ivy er en Rust HTTP-gateway som Perplexity-tjenester ringer.

Den håndterer CPU-sidearbeidet for forespørsler som JSON-parsing, tokenisering, inndatamalforming og buntoppdeling, og oversetter forespørsler til en egendefinert gRPC-protokoll for nedstrømsservere. Denne separasjonen lar oss konfigurere visse parametere rundt tokenisering og inndataformatering uten å måtte røre de tyngre inferensinstansene.

  • Tulip er inferensservergrensesnittet.

Det er en gRPC-server implementert med Rust, tokio og `tonic`. Tulip mottar gRPC-inferensforespørsler, og håndterer planlegging og buntting. Den sender deretter buntene til ROSE-motoren, og returnerer fullførte svar til klienter.

  • **ROSE** (Runtime-Optimized Serving Engine) implementerer modellinferens.

Den er primært definert i Python, og gir kjerner, lag og definisjoner for et bredt utvalg av modeller. ROSE implementerer fremoverpasseringene gjennom modeller, og tilbyr også CUDA-grafadministrasjon spesialisert for innbygginger. Den er brobygget til Tulip via en step()-funksjon, som tar en bunt og returnerer en referanse til beregningen den utfører på akseleratoren.

Betjeningsarkitektur fra en innbyggingsforespørsel gjennom Ivy til replikerte Tulip-servere

Pay Attention Beyond the Kernel

Både Transformer-baserte modeller og de underliggende Hopper/Blackwell-arkitekturene er modne teknologier, slik at innebygd inferens på GPU-siden har konvergert mot en i all vesentlighet optimal implementering på tvers av ulike inferensmotorer. Til tross for dette oppdaget vi ytterligere muligheter for forbedring i kjøretider og rammeverk som eksponerer modellene ende-til-ende for en klient. Spesielt fant vi ut at vi kan forbedre latenser ved å administrere CUDA-grafer nøye og ved å bygge en LazyTensor-abstraksjon for asynkront å spore et GPU-sideresultat i den opprinnelige Rust-motoren. Vi implementerte disse funksjonene i Tulip, slik at den effektivt kunne grensesnitte med modellimplementeringene til ROSE.

Tulip

Vi designet Tulip til å være et så lett grensesnitt over vår modellbetjening som mulig. Den håndterer innkommende forespørsler i Tokio asynkrone oppgaver, og opprettholder en samling av forespørsler den sporer og planlegger bunter fra for å sende til akseleratoren. Planleggingsmekanismen i Tulip er veldig enkel: forespørsler akkumuleres mens Tulip sender arbeid eller venter på resultater. Fra de akkumulerte forespørslene blir sekvenser plukket ut etter først-til-mølla-prinsippet for å bli kjørt gjennom modellen.

Den enkle planleggingsmekanismen er motivert av en observasjon av modellsytelse. For små innbyggingsmodeller, ved sekvenslengdene vi betjener, la vi merke til at den lineære kostnaden for tette lag dominerer over den kvadatiske kostnaden for oppmerksomhet. Dermed er latensen for det meste proporsjonal med antall tokens, ikke antall sekvenser. Følgelig, når en bunt er stor nok til å mette GPU-en, som er rundt 512 tokens på en modell under en milliard parametere, forbedrer ikke det å pakke flere sekvenser inn i den effektiviteten.

For å grensesnitte effektivt med modellen stoler Tulip på CUDA-grafer og lat resultatsporing for å overlappe GPU- og CPU-arbeid og fullt ut utnytte de tilgjengelige ressursene.

CUDA-grafadministrasjon

Å kjøre en modells fremoverpassering involverer både arbeid på CPU-siden og GPU-siden. CPU-en er ansvarlig for å planlegge bunter og starte kjerner med de aktuelle parameterne, mens GPU-en utfører de relevante matrisemultiplikasjons-, oppmerksomhets-, norm- eller aktiveringskjernene. For arbeidslaster med høy gjennomstrømming som trening og reindeksering, er CPU-sidekostnader ubetydelige fordi buntstørrelser og GPU-side-latens begge er store. På mindre buntstørrelser kan imidlertid CPU-sidearbeid veie tyngre enn GPU-sidearbeid.

Ivrig fremoverpassering: vertsinvoiseringer flettet inn med enhetskjerner

For å redusere overheadkostnader, i stedet for å starte uavhengige kjerner, kan en CUDA-graf bygges for å fangste metadataene som kreves for å starte alle kjernene i en fremoverpassering med et enkelt kall til CUDA-driveren. Dette eliminerer behovet for å kjøre dyre Python- og PyTorch-koder på nytt for konfigurasjonene CUDA-grafer kan fangstes for.

På tvers av hver modell sporer vi et infleksjonspunkt, som bestemmer minimum antall tokens der GPU-kjøring er dyrere enn kjernestart på CPU-siden. Fordi innbyggingsmodeller er små, observerer vi at dette infleksjonspunktet kommer ved bunter på tusenvis av tokens og titalls sekvenser. Noen oppmerksomhetsimplementeringer er avhengige av dynamiske verts-side-innganger for å konfigurere kjernestarter, noe som forhindrer fullmodells prefill-/tette CUDA-grafer. Vi upstreamede endringer til relevante kjerner for å aktivere dem i vår inferensmotor.

For å adressere overheadkostnader bygger vi helmodells CUDA-grafer for alle innbyggingsmodeller og overlapper CPU-arbeid med GPU-arbeid. Siden CUDA-grafer minimerer CPU-side-overheadkostnadene, har vi ledig tid til å starte og sette i kø kjøringen av den neste bunten når den er tilgjengelig, når en graf først er startet. Resultatene av den ventende bunten spores med en LazyTensor, som tillater en asynkron oppgave i Rust å blokkere til den forrige bunten fullfører kjøringen. CUDA-grafer hjelper lavlatensbetjening ved å sikre at vi ikke holdes tilbake av kostnaden for kjernestarter, og forenkler forbedret planlegging i tilfellet med høy gjennomstrømming ettersom de frigjør CPU-en til å utføre arbeid på den neste bunten raskere.

Cudagraph fremoverpassering

CUDA-grafer må fangstes for hver enkelt konfigurasjon, noe som for innbygginger betyr en graf per kombinasjon av sekvensantall og tokentall. Siden dette rutenettet er omfattende, fyller vi ut tokentall til bøtter som er multipler av 64 eller 256. Dette resulterer fremdeles i tusenvis av grafer som kan ta flere minutter å fangste for en typisk modell. Kostnaden ved fangsting kommer fra to kilder: en ivrig fremoverpassering som må utføres for å kompilere kjerner og sette opp buffere for ulike kjerner som trenger dem, etterfulgt av fangstkjøringen som kjører Python-koden på nytt.

Vi demper oppstartskostnader ved å fangste CUDA-grafer latskapsbasert (lazily) mens motoren betjener. Vi holder oversikt over hver konfigurasjon og sikrer at den går gjennom en ivrig oppvarmingskjøring før vi utløser grafangsting og avspilling ved andre treff. Alle etterfølgende kjøringer av den samme grafkonfigurasjonen går deretter gjennom CUDA-grafavspilling. Latskapsbasert grafangsting har en innvirkning på p99-latenser under oppstart; den er imidlertid verdifull for å spre flere minutter med ivrig arbeid over flere timer. Raskere oppstartstider gjør oss bedre i stand til å skalere og administrere innbyggingsdistribusjoner.

Lata tensorer

Gjennom CUDA er GPU-arbeid asynkront. Siden start av en kjernetid en kjernetid asynkront setter den i kø på en strøm, må vertskoden synkronisere eksplisitt for å lese ut de resulterende vektorene. For å legge til rette for en høyere grad av parallellitet og for å kunne sette i gang fremtidige bunter mens vi venter på at den forrige skal fullføres på enheten, stoler vi på en LazyTensor-abstraksjon for å spore verdier.

The LazyTensor sporer en vertsbuffer i sidelåst minne og en cudaMemcpyAsync-operasjon via en hendelse som kopierer data fra enheten. Den settes i gang etter oppstarten av fremoverpasseringen på den samme strømmen. Siden kopieringsoperasjonen må vente på at alle tidligere kjerner på strømmen skal utføres, sporer den tilhørende hendelsen både fullføringen av fremoverpasseringen og tilgjengeligheten av resultatet på CPU-en.

LazyTensor-kjøring

Vi utnytter LazyTensor-er i vår ROSE-enkodermotor for å overlappe GPU- og CPU-arbeid. I stedet for at hvert step()-kall kjører CUDA-grafen og venter på at den skal bli ferdig, returnerer step() en LazyTensor for asynkront å spore resultatet. Sammen med CUDA-grafer hjelper dette oss med å oppnå lave latenser og bedre gjennomstrømming.

Tidslinje som viser CPU-klargjøring og synkronisering som overlapper etterfølgende GPU-bunter

ROSE

Vi tilpasset vår ROSE-motor, som vi opprinnelig bygget for LLM-betjening, til også å håndtere kjøringen av innbyggingsmodeller. For å minimere innsatsen som trengs for å støtte innbyggingsmodeller, gjenbruker ROSE aggressivt kode mellom LLM-er og innbygginger. For eksempel går pplx-embed-betjening og Qwen3.5 LLM-avkoding gjennom de samme kjernene. Denne delingen gjør det enkelt for oss å betjene en innbyggingsmodell som opprinnelig ble finjustert fra en LLM for prototyper, evaluering og produksjonsinferens.

For tette lag er innbyggings- og LLM-inferens identiske siden token-vektorer behandles uavhengig. I oppmerksomhetslag håndteres forskjeller ved å legge til støtte for ujevne (ragged) inndata, i tillegg til de paged prefill- og decode-oppsettene som kreves av LLM-er. Når vi betjener en innbyggingsmodell, instansierer vi ikke en KV-mellomlager og sender til variasjoner av oppmerksomhetskjerner som støtter det ujevne formatet for å unngå utfylling. De støttende konverterings- og kalibreringsrutinene deles også med LLM-ene.

Ivy

Ivy, vårt HTTP-mellomlag for inferensproxy, spiller også en viktig rolle for ytelsen. Fordi forespørselsnyttelast varierer i produksjonen, kan ruting av individuelle forespørsler til individuelle replikaer forårsake ubalanse i lasten. Ivy deler opp forespørsler med store bunter i biter og lastebalanserer dem mellom replikaer, noe som forbedrer utnyttelsen og jevner ut latensen. Vårt nylige arbeid med intern unigram-tokenisering, fullstendig rullet ut i Ivy, forbedrer latensene drastisk sammenlignet med hyllevare-tokeniserere.

...men kjernene betyr fremdeles noe

ROSE støtter en rekke oppmerksomhetsbakender. Ulike kjerner kan egne seg til spesifikke problemstørrelser. Over tid integrerte vi FlashInfer 2-, FlashInfer 3- og FlashAttention 4-kjerner for å implementere ujevn oppmerksomhet.

Oppmerksomhetskjernens ytelse etter modellform og problemstørrelse

Generelt observerer vi at FlashAttention 4 er raskere. Imidlertid utkonkurrerer FlashInfer 3 den på Qwen-baserte modeller ved svært lange sekvenslengder. Siden ytelse og tuning kan variere med antall og dimensjon på oppmerksomhetshoder (attention heads), opprettholder vi støtte for flere konfigurasjoner og tar en individuell avgjørelse ved betjening.

Ytelsestester

Vi utfører ytelsestester mot vLLM v0.22.0, med kjøring av inferens på BF16-presisjon på faktiske modellvekter og inndata avledet fra evalueringsdatasett. Alle tidsbestemte kjøringer ble forutgått av oppvarmingskjøringer som verifiserte at avviket i cosinuslikhet er innenfor 0,1 %.

Lavlatens-innbygginger (p50 / p90 / p99 / maks ms)

Vi rapporterer kjøretider for forhåndstokenisert forespørselsbuntstørrelse 1, fullstendig sekvensielle forespørsler, sekvenslengder på 128, 512 og 4096 tokens.

Resultater for ytelsestest av innbygginger med lav latens

Lavlatens-scoring (p50 / p90 / p99 / maks ms)

Forhåndstokeniserte forespørselsbuntstørrelser 5, 25 og 50, sekvenslengde på 512 tokens.

Resultater for ytelsestest av scoring med lav latens

Høygjennomstrømmings-innbygginger (emb/s)

Forespørselsbuntstørrelse 100, fire samtidige prosesser som sender forespørsler, sekvenslengder på 512, 1024 og 4096 tokens.

Resultater for ytelsestest av innbygginger med høy gjennomstrømming

Innbygginger med høy samtidighet (p50 / p90 / p99 / maks ms)

Sekvenslengde 512, buntstørrelse 1, men vi sender 1, 2, 4, 8 og 16 samtidige forespørsler. Denne ytelsestesten inkluderer også tokeniseringskostnader gjennom Ivy, i tillegg til nettverkskostnaden mellom Ivy og Tulip.

Resultater for ytelsestest av innbygginger med høy samtidighet

Konklusjon og fremtidig arbeid

Betjeningsinfrastrukturen sammensatt av Ivy, Tulip og ROSE lar oss betjene innbygginger for Perplexity med lavere latens og bedre gjennomstrømming, noe som resulterer i mer nøyaktig søk til en redusert kostnad sammenlignet med hyllevareløsninger.

Ved å fokusere på spesifikke modeller og ta eierskap til hele stabelen, oppnår vi friheten som er nødvendig for å finne en effektiv balanse mellom ytelse og fleksibilitet, og blande svært gjenbrukbare og ytelsesdyktige Rust-primitiver sammen med mer generisk Python-modelleringskode. Mange åpen kildekode-inferensmotorer, som vLLM, SGLang og TokenSpeed, integrerer språk som Rust og C++ i sin stabel. Vi har satset på Rust de siste to årene og har høstet store belønninger i både ytelse og vedlikeholdbarhet. Ved å dele det meste av innbyggingsimplementeringen med vår LLM-betjeningsstabel, oppnår vi også gevinster i gjennomstrømming, uten at det kreves betydelig ingeniørarbeid brukt på vedlikehold av innbyggingsmodeller.

Ettersom modeller utvikler seg, vil vi fortsette å forbedre hvert lag i stabelen vår for å redusere både CPU-bundne og GPU-bundne latenser. Våre egendefinerte gRPC-baserte protokoller innenfor Ivy og Tulip lar oss finjustere kommunikasjon for å redusere nettverkslatenser, mens ROSE gir et fundament for å forbedre beregningsgjennomstrømmingen. I tillegg, etter hvert som støtten for fri-trådet Python vokser gjennom økosystemet, vil vi kunne forbedre Python-Rust-interoperabiliteten ytterligere for å redusere overheadkostnader.

Referanser