Disaggregiertes Prefill und Decode
Die Trennung von Prefill und Decode über Geräte hinweg beschleunigt die LLM-Inferenz und verbessert die Leistung.
Um Ausgabetokens aus einer Eingabeaufforderung zu generieren, wird die LLM-Inferenz in zwei Phasen unterteilt: Prefill und Decode. Prefill verarbeitet die Eingabetokens und füllt KV-Caches, bevor die Decode-Phase beginnt, die Token für Token generiert. Während ein einzelner Decode-Schritt typischerweise Millisekunden dauert, nimmt Prefill deutlich mehr Zeit in Anspruch. Erfolgt dies auf denselben Geräten, verschlechtert die Mischung von Prefill und Decode die Decode-Leistung. In diesem Artikel untersuchen wir eine bewährte Lösung: die disaggregierte Ausführung von Prefill und Decode auf getrennten Geräten, um sowohl den Prefill-Durchsatz als auch die Decode-Latenzen zu maximieren.
Leistung von Prefill vs. Decode
In einer typischen LLM-Serving-Engine wählt der Batch-Scheduler Anfragen für jeden Ausführungsschritt eines Modells aus. Bei der Ausführung auf einem einzelnen Gerät oder Knoten werden sowohl Prefill- als auch Decode-Anfragen gemeinsam verarbeitet. Die Kosten der Attention, die über die Sequenzlänge aggregiert werden, steigen sowohl für Prefill als auch für Decode proportional zur Länge der Einträge im KV-Cache (kv_len). Decode-Anfragen leiten normalerweise ein einzelnes Token (qo_len=1) mit minimalen Kosten durch andere Ebenen weiter, die unabhängig voneinander auf den Tokens einer Sequenz operieren. Prefill-Anfragen leiten Tausende oder Zehntausende von Tokens mit erheblichen Kosten durch dichte Schichten (große qo_len) weiter.
Die Latenz eines Forward-Passes wird stärker von der Anzahl der unabhängigen Tokens beeinflusst, die durch dichte Schichten geleitet werden (qo_len), als von der Anzahl der Tokens, die während der Attention aus dem KV-Cache abgerufen werden (kv_len). Attention kann sowohl über die Anzahl der Anfragen als auch über die kv_len proportional zu den Sequenzlängen parallelisiert werden, um eine gute Auslastung zu erreichen. Prefill ist rechengebunden: Da qo_len hoch ist, können GEMM-Kernel genügend Blöcke entlang der M-Dimension zuweisen, um die Rechenkapazitäten moderner GPUs voll auszuschöpfen. Decode ist speichergebunden: Aufgrund der typischerweise geringen Batch-Größen ist die Anzahl der Eingaben entlang M meist gering, was nur für einen Block ausreicht. Obwohl Split-K GEMM-Kernel die SM-Auslastung bei kleinen Token-Batch-Größen verbessern können, bleiben die Caches und die Matrixmultiplikationseinheiten typischerweise unterausgelastet.
Wenn Anfragen für Prefill und Decode gemischt werden, verursachen Batches mit Prefill-Anfragen höhere Latenzen beim Forward-Pass, was den Decode-Durchsatz der gesamten Instanz negativ beeinflusst. Obwohl das Mischen von Prefill- und Decode-Anfragen oder die Verwendung von chunked Prefill die Decode-Leistung leicht verbessern kann, ist es schwierig, einen ausreichenden Prefill-Durchsatz aufrechtzuerhalten, um genügend Anfragen pro Instanz zu verarbeiten und so den Decode-Durchsatz zu maximieren. Bei großen Modellen mit typischen Ausgabelängen muss Prefill häufig durchgeführt werden, um eine große Batch-Größe für Decode beizubehalten, was die durchschnittliche Latenz erheblich verschlechtert und zu Ruckeln bei der Ausgabe führt.
Diese Probleme können durch die Verwendung separater Knoten für Prefill und Decode gelöst werden. Durch die Zuordnung eines Prefiller-Knotens zu mehreren Decoder-Knoten können genügend Anfragen für Prefill geplant werden, um den Durchsatz zu maximieren und eine ausreichend hohe Anzahl gleichzeitiger Anfragen auf den Decoder-Knoten aufrechtzuerhalten, um auch den Decode-Durchsatz zu maximieren. Die Prefiller-Knoten befüllen die KV-Caches, die dann auf die Decoder-Knoten übertragen werden. Da die Decoder keine Pausen für Prefill einlegen müssen, werden die Latenzen deterministischer, da die Gesamtauswirkung der wachsenden kv_len aktiver Anfragen weniger stark ins Gewicht fällt. Der Preis dafür ist eine Erhöhung der Time to First Token (TTFT), da der Transfer von KV-Caches über das Netzwerk zehn bis Hunderte Millisekunden dauern kann.
KV Messenger
Bei Perplexity basiert unsere Implementierung für disaggregiertes Prefill und Decode auf einem KV Messenger, der mit der LLM-Engine interagiert, um KV-Cache-Transfers von Prefiller-Knoten zu den Decoder-Knoten über ein Netzwerk zu steuern. Auf der Prefiller-Seite akzeptiert der Messenger Anfragen von Decoder-Knoten, übergibt sie an den Batch-Scheduler und überwacht die Ausführung des Forward-Passes, um KV-Caches mit so geringer Latenz wie möglich zu versenden. Auf der Decoder-Seite blockiert der Messenger, nachdem nicht auslagerbare Seiten zugewiesen wurden, die Anfrage für die Decode-Planung, bis er über den Abschluss der KV-Cache- und Decoder-Kontext-Transfers benachrichtigt wird.
Die Disaggregation von Prefill erfordert Verbindungen mit hohem Durchsatz und niedriger Latenz; daher ist unsere Implementierung auf RDMA zugeschnitten und unterstützt sowohl EFA- als auch ConnectX-Netzwerkschnittstellen-Controller (NICs). Der KV Messenger basiert auf libfabric und nutzt unsere fabric-lib-Wrapper, um höherwertige, latenzarme Abstraktionen über die RDMA-Primitive (Remote Direct Memory Access) bereitzustellen und effiziente Seiten- und Metadatentransfers sowie latenzarmes Signaling zu implementieren. Im Hintergrund koordiniert fabric-lib eine GPU und ihre direkt angeschlossenen NICs, um Daten vom Prefiller-Knoten zum Decoder-Knoten zu kopieren.
Nach Erhalt weist der Prefiller-Knoten einen entsprechenden Satz von Quell-KV-Seiten zu und plant die Anfrage für Prefill unter Verwendung seiner lokalen Engine. Um die Latenz zu minimieren, warten die Transfers nicht auf den Forward-Pass: Stattdessen werden Kopien der KV-Seiten eingeleitet, sobald das Modell das Anhängen von KV-Cache-Einträgen an den KV-Cache für einzelne Ebenen abgeschlossen hat. Da Prefill-Anfragen in Chunks unterteilt werden können, benachrichtigt der Batch-Scheduler den KV Messenger vor der Ausführung über die aktuell geplanten Chunks. Um CUDA-Graphen zu unterstützen und gleichzeitig Ebenen verfolgen zu können, hält der Messenger einen dedizierten Thread bereit, der einen Zähler abfragt, der nach der Ausgabeprojektion der Attention inkrementiert wird. Der Zähler wird nur auf dem führenden Knoten in einer Sharded-Umgebung verwaltet: Obwohl die KV-Cache-Einträge nach dem Anhängen und vor der Attention gültig sind, wird die Ausgabeprojektion über Ränge reduziert, wodurch sie implizit synchronisiert werden. Sobald eine Änderung des Zählers beobachtet wird, wird der Messenger benachrichtigt und ruft fabric-lib auf, um den Transfer einer Ebene einzuleiten.
Nach Abschluss des Transfers des letzten Chunks werden auch zusätzliche Metadaten kopiert: Speculative Decoding oder MTP erfordern, dass Logits und verborgene Zustände zum Decoder verschoben werden. Diese Kopien werden ebenfalls über RDMA zu und von vorab zugewiesenen Puffern durchgeführt.
Nach Abschluss aller ausstehenden Transfers des letzten Chunks hebt der Prefiller-Knoten die Zuweisung der KV-Seiten auf und schließt die Anfrage ab. Der Decoder-Knoten wird nicht explizit benachrichtigt: Stattdessen verwendet er unmittelbare Zähler, um die Anzahl der abgeschlossenen Operationen zu verfolgen. Die Anzahl der RDMA-Operationen auf der Prefiller-Seite ist proportional zur Anzahl der übertragenen Seiten. Nach Abschluss der bekannten Anzahl von Seiten- und Kontextkopien ruft fabric-lib den KV Messenger auf, um anzuzeigen, dass eine Anfrage für das Decoding bereit ist. Der Messenger hebt die Zuweisung jeglichen Kontexts auf und übergibt die Anfrage an die LLM-Engine.
Sharded KV-Cache-Transfers
Wenn Prefiller und Decoder auf Tensor Parallelism (TP) basieren und die KV-Caches identisch sharden oder replizieren, koordiniert eine einzelne Transfer-Engine mehrere Geräte, um die Seiten aller Replikate zu senden und zu empfangen. Um trotz der Replikation des Modellausführers über mehrere Geräte und Prozesse hinweg einen einzigen Messenger und eine einzige Transfer-Engine verwenden zu können, werden cuMem und cuMemImportFromShareableHandle eingesetzt, um den Gerätespeicher zuzuweisen, der die KV-Caches stützt, und ihn im Hauptprozess abzubilden. Die Transfer-Engine untersucht die Topologie des Knotens, um die NICs und CPUs im nächstgelegenen NUMA-Knoten für die Transfers der einzelnen KV-Cache-Segmente zu finden.
Wenn Quelle und Ziel identisch geshardet sind, sind die Transfers trivial, da es eine Eins-zu-eins-Abbildung der Geräte und Seiten von Quelle und Ziel gibt. In dieser Situation hilft Sharding implizit bei den Transferlatenzen: Durch die Verwendung von mehr GPUs können mehr zugehörige NICs eingesetzt werden, wodurch eine vollständige Bandbreitenauslastung erreicht wird. Besteht jedoch eine Diskrepanz, muss die Transfer-Engine Seiten abhängig vom Verhältnis zwischen Quell- und Zielsegmenten aufteilen oder rekonstruieren.
Wenn der Prefiller den KV-Cache auf mehr Geräte aufteilt, werden vollständige Seiten auf dem Decoder rekonstruiert, indem die entsprechenden Hälften von den Prefiller-Geräten gesendet werden. Wenn der Decoder mehr Shards hat, empfängt er Seiten von mehreren Quellen. Der Decoder muss das Sharding-Schema des Prefillers kennen, um die Anzahl der erwarteten RDMA-Schreibvorgänge berechnen zu können. Bei Replikation gruppiert der Prefiller die Geräte in Replikat-Sets, die den vollständigen KV-Cache innerhalb ihrer selbst replizieren. Ziel-Replikat-Sets wird zufällig eines der Quell-Sets zugewiesen, um alle verfügbaren Geräte zum Einleiten von RDMA-Schreibvorgängen zu nutzen.
Geshardete Transfers erfordern eine leichte Anpassung der KV-Caches. Standardmäßig basiert FlashInfer auf dem NHD-Layout, das die Tokens innerhalb einer Seite innerhalb der Köpfe ordnet. Da Caches höchstwahrscheinlich entlang der Anzahl der Attention-Köpfe geshardet sind, erzeugt dies eine Diskontinuität innerhalb des Kopfes. RDMA-Transfers unterstützen keine impliziten strided writes, was eine Operation pro Kopf erfordert. Um die Anzahl der Interaktionen mit libfabric zu reduzieren, organisieren wir KV-Caches stattdessen unter Verwendung des HND-Layouts, das die Kopf-Dimension vor der Anzahl der Tokens platziert. Dies gewährleistet Kontinuität und ermöglicht es, eine Seite mit einem einzigen Schreibvorgang zu kopieren.
Speculative Decoding
Speculative Decoding erfordert leichte Anpassungen an disaggregiertes Prefill-Decode. In unserer Implementierung dürfen Prefiller-Knoten keine Tokens sampeln. Da die Sonar-Modelle von Perplexity strukturierte Ausgaben unterstützen, möchten wir die Komplexität der Synchronisierung der Schema-Prozessor-Implementierungen über Prefiller und Decoder hinweg nicht auf uns nehmen. Bei den MTP- und Speculative Decoding-Mechanismen umfasst das Prefilling des Entwurfsmodells bis zum letzten Token das Sampeln von Tokens aus dem Zielmodell.
Um diese Probleme zu umgehen, umfasst das Prefill nicht das letzte Token der Eingabesequenz. Stattdessen werden verborgene Zustände oder Logits aus dem Prefill vor dem letzten Token übertragen und im nächsten Schritt auf dem Decoder als Decode-Token behandelt. Obwohl dies die Latenzen leicht erhöht, da nach dem Prefill ein vollständiger Decode-Schritt durchgeführt werden muss, um das erste Token auszugeben, wird die Komplexität der Implementierung stark reduziert.
Disaggregierte Deployments
Wir haben mehrere disaggregierte Konfigurationen mit verschiedenen Modellen bereitgestellt oder damit experimentiert, um entweder Produktionsverkehr oder interne Evaluierungs-Workloads zu unterstützen. Basierend auf der Größe und dem Attention-Mechanismus der Modelle wählten wir geeignete Sharding-Schemata für Prefiller- und Decoder-Knoten, um die GPUs optimal auszulasten.
DeepSeek-R1
Bei DeepSeek haben wir sowohl Tensor-Parallel (TP)- als auch Data-Parallel (DP)-Deployments in Betracht gezogen. Wie in früheren Blog-Beiträgen erörtert, bieten TP-Deployments eine bessere Latenz bei geringerem Durchsatz und erfordern mehr GPUs, um hohe Verkehrslasten zu bewältigen. DP-Deployments skalieren bei Last deutlich besser, ihr Spitzendurchsatz ist jedoch aufgrund der Kosten für die Kommunikation zwischen Geräten oder Knoten geringer.
DeepSeek basiert auf Multi-Head Latent Attention, wodurch KV-Caches komprimiert werden. Da alle KV-Köpfe in einen einzigen latenten Vektor komprimiert sind, kann TP die KV-Caches nicht sharden, da es die latenten Vektoren auf allen Rängen replizieren muss. Das Sharding erfolgt nach der Dekomprimierung, da jeder Rang unterschiedliche Köpfe aus derselben latenten Darstellung extrahieren kann. Folglich sind alle KV-Cache-Shards über Prefiller- und Decoder-Shards hinweg identisch.
Bei einem intranode TP-Setup werden sowohl Prefiller als auch Decoder identisch geshardet. Transfers werden von allen Rängen aus versandt, um alle verfügbaren NICs voll auszunutzen. Bei einem DP-Deployment, bei dem die TP-Ranggröße geringer ist oder jeder DP-Rang einer einzelnen GPU zugewiesen ist, kann jedoch jedes Prefiller-Gerät, das eine replizierte Kopie des KV-Cache hält, diese versenden. Um Anfragen auf alle verfügbaren NICs zu verteilen, wählen wir zufällig eine GPU und eine NIC aus, um den KV-Cache vom Prefiller an den Decoder zu senden.
Mit gemischtem Prefill-Decode hatte unser R1-Deployment Schwierigkeiten, konstant 50 TPS zu überschreiten, aufgrund häufiger Prefill-Unterbrechungen in der Größenordnung von Hunderten von Millisekunden. Durch die Trennung von Prefill entstand zwar ein Malus von etwa 100 ms bei der TTFT für jede Anfrage, aber ein einzelner Prefiller-Knoten konnte konstante Batch-Größen auf 3 Decoder-Knoten aufrechterhalten und einen Durchsatz von über 90 TPS liefern, während eine Last von etwa 1 QPS pro Decoder-Knoten bewältigt wurde. Bei Data-Parallel-Deployments lag der TPS-Wert mit etwa 50 etwas niedriger, jedoch konnten die Instanzen eine Last von 1 QPS pro Rang bewältigen, bei 8 Rängen auf einem einzelnen Knoten.
Qwen3-Coder
Dieses 480B-Modell verwendet Grouped-Query Attention (GQA), sodass Attention leicht geshardet werden kann und von Tensor-Parallelität profitieren kann, ohne Speicher für KV-Caches zu opfern. Folglich konnten wir das Modell auf 8 GPUs sowohl für Prefill als auch für Decode sharden und etwa 3 Decoder-Knoten mit einem einzelnen Prefiller-Knoten koppeln. Da Attention geshardet ist, nutzen wir das HND KV-Cache-Layout, um Prefiller- und Decoder-KV-Caches zu sharden, Prefiller-Ränge mit Decoder-Rängen zu paaren und alle NICs voll auszunutzen, um Segmente parallel zu übertragen.
Zitieren
@online{perplexity2025DisaggregatedPrefillAndDecode,
author = {{Perplexity} and Nandor Licker and Lequn Chen and Kevin Hu and Erin McKnight and Phi Hoang},
title = {{Disaggregated prefill and decode}},
date = {2025-08-01},
url = {https://perplexity.coolkeypoint.com/hub/blog/disaggregated-prefill-and-decode}
}