Snabba inbäddningar på GPU:er
Snabb och exakt sökning är avgörande för hela Perplexity, från Sök och Dator till vår API-plattform. Bakom kulisserna utförs det tunga arbetet av inbäddnings- och rankningsmodeller, som hjälper våra system att identifiera de mest relevanta resultaten för en given sökfråga. Vi uppnår toppmodern
Snabb och exakt sökning är avgörande för hela Perplexity, från Sök och Dator till vår API-plattform. Bakom kulisserna utförs det tunga arbetet av inbäddnings- och rankningsmodeller, som hjälper våra system att identifiera de mest relevanta resultaten för en given sökfråga. Vi uppnår toppmodern kvalitet och svarstid genom att träna och servera våra egna modeller, såsom pplx-embed.
Den här artikeln ger en inblick bakom kulisserna i Perplexitys serveringsinfrastruktur för denna speciella klass av modeller. Vi diskuterar våra tekniker för att effektivt möta inferensbehoven för AI-native sökning, vilket möjliggör snabb prototyptillverkning och utvärdering av modeller samtidigt som det driver vårt sökindex i exabyte-skala. Dessa tekniker utökar gemensamt Pareto-gränsen för sökvalitet och effektivitet, vilket gör det möjligt att erbjuda agenter och användare bästa möjliga resultat till lägsta kostnad och svarstid.
Inbäddningar för sökning
I en typisk sökopsättning mappas indexerade dokument till en högdimensionell vektorrymd med hjälp av en inbäddningsmodell och lagras i en vektordatabas. Genom att bädda in en sökfråga med samma modell kan liknande dokument hittas genom att leta upp de vektorer som ligger näast sökfrågans vektor. Detta ger upphov till två olika trafikmönster för en inferensmotor att betjäna:
- Batchinbäddning: när databasen byggs, utökas eller omindexeras måste bulkdokument inbäddas i vektorrymden, vilket maximerar genomströmningen för att minimera kostnaden.
Efter vektorsökning måste stora batcher av dokument poängsättas, vilket skapar en balans mellan genomströmning och svarstid.
- Onlineinbäddning: vid sökning i databasen måste en kort sökfråga inbäddas för uppslagningar, vilket minimerar svarstiden.
Vi har byggt ut vår inferensinfrastruktur för att utnyttja så många gemensamma komponenter som möjligt över olika användningsområden. Eftersom vi vanligtvis använder små Transformer-modeller för att generera inbäddningar delar vi större delen av implementeringen med vår LLM-inferenskod: batchinbäddningar liknar beräkningsbunden prefill, medan onlineinbäddningar, som ofta körs på ett fåtal tokens, är beräkningsmässigt lika minnesbunden dekodning. Vi återanvänder därmed våra optimerade prefill- och decode-kernels för att servera inbäddningsmodeller. Som ett resultat kan vi uppnå enorm genomströmning vid batchinferens med minimalt extra ingenjörsarbete, samtidigt som vi bibehåller låg svarstid för onlineinbäddningsarbetslaster.
Tulips, Roses och lite Ivy
Vi exponerar inferens genom standardiserade API:er, både internt och externt via vår API-plattform. Bakom kulisserna är flera tjänster involverade i behandlingen av en inbäddningsförfrågan:
- Ivy är en Rust HTTP-gateway som Perplexity-tjänster anropar.
Den hanterar CPU-sidans arbete för förfrågningar som JSON-tolkning, tokenisering, indatamallning och batchuppdelning, och översätter förfrågningar till ett anpassat gRPC-protokoll för nedströmservrar. Denna separation gör att vi kan konfigurera vissa parametrar kring tokenisering och indataformatering utan att behöva röra de tyngre inferensinstanserna.
- Tulip är inferensservergränssnittet.
Det är en gRPC-server som implementerats med Rust, tokio och `tonic`. Tulip tar emot gRPC-inferensförfrågningar och hanterar schemaläggning och batchning. Den skickar sedan batcharna till ROSE-motorn och returnerar slutförda svar till klienterna.
- **ROSE** (Runtime-Optimized Serving Engine) implementerar modellinferens.
Den är primärt definierad i Python och tillhandahåller kernels, lager och definitioner för ett brett utbud av modeller. ROSE implementerar framåtriktade pass (forward passes) genom modeller och tillhandahåller även CUDA-grafhantering som är specialiserad för inbäddningar. Den är bryggad till Tulip via en step()-funktion, som tar en batch och returnerar en referens till den beräkning den utför på acceleratorn.

Uppmärksamhet bortom kerneln
Både Transformer-baserade modeller och de underliggande Hopper/Blackwell-arkitekturerna är mogna teknologier, så inbäddningsinferens på GPU-sidan har konvergerat till en till stor del optimal implementering över olika inferensmotorer. Trots det upptäckte vi ytterligare möjligheter till förbättringar i körtider och anpassningar som exponerar modellerna end-to-end för en klient. I synnerhet fann vi att vi kan förbättra svarstider genom att noggrant hantera CUDA-grafer och genom att bygga en LazyTensor-abstraktion för att asynkront spåra ett resultat på GPU-sidan i den ursprungliga Rust-motorn. Vi implementerade dessa funktioner i Tulip så att den effektivt kan gränssnitsa med modellimplementeringarna i ROSE.
Tulip
Vi designade Tulip för att vara ett så lättviktsgränssnitt över vår modellservering som möjligt. Den hanterar inkommande förfrågningar i Tokio-asynktasker, och upprätthåller en pool av förfrågningar som den spårar och schemalägger batcher från för att skicka till acceleratorn. Schemaläggningsmekanismen i Tulip är mycket enkel: förfrågningar samlas på sig medan Tulip skickar iväg arbete eller väntar på resultat. Från de samlade förfrågningarna väljs sekvenser ut enligtst-till-kvarn-principen (first-come, first-served) för att köras genom modellen.
Den enkla schemaläggningsmekanismen motiveras av en observation av modellprestanda. För små inbäddningsmodeller, vid de sekvenslängder vi serverar för, märkte vi att den linjära kostnaden för täta lager dominerar över den kvadradiska kostnaden för attention. Svarstiden är således till största del proportionell mot antalet tokens, inte antalet sekvenser. Följaktligen, när en batch är tillräckligt stor för att mätta GPU:ern, vilket är runt 512 tokens på en modell under en miljard parametrar, förbättras inte effektiviteten av att packa in fler sekvenser i den.
För att effektivt interagera med modellen förlitar sig Tulip på CUDA-grafer och lazy resultatspårning för att överlappa GPU- och CPU-arbete och fullt ut utnyttja tillgängliga resurser.
CUDA-grafhantering
Att köra en modells framåtriktade pass (forward pass) innefattar både arbete på CPU-sidan och GPU-sidan. CPU:ern ansvarar för schemaläggning av batcher och start av kernels med lämpliga parametrar, medan GPU:ern exekverar relevanta matrismultiplikations-, attention-, norm- eller aktiveringskernels. För arbetslaster med hög genomströmning, såsom träning och omindexering, är CPU-sidans omkostnader försumbara eftersom batchstorlekar och GPU-sidans svarstid båda är stora. Vid mindre batchstorlekar kan dock CPU-arbetet överväga GPU-arbetet.

För att dämpa omkostnader kan man istället för att starta oberoende kernels bygga en CUDA-graf för att fånga upp den metadata som krävs för att starta alla kernels i ett framåtriktat pass med ett enda anrop till CUDA-drivrutinen. Detta eliminerar behovet av att köra om dyr Python- och PyTorch-kod för de konfigurationer som CUDA-grafer kan fångas upp för.
För varje modell spårar vi en brytpunkt som fastställer det minsta antal tokens där GPU-exekvering är dyrare än kernel-start på CPU-sidan. Eftersom inbäddningsmodeller är små observerar vi att denna brytpunkt inträffar vid batcher på tusentals tokens och ett tjugotal sekvenser. Vissa attention-implementeringar förlitar sig på dynamiska värd-side-indata (host-side inputs) för att konfigurera kernel-starter, vilket förhindrar fullständiga modell-prefill/denta CUDA-grafer. Vi har skickat upp (upstreamed) ändringar till relevanta kernels för att aktivera dem i vår inferensmotor.
För att hantera omkostnader bygger vi CUDA-grafer för hela modellen för alla inbäddningsmodeller och överlappar CPU-arbete med GPU-arbete. Eftersom CUDA-grafer minimerar omkostnaderna på CPU-sidan har vi fri tid att starta och köa exekvringen av nästa batch när den är tillgänglig, så fort en graf har startats. Resultaten av den väntande batchen spåras med en LazyTensor, vilket gör att en asynkron uppgift i Rust kan blockera till dess att föregående batch har kört färdigt. CUDA-grafer hjälper servering med låg svarstid genom att säkerställa att vi inte hålls tillbaka av kostnaden för kernel-starter, och underlättar förbättrad schemaläggning i fallet med hög genomströmning eftersom de frigör CPU:n att arbeta på nästa batch tidigare.

CUDA-grafer måste fångas upp för varje unik konfiguration, vilket för inbäddningar innebär en graf per kombination av sekvensantal och antalet tokens. Eftersom detta rutnät är omfattande fyller vi ut (pads) tokenantal till storlekar som är multipel av 64 eller 256. Detta resulterar ändå i tusentals grafer som kan ta flera minuter att fånga upp för en typisk modell. Kostnaden för uppfångningen kommer från två källor: ett eager framåtriktat pass som måste exekveras för att kompilera kernels och sätta upp buffertar för olika kernels som behöver dem, följt av uppfångningskörningen som återexekverar Python-kod.
Vi dämpar uppstartskostnaderna genom att fånga upp CUDA-grafer lazy i takt med att motorn serverar. Vi håller reda på varje konfiguration och ser till att den går igenom en eager uppvärmningskörning innan grafuppfångning och -uppspelning utlöses vid den andra träffen. Alla efterföljande exekveringar av samma grafkonfiguration går digger igenom CUDA-grafuppspelning. Lazy grafuppfångning har en inverkan på p99-svarstider under uppstart; den är dock värdefull för att sprida ut flera minuter av eager arbete över flera timmar. Snabbare uppstartstider gör att vi bättre kan skala och hantera inbäddningsdistributioner.
Lazy Tensors
Genom CUDA är GPU-arbete asynkront. Eftersom start av en kernel asynkront köar den på en ström måste värdkoden (host code) explicit synkronisera för att läsa ut de resulterande vektorerna. För att underlätta en högre grad av parallellitet och kunna starta framtida batcher medan man väntar på att den föregående ska bli klar på enheten, förlitar vi oss på en LazyTensor-abstraktion för att spåra värden.
The LazyTensor spårar en värdbuffert i sidlåst minne och en cudaMemcpyAsync-operation via en händelse som kopierar data från enheten. Den startas efter lanseringen av det framåtriktade passet på samma ström. Eftersom kopieringsoperationen måste vänta på att alla tidigare kernels på strömmen ska exekveras, spårar den tillhörande händelsen både att det framåtriktade passet är slutfört och att resultatet finns tillgängligt på CPU:n.

Vi utnyttjar LazyTensorer i vår ROSE-enkodermotor för att överlappa GPU- och CPU-arbete. Istället för att varje step()-anrop kör CUDA-grafen och väntar på att den ska bli klar, returnerar step() en LazyTensor för att asynkront spåra dess resultat. I kombination med CUDA-grafer hjälper detta oss att uppnå låga svarstider och bättre genomströmning.

ROSE
Vi anpassade vår ROSE-motor, som vi ursprungligen byggde för LLM-servering, för att även hantera exekvering av inbäddningsmodeller. För att minimera ansträngningen som krävs för att stödja inbäddningsmodeller återanvänder ROSE aggressivt kod mellan LLM:er och inbäddningar. Till exempel går pplx-embed-servering och Qwen3.5 LLM-dekodning igenom samma kernels. Denna delning gör att vi enkelt kan servera en inbäddningsmodell som ursprungligen finjusterats från en LLM för prototyper, utvärdering och produktionsinferens.
För tätt sammankopplade lager (dense layers) är inbäddnings- och LLM-inferens identiska eftersom tokenvektorer bearbetas oberoende av varandra. I attention-lager hanteras skillnader genom att lägga till stöd för oregelbundna (ragged) indata, tillsammans med de paginerade prefill- och decode-installationer som krävs av LLM:er. När vi serverar en inbäddningsmodell instansierar vi inte en KV-cache och skickar till varianter av attention-kernels som stöder det oregelbundna formatet för att undvika utfyllnad (padding). De stödjande konverterings- och kalibreringsrutinerna delas också med LLM:erna.
Ivy
Ivy, vårt HTTP-proxy-lager för inferens, spelar också en viktig roll för prestandan. Eftersom förfrågningar varierar i produktion kan routning av enskilda förfrågningar till enskilda repliker orsaka obalans i lasten. Ivy delar upp stora batch-förfrågningar i mindre delar och lastbalanserar dem mellan repliker, vilket förbättrar resursutnyttjandet och jämnar ut svarstider. Vårt senaste arbete med egenutvecklad unigram-tokenisering, som är fullt utrullad i Ivy, förbättrar svarstiderna drastiskt jämfört med färdiga tokeniserare.
...men kernels spelar fortfarande roll
ROSE har stöd för en mängd olika attention-backend. Olika kernels kan vara lämpliga för specifika problemstorlekar. Över tid integrerade vi FlashInfer 2-, FlashInfer 3- och FlashAttention 4-kernels för att implementera ragged attention.

Generellt observerar vi att FlashAttention 4 är snabbare. FlashInfer 3 presterar dock bättre på Qwen-baserade modeller vid mycket långa sekvenslängder. Eftersom prestanda och optimering kan variera med antalet och dimensionen på attention-huvuden upprätthåller vi stöd för flera konfigurationer och fattar ett beslut från fall till fall vid servering.
Riktmärken (Benchmarks)
Vi jämför mot vLLM v0.22.0, där vi kör inferens med BF16-precision på faktiska modellvikter och indata som härletts från utvärderingsdatauppsättningar. Alla tidsmätningar föregicks av uppvärmningskörningar som verifierade att avvikelsen i cosinuslikhet ligger inom 0,1 %.
Inbäddningar med låg svarstid (p50 / p90 / p99 / max ms)
Vi rapporterar körtider för förökentokeniserad förfrågningsbatchstorlek 1, helt sekventiella förfrågningar, sekvenslängder på 128, 512 och 4096 tokens.

Poängsättning med låg svarstid (p50 / p90 / p99 / max ms)
Förökentokeniserade förfrågningsbatchstorlekar 5, 25 och 50, sekvenslängd på 512 tokens.

Inbäddningar med hög genomströmning (emb/s)
Förfrågningsbatchstorlek 100, fyra samtidiga processer som skickar förfrågningar, sekvenslängder på 512, 1024 och 4096 tokens.

Inbäddningar med hög samtidighet (p50 / p90 / p99 / max ms)
Sekvenslängd 512, batchstorlek 1, men vi skickar 1, 2, 4, 8 och 16 samtidiga förfrågningar. Detta riktmärke (benchmark) inkluderar även tokeniseringskostnader via Ivy, tillsammans med nätverksomkostnaderna mellan Ivy och Tulip.

Slutsats och framtida arbete
Serveringsinfrastrukturen som består av Ivy, Tulip och ROSE gör det möjligt för oss att servera inbäddningar för Perplexity med lägre svarstid och bättre genomströmning, vilket resulterar i noggrannare sökningar till en lägre kostnad jämfört med färdiga lösningar.
Genom att fokusera på specifika modeller och ta ägarskap över hela stacken får vi den frihet som krävs för att skapa en effektiv balans mellan prestanda och flexibilitet, och blanda starkt återanvändbara och prestandastarka Rust-primitiver vid sidan av mer gener A Python-modelleringskod. Många open source-inferensmotorer, såsom vLLM, SGLang och TokenSpeed, integrerar språk som Rust och C++ i sin stack. Vi har satsat på Rust under de senaste två åren och har skördat stora framgångar i både prestanda och underhållbarhet. Genom att dela det mesta av inbäddningsimplementeringen med vår LLM-serveringsstack får vi också vinster i genomströmning, utan att det krävs några betydande ingenjörsinsatser för underhåll av inbäddningsmodeller.
Allt eftersom modeller utvecklas kommer vi att fortsätta förbättra varje lager i vår stack för att minska såväl CPU-bundna som GPU-bundna svarstider. Våra anpassade gRPC-baserade protokoll inom Ivy och Tulip gör att vi kan finjustera kommunikationen för att minska nätverkssvarstider, medan ROSE utgör en grund för att förbättra beräkningsgenomströmningen. När stödet för fri-trådad Python växer i ekosystemet kommer vi dessutom att kunna förbättra interoperabiliteten mellan Python och Rust ytterligare för att minska omkostnaderna.