Chapitre 1 : La philosophie de conception de vLLM et vue d'ensemble de son architecture globale
Supposons que vous disposiez d'une A100 et que vous souhaitiez fournir un service d'inférence en ligne avec LLaMA-7B. L'approche la plus naïve consiste à : recevoir une requête, exécuter un model.generate(), puis renvoyer le résultat. Cette solution s'effondre immédiatement dès que la concurrence augmente — non pas parce que la puissance de calcul du GPU est insuffisante, mais à cause de deux problèmes : premièrement, la mémoire GPU est fragmentée. La génération autorégressive nécessite de mettre en cache les tenseurs Key/Value de chaque couche (KV Cache). Si chaque requête préalloue un bloc contigu de mémoire GPU selon max_model_len, une requête de 4096 tokens occuperait plusieurs dizaines de Mo, alors que la séquence réellement générée pourrait ne faire que 200 tokens. Pire encore, des requêtes de longueurs différentes entrent et sortent alternativement, découpant les blocs de mémoire contigus en morceaux épars, si bien qu'au final, alors que la quantité totale est suffisante, on ne trouve plus d'espace contigu suffisamment grand — c'est le problème classique de la fragmentation de la mémoire GPU. Deuxièmement, l'efficacité du traitement par lots est faible. Le traitement par lots statique traditionnel exige que toutes les requêtes d'un batch commencent et se terminent en même temps. Or la longueur de sortie d'une tâche de génération est par nature imprévisible : une requête peut s'arrêter après 10 tokens, une autre peut en générer 2000. Une fois qu'une requête courte est terminée, l'emplacement de batch qu'elle occupait ne peut qu'attendre passivement qu'une requête longue se termine, et le taux d'utilisation du GPU chute en chute libre. Les deux pierres angulaires de la conception de vLLM répondent précisément à ces deux points douloureux : PagedAttention élimine la fragmentation de la mémoire GPU grâce à un mécanisme de pagination, et Continuous Batching élimine le temps mort du traitement par lots grâce à un ordonnancement au niveau de l'itération. Ce chapitre n'approfondit pas les détails d'implémentation de ces deux mécanismes (ce sont les thèmes des chapitres 2 et 4), mais établit d'abord une carte globale : à quoi ressemble l'architecture de processus de vLLM v1, comment les responsabilités de chaque couche sont réparties, et par quels composants passe une requête depuis son entrée dans le système jusqu'à la production d'un token. Une fois cette carte comprise, l'analyse du code source de chaque chapitre suivant aura un point d'ancrage.
Architecture de processus : pourquoi vLLM n'est pas un programme monoprocessus
Modèle intuitif
Imaginez vLLM comme un restaurant. L'accueil (API Server) reçoit les clients et prend les commandes ; le cœur de la cuisine (EngineCore) décide quel plat préparer en premier et sur quel feu ; chaque feu (GPU Worker) est opéré exclusivement par un chef. Si une seule personne devait à la fois accueillir et cuisiner, elle serait débordée aux heures de pointe — c'est pourquoi vLLM sépare ces rôles en processus indépendants.
La motivation centrale de cette séparation en plusieurs processus est laséparation des préoccupations: l'analyse HTTP, la tokenization et le chargement de données multimodales sont des opérations gourmandes en CPU et potentiellement bloquantes, tandis que la propagation avant du modèle est gourmande en GPU. Si elles étaient placées dans le même processus, le GIL de Python ferait que les deux se pénaliseraient mutuellement. Après séparation en processus indépendants, l'API Server peut continuer à recevoir de nouvelles requêtes, EngineCore peut continuer à ordonnancer, et le GPU Worker peut continuer à calculer, les trois étant découplés via la file de messages ZMQ.
Topologie des processus et relations de quantité
L'architecture de processus de vLLM v1 peut se résumer par une formule. Pour un déploiement avecNGPU, degré de parallélisme tensorielTP, degré de parallélisme de pipelinePP, degré de parallélisme de donnéesDP, nombre d'API ServersA:
| Type de processus | Quantité | Responsabilité |
|---|---|---|
| API Server | A(par défaut égal àDP) | Traitement des requêtes HTTP, prétraitement des entrées, retour en streaming des résultats |
| EngineCore | DP(par défaut 1) | Ordonnancement, gestion du KV Cache, coordination des GPU Workers |
| GPU Worker | N(= DP × PP × TP) | Chargement des poids, exécution de la propagation avant, gestion de la mémoire GPU |
| DP Coordinator | DP > 1vaut 1 lorsque , sinon 0 | Équilibrage de charge entre rangs DP et coordination des vagues MoE |
📎 docs/design/arch_overview.md:113-113fournit la définition faisant autorité de ce tableau. Un déploiement typique à 4 GPU sur une seule machine (vllm serve -tp=4) génère 1 API Server + 1 EngineCore + 4 GPU Worker = 6 processus📎 docs/design/arch_overview.md:115-115. En revanche, un déploiement à 8 GPU avec TP=2/DP=4 gonfle à 4 + 4 + 8 + 1 = 17 processus📎 docs/design/arch_overview.md:123-123。
Voici un détail facile à négliger :Le nombre d'API Servers suit par défaut la taille du DP. Lorsque--data-parallel-size 4, 4 API Servers sont automatiquement lancés, chacun se connectant à tous les EngineCore via ZMQ dans une topologie plusieurs-à-plusieurs📎 docs/design/arch_overview.md:73-73. Cela signifie que n'importe quel API Server peut router une requête vers n'importe quel EngineCore, évitant ainsi un goulot d'étranglement unique.
Flux de données
La figure ci-dessous montre le chemin complet de circulation d'une requête entre les processus. Notez que chaque nœud est annoté avec les noms de classes et structures de données réels :
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 响应"| clientLe point clé de cette figure est :La communication entre API Server et EngineCore se fait par messagerie asynchrone, et non par appel de fonction. La requête est sérialisée en uneEngineCoreRequeststructure (unmsgspec.Struct, voir📎 vllm/v1/engine/__init__.py:109-113), envoyée via le type de messageADDde ZMQ📎 vllm/v1/engine/__init__.py:287-299. Après traitement, EngineCore empaquette le résultat enEngineCoreOutputset le renvoie📎 vllm/v1/engine/__init__.py:256-260。
Le choix de ZMQ plutôt que gRPC ou la mémoire partagée s'explique par la latence extrêmement faible de ZMQ dans les scénarios de communication inter-processus (de l'ordre de la microseconde), ainsi que son support natif des topologies plusieurs-à-plusieurs et de la sémantique de file de messages. Pour un service d'inférence sensible à la latence du premier token, la surcharge de communication doit être aussi réduite que possible.
Réflexion de conception : pourquoi EngineCore est un processus indépendant plutôt qu'un thread
Une question naturelle se pose : puisque EngineCore et API Server sont sur la même machine, pourquoi ne pas les placer dans le même processus avec une communication par threads ?
La réponse réside dans le mode de fonctionnement d'EngineCore. EngineCore exécute uneboucle active(busy loop), planifiant et distribuant continuellement les requêtes aux GPU Workers📎 docs/design/arch_overview.md:73-73. Cette boucle ne peut pas être interrompue — dès qu'elle est bloquée par le parsing HTTP ou la tokenization, toute la pipeline d'inférence subit des bulles. Un processus indépendant garantit que le temps CPU d'EngineCore ne sera pas préempté par la logique frontale.
De plus, un processus indépendant apporte égalementl'isolation des pannes: si l'API Server plante à cause d'une requête malformée, EngineCore et les GPU Workers ne sont pas affectés et peuvent continuer à servir les requêtes transférées par d'autres API Servers.
Modèle mental en couches : frontières de responsabilité de l'entrée jusqu'au GPU
Modèle intuitif
Si l'architecture des processus répond à « qui fait quoi et où », le modèle en couches répond à « quelle décision incombe à chaque couche ». L'organisation du code de vLLM suit un principe de stratification clair :La couche supérieure décide quoi faire, la couche inférieure décide comment le faire. La couche d'entrée décide quelles requêtes accepter, la couche cœur du moteur décide qui traiter en premier, la couche exécuteur décide quelle stratégie de parallélisme utiliser, et la couche Worker décide comment produire le résultat sur le matériel concret.
Structure à quatre couches
Couche d'entrée (Entrypoints)propose deux modes d'interaction : la classeLLMpour l'inférence hors ligne et la commandevllm servepour le service en ligne📎 docs/design/arch_overview.md:16-16📎 docs/design/arch_overview.md:56-56. La responsabilité principale de cette couche est le prétraitement des entrées — tokenization, chargement de données multimodales, parsing des paramètres d'échantillonnage — ainsi que la détokenization des sorties et le retour en streaming. Elle ne se soucie pas des stratégies de planification et ne touche pas au GPU.
Couche cœur du moteur (EngineCore)est le cerveau de tout le système. Elle détient le Scheduler (qui détermine quelles requêtes traiter à chaque decode step) et le KV Cache Manager (qui gère la mémoire paginée), et communique avec les GPU Workers via l'abstraction Executor📎 docs/design/arch_overview.md:79-85. La conception clé de cette couche estla séparation entre planification et exécution: le Scheduler ne produit que la décision « quels tokens exécuter à cette étape » (SchedulerOutput), la manière concrète de les exécuter sur GPU étant du ressort du Worker.
Couche exécuteur (Executor)est le pont entre EngineCore et les Workers. Elle encapsule les stratégies d'exécution distribuée —UniProcExecutorpour un processus unique,MultiprocExecutorpour plusieurs processus,RayDistributedExecutorpour un cluster Ray. L'interface abstraite de l'Executor permet à EngineCore de ne pas savoir si le sous-jacent est un seul GPU ou un TP à 8 GPU.
Couche Workerun processus Worker par GPU, contenant en interne un ModelRunner et l'objet modèletorch.nn.Moduleréel📎 docs/design/arch_overview.md:171-191. Le ModelRunner est responsable de la préparation des tenseurs d'entrée, de la capture des CUDA Graphs et de l'exécution du calcul forward. Cette couche est le seul endroit qui manipule directement la mémoire GPU et les flux CUDA.
Objet de configuration : état global traversant toutes les couches
Par quoi les quatre couches transmettent-elles l'information ? La réponse estVllmConfig— un dataclass géant contenant toute la configuration📎 vllm/config/vllm.py:357-357。
@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-371présente les champs principaux. La logique derrière ce choix de conception mérite d'être développée.
La documentation explique clairement pourquoi un grand objet de configuration est utilisé plutôt que des paramètres dispersés :Extensibilité. Supposons que l'on veuille ajouter une nouvelle fonctionnalité qui n'affecte que ModelRunner, il suffit d'ajouter un champ dansVllmConfig, ModelRunner le lit directement, sans modifier les signatures des constructeurs de Engine, Worker, Model📎 docs/design/arch_overview.md:203-203. Dans un framework d'inférence en évolution rapide, cette capacité d'« ajouter des champs sans modifier les interfaces » réduit considérablement la friction de développement.
Le prix à payer est queVllmConfigdevient extrêmement volumineux — comme on peut le voir à partir de📎 vllm/config/vllm.py:356-3509, cette classe dépasse 3000 lignes de code, contenant des dizaines de champs et de méthodes de validation.__post_init__La méthode📎 vllm/config/vllm.py:1405-2317fait plus de 900 lignes, assumant toute la validation croisée entre les éléments de configuration et la dérivation des valeurs par défaut.
Hachage et mise en cache de la configuration
VllmConfigIl existe également une capacité facilement négligée mais très importante :compute_hash() 📎 vllm/config/vllm.py:464-580. Il génère un hash court pour tous les éléments de configuration qui affectent la structure du graphe de calcul.
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-580montre le processus complet de calcul du hash. Notez l'avertissement dans les commentaires : « 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。
L'utilité de ce hash estclé de cache torch.compile. vLLM utilisetorch.compilepour compiler le graphe forward du modèle, et le résultat de la compilation est mis en cache sur disque. Au prochain démarrage, si le hash de configuration est identique, le cache de compilation peut être directement réutilisé, évitant ainsi le processus de compilation coûteux en temps. Si un élément de configuration affectant le graphe de calcul n'est pas inclus dans le hash, cela entraîne une erreur de correspondance de cache — utiliser un graphe compilé avec l'ancienne configuration pour exécuter la nouvelle configuration, résultant en une erreur silencieuse. C'est pourquoi les commentaires insistent à plusieurs reprises sur le fait que « les champs affectant le graphe de calcul doivent être inclus dans le hash ».
Parcours du cycle de vie d'une requête : du HTTP au Token
Mise en situation
Supposons qu'un client envoie àvllm serveun service lancé avec une requête/v1/completionscompatible OpenAI, avec le prompt « The capital of France is », demandant la génération de 16 tokens. Nous suivons le parcours complet de cette requête à travers le code source.
Étape 1 : Réception et prétraitement par l'API Server
Après réception de la requête HTTP, le processus API Server effectue la tokenisation et l'analyse des paramètres d'échantillonnage, puis construitEngineCoreRequest:
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-124définit la structure centrale de la requête. Notezmsgspec.Structcombiné avecarray_like=Trueetomit_defaults=Truela combinaison📎 vllm/v1/engine/__init__.py:109-113— c'est pourla performance de sérialisation。array_likepermettre à msgspec d'encoder avec un tableau positionnel plutôt qu'un dictionnaire,omit_defaultsignorer les champs avec valeurs par défaut, la combinaison des deux réduisant considérablement la taille des messages ZMQ.
gc=Falseindique à msgspec de ne pas générer de code de suivi GC pour cette structure📎 vllm/v1/engine/__init__.py:109-113. Pour les objets de message créés/détruits à haute fréquence, désactiver le suivi GC réduit la pression sur le ramasse-miettes Python, ce qui est une optimisation nécessaire dans un scénario traitant des milliers de requêtes par seconde.
Étape 2 : Ordonnancement par EngineCore
Après réception de la requête par EngineCore, le Scheduler la place dans la file d'attente. À chaque étape d'ordonnancement, le Scheduler décide si cette requête est incluse dans le lot courant. Si elle est incluse, le KV Cache Manager lui alloue des blocs physiques (opération centrale de PagedAttention, voir chapitre 2).
Le résultat de l'ordonnancement est encapsulé dansSchedulerOutput, envoyé au GPU Worker via l'Executor.
Étape 3 : Exécution du forward par le GPU Worker
Le ModelRunner du Worker reçoitSchedulerOutput, prépare les tenseurs d'entrée (y compris block table, slot mapping et autres attention metadata), exécute le forward du modèle, et échantillonne le token suivant.
Étape 4 : Retour des résultats
Le token produit par le Worker est encapsulé dansEngineCoreOutput:
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-217définit la structure de sortie.finish_reasonest unIntEnum, les valeurs possibles incluentSTOP、LENGTH、ABORT、ERROR、REPETITION 📎 vllm/v1/engine/__init__.py:68-69. Les commentaires expliquent pourquoi utiliserIntplutôt queStr:「Int rather than Str for more compact serialization」📎 vllm/v1/engine/__init__.py:56-57— encore une optimisation de la taille de sérialisation.
PlusieursEngineCoreOutputsont empaquetés dansEngineCoreOutputs, renvoyés à l'API Server via ZMQ📎 vllm/v1/engine/__init__.py:256-260。
Étape 5 : Retour en streaming par l'API Server
Après réception deEngineCoreOutputspar l'API Server, chaqueEngineCoreOutputest dé-tokenisé, puis poussé en streaming vers le client via SSE (Server-Sent Events).
Séquence complète
Le diagramme de séquence ci-dessous montre l'interaction complète inter-processus, annoté avec les vrais noms de fonctions et structures de données à chaque étape :
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 时请求退出Informations clés de ce diagramme :Chaque decode step produit unEngineCoreOutputsretour, plutôt que d'attendre la génération complète de la séquence entière avant de retourner. C'est précisément la manifestation du Continuous Batching — les séquences terminées sortent immédiatement, les nouvelles requêtes sont immédiatement ajoutées, et la sortie est retournée en streaming au client.
Réflexions de conception et pièges en production
Le modèle d'« initialisation différée » pour la validation de configuration
VllmConfig.__post_init__est le cœur de tout le système de configuration. Ce n'est pas une simple affectation de champ, mais unepipeline de validation multi-étapes:
1. D'abord, analyser le mode de l'encodeur multimodal📎 vllm/config/vllm.py:1416-1416
2. Ensuite, appelertry_verify_and_update_config(), pour donner aux hooks de configuration spécifiques au modèle l'opportunité de modifier la configuration📎 vllm/config/vllm.py:1434-1434
3. Puis, valider la cohérence entre la configuration parallèle, la configuration de quantification et la configuration LoRA📎 vllm/config/vllm.py:1442-1444
4. Enfin, traiter les vérifications de compatibilité des fonctionnalités d'exécution telles que la planification asynchrone, CUDA Graph, KV Transfer, etc.📎 vllm/config/vllm.py:1544-1635
Ce modèle d'« initialisation postérieure » résout une contradiction fondamentale :il existe des relations de dépendance entre les éléments de configuration, mais l'utilisateur peut les définir dans un ordre arbitraire. Par exemple,async_schedulingl'activation dépend du type de méthode de speculative_config, du support du backend de l'executor, de l'utilisation du pipeline parallelism, etc., parmi de multiples conditions📎 vllm/config/vllm.py:1544-1575. Si l'on plaçait cette logique dans le__set__du champ, cela créerait des dépendances circulaires complexes. En la centralisant dans__post_init__et en la traitant séquentiellement, la logique est claire et facile à déboguer.
Piège : conflit entre KV Connector et expandable_segments
📎 vllm/config/vllm.py:1219-1260Dans_verify_kv_transfer_compat, le
révèle un piège de production très subtil.ibv_reg_mrLors de l'utilisation de KV Connector (comme NIXL, Mooncake) pour un déploiement à séparation PD, ces connecteurs, via des mécanismes tels que, épinglent (pin) les pages de mémoire physique du KV cache. Mais si l'on définit simultanémentPYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, l'allocateur CUDA VMM de PyTorch peut, à l'exécution, remapper la même adresse virtuelle vers différentes pages physiques📎 vllm/config/vllm.py:1227-1233。
Quelle est la conséquence ? La zone mémoire RDMA enregistrée par le connecteur pointe vers des pages physiques déjà invalidées. Le premier transfert KV inter-nœuds signaleraIBV_WC_REM_ACCESS_ERRouNIXL_ERR_REMOTE_DISCONNECT 📎 vllm/config/vllm.py:1232-1233。
La stratégie de vLLM est unrefus conservateur: dès queexpandable_segments:Trueest détecté et qu'un KV connector est configuré, une exception est levée directement📎 vllm/config/vllm.py:1249-1260. La seule exemption est l'activation deenable_cumem_allocator— car l'allocateur CuMem désactiveraexpandable_segments 📎 vllm/config/vllm.py:1238-1241。
〔Inférence de conception et compromis architecturaux〕La leçon de ce cas est :l'enregistrement de mémoire RDMA et le remappage de mémoire virtuelle sont sémantiquement incompatiblesPYTORCH_CUDA_ALLOC_CONF。
. Toute fonctionnalité impliquant l'épinglage de mémoire GPU (transfert KV, buffers d'enregistrement NCCL, etc.) doit garantir que les pages physiques sous-jacentes ne seront pas déplacées silencieusement par l'allocateur. Lors du diagnostic de ce type de problème, si l'on constate qu'un transfert RDMA échoue lors de la première communication inter-nœuds, la première réaction devrait être de vérifier
__post_init__Piège : chaîne de dégradation automatique de la planification asynchroneasync_schedulingDans📎 vllm/config/vllm.py:1544-1635, la logique de traitement concernantdémontre une。
chaîne de dégradation automatiqueasync_schedulingsoigneusement conçueNoneLorsque l'utilisateur n'a pas explicitement défini
- (valeur📎
vllm/config/vllm.py:1578-1587 - ), vLLM tentera de l'activer automatiquement, mais doit vérifier successivement une série de conditions d'incompatibilité :📎
vllm/config/vllm.py:1588-1601 - S'il s'agit d'un modèle pooling, désactiver
disable_padded_drafter_batch=TrueSi la méthode speculative n'est pas dans la liste supportée, désactiver📎vllm/config/vllm.py:1602-1610 - Si📎
vllm/config/vllm.py:1611-1617 - , désactiver📎
vllm/config/vllm.py:1618-1624 - Si le backend de l'executor ne supporte pas, désactiver📎
vllm/config/vllm.py:1625-1633
S'il s'agit de ROCm DeepEP haut débit DBO, désactiver📎 vllm/config/vllm.py:1639-1640。
Ce n'est que si toutes les vérifications passent que l'activation est finalement effectuée〔Inférence de conception et compromis architecturaux〕La philosophie de conception de cette chaîne de dégradation est :
activer par défaut la configuration optimale, et en cas d'incompatibilité, dégrader silencieusement en enregistrant un avertissement
. C'est bien plus convivial que d'exiger de l'utilisateur qu'il configure manuellement chaque commutateur de compatibilité. Mais le coût est le suivant — lorsque les performances sont inférieures aux attentes, l'utilisateur doit consulter les logs pour découvrir que la planification asynchrone a été automatiquement désactivée. En production, si l'on constate une anomalie de débit, il est recommandé de vérifier dans les logs de démarrage la présence de l'avertissement « Async scheduling will be disabled ».
1. Résumé de ce chapitreCe chapitre établit le modèle mental global de vLLM v1, les points clés étant :
2. Les deux problèmes fondamentaux résolus par vLLM: la fragmentation de la mémoire GPU (gestion paginée PagedAttention) et le temps mort du traitement par lots (planification au niveau de l'itération Continuous Batching).A + DP + NArchitecture multi-processus
3. : API Server (entrée) → EngineCore (planification) → GPU Worker (exécution), trois couches de processus communiquant de manière asynchrone via ZMQ. Le nombre de processus suit la formuleModèle en quatre couches
4. : la couche d'entrée est responsable du prétraitement, la couche cœur du moteur est responsable des décisions de planification, la couche executor est responsable de la stratégie distribuée, la couche Worker est responsable du calcul GPU.VllmConfig est l'état global qui traverse toutes les couchescompute_hash(), supportant le cache de compilation via__post_init__, et réalisant la validation inter-configurations et la dérivation des valeurs par défaut via
5. Cycle de vie d'une requête:HTTP → tokenize → EngineCoreRequest → Scheduler → Worker forward → EngineCoreOutput→ retour en streaming SSE.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on change leEngineCoreRequestdemsgspec.Struct, passant dearray_like=True, omit_defaults=Trueà la valeur par défaut (c'est-à-direarray_like=False, omit_defaults=False), dans quels scénarios cela entraînerait-il des problèmes de performance ? Veuillez analyser en combinant📎 vllm/v1/engine/__init__.py:109-113et📎 vllm/v1/engine/__init__.py:256-260.
Analyse de référence:array_like=Truefait que msgspec encode les structures avec des tableaux positionnels plutôt qu'avec des dictionnaires,omit_defaults=Truesaute les champs dont la valeur est la valeur par défaut. Dans la configuration par défaut, chaqueEngineCoreRequestsera encodé en une structure de dictionnaire contenant tous les noms de champs, et sa taille peut gonfler de 2 à 3 fois. Dans des scénarios à forte concurrence (des milliers de requêtes par seconde), le volume de messages ZMQ entre l'API Server et EngineCore augmentera considérablement, entraînant une hausse de la charge CPU liée à la sérialisation/désérialisation et un gaspillage de bande passante réseau.EngineCoreOutputsutilise également ces deux paramètres📎 vllm/v1/engine/__init__.py:256-260, et il est généré à chaque decode step, avec un impact plus important. En outregc=Falsedésactive le suivi GC, ce qui peut réduire la pression sur le GC Python pour les objets à courte durée de vie et à haute fréquence.
Q2 : DansVllmConfig.__post_init__,async_schedulingla logique d'activation automatique (📎 vllm/config/vllm.py:1576-1635) adopte la stratégie « vérifier séquentiellement les conditions d'incompatibilité, et n'activer que si toutes passent ». Si une nouvelle fonctionnalité incompatible avec la planification asynchrone est ajoutée, mais que le développeur oublie d'ajouter la branche correspondante dans cette chaîne de vérification, quel problème cela causera-t-il ? Analysez du point de vue du comportement du système.
Analyse de référence: Si l'on oublie d'ajouter la branche de vérification, la planification asynchrone sera activée à tort. L'hypothèse centrale de la planification asynchrone est que « la décision de planification du step actuel ne dépend pas de la sortie du step précédent », ce qui permet à EngineCore de planifier le step suivant alors que le calcul GPU du step précédent n'est pas encore terminé. Si la nouvelle fonctionnalité viole cette hypothèse (par exemple, une logique de post-traitement qui doit lire les logits du step précédent), la planification asynchrone entraînera des conditions de course ou des résultats erronés. Plus insidieux encore, ce type de bug peut ne se déclencher que dans des séquences de concurrence spécifiques, et être difficile à reproduire. C'est précisément pourquoi📎 vllm/config/vllm.py:1549-1552le chemin d'activation explicite adopte une stratégie de « hard fail » — lorsque l'utilisateur l'active volontairement, une erreur est directement signalée plutôt qu'une dégradation silencieuse, forçant le développeur à faire face aux problèmes de compatibilité.
Q3: VllmConfig.compute_hash()le commentaire avertit que « les champs affectant le graphe de calcul doivent être ajoutés à la liste factors » (📎 vllm/config/vllm.py:465-467). Supposons qu'un nouveau champattention_sink_tokensaffecte la logique de calcul de l'attention mais soit omis dans le hachage ; quel type de défaillance cela déclencherait-il en environnement de production ? Pourquoi ce type de défaillance est-il particulièrement dangereux ?
Analyse de référence:compute_hash()la sortie est utilisée comme clé du cache de compilation torch.compile. Siattention_sink_tokensaffecte la structure du graphe de calcul mais n'est pas inclus dans le hachage, alors lorsque l'utilisateur passe deattention_sink_tokens=0àattention_sink_tokens=4, la valeur de hachage reste inchangée et vLLM réutilisera le graphe précédemment compilé (sans la logique sink token). Le résultat est que le modèle produit silencieusement des sorties erronées — sans erreur, sans plantage, simplement des résultats incorrects. Ce type de défaillance est particulièrement dangereux car : (1) il ne déclenche aucune exception ni avertissement dans les logs ; (2) la sortie reste un texte « apparemment raisonnable », avec seulement une baisse de qualité ou un comportement anormal ; (3) le diagnostic nécessite de comparer les correspondances du cache de compilation et les différences de configuration réelles, avec un coût de localisation extrêmement élevé. C'est pourquoi les commentaires insistent à plusieurs reprises sur le fait que les nouveaux champs doivent être évalués quant à leur impact sur le graphe de calcul.
Ce chapitre part d'une scène de plantage lors d'une requête d'inférence naïve, révélant deux contradictions fondamentales que vLLM doit résoudre : la fragmentation de la mémoire vidéo et la rotation à vide du traitement par lots, et présente les deux clés que sont PagedAttention et Continuous Batching. Nous avons ensuite survolé l'architecture globale de vLLM v1, clarifié le modèle de processus, la stratification des composants et le cycle de vie complet d'une requête. Avec cette carte globale en main, le chapitre suivant approfondira la structure de données la plus centrale de vLLM — Request, Sequence et le mécanisme de gestion des blocs du KV Cache — révélant comment PagedAttention implémente au niveau du code une cartographie de mémoire vidéo « logiquement contiguë, physiquement discrète ».
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 2 : Abstractions centrales : Request, Sequence et structures de données du KV Cache
Dans le chapitre précédent, nous avons établi le modèle mental stratifié de vLLM v1, en sachant qu'une requête part de l'API Server, traverse EngineCore et atteint finalement le Worker pour exécution. Mais comment une chaîne JSON dans un corps de requête HTTP devient-elle un objet interne au moteur pouvant être planifié, suivi et interrompu ? C'est la question à laquelle la classe Request doit répondre.
Le système de spécifications du KV Cache : de KVCacheSpec au registre
Request résout la question « qui doit calculer », tandis queKVCacheSpecrésout la question « où calculer ». Dans le monde de PagedAttention, le KV cache de chaque couche du modèle doit être décrit avec précision : combien de heads, quelle taille par head, combien de tokens un bloc peut stocker, et si une quantification est nécessaire. Ces informations sont encodées dansKVCacheSpecla hiérarchie d'héritage.
Modèle intuitif : KVCacheSpec est le « plan d'étage » de la mémoire vidéo
Si l'on imagine la mémoire GPU comme un terrain à aménager,KVCacheSpecest le plan d'étage de chaque bâtiment (chaque cache group) : il définit combien de pièces (head slot) par étage (chaque bloc), la taille de chaque pièce (head_size), et combien de personnes peuvent y loger (block_size tokens). EtKVCacheConfigC'est le plan d'aménagement de tout le quartier — combien de bâtiments au total, quelle surface occupe chaque bâtiment, quels bâtiments partagent la même fondation (block table).
Sans ce système de spécifications, l'allocation du KV cache ne pourrait reposer que sur des hypothèses codées en dur, incapable de prendre en charge la diversité des besoins des modèles, du MHA standard au MLA, de l'attention complète à la fenêtre glissante, de la quantification FP16 à FP8.
Structure de données : arbre d'héritage et champs clés de KVCacheSpec
KVCacheSpecest la classe de base de toutes les spécifications, c'est un@dataclass(frozen=True) 📎 vllm/v1/kv_cache_interface.py:150-152. frozen signifie que l'objet de spécification est immuable une fois créé — cela garantit que plusieurs composants (planificateur, Worker, KV Cache Manager) voient la même spécification, sans incohérence due à une modification quelque part.
La classe de base définit trois propriétés abstraites qui doivent être implémentées par les sous-classes :num_heads、tokens_per_state、state_content_size_bytes 📎 vllm/v1/kv_cache_interface.py:182-183. Ces trois propriétés déterminent ensemblepage_size_bytes— c'est-à-dire le nombre d'octets occupés par un block.
AttentionSpecest la sous-classe la plus centrale, elle introduitnum_kv_heads、head_size、dtype、kv_quant_modeet d'autres champs📎 vllm/v1/kv_cache_interface.py:485-498. Parmi eux,tokens_per_statela conception du champ est particulièrement ingénieuse : la valeur par défaut est 1, ce qui signifie qu'un state correspond à un token ; mais elle peut être définie comme un entier supérieur à 1 (comme le MLA sparse de DeepSeek-V4 qui compresse plusieurs tokens en un seul state), ou comme une fraction inférieure à 1 (comme le block pooling de Whisper qui utiliseFraction(1, block_pool_size)pour indiquer qu'un token correspond à plusieurs states)📎 vllm/v1/kv_cache_interface.py:501-501。
FullAttentionSpecajoute, sur la base deAttentionSpec,sliding_windowetattention_chunk_size 📎 vllm/v1/kv_cache_interface.py:566-566. Notez que sa docstring explique une décision de conception importante : lorsque l'allocateur hybride est désactivé, les couches d'attention à fenêtre glissante sont traitées comme une attention complète dans le KV Cache Manager (allocation de blocks pour tous les tokens), mais le calcul reste effectué selon la fenêtre glissante lors de l'exécution du modèle📎 vllm/v1/kv_cache_interface.py:540-545. C'est uneallocation conservatrice, calcul préciscomme stratégie.
MLAAttentionSpecest la spécification clé de la série de modèles DeepSeek. Elle définithead_size_vpar défaut à 0📎 vllm/v1/kv_cache_interface.py:670, car MLA ne stocke qu'un seul latent vector, sans V indépendant.alignmentLe champ est utilisé pour le remplissage d'alignement de page📎 vllm/v1/kv_cache_interface.py:646-652, ce qui est crucial pour les backends comme FlashMLA qui nécessitent un alignement spécifique.
MambaSpecquant à lui ne suit pas du tout la voie de l'attention. Il utiliseshapesetdtypesdes tuples pour décrire la forme du tenseur d'état📎 vllm/v1/kv_cache_interface.py:1027-1028,state_content_size_bytesest la somme de toutes les tailles de tenseurs d'état📎 vllm/v1/kv_cache_interface.py:1048-1052. Lemax_memory_usage_bytesde Mamba est calculé selonmamba_cache_modede trois manières différentes📎 vllm/v1/kv_cache_interface.py:1073-1084, ce qui reflète la complexité de la gestion d'état de Mamba — il ne croît pas linéairement comme l'attention, mais a une taille d'état fixe.
Piloté par scénario : conversion des spécifications vers la disposition de la mémoire vidéo
Lorsque le moteur démarre, il doit convertir leKVCacheSpecde toutes les couches en une disposition réelle de la mémoire vidéo. Ce processus est réalisé parKVCacheTensoretcreate_kv_cache_views.
KVCacheTensordécrit la position d'un groupe de couches de même forme dans l'allocation du KV cache📎 vllm/v1/kv_cache_interface.py:1406-1427. Ses champs principaux sontlayer_strideetblock_stride: le premier est la distance en octets entre couches adjacentes, le second est la distance en octets entre blocks adjacents. La docstring explique en détail deux modes de disposition : la disposition couche-externe (layer-outermost) donne une zone contiguë à chaque couche, la disposition block-externe (block-outermost) fait que chaque block contient les pages de toutes les couches📎 vllm/v1/kv_cache_interface.py:1416-1416。
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()"| v3create_kv_cache_viewsest au cœur de ce processus📎 vllm/v1/kv_cache_interface.py:353-417. Elle reçoit un buffer int8 plat, et crée viatorch.as_stridedune vue 4D pour chaque couche[B, H, N, C]. Le paramètre clé eststrides, calculé parcompute_layout_strides📎 vllm/v1/kv_cache_interface.py:314-350. Cette fonction calcule en sens inverse, en partant de la dimension la plus interne, le pas en octets de chaque dimension selon l'ordre des dimensions spécifié parlayout.stride_order.
Il y a ici une vérification de limite notable : lorsque kernel_block_size est inférieur à spec.block_size (c'est-à-dire qu'un manager block est divisé en plusieurs kernel blocks), le code vérifie si block_stride est égal à dense_page_size📎 vllm/v1/kv_cache_interface.py:381-382. Si ce n'est pas le cas, cela signifie qu'il y a du padding dans la disposition et qu'une division uniforme est impossible ; une ValueError avec une suggestion de correction explicite est alors levée.
Réflexion de conception : modèle de registre et extensibilité
KVCacheSpecRegistryest une conception clé de l'extensibilité de vLLM📎 vllm/v1/kv_cache_spec_registry.py:39-40. Il maintient deux dictionnaires globaux :_REGISTRY_KVCACHESPEC_LISTstocke la correspondance des classes de spec vers les métadonnées,_REGISTRY_ROLE_MANAGERSstocke la correspondance des rôles vers les managers📎 vllm/v1/kv_cache_spec_registry.py:35-36。
get_manager_classLa méthode illustre la logique de recherche centrale du registre : elle parcourt vers le haut le MRO (Method Resolution Order) de la classe de spec, et trouve la première classe de base déjà enregistrée📎 vllm/v1/kv_cache_spec_registry.py:129-130. Cela signifie qu'unCustomFullAttentionSpecpersonnalisé, s'il n'est pas enregistré séparément, héritera automatiquement du manager deFullAttentionSpec. Cetterecherche basée sur l'héritagepermet, lors de l'ajout d'un nouveau type de spec, de n'enregistrer que la partie différentielle.
check_kv_cache_spec_registryLa méthode valide au démarrage que les specs de toutes les couches sont enregistrées📎 vllm/v1/kv_cache_spec_registry.py:165-174. Notez qu'elle utiliseraise ValueErrorplutôt queassert, le commentaire précise explicitement que c'est pour que cela prenne effet également en environnement de production📎 vllm/v1/kv_cache_spec_registry.py:165-174. C'est une décision d'ingénierie importante : le flag-Ode Python supprime les assert, mais une erreur de configuration en production doit être exposée dès le démarrage, et non provoquer un crash à l'exécution.
La conception d'initialisation différée du registre (_ensure_registered) résout un problème de dépendance circulaire :kv_cache_interface.pya besoin de référencer le registre pour vérifier le type de spec, tandis que le registre a besoin d'importersingle_type_kv_cache_managerpour obtenir la classe de gestionnaire, qui dépend à son tour dekv_cache_interface. En différant l'enregistrement réel jusqu'à la première requête, ce cycle est brisé.
Résumé de ce chapitre
Ce chapitre a analysé les deux structures de données centrales de vLLM v1.Requestest le porteur du cycle de vie d'une requête à l'intérieur du moteur ; grâce à la double liste de tokens, au compteur de planification asynchrone et au mécanisme de block hash, il prend en charge les deux fonctionnalités clés que sont le traitement par lots continu et le cache de préfixes.KVCacheSpecet sa hiérarchie d'héritage définissent les spécifications de disposition de la mémoire GPU du KV cache, depuis le standardFullAttentionSpecjusqu'àMLAAttentionSpec、MambaSpec, couvrant les besoins variés des architectures de modèles. Le modèle de registre permet d'ajouter de nouveaux types de spec sans modifier le code principal, garantissant ainsi l'extensibilité du système.
À ce stade, nous avons vu clairement comment Request est converti depuis EngineCoreRequest, et comment il prend en charge les décisions de planification via les compteurs d'état, le block hash, etc. Mais comment une requête externe traverse-t-elle réellement l'API Server, le chat template et le traitement multimodal pour finalement devenir un EngineCoreRequest ? Le chapitre suivant abordera la couche d'entrée des requêtes, en traçant complètement ce chemin depuis HTTP/CLI jusqu'à EngineCore.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 3 : Entrée des requêtes : le chemin complet depuis HTTP/CLI jusqu'à EngineCore
Dans le chapitre précédent, nous avons analysé les deux structures de données centrales internes au moteur, Request et KVCacheSpec, et compris comment la séquence logique et les blocs de mémoire GPU physiques sont découplés. Mais comment un corps de requête HTTP ou une chaîne Python traverse-t-il réellement l'API Server, le chat template et le traitement multimodal pour finalement devenir un EngineCoreRequest ? Ce chapitre tracera complètement ce chemin et révélera comment les trois voies d'entrée — CLI synchrone, API asynchrone et classe LLM hors ligne — convergent vers le même cœur de moteur.
3.1 Le point de convergence des trois voies d'entrée : AsyncLLMEngine et LLMEngine
Avant d'approfondir l'analyse des requêtes, il faut d'abord examiner clairement la topologie des trois voies d'entrée. vLLM propose trois modes d'utilisation :vllm servele service HTTP compatible OpenAI lancé par , l'outil en ligne de commandevllm, ainsi que l'instanciation directe en Python de la classeLLMpour l'inférence hors ligne. Ils semblent indépendants, mais partagent en réalité le même cœur de moteur.
Examinons d'abord le mécanisme d'alias de la voie API asynchrone.
📎 vllm/engine/async_llm_engine.py:7-7
Ce fichier est si court qu'il ne ressemble presque pas à un module — il ne fait qu'une seule chose : faire pointer l'aliasAsyncLLMEngineversvllm.v1.engine.async_llm.AsyncLLM. C'est une trace typique de migration architecturale. À l'époque de vLLM v0,AsyncLLMEngineétait une classe volumineuse et complexe ; après la réécriture de l'architecture v1, la nouvelleAsyncLLMassume les mêmes responsabilités. Pour ne pas casser le code utilisateur existant, vLLM conserve l'ancien chemin de module comme couche de compatibilité.
Ce modèle « l'ancien chemin pointe par alias vers la nouvelle implémentation » apparaît de manière récurrente dans vLLM (commeapi_server.pyavec son avertissement de dépréciation), ce qui montre que le projet a adopté une stratégie progressive lors de la migration de v0 à v1 : le nouveau code utilise le nouveau chemin, l'ancien code ne génère pas d'erreur mais reçoit un avertissement, laissant aux utilisateurs une fenêtre de migration suffisante.
Examinons maintenant l'entrée de la voie hors ligne.
📎 vllm/entrypoints/llm.py:344-346
LLM.__init__appelle finalementLLMEngine.from_engine_args, en passantUsageContext.LLM_CLASS. Cette énumérationUsageContextest la clé pour distinguer les voies d'entrée — elle permet au moteur de savoir s'il fonctionne en mode traitement par lots hors ligne ou en mode service en ligne, afin d'ajuster les stratégies de journalisation, de métriques et de gestion des ressources.
📎 vllm/entrypoints/llm.py:357-359
Notez ici l'affectation deself.renderer = self.llm_engine.rendereretself.input_processor = self.llm_engine.input_processor. La classe hors ligneLLMn'implémente pas elle-même le rendu du chat template, mais réutilise lerendererinterne au moteur. Cela signifie que la logique d'analyse du chat template est le même code sur les voies hors ligne et en ligne, seul le moment de l'appel diffère.
La relation de convergence des trois voies peut être représentée par le diagramme de flux de données suivant.
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 --> coreCe diagramme révèle une conception clé : quelle que soit la provenance de la requête — HTTP, CLI ou Python —chat_utilsest l'unique point d'entrée pour le traitement multimodal et du chat template. Il unifie les formats d'entrée hétérogènes en uneConversationMessageliste plusMultiModalDataDict, puis les confie au renderer pour générer la séquence de tokens.
3.2 chat_utils : des messages hétérogènes à une structure de dialogue unifiée
chat_utils.pyest le module le plus complexe de toute la couche d'entrée des requêtes ; ses 2264 lignes de code traitent tous les formats d'entrée : format compatible OpenAI, extensions personnalisées, intégrations multimodales, appels d'outils, etc. Son rôle principal peut se résumer en une phrase : normaliser toute liste de messages transmise par l'utilisateur en uneConversationMessageliste compréhensible par le chat template, tout en extrayant les données multimodales dans unMultiModalDataDictséparé.
Modèle intuitif : traducteur et trieur de bagages
Imaginezchat_utilscomme un traducteur et trieur de bagages à l'aéroport. Les voyageurs (utilisateurs) viennent de différents pays (format OpenAI, format personnalisé, format Harmony) et parlent différentes langues. Le traducteur traduit d'abord les paroles de chacun dans une langue de travail commune (ConversationMessage), tout en triant les bagages enregistrés des passagers (images, audio, vidéo) sur des tapis roulants indépendants (MultiModalDataDict), en apposant une étiquette (UUID), puis en acheminant séparément les personnes et les bagages vers le même avion (moteur).
Sans cette couche, le moteur devrait comprendre les détails de chaque format d'entrée, la logique d'extraction des données multimodales serait dispersée dans chaque point d'entrée, et l'ajout de tout nouveau format nécessiterait de modifier le cœur du moteur.
Structure de données : collaboration à double classe entre tracker et parser
chat_utilsLe cœur de repose sur la collaboration de deux groupes de classes :BaseMultiModalItemTrackeret ses sous-classes sont responsables du « suivi » des éléments multimodaux,BaseMultiModalContentParseret ses sous-classes sont responsables de l'« analyse » de la partie contenu.
Examinons d'abord la disposition des champs du tracker.
📎 vllm/entrypoints/chat_utils.py:598-601
_items_by_modalityest undefaultdict[str, list[_T]], stockant les éléments à traiter groupés par modalité (image, audio, vidéo, etc.)._modality_orderenregistre spécifiquement pour la modalitévision_chunkla modalité d'origine de chaque chunk (image ou vidéo), car le modèle de chunk visuel unifié mappe les deux versvision_chunk, mais le traitement ultérieur doit connaître le type d'origine.
📎 vllm/entrypoints/chat_utils.py:613-615
use_unified_vision_chunk_modalityest uncached_property, lisant le flaguse_unified_vision_chunkdepuis la configuration HuggingFace. L'utilisation decached_propertyplutôt qu'un attribut ordinaire s'explique par le fait que cette vérification est déclenchée à chaque appel deadd, et la mise en cache évite les surcoûts répétés degetattr.
La méthodeadddu tracker est le point d'entrée principal.
📎 vllm/entrypoints/chat_utils.py:656-684
addLa méthode appelle d'abord_validate_addpour la validation, puis stocke les éléments sous différentes clés selon que la modalité de chunk visuel unifiée est utilisée ou non. Notons le traitement spécial deprompt_embeds: il ajoute directement à_items_by_modality["prompt_embeds"]et retourneNone, car les embeddings précalculés ne passent pas par le processeur HF et n'ont pas de chaîne de placeholder.
_validate_addLa logique de validation dans mérite un examen attentif.
📎 vllm/entrypoints/chat_utils.py:686-721
Il y a ici une branche subtile : lorsqueenable_mm_embeds=Trueet que la limite par prompt de cette modalité est de 0 et que la modalité d'origine se termine par_embeds, la validation du nombre est ignorée. Cela permet aux entrées d'embeddings de contourner la limite de nombre de la modalité d'origine — les embeddings sont précalculés et n'occupent pas les ressources de traitement de la modalité d'origine.
Piloté par scénario : comment une requête chat avec image est analysée
Supposons qu'un utilisateur envoie une requête chat contenant une URL d'image et du texte.parse_chat_messagesest le point d'entrée du chemin synchrone.
📎 vllm/entrypoints/chat_utils.py:2161-2197
parse_chat_messagescréeMultiModalItemTracker, parcourt chaque message en appelant_parse_chat_message_content, appelle enfin_postprocess_messagespour traiter les paramètres d'appel d'outil, puis matérialise les données multimodales viamm_tracker.resolve_items().
_parse_chat_message_contentest responsable de l'analyse d'un seul message.
📎 vllm/entrypoints/chat_utils.py:2007-2029
Il normalise d'abord le contenu :Nonedevient une liste vide, une chaîne devient une seule partie texte. Puis il appelle_parse_chat_message_content_parts, où le paramètrewrap_dictsest déterminé parcontent_format == "openai"— cela détermine si la sortie est une liste de dictionnaires structurés ou une chaîne concaténée.
_parse_chat_message_content_partsparcourt chaque partie.
📎 vllm/entrypoints/chat_utils.py:1814-1853
Chaque partie est traitée par_parse_chat_message_content_part. Siwrap_dicts=False, le texte et les placeholders sont finalement concaténés en une seule chaîne ; siwrap_dicts=True, une liste de dictionnaires structurés est retournée.
_parse_chat_message_content_partest le cœur de la distribution.
📎 vllm/entrypoints/chat_utils.py:1875-1884
Pour une partie en texte pur, une vérification de conservation du placeholder est d'abord effectuée, puis le format de retour est déterminé selonwrap_dicts. Pour une partie structurée,_parse_chat_message_content_mm_partest appelé pour extraire le type et le contenu.
📎 vllm/entrypoints/chat_utils.py:1690-1723
_parse_chat_message_content_mm_partrecherche la fonction d'analyse correspondante viaMM_PARSER_MAP. Notons la condition deuuid is None— si l'utilisateur a fourni un UUID, cela signifie que les données média ne sont peut-être pas dans le corps de la requête (déjà téléversées par un autre moyen), auquel cas on passe à la branche du champ URL direct ci-dessous.
📎 vllm/entrypoints/chat_utils.py:1731-1733
Lorsquepart_type is Noneouuuid is not None, le code tente d'extraire directement le champ URL de la partie. Cette « analyse permissive » vise à assurer la compatibilité avec les clients qui ne suivent pas strictement le format OpenAI.
Revenons à_parse_chat_message_content_part, les parties de type média sont distribuées vers les méthodesmm_parsercorrespondantes.
📎 vllm/entrypoints/chat_utils.py:1923-1968
Chaque type de média appelle la méthodeparse_*correspondante, qui en interne appelletracker.addpour ajouter l'élément au tracker et retourne une chaîne de placeholder. Enfin, seloninterleave_strings, on décide de retourner le placeholder ouNone。
📎 vllm/entrypoints/chat_utils.py:1984-1999
prompt_embedsest traité spécialement : quel que soitinterleave_strings, on retournePROMPT_EMBEDS_PLACEHOLDER_TOKEN. Le commentaire explique pourquoi — prompt_embeds est concaténé aux décalages de tokens, la position est importante, et passer par la logique de remplissage préalable demissing_placeholdersperturberait l'ordre.
Différences du chemin asynchrone
Le chemin asynchrone utiliseAsyncMultiModalItemTrackeretAsyncMultiModalContentParser. La différence principale réside dansresolve_items。
📎 vllm/entrypoints/chat_utils.py:906-952
La version asynchrone utiliseasyncio.gatherpour attendre concurremment tous les éléments de modalité. Le commentaire indique explicitement : chaque élément suivi est déjà un awaitable indépendant, le connecteur asynchrone décharge le travail de décodage bloquant vers le pool de threads, donc attendre séquentiellement une modalité puis la suivante augmenterait inutilement la latence.return_exceptions=Truepermet de ne lever l'exception qu'après que toutes les tâches sont terminées ou échouées, évitant d'abandonner les requêtes réseau encore en cours dès le premier échec.
Réflexion de conception : pourquoi séparer tracker et parser
La séparation entre tracker et parser est une conception intéressante. Le tracker est responsable de la « gestion d'état » — enregistrer combien d'éléments par modalité, valider les limites de nombre, maintenir l'ordre de modalité d'origine des vision_chunk. Le parser est responsable de l'« extraction de contenu » — récupérer les images depuis une URL, décoder les embeddings depuis du base64, gérer la conversion de format audio. Cette séparation permet aux chemins synchrone et asynchrone de partager la logique de suivi (BaseMultiModalItemTrackerest une classe de base abstraite), et de diverger uniquement au niveau du parser. Si l'on fusionnait en une seule classe, les différences entre synchrone et asynchrone s'infiltreraient dans la logique de suivi, entraînant une duplication de code et une complexification de la gestion d'état.
3.3 Des messages aux tokens : la passation entre renderer et EngineCore
chat_utilsLa listeConversationMessageetMultiModalDataDictproduits doivent encore passer par le rendu du template de chat pour devenir une séquence de tokens. Cette étape est effectuée par le renderer, après quoi la requête entre véritablement dans le moteur.
Piloté par scénario : rendu du template de chat et soumission de la requête
parse_chat_messagesAprès le retour, l'appelant (commeOpenAIServingChat) transmetconversationetmm_dataau renderer. Le renderer applique le chat template, rend la listeConversationMessagesous forme de texte, puis la tokenise en une séquence d'ID de tokens. Les placeholders multimodaux (comme<##IMAGE##>) sont remplacés après tokenisation par des tokens placeholders spécifiques au modèle.
Une fois le rendu terminé, la requête est encapsulée enEngineCoreRequest, puis déposée dans la file d'entrée de l'EngineCore viaAsyncLLM.add_request()ouLLMEngine.add_request().
📎 vllm/entrypoints/llm.py:420-484
La méthode hors ligneLLM.generateillustre cette chaîne : elle valide d'abordrunner_type, récupère les paramètres d'échantillonnage par défaut, puis appelle_run_completion。_run_completion. En interne,llm_engineappelle le renderer pour rendre le prompt, puis dépose la requête via
📎 vllm/entrypoints/llm.py:615-708
LLM.chat. La méthodemessagesillustre quant à elle le chemin chat : elle reçoit la liste_run_chat, appelleparse_chat_messages, qui en interne appelle
et le renderer.
LLM.__init__〔Inférences de conception et compromis architecturaux〕self.renderer = self.llm_engine.rendererDansself.renderer.warmup(ChatParams(...)), la ligneLLMrévèle une décision de conception importante : le renderer appartient au moteur et non à la couche d'entrée. Cela signifie que le chargement, la mise en cache et le préchauffage du chat template (AsyncLLM) sont effectués lors de l'initialisation du moteur, la couche d'entrée n'étant qu'un appelant. L'avantage est que le mode hors ligne
et le mode en ligne
_postprocess_messagespartagent la même implémentation de renderer et le même cache, évitant le rechargement répété du tokenizer et du chat template. De plus, le préchauffage du renderer peut être effectué au démarrage du moteur, évitant ainsi la latence de démarrage à froid de la première requête.
📎 vllm/entrypoints/chat_utils.py:2118-2158
Récupération d'erreurs et pièges en productiontool_callsLe traitement des paramètres d'appel d'outils dansargumentsest un piège typique en environnement de production.argumentsLorsqu'un message assistant contient
, le champ
📎 vllm/entrypoints/chat_utils.py:1856-1872
peut être une chaîne JSON, un dictionnaire ou un JSON invalide. Le code tente d'analyser la chaîne JSON ; en cas d'échec, il enregistre un avertissement et force la conversion en objet vide. Le commentaire explique la raison : desenable_prompt_embedsmalformés existent dans l'historique de conversation ; si l'on fait échouer la requête ici, chaque tour suivant échouera également et la conversation ne pourra pas être récupérée. Il s'agit d'une conception de tolérance aux pannes réfléchie — mieux vaut que le modèle voie des paramètres d'outil vides plutôt que de bloquer toute la conversation.PROMPT_EMBEDS_PLACEHOLDER_TOKENUn autre piège est la protection contre l'injection de placeholders réservés._reject_reserved_placeholder_in_textLorsque
📎 vllm/entrypoints/chat_utils.py:1889-1892
est activé,isinstance(part, str)est enregistré comme token spécial insécable. Si le texte utilisateur contient exactement cette séquence littérale, le tokenizer l'encodera comme le même ID de token, et le renderer croira à tort qu'il s'agit d'un point de concaténation, permettant à l'appelant de déplacer ou d'injecter la position de concaténation via un contenu en texte brut.
rejette ce type d'entrée lors de l'analyse des parties textuelles, colmatant ainsi cette faille de sécurité.
Notez que cette vérification est appelée à la fois dans la brancheLLMet dans la branche de texte structuré, garantissant que tous les chemins textuels sont protégés.chat_utilsRésumé du chapitreBaseMultiModalItemTrackerCe chapitre a retracé le premier segment du chemin d'une requête entrant dans le système depuis l'extérieur. Les trois chemins d'entrée — API HTTP, CLI et classe hors ligneBaseMultiModalContentParser— convergent tous finalement vers la couche d'analyse multimodale deparse_chat_messages.ConversationMessageest responsable de la gestion d'état,MultiModalDataDictest responsable de l'extraction de contenu ; leur séparation permet aux chemins synchrones et asynchrones de partager la logique de traçage.EngineCoreRequestnormalise les messages hétérogènes en une liste
et
, puis les confie au renderer interne du moteur pour effectuer le rendu du chat template et la tokenisation. Finalement, la requête est encapsulée en_parse_chat_message_content_mm_partet déposée dans la file d'entrée de l'EngineCore.uuid is NoneRéflexions et auto-évaluation du chapitreif isinstance(part_type, str) and part_type in MM_PARSER_MAP:Q1 : Dans
, si l'on supprime la condition:uuid is None(c'est-à-dire en la remplaçant parMM_PARSER_MAP[part_type](part)), dans quel scénario cela poserait-il problème ?image_urlAnalyse de référenceNoneLa conditionparse_image(None, uuid)existe pour gérer le scénario où « l'utilisateur fournit un UUID mais les données média ne sont pas dans le corps de la requête ». Lorsqu'un utilisateur fournit un UUID, les données média ont peut-être déjà été téléversées par un autre moyen (par exemple préalablement téléversées dans le cache média) ; dans ce cas, la partie du corps de la requête peut ne contenir que l'UUID sans l'URL ou les données réelles. Si l'on supprime cette condition, le code tentera d'analyser via_connector.fetch_image(None), mais la partie peut ne pas contenir le champ de données correspondant (par exempleuuid is not Nonevide), ce qui produirait un contenu📎 vllm/entrypoints/chat_utils.py:1713-1723. Plus grave encore, le📎 vllm/entrypoints/chat_utils.py:1731-1733。
Q2: AsyncMultiModalItemTracker.resolve_itemssuivant appelleraitasyncio.gather(..., return_exceptions=True), ce qui pourrait déclencher des requêtes réseau inutiles ou des exceptions.return_exceptions=FalseLa brancheFalseemprunte quant à elle le chemin d'extraction directe des champs, traitant correctement le cas « UUID présent sans données ». Voir
et:return_exceptions=FalseUtiliseasyncio.gatherau lieu dureturn_exceptions=TrueOn laisse toutes les tâches se terminer ou échouer avant de vérifier de manière unifiée, afin de garantir qu'aucune tâche ne soit abandonnée. Le commentaire l'explique clairement : « 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. » Voir📎 vllm/entrypoints/chat_utils.py:924-931。
Q3: _postprocess_messages, lorsqueargumentsest un JSON invalide, le code choisit de forcer la conversion en objet vide plutôt que de lever une exception. Si l'on changeait pour lever une exception, dans quel scénario de production cela entraînerait-il un état de conversation irrécupérable ?
Analyse de référence:argumentsLe champ existe dans l'historique de conversation (letool_callsdu message assistant). Si lors d'un tour de conversation le modèle génère unargumentsmal formé, cette erreur sera conservée dans l'historique de conversation. Si_postprocess_messageslève une exception lors de l'analyse de l'historique, alors chaque tour de requête suivant échouera à cause de cette erreur dans l'historique — même si l'entrée du tour actuel est parfaitement correcte. L'utilisateur ne pourra plus continuer cette conversation et devra abandonner toute la session pour recommencer. Forcer la conversion en objet vide permet à la conversation de continuer ; après avoir vu des paramètres d'outil vides, le modèle régénérera un appel correct. Le commentaire explique ce point : « A malformed arguments string lives in conversation history, so failing the request here would fail every subsequent turn too and leave the conversation unrecoverable. » Voir📎 vllm/entrypoints/chat_utils.py:2124-2139。
Le chapitre suivant abordera le planificateur, pour voir comment EngineCore orchestre ces requêtes avec le traitement par lots continu et une stratégie sensible à la mémoire vidéo.
À ce stade, la requête a achevé sa transformation normalisée depuis l'entrée externe vers EngineCoreRequest, et a atteint l'entrée du cœur du moteur. Mais une fois la requête entrée, elle n'est pas exécutée immédiatement — le moteur doit décider quelles requêtes traiter à chaque étape et comment allouer les ressources limitées de mémoire vidéo. Le chapitre suivant plongera dans la boucle de planification d'EngineCore, analysera comment le Scheduler équilibre débit et latence dans le traitement par lots continu, et comment le chunked prefill, le prefix caching et l'allocation de blocs KV coopèrent.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 4 : Planificateur : traitement par lots continu et orchestration des requêtes sensible à la mémoire vidéo
Après qu'une requête entre dans la file d'entrée d'EngineCore, elle n'est pas exécutée immédiatement. Quelles requêtes traiter à chaque étape, combien de budget de tokens allouer à chaque requête, qui sacrifier en priorité en cas de mémoire vidéo insuffisante — toutes ces décisions sont concentrées dans la méthodeScheduler.schedule(). Ce chapitre part des structures de données du planificateur et suit comment un appelschedule()organise la file waiting, la liste running et le pool de KV cache en un lot exécutable.
4.1 Structures de données du planificateur : trois files et un pool de mémoire vidéo
La question centrale à laquelle le planificateur doit répondre est :Sous un budget limité de tokens et de blocs KV, quelles requêtes doivent avancer de combien de tokens à cette étape ?Pour le comprendre, il faut d'abord voir quels états il détient.
Le planificateur maintient trois types de conteneurs de requêtes.self.requestsest un dictionnaire global,req_id -> Request, source unique de vérité pour toutes les requêtes actives📎 vllm/v1/core/sched/scheduler.py:208-209。self.waitingetself.skipped_waitingsont deux files de priorité ; la première contient les requêtes en attente normale de planification, la seconde contient les requêtes temporairement non planifiables en raison de dépendances asynchrones ou de contraintes (comme l'attente d'un KV distant, l'attente de la compilation de la grammaire de sortie structurée)📎 vllm/v1/core/sched/scheduler.py:208-209。self.runningest une liste ordinaire, contenant les requêtes déjà entrées en état d'exécution et détenant des blocs KV📎 vllm/v1/core/sched/scheduler.py:208-209。
Il y a ici une conception facile à négliger :max_num_running_reqsetmax_num_active_reqssont deux limites supérieures différentes. La première provient demax_num_seqs, détermine le nombre d'emplacements du model runner ; la seconde provient demax_num_active_seqs, limite seulement le nombre de requêtes pouvant entrer en RUNNING, et est par défaut égale à la première📎 vllm/v1/core/sched/scheduler.py:123-131. Cette séparation permet de réduire la taille réelle du lot de décodage concurrent sans diminuer la capacité de capture du CUDA graph.
Le côté mémoire vidéo est géré uniformément parKVCacheManager, qui détient en interneBlockPool。BlockPoolLe cœur deself.blocksestKVCacheBlock(la liste de tous lesfree_block_queue) et📎 vllm/v1/core/block_pool.py:171-177(une liste doublement chaînée de blocs libres ordonnée selon l'ordre d'éviction)null_block. Notez la présence deis_null=True: c'est le premier bloc retiré de la tête de la file libre,📎 vllm/v1/core/block_pool.py:183-187, le compteur de références ne participe pas à la maintenance courante, il sert spécialement de placeholder
. Lorsqu'une position de token d'une requête n'a pas besoin d'un vrai bloc KV (par exemple une position sautée par la fenêtre glissante), ce null block est inséré dans la block table.BlockHashToBlockMapLa structure d'index du cache de préfixes estBlockHashWithGroupId, elle mappeKVCacheBlockvers un{block_id: KVCacheBlock}ou un dictionnaire📎 vllm/v1/core/block_pool.py:56-59. Pourquoi utiliser un type union ? Les commentaires donnent la réponse : la plupart des hachages ne correspondent qu'à un seul bloc, et utiliser un dictionnaire entraînerait des surcoûts de GC inutiles ; ce n'est que lorsque le même hachage est partagé par plusieurs blocs qu'on passe à un dictionnaire.📎 vllm/v1/core/block_pool.py:56-59. Il s'agit d'un compromis typique consistant à échanger de la complexité de type contre un surcoût d'exécution.
KVCacheBlocksest l'objet d'interface entre le planificateur et le gestionnaire de KV cache, qui masque les structures de données internes. Sonblockschamp esttuple[Sequence[KVCacheBlock], ...], la dimension externe est le KV cache group, la dimension interne est la séquence de blocs📎 vllm/v1/core/kv_cache_manager.py:41-54. Les commentaires expliquent clairement pourquoi ne pas utiliser le bloc comme dimension externe : cela supposerait que tous les groupes ont le même nombre de blocs, alors qu'à l'avenir il pourrait être possible de configurer une taille de bloc différente pour différents groupes.📎 vllm/v1/core/kv_cache_manager.py:43-48。
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"| WCe schéma ancre le flux de données entre le planificateur et le pool de mémoire GPU : les requêtes de la file waiting entrent dans running viaallocate_slots, les requêtes running retournent dans waiting lorsqu'elles sont préemptées, les blocs libérés retournent dans la file d'attente libre, et la table de hachage du cache de préfixes est le point d'entrée pour que les requêtes waiting touchent le cache.
4.2 Flux principal de schedule() : priorité au running, complément par le waiting, préemption en dernier recours
schedule()est la méthode centrale de tout le planificateur, elle retourne unSchedulerOutput, décrivant ce qu'il faut exécuter à cette étape. Les commentaires au début de la méthode précisent la philosophie de conception : dans le planificateur, il n'y a pas de distinction entre « phase de décodage » et « phase de préremplissage », chaque requête n'a quenum_computed_tokensetnum_tokens_with_spec, et la tâche du planificateur est de faire rattraper le second par le premier📎 vllm/v1/core/sched/scheduler.py:559-568. Cette vision unifiée est la base permettant la coexistence du chunked prefill, du prefix caching et du décodage spéculatif.
4.2.1 Initialisation du budget et calcul des seuils
Avant d'entrer dans la boucle principale, le planificateur définit d'abord deux budgets :token_budgetinitialisé àmax_num_scheduled_tokens,input_budgetinitialisé àmax_num_batched_tokens 📎 vllm/v1/core/sched/scheduler.py:577-580. Les deux sont généralement égaux, mais lorsque le modèle peut ajouter des tokens dans le lot (comme en décodage spéculatif),max_num_scheduled_tokenssera inférieur àmax_num_batched_tokens, la différence étant l'espace réservé aux draft tokens.
long_prefill_token_thresholdLe traitement de📎 vllm/v1/core/sched/scheduler.py:606-616mérite un examen séparé. Son rôle est d'empêcher qu'un long prefill affame les autres requêtes, mais s'il n'y a qu'une seule requête actuellement, personne ne sera affamé, donc le seuil est mis à zéroadaptive_long_prefill_threshold. Lorsqueinput_budget // num_eligible_reqsest activé, le seuil est également relevé à📎 vllm/v1/core/sched/scheduler.py:617-622。
, garantissant que le budget d'une requête individuelle ne sera pas comprimé en dessous de sa part équitable.
4.2.2 Boucle de planification des requêtes runningself.runningLa boucle principale parcourt à partir de la tête dereq_index, le curseur étant📎 vllm/v1/core/sched/scheduler.py:624-627. Pour chaque requête, une série de vérifications de saut est d'abord effectuée :
- En planification asynchrone, si le placeholder de sortie de la requête indique qu'elle a déjà atteint
max_tokens, on saute pour éviter d'exécuter une étape supplémentaire📎vllm/v1/core/sched/scheduler.py:631-645。 - Dans le scénario V2 + PP + asynchrone, si l'étape actuelle n'a pas encore atteint
next_decode_eligible_step, on saute pour correspondre au rythme de diffusion des tokens d'échantillonnage côté worker📎vllm/v1/core/sched/scheduler.py:647-651。 - Lorsque l'équilibrage DP prefill est activé, les chunks de prefill sur les étapes non alignées au rythme sont différés📎
vllm/v1/core/sched/scheduler.py:653-657。
Après avoir passé les vérifications de saut, on calcule de combien de tokens cette requête peut avancer à cette étape :
num_new_tokens = request.num_tokens_with_spec
+ request.num_output_placeholders
- request.num_computed_tokenspuis successivement contraint parlong_prefill_token_threshold、token_budget、input_budget - draft_slotsetmax_model_len. Si la requête comporte une entrée d'encodeur, elle doit également être ajustée par📎 vllm/v1/core/sched/scheduler.py:670-688_try_schedule_encoder_inputsL'étape suivante est la plus cruciale : allouer les KV blocks.📎 vllm/v1/core/sched/scheduler.py:700-712。
allocate_slotsest encapsulé dans une bouclewhile True📎 vllm/v1/core/sched/scheduler.py:742-747. Si elle retourneNone, cela signifie que la mémoire GPU est insuffisante, le planificateur commence la préemption : sélectionner une victime selon la stratégie (la stratégie PRIORITY choisit celle de plus basse priorité, la stratégie FCFS choisit celle en fin de liste running)📎 vllm/v1/core/sched/scheduler.py:761-767, appeler_preempt_requestpour la renvoyer dans la file waiting, puis réessayer l'allocation📎 vllm/v1/core/sched/scheduler.py:801-806. Si la victime est la requête actuelle elle-même, cela signifie qu'il n'y a plus d'objet à préempter, on sort de la boucle et la requête actuelle ne peut pas non plus être planifiée.📎 vllm/v1/core/sched/scheduler.py:807-813。
Il y a un détail subtil dans la logique de préemption : sous la stratégie PRIORITY, si la requête préemptée est déjà dansscheduled_running_reqs(c'est-à-dire que des ressources lui ont déjà été allouées à cette étape), il faut restituer intégralement son budget de tokens, ses blocks, ses tokens spéculatifs et son budget d'encodeur📎 vllm/v1/core/sched/scheduler.py:779-797. Cela garantit la cohérence du registre des budgets.
Après une allocation réussie, la requête est ajoutée àscheduled_running_reqs, on enregistre les blocks et le nombre de tokens, on déduit le budget📎 vllm/v1/core/sched/scheduler.py:815-823. Les tokens liés au décodage spéculatif sont ici découpés et enregistrés.📎 vllm/v1/core/sched/scheduler.py:825-841。
4.2.3 Admission des requêtes waiting
Après la fin de la boucle running, si aucune préemption n'a eu lieu à cette étape et que le planificateur n'est pas en pause, on commence à traiter la file waiting📎 vllm/v1/core/sched/scheduler.py:868-872. Avant l'admission, deux limites sont vérifiées :max_num_active_reqsetinput_budget 📎 vllm/v1/core/sched/scheduler.py:873-879。
La planification des requêtes waiting comporte une étape supplémentaire de recherche dans le cache de préfixes par rapport au running. Lorsquerequest.num_computed_tokens == 0, appeler_get_local_prefix_cache_hitpour rechercher une correspondance dans le cache local📎 vllm/v1/core/sched/scheduler.py:932-939. Si un KV connector est configuré, on interroge également le cache distant pour une correspondance.📎 vllm/v1/core/sched/scheduler.py:942-954。
Il y a ici une logique fine de gestion des conflits entre correspondances locales et distantes. Une correspondance locale peut ne pas être alignée sur les blocs (partial_tail), et si une correspondance distante dépasse strictement la correspondance locale complète, on abandonne la queue du sous-bloc local pour la laisser être couverte par le chargement distant, évitant ainsi le copy-on-write📎 vllm/v1/core/sched/scheduler.py:977-988. Inversement, on conserve la queue locale et on ne charge pas l'externe.📎 vllm/v1/core/sched/scheduler.py:989-995。
Après une admission réussie, la requête est retirée de la file waiting, son état est défini à RUNNING, et elle est ajoutée à la liste running📎 vllm/v1/core/sched/scheduler.py:1263-1319. Si après cette étape elle est encore en prefill (num_computed_tokens + num_new_tokens < request.num_tokens), on l'ajoute à l'ensemble_inflight_prefills📎 vllm/v1/core/sched/scheduler.py:1326-1328。
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 --> buildCe graphe de flux de contrôle couvre les deux grandes boucles et la branche de préemption deschedule(). Noter le chemin de nouvelle tentative de préemption après échec deallocate_slotsdans la boucle running, ainsi que le déplacement des requêtes en état blocked versskipped_waitingdu contournement.
4.3 Le cœur de la conscience de la mémoire vidéo : allocate_slots et la préemption
allocate_slotsest la porte entre l'ordonnanceur et la mémoire vidéo. Sa liste de paramètres est elle-même un registre de mémoire vidéo :num_new_tokensest le nombre de tokens à recalculer,num_new_computed_tokensest le nombre de tokens nouvellement touchés par le cache de préfixe,num_external_computed_tokensest le nombre de touches externes fournies par le connector,num_lookahead_tokensest le nombre d'emplacements réservés pour le décodage spéculatif📎 vllm/v1/core/kv_cache_manager.py:371-383。
Le commentaire au début de la méthode décrit précisément la disposition des blocs à l'aide d'un schéma ASCII📎 vllm/v1/core/kv_cache_manager.py:417-438:
| < comp > | < new_comp > | < ext_comp > | < new > | < lookahead > |
| < to be computed > |
| < to be allocated > |compest les tokens déjà calculés,new_compest les touches du cache de préfixe,ext_compest les touches externes,newest le nouveau calcul de cette étape,lookaheadest la réservation spéculative. L'allocation se fait en trois phases : d'abord libérer les blocs inutiles et vérifier s'il y a suffisamment de blocs libres, puis traiter les tokens de préfixe, et enfin allouer des blocs pour les tokens nouvellement calculés📎 vllm/v1/core/kv_cache_manager.py:458-461。
4.3.1 Ligne de flottaison et contrôle d'admission
allocate_slotscontient deux portes d'admission. La première estfull_sequence_must_fit: lorsqu'elle est activée, on vérifie d'abord si la séquence de requête entière (et non seulement le premier chunk) peut tenir, sinon on retourne directementNone 📎 vllm/v1/core/kv_cache_manager.py:515-531. Cela empêche une admission excessive sous chunked prefill de provoquer des oscillations du KV cache.
La seconde est la ligne de flottaison.watermark_blocksne prend effet que lorsque l'état de la requête est WAITING ou PREEMPTED et qu'une requête a déjà été ordonnancée📎 vllm/v1/core/kv_cache_manager.py:506-513. Elle exige de conserver au moins une certaine proportion de blocs libres après l'allocation, afin d'éviter les expulsions et préemptions fréquentes.reserved_blocksest utilisé dans les scénarios de chargement KV asynchrone, pour garantir que les blocs réservés du prefill en cours ne soient pas consommés par de nouvelles requêtes📎 vllm/v1/core/kv_cache_manager.py:564-570。
4.3.2 Le coût et la récupération de la préemption
_preempt_requestfait une chose qui semble brutale mais nécessaire : réinitialiser lenum_computed_tokensde la requête à 0📎 vllm/v1/core/sched/scheduler.py:1560-1561. Cela signifie que la requête préemptée doit refaire le prefill depuis le début lors de la prochaine ordonnancement. Pourquoi cette conception ? Parce que les KV block de vLLM sont privés à la requête, la préemption doit libérer tous les blocs, et après libération, il n'est pas garanti de récupérer les mêmes blocs lors de la réallocation, donc on ne peut que recalculer depuis le début. L'existence du cache de préfixe compense partiellement ce coût : si le préfixe de la requête préemptée est déjà mis en cache, il peut être touché lors de la réordonnancement, sans véritable recalcul.
La préemption gère également le problème des « sorties obsolètes » sous ordonnancement asynchrone.num_stale_output_tokensest défini ànum_in_flight_tokens, marquant toutes les sorties en cours comme obsolètes📎 vllm/v1/core/sched/scheduler.py:1571-1574. Ces tokens seront quand même livrés (les jeter perturberait le taux d'acceptation du décodage spéculatif), mais ne modifieront pas les compteurs réinitialisés.drop_stale_outputle flag détermine s'il faut jeter ou livrer📎 vllm/v1/core/sched/scheduler.py:1539-1547。
4.3.3 Libération différée : risque de lecture après écriture des connecteurs asynchrones
Lorsqu'on utilise un KV connector et qu'il existe plusieurs lots en cours,defer_block_freeest défini àTrue 📎 vllm/v1/core/sched/scheduler.py:175-181. La raison : une étape peut encore écrire dans les blocs KV d'une requête libérée, tandis qu'un connector consommateur peut réallouer et remplir ces blocs via un chargement non ordonné par rapport à cette écriture.
La libération différée est implémentée viadeferred_freesune deque, chaque entrée étant(fence_seq, blocks) 📎 vllm/v1/core/sched/scheduler.py:388-390。_free_request_blocksvérifie_request_blocks_can_be_freed, si la dernière étape d'ordonnancement de la requête n'a pas encore été traitée, on place les blocs dans la file différée📎 vllm/v1/core/sched/scheduler.py:2679-2688。_drain_deferred_freesdansupdate_from_outputavanceprocessed_step_seqpuis appelle, libérant les blocs dont la fence est satisfaite📎 vllm/v1/core/sched/scheduler.py:2701-2706。
4.4 Détermination des touches du cache de préfixe et cycle de vie des blocs
Le point d'entrée de la recherche du cache de préfixe estKVCacheManager.get_computed_blocks. Il vérifie d'abord si le cache est activé et si la requête n'est pas marquée pour ignorer la lecture📎 vllm/v1/core/kv_cache_manager.py:286-287. Puis appellecoordinator.find_longest_cache_hit, en passantrequest.block_hashesetmax_cache_hit_length = request.num_tokens - 1 📎 vllm/v1/core/kv_cache_manager.py:295-300。
Pourquoinum_tokens - 1? Le commentaire explique : lorsque tous les tokens touchent le cache, il faut recalculer le dernier token pour obtenir les logits📎 vllm/v1/core/kv_cache_manager.py:289-294. C'est un cas limite facile à négliger : même si le préfixe est entièrement touché, il faut calculer au moins un token.
Le cycle de vie des blocs est géré parBlockPool.get_new_blocksretire un bloc de la tête de la file libre, si le cache est activé, appelle d'abord_maybe_evict_cached_blockpour effacer ses métadonnées de hachage, puis incrémente le compteur de références📎 vllm/v1/core/block_pool.py:683-702。free_blocksdécide de replacer en tête ou en queue de file selon que le bloc a un hachage : les blocs sans hachage sont réutilisés en LIFO (meilleure localité GPU), les blocs avec hachage en FIFO (comportement d'éviction LRU)📎 vllm/v1/core/block_pool.py:785-805。
cache_full_blocksest le moment où un bloc est écrit dans la table de hachage du cache de préfixe. Il parcourt les blocs nouvellement pleins, ignore les blocs null et les blocs masqués, calcule un hachage pour chaque bloc et l'insère danscached_block_hash_to_block 📎 vllm/v1/core/block_pool.py:272-300. Si le bloc a déjà un hachage (scénario où un bloc partiel est promu en bloc plein), on retire d'abord l'ancien hachage puis on insère le nouveau📎 vllm/v1/core/block_pool.py:285-293。
touchgère le compteur de références lors d'une touche de cache : si le bloc est dans la file libre (ref_cnt == 0), on le retire d'abord de la file, puis on incrémente le compteur de références📎 vllm/v1/core/block_pool.py:754-770. Cela garantit que les blocs touchés ne seront pas évincés.
Réflexions de conception
Pourquoi la préemption choisit-elle « recalcul depuis le début » plutôt que « conservation partielle » ?La conservation partielle nécessite d'enregistrer la position physique des blocs de chaque requête au moment de la préemption, et de tenter de restaurer le mapping lors de la réordonnancement. Mais le pool de blocs est partagé globalement, d'autres requêtes peuvent déjà avoir occupé ces blocs. La complexité et le coût mémoire de maintenir ce mapping dépassent le coût du recalcul, surtout lorsque le cache de préfixe peut toucher la majeure partie du préfixe.
Pourquoi la ligne de flottaison est-elle à 0 par défaut ?La ligne de flottaison est une assurance contre les préemptions fréquentes, mais elle sacrifie le taux d'utilisation de la mémoire vidéo. La désactiver par défaut signifie que vLLM privilégie le débit plutôt que la stabilité, l'utilisateur doit l'activer lui-même selon les caractéristiques de la charge.
skipped_waitingLa raison d'être de la file.Sans cette file d'attente, les requêtes bloquées occuperaient en permanence la tête de la file waiting, empêchant les requêtes suivantes d'être planifiées (selon la stratégie FCFS). En la séparant, le planificateur peut ignorer les requêtes bloquées et continuer à traiter les suivantes, tout en conservant l'état des requêtes bloquées pour une promotion ultérieure.
Résumé du chapitre
Le cœur du planificateur réside dans lesschedule()deux boucles de la méthode : la boucle running garantit en priorité la progression des requêtes déjà en cours, tandis que la boucle waiting admet de nouvelles requêtes lorsque le budget le permet. En cas d'insuffisance de mémoire GPU, de l'espace est libéré en préemptant la requête de plus faible priorité dans la liste running ; la requête préemptée voit sonnum_computed_tokensréinitialisé à 0, mais le cache de préfixes compense partiellement le coût de recalcul.allocate_slotsest la porte de la mémoire GPU, qui empêche la surallocation viafull_sequence_must_fit, les niveaux d'eau etreserved_blockstrois niveaux de contrôle d'admission. Le cache de préfixes permet le partage entre requêtes via un index par hachage de blocs, et le critère de succès est plafonné parnum_tokens - 1afin de garantir qu'au moins un token soit calculé pour obtenir les logits.
Réflexions et auto-évaluation de ce chapitre
Q1 : Dans la boucle running deschedule(), siallocate_slotsrenvoieNoneet que_request_blocks_can_be_freedrenvoieFalsepour la victime, le codebreaksort de la boucle. Si l'on supprime cette vérification et qu'on appelle directement_preempt_request, dans quel scénario cela entraînerait-il une incohérence d'état ?
Analyse de référence:_request_blocks_can_be_freedvérifierequest.last_sched_seq <= self.processed_step_seq 📎 vllm/v1/core/sched/scheduler.py:2672-2677. Lorsquedefer_block_freeest activé, si la dernière étape de planification de la victime n'a pas encore été traitée, ses blocs peuvent encore être écrits par des étapes GPU en vol. Une préemption directe appellerait_free_request_blocks, et ce dernier, lorsque_request_blocks_can_be_freedvautFalse, placera les blocs dansdeferred_freesau lieu de les libérer immédiatement📎 vllm/v1/core/sched/scheduler.py:2679-2688. Mais la sémantique de la préemption est de « libérer immédiatement les blocs pour la requête courante », et une libération différée ne peut pas satisfaire ce besoin ;allocate_slotséchouera à nouveau, formant une boucle infinie. Plus grave encore, si les blocs de la victime sont libérés de manière différée puis alloués à la requête courante, alors que le GPU écrit encore dans les blocs de la victime, une course de données se produira.
Q2: get_computed_blocksdansmax_cache_hit_length = request.num_tokens - 1. Si l'on remplace parrequest.num_tokens, dans quels cas cela entraînerait-il une sortie erronée ?
Analyse de référence: lorsque tous les tokens d'une requête touchent le cache,num_computed_tokenssera égal ànum_tokens. Le planificateur estime alors qu'aucun nouveau token n'a besoin d'être calculé, mais l'échantillonnage des logits nécessite l'état caché de la dernière position, et cet état caché provient de la propagation avant. Si aucun token n'est calculé, il n'y a pas de logits à échantillonner, et la requête restera bloquée ou produira une sortie erronée. Le commentaire l'explique explicitement📎 vllm/v1/core/kv_cache_manager.py:289-294. De plus,allocate_slotsexige quenum_computed_tokenssoit aligné sur la taille de bloc ; recalculer le dernier token peut déclencher le recalcul de tout un bloc, ce qui est une limitation connue de l'implémentation actuelle.
Q3: _preempt_requestréinitialisenum_computed_tokensà 0, mais conserverequest.num_tokens(prompt + tokens déjà générés). Si le cache de préfixes ne touche pas lors de la replanification d'une requête préemptée, combien de tokens doit-elle recalculer ? Et si elle touche, combien peut-elle économiser ?
Analyse de référence:num_computed_tokens = 0signifie que lors de la replanification, on repart du premier token ;📎 vllm/v1/core/sched/scheduler.py:1561。request.num_tokensreste inchangé, incluant le prompt original et les tokens de sortie déjà générés. Si le cache de préfixes ne touche pas, il faut recalculer le prefill de tous lesnum_tokenstokens. Si elle touche,get_computed_blocksrenverra les blocs touchés, etnum_computed_tokensreprendra à partir de la position touchée📎 vllm/v1/core/kv_cache_manager.py:296-300. À noter que les tokens de sortie de la requête préemptée sont aussi dansnum_tokens; leurs hachages de préfixe ont été mis en cache lors de la génération (si activé), donc lors de la replanification, les préfixes de ces tokens de sortie peuvent aussi toucher. Maismax_cache_hit_length = num_tokens - 1signifie que le dernier token doit toujours être recalculé.
La sortie du planificateur,SchedulerOutput, précise le contenu de l'exécution de cette étape : les ID de blocs des nouvelles requêtes, le nombre de tokens des requêtes en cache, les tokens spéculatifs, les entrées d'encodeur, etc. Le chapitre suivant tracera comment cette sortie est consommée par le ModelRunner, depuisSchedulerOutputjusqu'à la propagation avant sur GPU.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 5 : Colonne vertébrale de l'exécution du modèle : de SchedulerOutput à la propagation avant sur GPU
Dans le chapitre précédent, nous avons vu que le Scheduler, à chaque étape de sa boucle de planification, décide quelles requêtes entrent dans la file running, lesquelles sont préemptées, lesquelles attendent faute de mémoire GPU, et produit finalement un SchedulerOutput — qui décrit ce qu'il faut calculer à cette étape : quelles requêtes, combien de tokens chacune, et quels blocs KV utiliser. Mais cette liste n'est qu'une intention logique ; le GPU, lui, a besoin de tenseurs physiques. Ce chapitre trace comment SchedulerOutput est distribué par l'Executor aux Workers, puis traduit par le GPUModelRunner en entrées exécutables par le GPU telles que input_ids, positions, slot_mapping et block table, et finalement injecté dans chaque couche du modèle via forward_context pour décrire le lot partagé entre les couches, réalisant le passage de la décision de planification à la propagation avant.
5.1 Executor : acheminer le résultat de planification vers chaque carte
Modèle intuitif
Executorest le « messager » entre EngineCore et les GPU Workers. Sans lui, EngineCore devrait savoir lui-même combien de cartes il y a dans le cluster, dans quel processus se trouve chaque carte, et commentSchedulerOutputSérialiser le passé — la logique de planification s'entremêle avec la topologie distribuée.ExecutorExtraire cette responsabilité : EngineCore se contente d'appelerexecute_model(scheduler_output), le reste — « à qui envoyer, comment envoyer, combien de résultats recevoir » — est décidé par l'Executor.
Hiérarchie de classes et champs
Executorest une classe de base abstraite dont les champs au niveau de la classe encodent directement les capacités du backend📎 vllm/v1/executor/abstract.py:48-49:
uses_ray: bool = False # whether the executor uses Ray for orchestration.
supports_pp: bool = False # whether the executor supports PPCes deux indicateurs ne sont pas décoratifs — le code de niveau supérieur les lit pour décider d'activer ou non certains chemins d'optimisation.__init__initialise danssleeping_tags、kv_output_aggregator、ec_output_aggregatortrois champs d'état📎 vllm/v1/executor/abstract.py:119-120, respectivement utilisés pour le suivi des étiquettes de mode veille, l'agrégation de sortie du connecteur KV, et l'agrégation de sortie du connecteur d'encodeur.
Sélection du backend :get_classroutage par branche de
get_classest une fabrique statique qui, selon la configurationdistributed_executor_backend, retourne la classe Executor concrète📎 vllm/v1/executor/abstract.py:51-96. Sa structure de branchement mérite un examen attentif :
- Si la configuration elle-même est un
type, vérifier s'il s'agit d'une sous-classe deExecutorpuis l'utiliser directement📎vllm/v1/executor/abstract.py:52-61; "ray"La branche comporte également des sous-branches :VLLM_USE_RAY_V2_EXECUTOR_BACKENDsi vrai, utiliserRayExecutorV2, sinon utiliserRayDistributedExecutor📎vllm/v1/executor/abstract.py:64-72;"mp"correspond àMultiprocExecutor,"uni"correspond àUniProcExecutor📎vllm/v1/executor/abstract.py:73-80;- Un backend personnalisé sous forme de chaîne est résolu dynamiquement via
resolve_obj_by_qualnamerésolution dynamique📎vllm/v1/executor/abstract.py:85-90。
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"]Step-by-Step : un flux d'appel deexecute_modelappel de
Mise en situation : EngineCore termine une étape de planification, obtientSchedulerOutput, appelleexecutor.execute_model(scheduler_output)。
Executor.execute_modelL'implémentation de est minimaliste📎 vllm/v1/executor/abstract.py:237-238:
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]Le point clé réside danscollective_rpc— il diffuse le nom de la méthode et les paramètres à tous les Workers, collecte la liste des valeurs de retour de chaque Worker, puisoutput[0]ne prend que le premier. Pourquoi ne prendre que le premier ? Parce qu'en parallélisme tensoriel, tous les Workers exécutent la même passe avant logique, et les sorties sont sémantiquement équivalentes ; le résultat d'échantillonnage est déterminé par le dernier stage PP ou le rank 0, prendreoutput[0]évite une agrégation redondante.collective_rpcLa documentation de recommande explicitement de « ne transmettre que des messages de contrôle, la communication du plan de données étant établie séparément »📎 vllm/v1/executor/abstract.py:220-221, c'est précisément la position deSchedulerOutput— c'est un message de contrôle, les véritables données de tokens circulent en interne entre les Workers via des tenseurs GPU.
sample_tokenssuit le même modèle📎 vllm/v1/executor/abstract.py:257-258, mais le type de retour n'inclut pasNone— l'échantillonnage produit nécessairement un résultat. La répartition de ces deux méthodes correspond à la conception « séparation exécution-échantillonnage » de vLLM v1 :execute_modelpeut retournerNone(indiquant que la passe avant a été soumise mais que l'échantillonnage est différé), auquel cas l'état est temporairement stocké dansExecuteModelState.
Réflexions de conception
collective_rpcest déclaré comme@abstractmethod 📎 vllm/v1/executor/abstract.py:186-192, ce qui signifie que chaque backend doit implémenter lui-même « comment envoyer le RPC aux Workers ».MultiprocExecutorutilise des files de mémoire partagée,RayDistributedExecutorutilise des appels d'acteurs Ray,UniProcExecutoreffectue des appels locaux directs. Cette abstraction permet au code de niveau supérieur de ne pas se soucier des détails distribués.
Un détail facile à négliger :supported_tasksest marqué comme@cached_property 📎 vllm/v1/executor/abstract.py:306-309, le commentaire déclare explicitement « éviter les appels RPC inutiles ». Parce queget_supported_tasksnécessite une communication inter-processus, et que la liste des tâches reste inchangée pendant le cycle de vie du modèle, la mise en cache est une optimisation correcte et nécessaire.
5.2 GPUModelRunner : de SchedulerOutput aux tenseurs d'entrée
Modèle intuitif
GPUModelRunnerest un « traducteur » : il traduit la description logique deSchedulerOutput(ID de requête, nombre de tokens, ID de bloc) en tenseurs physiques directement consommables par le GPU. Sans lui, la couche modèle devrait gérer elle-même des questions comme « dans quel emplacement KV se trouve le 7e token de la 3e requête » — une fuite de préoccupations catastrophique.
État central et disposition mémoire
GPUModelRunnerhérite de trois Mixins📎 vllm/v1/worker/gpu_model_runner.py:479-480:LoRAModelRunnerMixin、KVConnectorModelRunnerMixin、ECConnectorModelRunnerMixin, fournissant respectivement les capacités d'adaptation LoRA, de connecteur KV et de connecteur d'encodeur.
__init__met en cache tous les objets de configuration📎 vllm/v1/worker/gpu_model_runner.py:488-498, et initialise plusieurs indicateurs clés :
check_ep_fault: uniquement lorsque le parallélisme de données > 1 et qu'il s'agit d'un modèle MoE, vérifier si le gestionnaire EP all2all supporte la tolérance aux pannes📎vllm/v1/worker/gpu_model_runner.py:507-509;is_pooling_model: déterminé parrunner_type == "pooling"détermine📎vllm/v1/worker/gpu_model_runner.py:515;enable_prompt_embeds: activer ou non l'entrée prompt embedding📎vllm/v1/worker/gpu_model_runner.py:516。
ExecuteModelStateest unNamedTuple, portant l'état temporaire entreexecute_model()etsample_tokens()état temporaire entre📎 vllm/v1/worker/gpu_model_runner.py:463-476. La conception de ses champs révèle l'essence de la séparation exécution-échantillonnage :logits、hidden_states、sample_hidden_statesest le produit de la passe avant,spec_decode_metadata、slot_mappingsest la métadonnée encore nécessaire à la phase d'échantillonnage. Le commentaire indique explicitement qu'il s'agit d'un « état de cache temporaire transmis après que execute_model() retourne None »📎 vllm/v1/worker/gpu_model_runner.py:464-464。
Step-by-Step:_update_statesComment synchroniser l'état du cache
Mise en situation : le planificateur décide de traiter à cette étape la requête A (nouvelle requête), B (continuation du decode de l'étape précédente), C (restaurée après préemption), tandis que la requête D est terminée.
Première étape : nettoyer les requêtes terminées.parcourtfinished_req_ids, retire l'état du dictionnaireself.requests, retire deinput_batch. Noter le cas limite signalé par le commentaire :📎 vllm/v1/worker/gpu_model_runner.py:1202-1217etfinished_req_idspeuvent se chevaucher — lorsqu'une requête est abandonnée puis resoumise avec le même ID, elles sont considérées comme deux requêtes distinctesscheduled_req_idspeuvent se chevaucher📎 vllm/v1/worker/gpu_model_runner.py:1211-1215。
Deuxième étape : remettre à zéro les blocs KV nouvellement alloués.Sinew_block_ids_to_zeron'est pas vide, appeler_zero_block_idspour remettre à zéro la mémoire GPU, empêchant des NaN obsolètes de polluer l'attention ou les calculs SSM📎 vllm/v1/worker/gpu_model_runner.py:1219-1222. C'est le prérequis de sécurité pour la réutilisation des blocs PagedAttention.
Troisième étape : calculer l'ensemble des requêtes non planifiées.C'est l'étape la plus sujette aux erreurs📎 vllm/v1/worker/gpu_model_runner.py:1238-1247:
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)Le commentaire explique pourquoi c'estscheduled_req_ids - resumed_req_idsplutôt que directementscheduled_req_ids: habituellementcached_req_idsetresumed_req_idssont disjoints, mais dans le scénario de préemption forcée déclenché parreset_prefix_cache, les requêtes restaurées doivent d'abord être retirées du lot persistant avant d'être réintégrées📎 vllm/v1/worker/gpu_model_runner.py:1241-1246。
Quatrième étape : traiter les nouvelles requêtes.Pour chaquescheduled_new_reqs, construireCachedRequestState 📎 vllm/v1/worker/gpu_model_runner.py:1295-1308. Si le type d'échantillonnage estRANDOM_SEED, créer untorch.Generator 📎 vllm/v1/worker/gpu_model_runner.py:1277-1284avec graine. Si le modèle utilise M-RoPE, appeler_init_mrope_positionspour précalculer les positions📎 vllm/v1/worker/gpu_model_runner.py:1319-1321。
Cinquième étape : mettre à jour les requêtes en cours.Pour chaquescheduled_cached_reqs, mettre à journum_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1402, gérer l'ajout ou le remplacement d'ID de bloc📎 vllm/v1/worker/gpu_model_runner.py:1437-1448. Si la requête n'est pas dans le lot persistant (req_index is None), ajouter àreqs_to_add 📎 vllm/v1/worker/gpu_model_runner.py:1450-1465。
Sixième étape : compression et réorganisation. condense()Combler les trous laissés par les requêtes de suppression📎 vllm/v1/worker/gpu_model_runner.py:1511-1512,_may_reorder_batchPermettre au backend d'attention de réorganiser à la demande📎 vllm/v1/worker/gpu_model_runner.py:1513-1514,refresh_metadata()Rafraîchir les métadonnées de lot📎 vllm/v1/worker/gpu_model_runner.py:1515-1516。
Préparation des tenseurs d'entrée :_prepare_input_idsle chemin rapide asynchrone de
_prepare_input_idstraite un problème subtil : en ordonnancement asynchrone, le token échantillonné de l'étape précédente est encore sur le GPU, et celui de l'étape couranteinput_idsdoit les y insérer📎 vllm/v1/worker/gpu_model_runner.py:1767-1772。
Le chemin normal (prev_sampled_token_ids is None) copie directement les tenseurs CPU vers le GPU📎 vllm/v1/worker/gpu_model_runner.py:1788-1794. Le chemin asynchrone parcourt les requêtes, calcule l'index du dernier token de chaque requête dans leinput_idsaplati📎 vllm/v1/worker/gpu_model_runner.py:1809-1836. Les commentaires donnent un exemple concret :cu_num_tokens = [2, 5, 8]、draft_tokens = [1, 2, 2]lorsquesample_flattened_indices = [0, 2, 5],spec_flattened_indices = [1, 3, 4, 6, 7] 📎 vllm/v1/worker/gpu_model_runner.py:1820-1822。
il existe une optimisation clé📎 vllm/v1/worker/gpu_model_runner.py:1859-1868:
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,
)
returnLorsque le lot est inchangé et sans réorganisation, les indices sont0..N-1la même permutation, on peut directement utiliser une copie par tranche unique, évitant le coût du scatter. C'est l'expression directe de l'optimisation des lots persistants.
slot_mappinget la block table
_get_slot_mappingsrenvoie deux formats📎 vllm/v1/worker/gpu_model_runner.py:4078-4078: indexé par KV cache group,dict[int, torch.Tensor]utilisé pour les métadonnées d'attention, indexé par nom de couche,dict[str, torch.Tensor]utilisé pourForwardContext. Pour un KV cache group encoder-only, le slot mapping est un tenseur entièrement nul📎 vllm/v1/worker/gpu_model_runner.py:4096-4115; sinon on découpe depuisblock_table.slot_mapping.gputranche📎 vllm/v1/worker/gpu_model_runner.py:4107-4109. Le remplissage de queue inutilisé-1, le commentaire explique que c'estreshape_and_cachenécessaire en mode CUDA graph complet📎 vllm/v1/worker/gpu_model_runner.py:4118-4122。
_get_block_tablepour chaque KV cache group, obtenir le tenseur de l'appareil📎 vllm/v1/worker/gpu_model_runner.py:2319-2335, et utiliserNULL_BLOCK_IDpour remplir les lignes de padding CUDAGraph — le bloc 0 est réservé au padding📎 vllm/v1/worker/gpu_model_runner.py:2332-2334。
5.3 forward_context : description de lot partagée entre les couches
Modèle intuitif
forward_contextest le « tableau d'affichage unifié » collé à l'avant de la salle de classe : chaque couche du modèle lève les yeux et voit l'arrangement des places (attention metadata) et les règles (slot mapping) de l'examen en cours, sans avoir à les demander individuellement. Sans lui, chaque couche d'attention devrait recevoir ces informations via les paramètres — or laforwardsignature de la couche du modèle est fixe, impossible de passer des paramètres individuellement pour chaque couche.
Structure de données
ForwardContextest un@dataclass 📎 vllm/forward_context.py:141-202, champs principaux :
no_compile_layers: depuisstatic_forward_contextcopie, marque les couches ne participant pas à la compilation📎vllm/forward_context.py:132-137;attn_metadata: mapping du nom de couche vers les métadonnées d'attention, en mode DBO c'est une liste de longueur 2 (une par microbatch)📎vllm/forward_context.py:144-152;slot_mapping: mapping du nom de couche vers le tenseur slot mapping📎vllm/forward_context.py:145;cudagraph_runtime_mode: mode CUDA graph à l'exécution, par défautNONE📎vllm/forward_context.py:155-157;batch_descriptor: descripteur de lot, utilisé pour la distribution CUDA graph📎vllm/forward_context.py:158;is_padding: masque booléen sur l'axe des tokens,Trueindique les lignes de padding📎vllm/forward_context.py:162-165。
BatchDescriptorest un autre@dataclass(frozen=True) 📎 vllm/forward_context.py:30-57, la conception des champs suit le principe de « minimisation des éléments de description » :num_tokens、num_reqs(peut être None en mode PIECEWISE),uniform(toutes les requêtes ont le même nombre de tokens),has_lora、num_active_loras. Le commentaire expliquenum_active_lorasla raison d'être de : lorsquecudagraph_specialize_lora_countest activé, chaque valeur de nombre LoRA capture un CUDA graph indépendant, carfused_moe_lorala taille de grille des kernels comme dépend de cette valeur📎 vllm/forward_context.py:60-64。
Singleton global et gestion de contexte
_forward_contextest une variable globale au niveau du module📎 vllm/forward_context.py:199-201, viaoverride_forward_contextle gestionnaire de contexte sauvegarde l'ancienne valeur à l'entrée et la restaure à la sortie📎 vllm/forward_context.py:263-274。set_forward_contextest une encapsulation de plus haut niveau📎 vllm/forward_context.py:277-394, qui gère en plus la construction des métadonnées DP, la création automatique du batch descriptor, l'injection de kwargs spécifiques à la plateforme.
Step-by-Step : depuisexecute_modeljusqu'au forward du modèle
Mise en situation :GPUModelRunner.execute_modela préparé tous les tenseurs d'entrée, sur le point d'appeler le modèle.
Dansexecute_model,set_forward_contextest appelé📎 vllm/v1/worker/gpu_model_runner.py:4408-4420:
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_contextconstruit d'abord en interneDPMetadata(si DP ou MoE à parallélisme de séquence est activé)📎 vllm/forward_context.py:299-328, puis appellecreate_forward_contextpour construireForwardContextl'instance📎 vllm/forward_context.py:347-358, et enfin viaoverride_forward_contextdéfinit la variable globale📎 vllm/forward_context.py:361-362。
La couche du modèle viaget_forward_context()lit📎 vllm/forward_context.py:208-214. Si non défini, l'assertion échoue et suggère d'utiliserset_forward_context。
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]Réflexions de conception
Pourquoi utiliser une variable globale plutôt qu'un passage de paramètre explicite ? Parce que laforwardsignature de la couche du modèle est fixée par la convention HuggingFace, impossible d'injecter des paramètres supplémentaires par couche. Variable globale + gestionnaire de contexte est la seule solution permettant une injection inter-couches sans modifier le code du modèle. Le coût est une dépendance implicite —get_forward_context()l'appelant de doit s'assurer qu'il est dansset_forward_contextla portée de.
is_paddingLa conception du champ mérite attention📎 vllm/forward_context.py:162-165: le commentaire dit « le consommateur peut l'utiliser pour sauter le travail sur les padding tokens ». C'est une optimisation dans le contexte CUDA graph — les lignes de padding participent à la capture du graphe mais ne doivent pas produire de calcul réel.
all_moe_layersetmoe_layer_indexsont une paire d'astucieux workarounds📎 vllm/forward_context.py:170-195. Le commentaire explique en détail le problème :vllm.moe_forwardles opérateurs personnalisés codent en dur la chaîne du nom de couche dans le graphe, ce qui rend le temps de démarrage à froid de torch.compile trop long. La solution est de stocker la liste des noms de couches dansForwardContext, les opérateurs personnalisés dépilent les chaînes dans l'ordre et incrémentent un compteur. Le commentaire admet aussi que cela repose sur l'hypothèse que « les opérateurs personnalisés s'exécutent dans l'ordre et que torch.compile ne réordonne pas »📎 vllm/forward_context.py:182-184。
Réflexions de conception et pièges en production
Cohérence d'état en ordonnancement asynchrone. _update_statesadopte une stratégie d'« hypothèse optimiste » en décodage spéculatif asynchrone : suppose que tous les draft tokens de l'étape précédente sont acceptés, étend d'abordoutput_token_ids, puis enregistre une fonction de correction différée📎 vllm/v1/worker/gpu_model_runner.py:1376-1384. La fonction de correction est appelée après le lancement du forward du modèle📎 vllm/v1/worker/gpu_model_runner.py:1509-1510, lit le nombre réel d'acceptations depuis le GPU et rétrogradenum_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1547-1558. La subtilité de cette conception : la correction a lieu après le « lancement du lot », ne bloque pas le forward, et maintient la continuité du pipeline asynchrone.
_may_reorder_batchLa condition de déclenchement de.Cette méthode vérifie d'abordkv_cache_groupssi est vide📎 vllm/v1/worker/gpu_model_runner.py:1131-1132. Le commentaire explique pourquoi on ne peut pas simplement vérifieris_attention_free: Le modèle Mamba est également sans attention, mais il utilise un KV cache pour conserver son état interne📎 vllm/v1/worker/gpu_model_runner.py:1116-1139. Seuls les modèles qui n'ont véritablement pas de groupe KV cache sautent la réorganisation.
_prepare_input_idsle piège du calcul d'indice.Lorsqu'un lot contient à la fois des requêtes de décodage de l'étape précédente et de nouvelles requêtes,num_common_tokens < total_without_spec, il faut d'abord copier le tenseur CPU puis effectuer le scatter📎 vllm/v1/worker/gpu_model_runner.py:1849-1854. Sinum_common_tokens == 0, cela signifie qu'aucune requête ne chevauche l'étape précédente, et on retourne directement📎 vllm/v1/worker/gpu_model_runner.py:1855-1858. La distinction entre ces deux branches est cruciale — en omettre une seule entraîneinput_idspartiellement non initialisé.
AsyncGPUModelRunnerOutputla synchronisation des flux.La copie de sortie s'effectue sur un flux CUDA indépendant📎 vllm/v1/worker/gpu_model_runner.py:308-328, en utilisantblocking=Truel'Event pour éviter le verrouillage du pilote CUDA par attente active📎 vllm/v1/worker/gpu_model_runner.py:296-298。get_output()synchroniser d'abord puis libérer la référence du tenseur de périphérique📎 vllm/v1/worker/gpu_model_runner.py:336-340, l'ordre ne doit pas être inversé — sinon le tenseur pourrait être récupéré avant la fin de la copie.
Résumé de ce chapitre
Ce chapitre a retracéSchedulerOutputle chemin complet depuis EngineCore jusqu'au forward GPU.ExecutorViacollective_rpcla diffusion des résultats de planification à tous les Workers,GPUModelRunnerle_update_statessynchronise l'état du cache,_prepare_inputsconstruit les tenseurs d'entrée,_get_slot_mappingsgénère la correspondance des slots KV, et enfinset_forward_contextinjecte la description du lot dans le contexte global pour consommation par les différentes couches du modèle. Le chemin de planification asynchrone maintient la continuité du pipeline via une hypothèse optimiste + correction différée, tandis queForwardContextla conception de singleton global résout la contradiction entre la signature fixe des couches du modèle et l'injection de métadonnées inter-couches.
Réflexions et auto-évaluation de ce chapitre
Q1: _update_statesDansunscheduled_req_ids = cached_req_ids - (scheduled_req_ids - resumed_req_ids)cette expression, si l'on retireresumed_req_idsde la soustraction pour obtenircached_req_ids - scheduled_req_ids, dans quel scénario cela entraînerait-il une incohérence d'état ?
Analyse de référence: Le commentaire indique explicitement que📎 vllm/v1/worker/gpu_model_runner.py:1241-1246,cached_req_idsetresumed_req_idsne se recoupent généralement pas, mais dans le scénario de préemption forcée déclenché parreset_prefix_cache, une requête peut apparaître simultanément danscached_req_idsetresumed_req_ids. Dans ce cas,scheduled_req_ids - resumed_req_idsexclut cette requête de l'ensemble « planifié », la faisant tomber dansunscheduled_req_ids, ce qui la retire d'abord du lot persistant, puis la réintègre via le chemin resumed normal. Si l'on retireresumed_req_ids, la requête serait considérée comme « planifiée » et conservée dans le lot, mais son ID de bloc a déjà été remplacé (req_state.block_ids = new_block_ids 📎 vllm/v1/worker/gpu_model_runner.py:1448), entraînant une inadéquation entre l'ancienne ligne de la block table et le nouvel ID de bloc, et le calcul d'attention lirait des positions KV erronées.
Q2: _prepare_input_idsLe chemin rapide de📎 vllm/v1/worker/gpu_model_runner.py:1859-1868utilisecommon_indices_match and max_flattened_index == (num_common_tokens - 1)comme condition. Si l'ordre des requêtes dans le lot change (par exemple, le backend d'attention réorganise le lot), mais quecommon_indices_matchreste True, que se passe-t-il ?
Analyse de référence:common_indices_matchDans la boucle, viaprev_index == flattened_indexon accumule📎 vllm/v1/worker/gpu_model_runner.py:1835。prev_indexprovenant deprev_positions, mappant la position du lot actuel à la position du lot précédent ;flattened_indexest l'indice aplati du dernier token de cette requête dans le lot actuel. Si le lot est réorganisé,prev_indexetflattened_indexla correspondance change,common_indices_matchdeviendra False, et le chemin rapide ne se déclenchera pas. Mais si la réorganisation fait恰好 queprev_index == flattened_indexsoit vrai pour toutes les requêtes (par exemple, en échangeant deux requêtes avec le même nombre de tokens), le chemin rapide copierait erronément par tranche directe viaprev_sampled_token_ids[:num_common_tokens, 0]— cela remplirait la position de la requête B avec le token échantillonné de la requête A.max_flattened_index == num_common_tokens - 1Cette condition supplémentaire vise précisément à prévenir ce cas dégénéré : elle exige que les indices aplatis soient exactement une permutation de0..N-1, excluant toute réorganisation non triviale.
Q3: ForwardContextUtilise une variable globale au niveau du module_forward_contextplutôt qu'une variable locale au thread. Dans la planification asynchrone oùexecute_modeletsample_tokenssont séparés, sisample_tokensest appelé avant la fin du forward, que retourneget_forward_context()? Quel problème cela entraînerait-il ?
Analyse de référence:set_forward_contextest un gestionnaire de contexte📎 vllm/forward_context.py:278-288, qui à la sortie du blocwithrestaure l'ancienne valeur viaoverride_forward_contextdefinally. Dans📎 vllm/forward_context.py:263-274, le blocexecute_modeldeset_forward_contextn'enveloppe que l'appelwithà_model_forward, et le contexte est restauré dès le retour du forward. Si📎 vllm/v1/worker/gpu_model_runner.py:4408-4433est appelé après la fin du forward,sample_tokenséchouera à l'assertionget_forward_context(), car📎 vllm/forward_context.py:208-214a été réinitialisé à_forward_context(ou à la valeur externe). C'est précisément la raison d'être deNone: l'état nécessaire à l'échantillonnage (ExecuteModelState) est explicitement conservé dans un NamedTuple, plutôt que de dépendre du passage implicite de📎 vllm/v1/worker/gpu_model_runner.py:463-476. Si l'on suppose à tort quelogits、hidden_states、slot_mappingsest encore disponible dansForwardContext, cela déclenchera une erreur d'assertion ou lira des métadonnées erronées.ForwardContextNous avons ainsi parcouru le chemin complet de SchedulerOutput à la propagation forward GPU : distribution par l'Executor, exécution par le Worker, traduction par GPUModelRunner de la liste logique en tenseurs physiques, et injection de la description du lot dans chaque couche via forward_context. Cependant, la partie la plus coûteuse du forward du modèle — le calcul d'attention — n'a pas encore été détaillée. Le chapitre suivant plongera dans les backends d'attention, pour voir comment la block table et le slot mapping dans attn_metadata sont consommés par le noyau PagedAttention, et comment différents backends tels que FlashAttention, FlashInfer, Triton sont sélectionnés et planifiés via une interface unifiée.sample_tokens← Chapitre précédent : Chapitre 4
Retour en haut ↑
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Progression de l'ouvrage : Chapitre 6 / 14
Dans le chapitre précédent, nous avons vu comment GPUModelRunner traduit les résultats de planification en tenseurs physiques tels que input_ids, slot_mapping et block_table, et les injecte dans chaque couche via forward_context. Mais le véritable gros consommateur de temps GPU — le calcul d'attention — reste en suspens. Qui consomme réellement les tenseurs dans attn_metadata ? Pourquoi FlashAttention, FlashInfer et Triton peuvent-ils être interchangeables sous le même code de modèle ? La réponse réside dans la couche d'abstraction AttentionBackend. Elle découple « comment calculer l'attention » de « comment le modèle l'appelle » : la couche modèle ne détient qu'une référence AttentionImpl et appelle l'interface unifiée forward(query, key, value, kv_cache, attn_metadata, output) ; tandis que le backend concret se charge de traduire block_table, slot_mapping, seq_lens en paramètres que son propre noyau peut consommer. Ce chapitre suit FlashAttentionBackend comme fil conducteur, car il couvre simultanément la sémantique de gather de PagedAttention, la compatibilité CUDA Graph, l'attention en cascade, le contexte distribué DCP et les branches les plus riches. Une fois maîtrisé, les autres backends ne sont que des variantes de mappage de paramètres. La motivation de cette conception « enregistrement de backend + interface unifiée » est directe : les noyaux d'attention évoluent extrêmement vite (FA2→FA3→FA4, itérations de FlashInfer, Triton maison), et si la couche modèle dépendait directement d'un noyau concret, chaque mise à niveau du noyau nécessiterait de modifier le code du modèle. La couche d'abstraction isole le changement derrière une seule méthode de fabrique get_impl_cls().
Sélection du backend : déclaration de capacités et construction des métadonnées
Modèle intuitif
ConsidérezAttentionBackendcomme une offre d'emploi : elle ne travaille pas, elle déclare seulement « quels dtype, quels head_size, quels formats de quantification KV cache, quels types d'attention je peux traiter ». Le planificateur utilise la configuration du modèle pour faire correspondre, et en cas d'échec, passe au candidat suivant. Sans cette couche de déclaration, le système ne découvrirait qu'à l'exécution que « ce noyau ne supporte pas ce head_size », et planterait directement.
Matrice de capacités : les champs comme contrat
FlashAttentionBackendLes attributs de classe de sont ses limites de capacités.supported_dtypesrestreint à fp16/bf16📎 vllm/v1/attention/backends/flash_attn.py:287-287;supported_kv_cache_dtypesautorise en plus la série fp8📎 vllm/v1/attention/backends/flash_attn.py:298-299. Mais « déclarer le support » ne signifie pas « support inconditionnel » —supports_kv_cache_dtypepour le KV quantifié, délègue davantage àflash_attn_supports_kv_cache_dtypepour effectuer un jugement lié au dispositif📎 vllm/v1/attention/backends/flash_attn.py:431-438。
Plus fin encore estsupports_combination: il reçoit un ensemble complet de paramètres combinés tels que head_size, dtype, block_size, use_mla, has_sink, et retourneNonepour indiquer la disponibilité, ou une chaîne pour indiquer la raison du rejet📎 vllm/v1/attention/backends/flash_attn.py:454-507. Par exemple, sink est rejeté sur une puissance de calcul < 9.0📎 vllm/v1/attention/backends/flash_attn.py:467-468, et sur SM90, FP8 KV avec mm_prefix doit passer par Triton📎 vllm/v1/attention/backends/flash_attn.py:472-472. Cette conception de « retourner une chaîne de raison » permet à la couche supérieure de fournir des erreurs diagnostiquables plutôt qu'un repli silencieux.
Le choix de block_size est également piloté par les capacités. Par défaut, retourneMultipleOf(16), mais SM90 FP8-KV force 64📎 vllm/v1/attention/backends/flash_attn.py:297-324, et le noyau FA4 avec head_size=256 forceFA4_HD256_PAGE_SIZE 📎 vllm/v1/attention/backends/flash_attn.py:326-352. Cela explique pourquoi la taille de bloc du KV cache n'est pas arbitraire — elle est contrainte en retour par la taille de tuile TMA du noyau.
Structure des métadonnées : disposition des champs de FlashAttentionMetadata
FlashAttentionMetadataest une dataclass, les champs se répartissent en quatre groupes📎 vllm/v1/attention/backends/flash_attn.py:511-566:
Le premier groupe est la description de base du lot :num_actual_tokens(nombre réel de tokens sans padding),max_query_len、query_start_loc(somme préfixe, utilisée par les noyaux varlen pour localiser le début et la fin de chaque séquence),seq_lens、block_table、slot_mapping 📎 vllm/v1/attention/backends/flash_attn.py:520-526. Notez le schéma ASCII dans les commentaires du code source📎 vllm/v1/attention/backends/flash_attn.py:512-518, il distingue précisémentcontext_len(KV historiques),query_len(nouveaux ajouts),seq_len(somme des deux) — c'est la clé pour comprendre les paramètres des noyaux varlen.
Le deuxième groupe est les champs d'attention en cascade :use_cascade、common_prefix_len、cu_prefix_query_lensetc.📎 vllm/v1/attention/backends/flash_attn.py:528-533。
Le troisième groupe est les champs DCP (Decode Context Parallel) :max_dcp_context_kv_len、dcp_context_kv_lens, ainsi que les compteurs distinguant le nombre de requêtes decode/prefill📎 vllm/v1/attention/backends/flash_attn.py:535-544。
Le quatrième groupe est la planification optionnelle et les masques spéciaux :scheduler_metadata(utilisé pour la planification FA3 AOT),causal(peut être bool ou tenseur, supporte le causal par séquence),mm_prefix_query_range_tensor(plages bidirectionnelles multimodales), champs liés à R-SWA📎 vllm/v1/attention/backends/flash_attn.py:546-566。
causalLe type du champ estbool | torch.Tensorplutôt qu'un simple bool, afin de supporter le scénario « dans le même lot, certaines séquences causales, d'autres non causales » (comme PrefixLM). Lorsqu'il est un tenseur, le paramètredynamic_causalde FA4 prend le relais, FA2/FA3 lèvera directement NotImplementedError📎 vllm/v1/attention/backends/flash_attn.py:1429-1433。
build() étape par étape
Mise en situation : un lot mixte, 3 séquences decode + 2 séquences prefill, sans cascade, sans DCP.
Première étape, à partir decommon_attn_metadatadéballer les tenseurs de base📎 vllm/v1/attention/backends/flash_attn.py:824-832. Deuxième étape, décider s'il faut activer l'ordonnancement 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_scheduledans__init__est déterminé parget_flash_attn_version() == 3— seul FA3 prend en charge les métadonnées d'ordonnancement précalculées. Troisième étape, remplir paresseusement lors du premier build📎 vllm/v1/attention/backends/flash_attn.py:709-709: parcourir toutes lesaot_sliding_windowcouches pour collecter la configuration de fenêtre glissante ; si la configuration est unique, l'adopter ; si plusieurs, désactiver AOTFlashAttentionImplQuatrième étape, calculer📎 vllm/v1/attention/backends/flash_attn.py:848-851。
. Par défaut 0 (laisser FA3 utiliser l'heuristique), défini àmax_num_splitsuniquement lorsque full CUDA graph est activé et que le nombre de tokens est dans la plage de captureself.max_num_splits 📎 vllm/v1/attention/backends/flash_attn.py:856-866. Le commentaire explique la raison :num_splits > 1alloue[num_splits, num_heads, num_tokens, head_size]un buffer intermédiaire, coût mémoire élevé, ne vaut le coup que dans le scénario CUDA graph📎 vllm/v1/attention/backends/flash_attn.py:862-865。
Cinquième étape, emprunter la branche non-cascadée non-DCP, appeler_get_scheduler_metadatapour générer les métadonnées d'ordonnancement de FA3📎 vllm/v1/attention/backends/flash_attn.py:976-986. Sixième étape,_store_scheduler_metadatagère le scénario CUDA graph : copier les nouvelles métadonnées dans le buffer préalloué, et mettre à zéro la partie restante📎 vllm/v1/attention/backends/flash_attn.py:671-684. Cette mise à zéro est cruciale — le commentaire indique explicitement que sinon certains thread blocks liraient des métadonnées invalides et écraseraient le buffer de sortie📎 vllm/v1/attention/backends/flash_attn.py:671-672。
Septième étape, construireFlashAttentionMetadataet retourner📎 vllm/v1/attention/backends/flash_attn.py:992-1015。
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() : la chaîne complète des métadonnées à l'appel du kernel
Modèle intuitif
forward()est l'« atelier d'assemblage final » du backend : il reçoit les Q/K/V calculés par les couches du modèle, les tenseurs KV cache, et les métadonnées construites à l'étape précédente, ajuste la disposition physique du KV cache à la forme attendue par le kernel, puis dispatche vers le kernel spécifique. Sans cette étape, le kernel lirait une disposition mémoire erronée, produisant des erreurs silencieuses — plus difficiles à diagnostiquer qu'un crash.
Transformation de la disposition mémoire du KV cache
La forme physique du KV cache de vLLM est[num_blocks, num_kv_heads, block_size, 2 * head_size]— K et V concaténés sur la dernière dimension📎 vllm/v1/attention/backends/flash_attn.py:1246-1247. Mais les kernels FlashAttention attendent K et V séparés, avec la disposition[num_blocks, block_size, num_kv_heads, head_size]。
La transformation a lieu au début deforward():kv_cache.transpose(1, 2).split(self.head_size, dim=-1) 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310。transpose(1,2)transforme[blocks, heads, block_size, 2D]en[blocks, block_size, heads, 2D],spliten découpant K et V le long de la dernière dimension. Noter quetransposene modifie que les strides sans déplacer les données, donc les kernels suivants doivent prendre en charge l'accès non contigu.
Vient ensuitecanonicalize_singleton_dim_strides 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310. Le commentaire précise la motivation : lorsquenum_kv_heads=1(fréquent en scénario TP), les strides des dimensions de taille 1 sont dégénérés, et FA3/FA4 sur H100+ utilisent TMA, exigeant un alignement d'au moins 16 octets pour les strides📎 vllm/v1/attention/backends/flash_attn.py:1310-1310. C'est un piège typique « logiquement équivalent, physiquement invalide ».
Flux des paramètres du chemin non-cascadé
Après être entré dans la brancheif not attn_metadata.use_cascade, les paramètres sont mappés un à un📎 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_shapeprend(batch_size, num_kv_heads), utilisé pour la diffusion du scale de quantification FP8 — le commentaire indique que flash-attn attend une forme descale de(num_sequences, num_kv_heads), utiliser.expand()pour éviter la copie📎 vllm/v1/attention/backends/flash_attn.py:1258-1258。
Puis vient le traitement de symétrisation de la fenêtre glissante._maybe_symmetrize_windowlogique : la fenêtre glissante causale(w, 0)doit devenir(w, w)en scénario non-causal, permettant aux requêtes bidirectionnelles de regarder dans les deux directions📎 vllm/v1/attention/backends/flash_attn.py:587-589. Le commentaire souligne également que « la window propre à la couche est prioritaire sur celle du group », car un KV cache group peut contenir à la fois des couches fenêtrées et des couches globales (comme Gemma-3 lorsque hybrid KV cache manager est désactivé)📎 vllm/v1/attention/backends/flash_attn.py:1362-1365。
Branche de masque : mm_prefix et R-SWA
Lorsquemm_prefix_query_rangesest non vide et satisfait les conditions FA4 + causal statique, le code construit lemask_mod 📎 vllm/v1/attention/backends/flash_attn.py:1374-1407de CuTE-DSL. Les actions clés sontcausal = Falseetsliding_window_size = None 📎 vllm/v1/attention/backends/flash_attn.py:1406-1407. Le commentaire explique la raison : la sémantique de mm_prefix est(causal ∧ window) ∨ bidirectional-range, pas un sous-ensemble de causal ; après FA #155, définir mask_mod ne supprime plus automatiquement causal/local, l'appelant doit le désactiver explicitement, sinon le chemin causal intégré court-circuite mask_mod📎 vllm/v1/attention/backends/flash_attn.py:1402-1405。
_make_mm_prefix_mask_modutilisefunctools.cachepour mettre en cache📎 vllm/v1/attention/backends/flash_attn.py:1793-1802. Le commentaire donne une raison technique : lehash_callablede FA4 mélange lerepr()de l'unité de closure dans la clé de compilation, le_load_q_rangeimbriqué ayant une adresse différente à chaque appel, ce qui déclenche une recompilation JIT complète à chaque forward📎 vllm/v1/attention/backends/flash_attn.py:1793-1802. C'est un exemple typique de piège de performance en production.
À l'intérieur du masque se trouve un détail de conversion de coordonnées : FA4 transmet leq_idxlocal (0-based dans le chunk prefill courant), tandis quekv_idxest la position absolue. Le code utiliseq_abs = q_idx + seqlen_k - seqlen_qpour restaurer la position absolue📎 vllm/v1/attention/backends/flash_attn.py:1859-1865。__vec_size__ = 1a aussi sa raison d'être :_load_q_rangelit lane 0, un appel ne peut pas traverser une ligne de query📎 vllm/v1/attention/backends/flash_attn.py:1897-1897。
Le mask_mod de R-SWA est similaire, mais la sémantique estcausal & (in_prefix | in_window) 📎 vllm/v1/attention/backends/flash_attn.py:1945-1948, etuse_fast_sampling = Truefait que FA4 saute les KV blocks entièrement masqués, sans charger leurs données📎 vllm/v1/attention/backends/flash_attn.py:1950-1950。
Traitement spécial de FA4 hd256
Lorsqueself.fa4_hd256est vrai, le code force l'alignement sur les pages :num_pages = cdiv(max_seqlen_k, FA4_HD256_PAGE_SIZE),max_seqlen_karrondi supérieur à la frontière de page,block_tabletronqué au nombre exact de pages,num_splits = 1 📎 vllm/v1/attention/backends/flash_attn.py:1442-1448. Le commentaire indique que le kernel hd256 exige une longueur alignée sur les pages, un block table de largeur exacte, et ne prend pas en charge SplitKV.
Appel final à_FA4_DENSE_ATTENTION_KERNEL(...), transmettant q, k, v, out, cu_seqlens_q, seqused_k, block_table, softcap, mask_mod, aux_tensors, etc.📎 vllm/v1/attention/backends/flash_attn.py:1450-1475。
Écriture du KV cache : do_kv_cache_update
forward()ne fait que lire le KV cache, l'écriture est effectuée pardo_kv_cache_update. Il appellereshape_and_cache_flash, utiliseslot_mappingpour écrire en scatter les K/V nouvellement calculés dans le cache📎 vllm/v1/attention/backends/flash_attn.py:1532-1541. Le commentaire indique :key/valueest padded tandis queslot_mappingNon, mais aucune découpe manuelle n'est nécessaire, car l'op utiliseslot_mappingla shape de📎 vllm/v1/attention/backends/flash_attn.py:1527-1531pour déterminer le nombre réel de tokens📎 vllm/v1/attention/backends/flash_attn.py:1520-1521。
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---
Copie
〔Inférence de conception et compromis architecturaux〕。supports_combinationSéparation entre déclaration de capacité et implémentation
retourne une chaîne de raison plutôt qu'un bool, afin que la couche supérieure puisse enregistrer « pourquoi FA n'a pas été utilisé » lors du repli vers un autre backend, ce qui réduit considérablement le coût de diagnostic en production. Contrairement à un repli silencieux, cette conception explicite la base de décision.。_store_scheduler_metadataLa compatibilité avec CUDA Graph est une contrainte invisible de la conception des métadonnées📎 vllm/v1/attention/backends/flash_attn.py:671-684Le mode « copie + remise à zéro de la queue » de📎 vllm/v1/attention/backends/flash_attn.py:787-798apparaît de manière récurrente dans le tampon persistant R-SWA📎 vllm/v1/attention/backends/flash_attn.py:800-813et la zone de staging mm_prefix__init__. Le schéma commun est : préallouer un tampon persistant de taille maximale dansbuild(), et ne faire que de la copie sans allocation dans📎 vllm/v1/attention/backends/flash_attn.py:1044-1046。
. La raison est indiquée dans les commentaires — aucune opération d'allocation ne peut avoir lieu pendant la capture de CUDA graph。supports_draft_decode_metadata_update = self.dcp_world_size == 1 📎 vllm/v1/attention/backends/flash_attn.py:742-742Exclusion mutuelle entre DCP et fused draft decodeskip_dcp_context_attention(). Le commentaire explique : fused draft decode réutilise les objets de métadonnées capturés à travers les étapes de draft, mais les décisions côté hôte au moment du build de DCP (comme📎 vllm/v1/attention/backends/flash_attn.py:736-741) modifient la forme des métadonnées, et ces champs Python ne sont pas rafraîchis en place entre les replays de graph
. C'est un compromis typique de « choisir la correction en cas de conflit entre optimisation de performance et correction ».。use_cascade_attentionSeuil heuristique de l'attention en cascade📎 vllm/v1/attention/backends/flash_attn.py:1967-1967utilise une série de seuils pour filtrer : common_prefix_len < 256 est rejeté directement📎 vllm/v1/attention/backends/flash_attn.py:1978-1979, alibi/sliding_window/local_attention ne sont pas pris en charge📎 vllm/v1/attention/backends/flash_attn.py:1982-1984, un nombre de requêtes < 8 est rejeté📎 vllm/v1/attention/backends/flash_attn.py:1985-1987, le scénario DCP est désactivé📎 vllm/v1/attention/backends/flash_attn.py:2011-2029. Après validation, un modèle de performance approximatif compare le nombre de CTA et de vagues entre cascade et FlashDecoding📎 vllm/v1/attention/backends/flash_attn.py:2009-2010。
. Le commentaire admet que ce modèle est « very rough »:forward()Points de friction en productionview/slicecontient un commentaire bien visible avertissant que, sous piece-wise CUDA graph, cette méthode s'exécute en mode eager,📎 vllm/v1/attention/backends/flash_attn.py:1277-1284et que des méthodes apparemment sans opération GPU comme[:num_actual_tokens]sont en réalité très lentes ; toute modification doit être benchmarkée
---
. Cela explique pourquoi le code utilise massivement le slicing
plutôt qu'une écriture plus « élégante » — chaque endroit est le résultat d'un compromis de performance.FlashAttentionBackendRésumé de ce chapitresupports_*Ce chapitre suitbuild()à travers le cycle de vie complet du backend d'attention : déclaration de capacité (sérieCommonAttentionMetadata) → construction des métadonnées (FlashAttentionMetadatatraduitforward()entranspose+split) → appel des noyaux (
transforme la disposition du KV cache, construit les masques, dispatche vers les noyaux FA). Les mécanismes clés incluent : la transformation de disposition
du KV cache, la normalisation des strides dégénérés, le mode de tampon persistant sous CUDA graph, la construction de masques CuTE-DSL pour mm_prefix/R-SWA, et la décision heuristique de l'attention en cascade.logitsPrincipe de conception clé : séparation entre déclaration de capacité et implémentation, préallocation des métadonnées pilotée par la compatibilité CUDA graph, priorité à la correction en cas de conflit entre optimisation de performance et correction (DCP désactive fused draft decode).
Le prochain chapitre se tournera vers l'échantillonnage et la sortie :
comment_store_scheduler_metadatadevient un token via la chaîne de processeurs (température, top-p, pénalités), comment la sortie structurée contraint le décodage, et comment le retour en streaming coopère avec le planificateur.self.scheduler_metadata[n:] = 0Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime l'opération de remise à zéro de:_store_scheduler_metadatadans📎 vllm/v1/attention/backends/flash_attn.py:671-684, dans quels scénarios cela entraînerait-il une sortie erronée ? Pourquoi le commentaire insiste-t-il particulièrement sur ce point ?📎 vllm/v1/attention/backends/flash_attn.py:671-672Analyse de référence
Q2: _make_mm_prefix_mask_modcopie les nouvelles métadonnées dans les n premières positions du tampon préallouéfunctools.cachedans le scénario CUDA graph. Si la queue n'est pas remise à zéro, les métadonnées de planification résiduelles du build précédent seront lues par le noyau actuel. Le commentaire indique explicitement que « some thread blocks may use the invalid scheduler metadata and overwrite the output buffer »
. Scénario déclencheur : la taille de batch passe de grande à petite (par exemple de 8 séquences à 3), les 3 premières positions du tampon contiennent les nouvelles données, mais les positions 4 à 8 contiennent encore les données de l'ancien batch. Les métadonnées de planification de FA3 incluent les informations d'allocation de tiles ; si le noyau lit selon batch_size et que le calcul de batch_size présente un écart ou que le noyau scanne selon un stride fixe, il lira des données sales et corrompra la sortie. C'est le piège classique de la réutilisation de tampons sous CUDA graph : le cycle de vie du tampon s'étend sur plusieurs replays, un nettoyage explicite est indispensable.utilisehash_callablecomme cache, le commentaire indiquant que sinon cela « force a full JIT recompile every forward ». Si l'on supprime ce décorateur de cache, de combien la performance se dégraderait-elle ? Pourquoi la clé de compilation de FA4 est-elle affectée par l'adresse de la closure ?repr()Analyse de référence📎 vllm/v1/attention/backends/flash_attn.py:1793-1802。_make_mm_prefix_mask_mod: le commentaire explique que_load_q_rangede FA4 intègrerepr()Contient des adresses mémoire, différentes à chaque fois → la clé de compilation diffère à chaque fois → FA4 estime qu'une recompilation JIT est nécessaire. Après mise en cache, identique(sliding_window, sliding_window_left)Les paramètres réutilisent le même objet fonction, la clé de compilation est stable. Le degré de dégradation des performances dépend du temps de compilation de FA4, mais on peut affirmer qu'« un cycle de compilation complet est déclenché à chaque forward », compilant une fois à chaque étape de la boucle decode, la latence passant de l'ordre de la milliseconde à celui de la seconde. C'est un cas typique d'invalidation du cache JIT provoquée par une « fermeture Python apparemment inoffensive ».
Q3: supports_draft_decode_metadata_update = self.dcp_world_size == 1Cette ligne désactive le fused draft decode dans le scénario DCP. Supposons que vous la modifiiez de force enTrue, quelles erreurs concrètes surviendraient dans la combinaison décodage spéculatif + DCP ?
Analyse de référence: le commentaire indique que le fused draft decode réutilise entre les étapes de draft l'objet de métadonnées capturé, tandis que les décisions côté hôte au moment du build de DCP (commeskip_dcp_context_attention()) modifient la forme des métadonnées/le chemin de contrôle, par exemplemax_dcp_context_kv_len 📎 vllm/v1/attention/backends/flash_attn.py:736-741. Ces champs Python ne sont pas rafraîchis en place entre les replays de CUDA graph. Erreur concrète : la longueur de séquence croît entre les étapes de draft,skip_dcp_context_attentionle verdict peut passer de True à False (ou inversement), mais l'objet de métadonnées réutilisé conserve encore l'ancienne valeur. Si l'ancienne valeur estmax_dcp_context_kv_len = 0, le kernel emprunte le chemin « sans contexte DCP »📎 vllm/v1/attention/backends/flash_attn.py:1565-1589, saute l'attention de contexte inter-rank, ce qui fait que la sortie perd des informations de contexte — erreur silencieuse, sans crash. C'est précisément l'illustration de « privilégier la correction lorsque l'optimisation des performances entre en conflit avec la correction ».
À ce stade, la chaîne complète du backend d'attention, de l'interface abstraite à l'implémentation du kernel, est établie : la couche modèle appelle uniformément via AttentionImpl, le backend se charge de traduire les métadonnées telles que block_table, slot_mapping en paramètres concrets de kernel, et l'implémentation PagedAttention de FlashAttentionBackend illustre la sémantique de gather sous KV Cache paginé ainsi que la stratégie de compatibilité avec CUDA Graph. Mais le calcul d'attention ne produit que des états cachés ; ce que le modèle doit finalement produire, c'est le prochain token. Comment ces états cachés deviennent-ils des logits, comment les logits passent-ils par l'échantillonnage et le post-traitement, et sont-ils finalement renvoyés au client sous forme de texte en streaming ? Le chapitre suivant retracera ce dernier kilomètre.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 7 : Échantillonnage et sortie : traitement des Logits, sortie structurée et retour en streaming
Dans le chapitre précédent, nous avons retracé comment le backend d'attention traduit le block table en paramètres de kernel, réalisant un calcul d'attention de type gather sur une mémoire GPU non contiguë. Mais l'attention ne produit que des états cachés — ce que le modèle doit réellement livrer à l'utilisateur, c'est le texte du prochain token. Ce chapitre retrace ce dernier kilomètre : après projection des états cachés en logits via lm_head, comment ceux-ci traversent une chaîne de processeurs soigneusement ordonnée (température, pénalités, top-k/top-p, contraintes structurelles), sont échantillonnés en token id, puis restaurés en texte par le detokenizer et poussés en streaming. Toute étape de cette chaîne dont l'ordre est erroné ou dont l'état fuit entraîne une dégradation silencieuse de la qualité de sortie.
Sampler : l'ordre de la chaîne de processeurs, c'est la correction
Modèle intuitif: le Sampler est comme une chaîne de montage, les logits sont l'ébauche à usiner. Chaque poste (processor) de la chaîne modifie l'ébauche, et l'ordre des postes détermine directement le produit fini — dégrossir puis polir et polir puis dégrossir donnent deux choses différentes. Sans cette chaîne, le modèle ne pourrait produire que la distribution de probabilité brute, et l'utilisateur obtiendrait un « échantillonnage nu » sans contrôle de la température, sans suppression des répétitions, sans contrainte de format.
Structures de données et disposition mémoire
Le Sampler lui-même estnn.Module, mais son état central est extrêmement mince : il ne détient que le sous-moduletopk_topp_sampler, les indicateurslogprobs_modeetuse_fp64_gumbel. Le véritable état au niveau du batch est entièrement encapsulé dans📎 vllm/v1/sample/sampler.py:61-64, transmis via les paramètres de forward. Cette conception « Sampler sans état + métadonnées externes » est délibérée : l'instance de Sampler n'est créée qu'une seule fois durant le cycle de vie du moteur, alors que la composition du batch change à chaque decode step ; externaliser l'état est la seule façon de permettre au Sampler d'être capturé par CUDA Graph puis rejoué en toute sécurité.SamplingMetadataLa constante clé est
. Elle sert simultanément deux sémantiques : une température inférieure à cette valeur est considérée comme greedy, et_SAMPLING_EPS = 1e-5 📎 vllm/v1/sample/sampler.py:18sert de garde-fou contre la division par zéro dansapply_temperature.
Step-by-Step Walkthrough
Mise en situation : un batch mélange des requêtes greedy et des requêtes d'échantillonnage aléatoire, certaines ayant aussi activé logprobs.
Première étape, capture des logprobs bruts.Avant d'appliquer toute pénalité ou température, si la requête nécessite des logprobs, on décide d'abord selonlogprobs_modele contenu de la capture📎 vllm/v1/sample/sampler.py:84-93. Notons que le commentaire souligne explicitement la différence avec V0 : V1 utiliseles logits bruts(avant pénalités et température) pour calculer les top-k logprobs📎 vllm/v1/sample/sampler.py:72-77. C'est un contrat sémantique — le logprob vu par l'utilisateur doit refléter la distribution réelle du modèle, et non une distribution déformée par les pénalités.
Deuxième étape, unifier en float32. 📎 vllm/v1/sample/sampler.py:95-96Que l'entrée soit en bf16 ou fp16, on convertit en float32. La raison est que les opérations suivantes de log_softmax, top-k et probabilité cumulée accumulent des erreurs en basse précision, surtout lorsque le vocabulaire atteint 150 000.
Troisième étape, chaîne de processeurs invariants pour non-argmax. apply_logits_processorsAppliquer successivement : masque de liste blanche de tokens autorisés, exclusion de bad words,non_argmax_invariantprocesseurs, termes de pénalité📎 vllm/v1/sample/sampler.py:391-404. La classification ici est la conception centrale —non_argmax_invariantdésigne ceuxqui modifient le résultat glouton(comme min_tokens, logit_bias), ils doivent prendre effet avant l'échantillonnage glouton ; tandis que lesargmax_invariantprocesseurs (comme min_p) ne modifient pas l'argmax et peuvent être différés après la température.
Quatrième étape, échantillonnage. sampleLa méthode vérifie d'abord si tout est aléatoire📎 vllm/v1/sample/sampler.py:256-271: siall_greedy, retour direct par argmax ; sinon, calculer d'abord le résultat glouton en réserve, puis appliquer la température, les processeurs invariants pour argmax, top-k/top-p📎 vllm/v1/sample/sampler.py:275-291. Enfin utilisertorch.wherepour choisir entre le résultat glouton et aléatoire selon le seuil de température📎 vllm/v1/sample/sampler.py:305-306, et réutilisergreedy_sampledle tenseur comme tampon de sortie, évitant une allocation supplémentaire.
Cinquième étape, collecter les logprobs et encapsuler la sortie.Selonnum_logprobstrois cas : None ne retourne que les logprobs des tokens spécifiés ; -1 retourne tous les logprobs non triés ; sinon top-k📎 vllm/v1/sample/sampler.py:120-131. Enfin, l'id de token est converti en int32 pour compresser la taille, étendu en[num_requests, 1]tenseur bidimensionnel📎 vllm/v1/sample/sampler.py:138-148。
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"]Réflexions de conception et pièges rencontrés
Pourquoi les termes de pénalité doivent-ils être avant la température ?La température est une mise à l'échelle de la distribution, la pénalité est un ajustement additif/soustractif sur des tokens spécifiques. Si l'on met à l'échelle avant de pénaliser, l'amplitude absolue de la pénalité sera amplifiée ou réduite par la température, entraînant un comportement incohérent des mêmes paramètres de pénalité à différentes températures. V1 fixe la pénalité avant la température, garantissant la stabilité sémantique des paramètres.
mark_unbackedLe piège de compilation deDansgather_logprobs,batched_count_greater_thanest compilé, et lorsque la dimension batch passe de 1 à ≥2, cela déclenche une recompilation de spécialisation 0/1 de dynamo📎 vllm/v1/sample/sampler.py:345-348。mark_unbackedmarque cette dimension comme entièrement symbolique, évitant cette recompilation. En production, si l'on observe un soudain blocage après la première requête de decode, c'est très probablement ce type de recompilation.
gpu_sync_allowedLa frontière de synchronisation de batched_count_greater_thanpeut déclencher une synchronisation GPU en interne, vLLM utilisegpu_sync_allowed(first_only=True)le contexte pour déclarer explicitement « ici la synchronisation est autorisée, mais seulement la première fois »📎 vllm/v1/sample/sampler.py:345-348. Si une synchronisation inattendue se produit dans une zone de capture CUDA Graph, cela provoquera l'échec de la capture — c'est un indice clé pour diagnostiquer les problèmes de capture de graphe.
Sortie structurée : machine à états à double voie entre bitmask et grammaire
Modèle intuitif: la sortie structurée équivaut à mettre une « paire de lunettes grammaticales » sur l'échantillonneur — à chaque étape, il ne voit que les tokens conformes au JSON schema ou à la grammaire. Sans cela, le modèle pourrait générer du JSON syntaxiquement incorrect, faisant planter directement l'analyseur en aval. L'essence de l'implémentation de vLLM réside dans : la machine à états grammaticale progresse côté CPU, tandis que les contraintes sont transmises côté GPU sous forme de bitmask pour l'échantillonnage.
Structures de données et disposition mémoire
StructuredOutputManagerest un singleton au niveau du moteur, détenantbackend(l'un parmi xgrammar/guidance/outlines/lm-format-enforcer),reasoner_clset deux pools de threads📎 vllm/v1/structured_output/__init__.py:39-98。
Le bitmask est la structure de données centrale :_grammar_bitmaskest un tenseur int32 de forme[max_batch_size * (1 + max_num_spec_tokens), vocab_size/32]. Chaque bit correspond à la validité d'un token.📎 vllm/v1/structured_output/__init__.py:327-336représente « tout à 1 » — tous les tokens sont valides_full_mask = torch.tensor(-1, dtype=torch.int32)Les deux pools de threads ont une répartition claire :📎 vllm/v1/structured_output/__init__.py:59。
est responsable de la compilation grammaticale (intensif CPU, nombre de workers égal à la moitié du nombre de CPU)executorest responsable du remplissage parallèle de bitmasks pour les grands batchs, activé uniquement lorsque le batch dépasse 128📎 vllm/v1/structured_output/__init__.py:71-78;executor_for_fillmaskInitialisation de la grammaire.📎 vllm/v1/structured_output/__init__.py:62-69。
Step-by-Step Walkthrough
Lors de la première entrée d'une requêteest appelégrammar_init. Si le backend n'est pas initialisé, choisir l'implémentation selon la configuration📎 vllm/v1/structured_output/__init__.py:115-176. Ensuite soumettre la tâche de compilation : par défaut asynchrone📎 vllm/v1/structured_output/__init__.py:130-165, mais en modeexecutor.submitdoit être synchroneexternal_launcherGénération du bitmask.📎 vllm/v1/structured_output/__init__.py:167-176。
À chaque decode step,génère les masques pour toutes les requêtes structurées du batchgrammar_bitmask. Les grands batchs empruntent le chemin parallèle : soumission par lots de 16 au pool de threads📎 vllm/v1/structured_output/__init__.py:314-442. Les petits batchs empruntent le chemin série, faisant progresser l'état grammatical token par token📎 vllm/v1/structured_output/__init__.py:346-373Alignement des masques en décodage spéculatif.📎 vllm/v1/structured_output/__init__.py:374-433。
C'est la partie la plus ingénieuse. Lorsqu'il y a des draft tokens, chaque requête nécessitelignes de masque. Le chemin série traite token par token : si un draft token est rejeté par la grammaire, enregistrer1 + max_num_spec_tokens, les lignes suivantes copient directement le masque de cette lignefailed_index. Cela garantit que « après le rejet d'un draft, l'état de contrainte des positions suivantes revient au point de rejet ».📎 vllm/v1/structured_output/__init__.py:396-418Rollback d'état.
Pendant le remplissage du bitmask, l'état grammatical a été avancé depas, mais les draft tokens n'ont pas encore été réellement acceptés, il faut doncstate_advancementsrevenir en arrièregrammar.rollback(state_advancements). L'acceptation réelle a lieu dans📎 vllm/v1/structured_output/__init__.py:422-430copieaccept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466。
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: 传入采样内核Pourquoi external_launcher doit-il compiler de manière synchrone ?
Les commentaires donnent la raison précise : la compilation asynchrone ferait que lestransitions d'état se produisent à des moments différents sur différents rangs TP, brisant l'hypothèse de déterminisme dont dépend external_launcherWAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR → WAITING 状态转换在不同 TP rank 上发生于不同时刻,破坏 external_launcher 依赖的确定性假设 📎 vllm/v1/structured_output/__init__.py:47-56. C'est un cas typique de conflit entre le déterminisme distribué et l'optimisation asynchrone.
Point de départ des contraintes sous un modèle de raisonnement. _get_constraint_startDétermine à partir de quel token commencer à appliquer les contraintes syntaxiques📎 vllm/v1/structured_output/__init__.py:220-292. Pour les modèles avec chaîne de pensée, la phase de reasoning ne doit pas être soumise aux contraintes JSON ; elle ne démarre qu'après la fin du reasoning.enable_in_reasoningLorsque True, retourne directement 0 (contrainte sur toute la séquence)📎 vllm/v1/structured_output/__init__.py:235-236. Si le reasoner prend en chargefind_reasoning_end_offset, l'utiliser pour localiser précisément📎 vllm/v1/structured_output/__init__.py:261-267; sinon, revenir à une recherche par régression token par token📎 vllm/v1/structured_output/__init__.py:287-291。
validate_tokensla sémantique de préfixe deLors du décodage spéculatif, les draft tokens peuvent violer la grammaire,validate_tokensretourne le « plus long préfixe légal »📎 vllm/v1/structured_output/__init__.py:294-312. Notez qu'il retire d'abord le remplissage spéculatif (-1), puis calcule le point de départ des contraintes, et enfin n'effectue la validation syntaxique que sur les tokens dans l'intervalle contraint.
Detokenizer : le jeu de frontières entre décodage incrémental et stop string
Modèle intuitif: le detokenizer est comme un greffier qui recopie caractère par caractère, traduisant les token ids en texte lisible par l'humain. La difficulté réside dans le fait que les tokens et les caractères ne correspondent pas un à un (un token peut ne correspondre qu'à la moitié d'un caractère UTF-8), et qu'une stop string peut s'étendre sur plusieurs tokens. Sans décodage incrémental, il faudrait redécoder toute la séquence depuis le début à chaque étape, et le coût O(n²) écraserait le débit.
Structures de données et disposition mémoire
IncrementalDetokenizerLa classe de base ne détient quetoken_idsla liste📎 vllm/v1/engine/detokenizer.py:32-33。BaseIncrementalDetokenizerajoute les champs liés au stop :stopliste,min_tokens、include_stop_str_in_output、stop_buffer_lengthet_last_output_text_offset 📎 vllm/v1/engine/detokenizer.py:70-94。
stop_buffer_lengthsont essentiels : lorsque la stop string n'est pas incluse dans la sortie, il est égal à la longueur de la plus longue stop string moins un📎 vllm/v1/engine/detokenizer.py:87-90. Ce « tampon de repli » garantit que la sortie en flux ne émet pas prématurément des caractères qui pourraient être un préfixe de stop string.
Deux chemins d'implémentation :FastIncrementalDetokenizerutiliser leDecodeStream 📎 vllm/v1/engine/detokenizer.py:166-246;SlowIncrementalDetokenizerde la bibliothèque tokenizers, utiliser ledetokenize_incrementally 📎 vllm/v1/engine/detokenizer.py:249-305côté Python. Le critère de choix est une version de tokenizers ≥ 0.22.0 et un type de tokenizer correspondant📎 vllm/v1/engine/detokenizer.py:32-33📎 vllm/v1/engine/detokenizer.py:61-63。
Step-by-Step Walkthrough
décodage incrémental. updatereçoit les nouveaux token ids etstop_terminatedle flag📎 vllm/v1/engine/detokenizer.py:96-142. Si le stop termine et n'inclut pas la stop string, alors le dernier token est exclu du décodage📎 vllm/v1/engine/detokenizer.py:107-111. Ensuite, appel token par token àdecode_nextaccumule le texte📎 vllm/v1/engine/detokenizer.py:117-122。
détection de stop string. check_stop_stringsne recherche que dans la plage des nouveaux caractères📎 vllm/v1/engine/detokenizer.py:308-360. Le point de départ de la recherche est1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338, ce décalage garantit que les stop strings à cheval sur les frontières de tokens sont également capturées. Lorsque plusieurs stop strings correspondent simultanément, on choisitcelle qui se termine le plus tôt📎 vllm/v1/engine/detokenizer.py:342-347。
découpage de la sortie en flux. get_next_output_textselondeltale paramètre détermine s'il faut retourner le tout ou l'incrément📎 vllm/v1/engine/detokenizer.py:148-163. Si non terminé, conserverstop_buffer_lengthcaractères non émis📎 vllm/v1/engine/detokenizer.py:145-146, utiliser_last_output_text_offsetpour enregistrer la position déjà envoyée📎 vllm/v1/engine/detokenizer.py:148-163。
récupération après exception. FastIncrementalDetokenizer._protected_stepgère deux types d'exceptions : OverflowError/TypeError journalise et retourne None📎 vllm/v1/engine/detokenizer.py:225-229; l'erreur « Invalid prefix » quant à ellereconstruit le DecodeStreamet réessaie📎 vllm/v1/engine/detokenizer.py:222-246. Ce dernier cas répond à la situation limite où le tokenizer produit une sortie UTF-8 non monotone.
Réflexions de conception et pièges rencontrés
Le compromis sur stop_buffer_length.Plus le tampon est long, plus la latence du flux est grande (le moment où l'utilisateur voit le texte est repoussé), mais moins on risque de manquer une stop string à cheval sur plusieurs tokens. Prendre « la longueur de la plus longue stop string moins un » est une borne inférieure exacte : tout préfixe de stop string a au plus cette longueur.
min_tokens et stop_check_offset.Lorsque le nombre de tokens de sortie n'atteint pasmin_tokens,stop_check_offsetest continuellement repoussé à la fin du texte📎 vllm/v1/engine/detokenizer.py:120-122, ce qui signifie que ce texte ne sera pas soumis à la détection de stop. Cela empêche le modèle de heurter une stop string dès le début et de produire une sortie vide.
Le cache added_token_ids du chemin Fast.Lorsquespaces_between_special_tokensest False, il faut supprimer les espaces entre les tokens spéciaux📎 vllm/v1/engine/detokenizer.py:192-207. Le code met en cacheadded_token_idssur l'objet tokenizer📎 vllm/v1/engine/detokenizer.py:195-200, évitant de reconstruire le dictionnaire à chaque decode.
Réflexions de conception
Les trois modules partagent une même philosophie de conception :séparer l'avancement de l'état de la vérification des contraintes, pour que le côté GPU n'effectue que des opérations tensorielles sans état. Le Sampler est sans état, l'état est dansSamplingMetadata; la machine à états syntaxique avance côté CPU, le GPU ne consomme que le masque de bits ; le_last_output_text_offsetdu detokenizer est l'unique curseur de flux. Cette séparation permet à chaque composant côté GPU d'être capturé par CUDA Graph.
Une autre ligne directrice estl'ordre est la sémantique. L'ordre de la chaîne de processeurs du Sampler, le point de départ des contraintes de la sortie structurée, le décalage de détection de stop du detokenizer : une erreur d'ordre n'importe où ne provoquera pas de crash, mais produira silencieusement des résultats erronés — c'est précisément ce qui rend ce type de code si difficile à déboguer.
Résumé de ce chapitre
- La chaîne de processeurs du Sampler est strictement ordonnée : instantané des logprobs bruts → float32 → liste blanche/bad words → non-argmax-invariant → pénalités → température → argmax-invariant → top-k/top-p.
- La sortie structurée transmet l'état syntaxique côté CPU au GPU via un masque de bits, et en décodage spéculatif, assure la cohérence de l'état par
failed_indexcopie etrollback. - Le Detokenizer utilise
stop_buffer_lengthun tampon de repli pour équilibrer la latence du streaming et la détection des stop strings à travers les tokens ; le chemin Fast dépend de tokenizers ≥ 0.22.0DecodeStream。
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on déplace leapply_logits_processorsterme de pénalité (apply_penalties) après la température, quel biais concret apparaîtrait dans un scénario d'échantillonnage à haute température avec temperature=2.0 ? Pourquoi ?
Analyse de référence: La température est une mise à l'échelle de tout le vecteur de logits (logits.div_(temp))📎 vllm/v1/sample/sampler.py:241-242. Le terme de pénalité (comme repetition penalty) est un ajustement multiplicatif/additif sur des tokens spécifiques. Si l'on met à l'échelle avant de pénaliser, l'amplitude absolue de la pénalité est amplifiée 2 fois par la température, ce qui fait que le même ensemble derepetition_penaltyparamètres a un effet de suppression bien plus fort à haute température qu'à basse température ; la sémantique des paramètres dérive avec la température. V1 fixe la pénalité avant la température📎 vllm/v1/sample/sampler.py:403-404, garantissant que l'amplitude de la pénalité est découplée de la température. De plus, la pénalité appartient à la catégorienon_argmax_invariant(elle affecte le résultat glouton), et le chemin glouton retourne déjà avant la température📎 vllm/v1/sample/sampler.py:261-271; si on la déplaçait après la température, les requêtes glouton contourneraient complètement la pénalité, ce qui rendrait le comportement incohérent.
Q2 : Dans legrammar_bitmaskchemin série, si l'on supprime la lignegrammar.rollback(state_advancements) 📎 vllm/v1/structured_output/__init__.py:422-430, que se passerait-il dans la combinaison décodage spéculatif + sortie structurée ? Analysez en vous appuyant sur le moment d'appel deaccept_tokens.
Analyse de référence: Lors du remplissage du masque de bits, le code appellegrammar.accept_tokenspour chaque draft token afin de faire avancer l'état syntaxique et générer le masque de la position suivante📎 vllm/v1/structured_output/__init__.py:396-418, mais il ne s'agit que d'une « avancée exploratoire » — le draft token n'a pas encore été validé et accepté par le modèle cible. Si l'on supprimerollback, l'état syntaxique resterait définitivement à la position « tous les drafts sont acceptés ». Lorsque le modèle cible rejette effectivement une partie des draft tokens, la séquence de tokens réellement acceptée ne correspond plus à l'état syntaxique :accept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466validerait sur la base d'un état syntaxique erroné, ce qui conduirait à rejeter des tokens valides ou à laisser passer des tokens invalides. Le résultat est une corruption silencieuse de la sortie JSON : pas de crash, mais un échec d'analyse en aval.
Q3: check_stop_stringsLe point de départ de la recherche est1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338. Si l'on passait à une recherche complète depuis 0, serait-ce fonctionnellement correct ? Quels problèmes de performance cela poserait-il dans un scénario de streaming sur de longues séquences ?
Analyse de référence: Fonctionnellement correct — une recherche depuis 0 trouverait toutes les correspondances, y compris celles à travers les frontières de tokens. Mais en termes de performance, à chaque étape on feraitoutput_textsur tout lefind, la complexité passant de O(new_char_count) à O(total_length), soit O(n²) sur de longues séquences. Plus grave encore, une recherche depuis 0 pourrait correspondre àdes sous-chaînes de stop string dans le texte historiquedéjà envoyé à l'utilisateur, provoquant un déclenchement répété du stop ou une troncature erronée. L'offset1 - new_char_count - stop_string_lende la conception originale couvre précisément la fenêtre minimale nécessaire « nouveaux caractères + préfixe de stop string susceptible de traverser la frontière », garantissant à la fois l'absence de détection manquée et l'évitement des fausses correspondances historiques.
À ce stade, la chaîne complète d'inférence sur une seule machine est opérationnelle : du calcul d'attention à la sortie d'échantillonnage, chaque maillon influence directement la qualité du texte final livré. Mais lorsque la taille du modèle dépasse la capacité d'une seule carte, cette chaîne doit être exécutée en coordination sur plusieurs dispositifs. Dans le chapitre suivant, nous quitterons la machine unique pour entrer dans le parallélisme distribué : comment TP, PP et EP découpent le modèle, et comment les primitives de communication synchronisent ces résultats d'échantillonnage entre les ranks.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 8 : Parallélisme distribué : TP, PP, EP et primitives de communication
Dans le chapitre précédent, nous avons parcouru le dernier kilomètre du cycle de vie d'une inférence unique, de l'échantillonnage des logits à la sortie en streaming. Mais lorsque le modèle est trop grand pour tenir sur une seule carte, ce pipeline doit être découpé et exécuté en coordination sur plusieurs dispositifs. La question première de l'inférence distribuée n'est pas « comment découper le modèle », mais « une fois découpé, qui parle à qui et de quelle manière ». vLLM confie ces deux questions respectivement à la topologie des groupes de processus de parallel_state.py et à l'implémentation du communicateur de custom_all_reduce.py. Ce chapitre suit la chaîne « création de groupes → découpage → communication → rééquilibrage de charge » pour démonter couche par couche les stratégies de parallélisme TP, PP, EP et les primitives de communication sous-jacentes.
8.1 Topologie des groupes de processus : comment une grille de ranks découpe TP/PP/DP/EP
Modèle intuitif
Imaginez 8 GPU comme une longue table de 8 places. Le parallélisme tensoriel (Tensor Parallelism, TP) exige que « les convives de la même table lèvent leur verre en même temps », le parallélisme de pipeline (Pipeline Parallelism, PP) exige que « les sièges adjacents se passent les plats en relais », le parallélisme de données (Data Parallelism, DP) exige que « chaque table mange de son côté mais qu'on fasse les comptes à la fin », et le parallélisme d'experts (Expert Parallelism, EP) exige que « les tokens soient triés par service ». Sans une organisation unifiée des places, chaque modulenew_group, il se produit un décalage de communication du type « je pensais que tu étais dans le groupe TP, alors qu'en fait tu es dans le groupe DP » — dès qu'un rank est absent lors d'une communication collective, NCCL se bloque directement au lieu de signaler une erreur.
Structures de données et disposition mémoire
GroupCoordinatorest le support de tout cela. La conception de ses champs correspond directement au «多重身份 d'un processus sur plusieurs dimensions parallèles » :
rankest le rank global,ranksest la liste des ranks globaux des membres de ce groupe,world_sizeest la taille du groupe📎vllm/distributed/parallel_state.py:434-436。local_ranksert à lier le périphérique,rank_in_groupest l'indice au sein du groupe — le code source utilise une table pour distinguer précisément les deux : dans un groupe de 4 cartes réparties sur deux nœuds, lelocal_rankdu rank 2 est 0 (c'est la première carte sur le nœud 1), mais sonrank_in_groupest 2📎vllm/distributed/parallel_state.py:437-445。cpu_groupetdevice_groupexistent par paire : le premier passe par gloo pour la communication de métadonnées/objets, le second passe par NCCL pour la communication de tenseurs📎vllm/distributed/parallel_state.py:446-447。
Il y a ici une conception clé :Pourquoi chaque groupe doit-il maintenir un groupe CPU ?Parce quebroadcast_object、send_objectce type d'opération transmet des objets Python (octets sérialisés) ; passer par NCCL gaspille de la mémoire GPU et peut polluer le périphérique CUDA courant.barrier()Les commentaires de expriment cela très clairement : la barrière de NCCL est en interne un broadcast, qui crée furtivement des tenseurs GPU et risque de perturber le périphérique courant, il faut donc utiliser le groupe CPU📎 vllm/distributed/parallel_state.py:1355-1362。
Step-by-Step:initialize_model_parallelComment découper la grille
Prenons un scénario concret : 8 cartes, TP=2, PP=4, DP=1. L'essentiel est de reshape une séquence de ranks unidimensionnelle en une grille multidimensionnelle, puis de la découper le long de chaque dimension.
Première étape, construire la grille de ranks. L'ordre de disposition est explicitement défini commeExternalDP x DP x PP x PCP x TP 📎 vllm/distributed/parallel_state.py:2045-2060:
all_ranks = torch.arange(world_size).reshape(
-1, data_parallel_size, pipeline_model_parallel_size,
prefill_context_model_parallel_size, tensor_model_parallel_size,
)Deuxième étape, découper le groupe TP : view la grille en(-1, tp_size)puis unbind, on obtient[g0,g1],[g2,g3],... 📎 vllm/distributed/parallel_state.py:2065-2077. Notez que le groupe TP passe en plususe_message_queue_broadcaster=True, car le groupe TP a besoin d'un broadcast en mémoire partagée pour distribuer les métadonnées.
Troisième étape, découper le groupe PP :all_ranks.transpose(2, 4)On déplace la dimension PP en dernière position puis on découpe, on obtient[g0,g2,g4,g6],[g1,g3,g5,g7] 📎 vllm/distributed/parallel_state.py:2175-2188. C'est exactement l'exemple donné dans la docstring📎 vllm/distributed/parallel_state.py:1997-1997。
Quatrième étape, découper le groupe DP :transpose(1, 4)puis découper📎 vllm/distributed/parallel_state.py:2195-2202。
Cinquième étape, découper le groupe EP — il y a ici un détail facile à négliger : le groupe EP n'est créé que pour les modèles MoE, les modèles dense sont directement ignorés📎 vllm/distributed/parallel_state.py:2210-2241. L'ensemble des ranks du groupe EP est le produit deDP x PCP x TP, ce qui signifie que EP réutilise les cartes physiques de DP et TP, plutôt qu'une dimension indépendante.
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 --> doneRéflexions de conception et pièges
Pourquoi EPLB a-t-il besoin d'un groupe de processus indépendant ?Les commentaires donnent la réponse : isoler la communication EPLB de la communication collective du forward MoE, pour éviter que le « torch.distributed d'exécution » et le « torch.distributed d'EPLB » ne se bloquent mutuellement📎 vllm/distributed/parallel_state.py:2243-2246. C'est un compromis typique « échanger un domaine de communication indépendant contre du déterminisme » — le coût mémoire supplémentaire d'un PG est échangé contre l'absence de blocage du forward lors du transfert de poids.
Contrainte de synchronisation du groupe DPest le piège le plus fréquemment rencontré en production : tous les ranks d'un même groupe DP doivent appelergenerateen même temps, sinon blocage📎 vllm/distributed/parallel_state.py:2048-2051. Car au sein du groupe DP s'effectue un all-reduce des gradients/résultats d'échantillonnage ; l'absence de n'importe quel rank bloque définitivement la communication collective.
Ordre de destructionIl y a également des subtilités.destroy()On détruit d'abord le device communicator, puis le device_group et le cpu_group📎 vllm/distributed/parallel_state.py:1380-1393. Les commentaires expliquent la raison : le device communicator peut détenir des espaces de travail de communication collective dépendant de ces PG (comme la barrière IPC PCIe de FlashInfer), il faut donc les libérer en premier📎 vllm/distributed/parallel_state.py:1377-1377。
8.2 Primitives de communication : comment un all-reduce personnalisé contourne NCCL
Modèle intuitif
L'all-reduce de NCCL est un « camion universel », capable de transporter n'importe quelle marchandise sur n'importe quelle route, mais avec des coûts de démarrage et de protocole fixes. Lorsque vous devez effectuer de manière répétée des all-reduce sur de petits tenseurs sur une machine à 8 cartes entièrement interconnectées en NVLink (chaque couche attention/MLP de TP doit le faire), le « péage » du camion universel devient non négligeable. L'all-reduce personnalisé est un « petit chariot dédié » : activé uniquement sur la même machine, en interconnexion NVLink complète, et pour des tailles de tenseurs appropriées, en échangeant uncudaMemcpycontre les coûts de handshake et de protocole de NCCL.
Structures de données et disposition mémoire
CustomAllreduceL'initialisation de est une combinaison de « détection de capacités + préallocation de ressources ». Champs clés :
_SUPPORTED_WORLD_SIZES = [2, 4, 6, 8, 16]: ne prend en charge que ces tailles de groupe📎vllm/distributed/device_communicators/custom_all_reduce.py:113-129。meta_ptrs: métadonnées de synchronisation + tampon de résultats intermédiaires, tailleops.meta_size() + max_size📎vllm/distributed/device_communicators/custom_all_reduce.py:291-294。buffer_ptrs: tampon IPC préenregistré, en mode eager le tenseur d'entrée est d'abord copié ici avant le calcul📎vllm/distributed/device_communicators/custom_all_reduce.py:298-305。rank_data: tenseur uint8 de 8 Mo, stockant les tuples de pointeurs de tampons IPC de tous les ranks📎vllm/distributed/device_communicators/custom_all_reduce.py:309-315。
Pourquoi les tampons doivent-ils être préenregistrés ?Parce que la capture CUDA Graph exige que toutes les adresses soient fixées au moment de la capture.register_graph_buffersÀ la fin de la capture, on diffuse à tous les ranks toutes les adresses de tampons utilisées et on les enregistre📎 vllm/distributed/device_communicators/custom_all_reduce.py:474-491。
Step-by-Step : le flux de décision d'un all-reduce
Prenons un scénario : la sortie d'une couche MLP dans le groupe TP nécessite un all-reduce, l'entrée est un tenseur bf16 de 4 Mo.
Première étape,custom_all_reducevérifier si c'est désactivé, sishould_custom_ar 📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-533。
Deuxième étape,should_custom_arFiltrage un par un : world_size > 8 rejeté ; dtype doit être fp32/fp16/bf16 ; le nombre d'octets doit être un multiple de 16 ; doit être faiblement contigu ; world_size==2 ou fully connected pour continuer📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508。
Troisième étape, aiguillage selon qu'on est dans une capture CUDA Graph ou non : en capture on utiliseregistered=True(adresse déjà fixée), sinonregistered=False(nécessite d'abord un memcpy vers un buffer pré-enregistré)📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-545。
Quatrième étape, appel effectif deops.all_reduce, en passantbuffer_ptrs[rank]etmax_size 📎 vllm/distributed/device_communicators/custom_all_reduce.py:519-527。
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 --> outRéflexions de conception et pièges rencontrés
Le chemin de repli pour les scénarios multi-nœudsest la partie la plus ingénieuse de ce code.same_nodeLorsquemnnvl_onlyest faux,📎 vllm/distributed/device_communicators/custom_all_reduce.py:198-199est mis à vrai📎 vllm/distributed/device_communicators/custom_all_reduce.py:228-233。_group_can_attempt_mnnvl, puis on vérifie la capacité MNNVL (Multi-Node NVLink). Si toutes les cartes du groupe ne supportent pas MNNVL, on désactive directement la communication collective personnalisée📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73on utilise un all-reduce CPU (opération MIN) pour garantir que tous les ranks suivent le même flux de contrôle
— c'est la protection clé dans un cluster hétérogène pour éviter que « certains ranks entrent dans le chemin MNNVL, d'autres passent par NCCL » et provoquent un blocage.:_can_p2pLe coût de la vérification P2Pgpu_p2p_access_checkparcourt tous les peers pour faire📎 vllm/distributed/device_communicators/custom_all_reduce.py:278-278, le commentaire indique que le premier calcul est coûteux mais qu'il est mis en cacheVLLM_SKIP_P2P_CHECK. En production, si le démarrage est lent, on peut définir📎 vllm/distributed/device_communicators/custom_all_reduce.py:86-100。
pour l'ignorer et faire directement confiance au rapport P2P du driverLa sélection à trois niveaux de backend pour reduce-scatter_select_reduce_scatter_backendmérite un examen séparé :mnnvl_multimem > mnnvl_lamport > legacy 📎 vllm/distributed/device_communicators/custom_all_reduce.py:601-636retourne par ordre de priorité(2,4,8). Le chemin multimem exige que world_size soit dans📎 vllm/distributed/device_communicators/custom_all_reduce.py:103-104et que la capacité du device soit (10,0) ou (10,3) (niveau Blackwell)VLLM_BATCH_INVARIANT. Attention :📎 vllm/distributed/device_communicators/custom_all_reduce.py:628désactive le chemin multimem
— car l'ordre de réduction de multimem est non déterministe, ce qui brise l'invariance de batch.
8.3 EPLB : logique d'ordonnancement du rééquilibrage de charge des experts
Modèle intuitif
Dans un modèle MoE, 256 experts logiques sont répartis sur 32 cartes, 8 par carte. Mais sous le trafic réel, certains « experts populaires » (par exemple ceux traitant des structures syntaxiques courantes) sont routés par un grand nombre de tokens, ce qui fait de la carte qui les héberge un goulot d'étranglement, tandis que les autres cartes tournent à vide. EPLB (Expert Parallel Load Balancer) consiste à « ajouter des réplicas aux experts populaires » : copier les poids des experts populaires sur des cartes inoccupées pour y dériver des tokens. Sans lui, le débit réel du MoE serait bloqué par la carte la plus lente.
EplbModelStateStructures de données et disposition mémoire
physical_to_logical_maputilise trois tables de correspondance pour décrire la relation « expert logique ↔ expert physique » :(num_moe_layers, num_physical_experts): forme📎vllm/distributed/eplb/eplb_state.py:105-120。logical_to_physical_map, chaque emplacement physique stocke l'id de l'expert logique qu'il porte(num_moe_layers, num_logical_experts, max_replicas+1): forme📎vllm/distributed/eplb/eplb_state.py:123-146。logical_replica_count, matrice creuse, -1 signifie aucune correspondance📎vllm/distributed/eplb/eplb_state.py:147-161。
expert_load_window: nombre de réplicas de chaque expert logique(window_size, num_moe_layers, num_physical_experts) 📎 vllm/distributed/eplb/eplb_state.py:180-187est une fenêtre glissante, de forme📎 vllm/distributed/eplb/eplb_state.py:180-187。
. Le commentaire précise : on enregistre désormais la charge de tous les experts physiques et non seulement des experts locaux, afin de garantir la cohérence des statistiques entre différentes méthodes de dispatch (naive all-to-all, DeepEP) ; en naive all-to-all, chaque rank DP contribue au même ensemble de tokens, la charge est donc multipliée par dp_size
Step-by-Step : la chaîne complète d'un réarrangementexpert_rearrangement_stepMise en situation :rearrange()。
atteint le seuil, déclenchescatter_add_Première étape, remapper la charge physique vers les experts logiques. On utilisephysical_to_logical_mappour agréger seloninvalid_idx, les emplacements invalides (<0) sont placés dans le bucket📎 vllm/distributed/eplb/eplb_state.py:794-816。
puis finalement jetés_allreduce_listDeuxième étape, all-reduce inter-ranks pour obtenir la charge logique globale.📎 vllm/distributed/eplb/eplb_state.py:1045-1068。
concatène les charges de plusieurs modèles puis fait un seul all-reduce avant de les séparer, évitant ainsi plusieurs communicationspolicy.rebalance_expertsTroisième étape, appel de la stratégie pour calculer la nouvelle correspondance.📎 vllm/distributed/eplb/eplb_state.py:859-867。
s'exécute sur le host, donc la fenêtre de charge et la correspondance actuelle doivent être recopiées sur le CPU📎 vllm/distributed/eplb/eplb_state.py:869-923Quatrième étape, jugement « saut de réarrangement » spécifique à ROCm : si la nouvelle correspondance améliore le déséquilibre de charge des ranks de moins de 5 %, on saute ce réarrangement
. C'est une optimisation pragmatique — le réarrangement lui-même a un coût de communication, si le gain est insuffisant on ne le fait pas.📎 vllm/distributed/eplb/eplb_state.py:925-942。
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() 提交
endcopie
Réflexions de conception et pièges rencontrésLa primitive de synchronisation en mode asynchronerebalancedest l'endroit le plus subtil de ce code.📎 vllm/distributed/eplb/eplb_state.py:194-203Le flagrebalancedrepose sur le GIL pour se synchroniser entre le thread principal et le worker async_all_ranks_result_ready. Mais le commentaire avertit :📎 vllm/distributed/eplb/eplb_state.py:664-665。_all_ranks_result_readydoit rester cohérent sur tous les ranks, sinon📎 vllm/distributed/eplb/eplb_state.py:1024-1043。
l'all-reduce dans:_should_record_current_stepprovoquera un blocagewindow_sizeon privilégie le groupe CPU pour l'all-reduce, car le groupe CPU est plus fiable📎 vllm/distributed/eplb/eplb_state.py:689-709L'optimisation « enregistrement anticipé » de la fenêtre glissantestep_interval - window_sizen'active l'enregistrement que lorsque la distance jusqu'au prochain réarrangement ne dépasse pas📎 vllm/distributed/eplb/eplb_state.py:1196-1199。should_record_tensorétapesfill_. Le commentaire explique : les données des📎 vllm/distributed/eplb/eplb_state.py:272-278。
étapes précédant chaque cycle de réarrangement seront écrasées par la fenêtre glissante, les enregistrer ne sert à rien et gaspille du calcul GPU:enable_elastic_epest le même tenseur scalaire partagé par toutes les couches, une seulephysical_expert_capacitymet à jour toutes les coucheselastic_ep_max_dp_sizeLa réservation de capacité de l'EP élastique📎 vllm/distributed/eplb/eplb_state.py:375-386lors dereconfigure_physical_expert_slots,📎 vllm/distributed/eplb/eplb_state.py:1135-1160。
_commit_eplb_mapsréserve selon, la table de correspondance remplit les emplacements excédentaires avec -1PIN_MEMORY. Ainsi, lors d'une extension, il n'est pas nécessaire de réallouer la mémoire GPU, il suffit de remplir les emplacements -1 avec de vrais experts.non_blocking=Trueest chargé de rafraîchir la vue lors des extensions/réductions📎 vllm/distributed/eplb/eplb_state.py:1392-1400La gestion de la pin memory dans
: lorsque
Trois blocs de code partagent une même philosophie de conception :Remplacer la certitude par une dégradation déterministe via la détection de capacités。GroupCoordinatorLorsqueworld_size == 1on contourne directement toute communication collective📎 vllm/distributed/parallel_state.py:736-738;CustomAllreduceon retourneNonesi l'une des conditions n'est pas remplie, permettant à l'appelant de revenir à NCCL📎 vllm/distributed/device_communicators/custom_all_reduce.py:532-533; EPLB saute la redistribution lorsque l'amélioration est inférieure à 5 %📎 vllm/distributed/eplb/eplb_state.py:916. Ce modèle « échec rapide + dégradation élégante » permet au même code de fonctionner sur toute la gamme de matériels, du mono-GPU au MNNVL multi-nœuds, sans écrire de branches pour chaque configuration.
Un autre point commun estla priorité donnée à la cohérence du flux de contrôle sur la performance。_group_can_attempt_mnnvlon force tous les ranks à emprunter la même branche via un all-reduce CPU📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73,_all_ranks_result_readyDe même📎 vllm/distributed/eplb/eplb_state.py:1024-1043. Dans un système distribué, « certains ranks empruntent le chemin rapide et d'autres le chemin lent » est bien plus dangereux que « tous les ranks empruntent le chemin lent » — le premier provoque un blocage, le second n'est que lent.
Résumé de ce chapitre
GroupCoordinatorOn reshape une séquence de ranks unidimensionnelle en une grilleExternalDP x DP x PP x PCP x TPet on découpe le long de chaque dimension les groupes de processus TP/PP/DP/EP/EPLB ; chaque groupe maintient simultanément deux PG : CPU (gloo) et device (NCCL).CustomAllreduceOn décide via la détection de capacités (même machine, interconnexion NVLink complète, taille du tenseur, dtype, alignement sur 16 octets) s'il faut prendre en charge l'all-reduce, avec dégradation vers MNNVL ou NCCL en scénario multi-nœuds.- EPLB utilise trois tables de correspondance pour décrire les relations entre experts logiques et physiques, en statistiquant la charge via une fenêtre glissante, en calculant de nouvelles correspondances par stratégie, et en déplaçant les poids via un communicateur, avec prise en charge des modes synchrone et asynchrone.
- Le principe de conception commun aux trois : détection de capacités + dégradation déterministe + priorité à la cohérence du flux de contrôle.
Réflexions et auto-évaluation de ce chapitre
Q1: GroupCoordinator.destroy()On détruit d'abord le device communicator puis le process group📎 vllm/distributed/parallel_state.py:1380-1393. Si l'on inverse l'ordre, en détruisant d'abord le PG puis le communicator, dans quel scénario cela provoquerait-il un crash ?
Analyse de référence: Le commentaire indique explicitement que le device communicator peut détenir des espaces de travail de communication collective dépendant de ces PG, par exemple la barrière FlashInfer PCIe IPC📎 vllm/distributed/parallel_state.py:1377-1377. Si l'on détruit d'abord le PG, et que le communicatordestroy()doit encore utiliser ces PG en interne pour une barrière ou une communication de nettoyage, il accédera à un ProcessGroup déjà détruit, déclenchant un use-after-free ou un échec d'assertion interne à NCCL. L'ordre correct est « le dépendant meurt en premier » : le communicator dépend du PG, donc le communicator est détruit en premier.
Q2: should_custom_arOn exigeinp_size % 16 == 0 📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508. Si l'on supprime cette vérification, que se passerait-il avec un tenseur bf16 de 15 octets (par exemple 7,5 éléments, en réalité impossible, mais supposons 8 éléments = cas limite de 16 octets) ? Pourquoi le kernel personnalisé a-t-il besoin de cet alignement ?
Analyse de référence: Le kernel all-reduce personnalisé utilise en interne des chargements vectorisés (comme un load 128 bits), exigeant que l'adresse et la taille soient alignées sur 16 octets pour pouvoir utiliserfloat4des instructions de chargement large telles que . Un défaut d'alignement entraîne une lecture hors limites du kernel ou déclenche une exception d'adresse mal alignée. Plus insidieux encore,buffer_ptrsle tampon préenregistré est alloué selonmax_size; si la taille d'entrée n'est pas un multiple de 16, des données résiduelles en fin de tampon peuvent être réduites avec le reste après copie, produisant des erreurs silencieuses. Cette vérification est donc à la fois une protection de correction et un prérequis de performance.
Q3 : En mode asynchrone d'EPLB,rebalancedle flag dépend de la synchronisation du GIL📎 vllm/distributed/eplb/eplb_state.py:194-203, et le commentaire avertit que tous les ranks doivent rester cohérents, sinon l'all-reduce se bloque📎 vllm/distributed/eplb/eplb_state.py:664-665. Supposons qu'un rank, à cause d'une gigue réseau, voie son async worker mettrerebalancedà False de manière anticipée, tandis que les autres ranks le gardent à True,_all_ranks_result_readyque se passe-t-il ?
Analyse de référence:_all_ranks_result_readyOn effectue un all-reduce somme surhas_resultpuis on vérifie s'il est égal à la taille du groupe📎 vllm/distributed/eplb/eplb_state.py:1030-1032. Si lerebalancedd'un rank passe à False de manière anticipée, sonpending_resulta peut-être déjà été consommé,has_resultvaut 0, ce qui fait que le résultat de la somme est inférieur à la taille du groupe, et les autres ranks attendent indéfiniment. Pire encore, si ce rank a déjà quitté lawhile ms.rebalancedboucle, il ne participera plus aux all-reduce suivants, et les all-reduce des autres ranks se bloqueront définitivement — c'est ce que le commentaire appelle « hang at collective communication calls ». Les protections consistent à_all_ranks_result_readyutiliser le groupe CPU plutôt que le groupe device, et àdrain_asyncvider explicitement tous les pending result avant la redistribution📎 vllm/distributed/eplb/eplb_state.py:985-1022。
Nous avons ainsi clarifié les mécanismes de création de groupes, de découpage et de rééquilibrage de charge pour la communication entre cartes. Mais les défis de communication de l'inférence distribuée ne se limitent pas à l'intérieur d'une seule instance — lorsque le prefill et le decode sont répartis sur des instances différentes, le KV Cache doit être transféré entre nœuds. Dans le chapitre suivant, nous quitterons la « communication entre cartes » pour entrer dans la « communication entre instances » : comment le KV Cache est transféré entre les instances prefill et decode dans un déploiement désagrégé, et comment l'abstraction KV Connector unifie les backends de transfert tels que NIXL, Mooncake, etc.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 9 : Transfert de KV Cache et déploiement désagrégé (PD Disaggregation)
Dans le chapitre précédent, nous avons concentré notre attention à l'intérieur d'une seule instance d'inférence : comment les groupes de processus TP/PP/DP/EP sont créés, comment les tenseurs sont découpés entre les cartes, et comment EPLB rééquilibre les experts dans la couche MoE. Mais tous ces mécanismes reposent sur une même prémisse — le prefill et le decode s'exécutent dans la même instance, et le KV Cache reste dans la mémoire locale du début à la fin. Le déploiement désagrégé (Prefill-Decode Disaggregation, en abrégé PD Disaggregation) brise cette prémisse. Il sépare le prefill et le decode en deux instances vLLM indépendantes : l'instance prefill effectue uniquement le calcul forward du prompt, produit le KV Cache puis le transmet à l'instance decode ; l'instance decode utilise ce KV Cache pour poursuivre la génération autorégressive. L'avantage de cette approche est que les ressources peuvent être configurées indépendamment selon les caractéristiques de chaque phase — le prefill est intensif en calcul, adapté à un grand TP et un grand batch ; le decode est intensif en accès mémoire, adapté à un petit batch et à une planification à faible latence. Les deux ne se pénalisent plus mutuellement. Le coût : le KV Cache doit être transféré entre instances. C'est le protagoniste de ce chapitre — le KV Connector. Le commentaire d'en-tête du fichier vllm/distributed/kv_transfer/kv_connector/v1/base.py énumère déjà les primitives essentielles de toute l'abstraction : le côté Scheduler est responsable de lier les métadonnées, de vérifier les hits de cache distant, et de décider s'il faut libérer les blocs de manière asynchrone ; le côté Worker est responsable du chargement et de la sauvegarde effectifs du KV. L'objectif de conception de cette interface est de découpler complètement la logique de planification de haut niveau des backends de transfert de bas niveau (NIXL, Mooncake, MoRIIO). D'un point de vue d'ingénierie, le plus grand risque de la PD Disaggregation n'est pas la lenteur du transfert, mais l'incohérence d'état : l'instance prefill croit que le KV a été envoyé, mais l'instance decode ne l'a pas reçu ; ou l'instance decode libère le bloc prématurément alors que le prefill y écrit encore. Ce chapitre vise précisément à élucider comment ce système de connecteurs utilise des protocoles de handshake, des leases, des heartbeats et des mécanismes de reprise sur échec pour couvrir ces cas limites.
I. KVConnectorBase_V1 : abstraction à double rôle et contrat de métadonnées
Modèle intuitif
Le KV Connector est comme un système de livraison entre deux succursales. La succursale Prefill prépare les produits semi-finis (KV Cache), les emballe et les envoie à la succursale Decode pour poursuivre la transformation. Mais un système de livraison ne peut pas se limiter à une seule action d'« expédition » — il a besoin d'un bordereau de transport (metadata) indiquant quoi envoyer et où l'envoyer ; il a besoin d'un mécanisme de signature pour confirmer la réception ; et il a besoin d'un ensemble de règles de timeout pour éviter qu'un colis reste bloqué indéfiniment en occupant un emplacement.
Sans cette abstraction, chaque backend de transfert (NIXL, Mooncake) devrait implémenter sa propre logique de planification, et le Scheduler de vLLM devrait écrire un ensemble de code d'adaptation pour chaque backend. La valeur de KVConnectorBase_V1 est de figer ce contrat.
Double rôle : côté Scheduler et côté Worker
📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:137-142définit les deux rôles du connecteur :
class KVConnectorRole(enum.Enum):
# Connector running in the scheduler process
SCHEDULER = 0
# Connector running in the worker process
WORKER = 1Cette division n'est pas arbitraire. Le processus Scheduler est responsable des décisions de planification globales — quelles requêtes nécessitent un transfert, quand les blocs peuvent être libérés ; le processus Worker est responsable du déplacement effectif des données. Les deux communiquent viaKVConnectorMetadata.
📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:153-158définit la classe de base des métadonnées dans la direction Scheduler vers Worker :
class KVConnectorMetadata(ABC): # noqa: B024
"""Abstract Metadata used to communicate
Scheduler KVConnector -> Worker KVConnector.
"""
passDans la direction inverse Worker vers Scheduler,📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:161-176définitKVConnectorWorkerMetadata, qui exige l'implémentation de la méthodeaggregate— car dans un engine step, plusieurs workers peuvent chacun retourner des métadonnées, qui doivent être agrégées avant d'être transmises au Scheduler.
Structure de données essentielle : KVConnectorTransferResults
📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:87-96définit la structure instantanée des résultats de transfert :
@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)Notez la conception clé dans les commentaires :Les réceptions échouées apparaissent également dansfinished_recving. Ceci permet au Scheduler de libérer les requêtes de l'état « en attente de transfert » — même si le transfert échoue, la requête ne doit pas rester bloquée indéfiniment. Les informations d'échec sont transmises séparément viafailed_recving, et le Scheduler décide en conséquence de réessayer ou de dégrader.
Hooks de cycle de vie : de la requête à la libération
Le cycle de vie complet du connecteur s'articule autour de quelques hooks clés. Côté Scheduler :
get_num_new_matched_tokens📎vllm/distributed/kv_transfer/kv_connector/v1/base.py:485-518: interroge combien de tokens le cache distant peut couvrir. Le commentaire souligne particulièrement qu'« il ne faut considérer que le plus grand préfixe réellement disponible » ; si certains tokens sont inaccessibles en raison de problèmes de connexion ou d'éviction, ils ne doivent pas être comptés.update_state_after_alloc📎vllm/distributed/kv_transfer/kv_connector/v1/base.py:520-544: met à jour l'état après l'allocation des blocs. Le commentaire signale un piège courant — pour déterminer s'il faut charger, il faut examinernum_external_tokens, et non siblocksest vide, car les sous-connecteurs non sélectionnés de MultiConnector reçoivent également de vrais blocs.request_finished📎vllm/distributed/kv_transfer/kv_connector/v1/base.py:579-598: appelé à la fin de la requête, retourneTruepour indiquer que le connecteur prend en charge la libération asynchrone des blocs.
Côté Worker :
start_load_kv/wait_for_layer_load: chargement couche par couche, compatible avec le pipeline.save_kv_layer/wait_for_save: sauvegarde couche par couche.get_transfer_results📎vllm/distributed/kv_transfer/kv_connector/v1/base.py:396-397: retourne l'état d'achèvement du transfert asynchrone.
📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:192-201Il existe également une conception facile à négliger mais cruciale —requires_kv_deliveryla propriété :
@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_producerLe commentaire explique la motivation : si une requête est préemptée avant que la passation KV ne soit terminée, il faut recalculer plutôt que de la laisser se terminer et transférer des blocs déjà libérés par la préemption. Seul le rôle producer nécessite une livraison fiable ; un cache best-effort perdu ne représente qu'un futur cache miss.
Métadonnées de handshake
📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:145-150définit la classe de base des métadonnées de handshake :
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 » signifie que le handshake ne passe pas par le chemin de requête normal, mais communique directement entre les workers P/D. Cela prépare le terrain pour le protocole de handshake ZMQ de NIXL.
---
II. Connecteur NIXL : handshake, enregistrement et construction de descripteurs
Modèle intuitif
NIXL (NVIDIA Inference Xfer Library) est une bibliothèque de transport bas niveau fournie par NVIDIA, prenant en charge plusieurs backends tels qu'UCX, GDS, etc. Le rôle de NixlBaseConnectorWorker s'apparente à un centre de tri d'une société de livraison — il doit d'abord établir une ligne dédiée avec le centre de tri distant (handshake), enregistrer la disposition de ses propres rayonnages (enregistrer les régions mémoire du KV Cache), puis seulement ensuite il peut retirer et expédier efficacement les marchandises par adresse.
Sans ce mécanisme, chaque transfert devrait renégocier les adresses et rétablir les connexions, et la latence deviendrait inacceptable.
Disposition mémoire : Region et Descriptor
Les concepts fondamentaux de NIXL sontregion(région mémoire) etdescriptor(descripteur). Chaque couche de KV Cache est enregistrée dans NIXL comme une ou plusieurs regions, chaque region ayant une adresse de base, une longueur de bloc et un pas de bloc.
📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:740-751énumère les champs fondamentaux liés aux regions :
# 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-900précise davantage l'origine du pas de bloc :
# 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]()L'observation clé ici est la suivante :block_stride n'est pas égal à block_len. Dans les dispositions entrelacées entre couches telles que BLHNC/BHLNC, l'étendue réelle d'un bloc peut être supérieure à sa longueur de données utiles. Si l'on utilise directement block_len comme pas, on lira des adresses erronées.
Protocole de handshake : ZMQ + hash de compatibilité
Le handshake est la partie la plus complexe du connecteur NIXL.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:974-1128La méthode_nixl_handshakede
illustre l'intégralité de ce processus.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:988-998La première étape consiste à définir le contexte du périphérique CUDA.
# 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)en explique la raison :
Copier📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1029-1036:
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()La deuxième étape consiste à envoyer une requête de métadonnées via ZMQ.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1042-1045Copier
Le timeout de 5 secondes évite une attente infinie si le pair est mort. Par ailleurs, le code estime le décalage d'horloge via le RTT📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1063-1080:
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. "
...
)La troisième étape est la vérification de compatibilité.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1372-1376Copier
self.compat_hash = compute_nixl_compatibility_hash(
self.vllm_config,
self.backend_name,
transfer_mode=self._TRANSFER_MODE,
):transfer_modeCopier📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:163-166Notons que
participe également au hash —
Le commentaire de📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:824-835:
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=1Planification asynchrone du handshake_handshake_lockLe handshake est asynchrone et s'exécute via un pool de threads._handshake_futuresCopier_remote_agentscar NIXL ne garantit pas la sûreté des threads.
_ensure_handshake 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1257-1317protège les deux dictionnaires
et
.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:172-310implémente un lancement de handshake idempotent : si le handshake a déjà réussi, retourne None directement ; s'il est en cours, retourne le Future existant ; sinon, soumet une nouvelle tâche et enregistre un callback._compute_desc_idsConstruction des descripteurs : du block ID au NIXL descriptor
Une fois le handshake terminé, il faut construire des descripteurs pour chaque requête.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:226-262La méthode
# 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.en est le cœur.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:285-304:
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).. Le commentaire explique le traitement dans le scénario HMA :
Copier📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2130-2178Pour les modèles hybrides SSM, la disposition des descripteurs est plus complexeadd_remote_agentCopier
Lorsque D.world_size > P.world_size, plusieurs workers D lisent différents fragments de KV head depuis le même worker P. La documentation donne un exemple concret : D TP=4, P TP=2, tp_ratio=2. D-Worker0 lit la première moitié des KV heads de P-Worker0, D-Worker1 lit la seconde moitié.
Pour les modèles MLA, le KV Cache est répliqué entre les workers TP, donc rank_offset est toujours 0.
Lease et heartbeat : empêcher la libération prématurée des blocks
C'est l'un des designs les plus ingénieux du connecteur NIXL. Après que l'instance Prefill a envoyé le KV, elle ne peut pas libérer immédiatement le block — car l'instance decode pourrait encore être en train de lire. Mais si on ne libère jamais, la mémoire GPU fuit.
La solution est le lease.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:528-528:
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 // 3Le lease par défaut est de 30 secondes, prolongé de 20 secondes à chaque heartbeat (2/3).
Le traitement du heartbeat se fait dans📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034:
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)Attentionmax(old, new_expiry)— le heartbeat ne peut que prolonger le lease, pas le raccourcir.
La récupération après expiration du lease se fait dans📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2986-3012:
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.
"""Le commentaire signale une erreur facile à commettre : on ne peut pas arrêter le scan dès qu'on rencontre la première requête non expirée, car le heartbeat met à jour le délai d'expiration sur place, ce qui fait que la map n'est pas triée par ordre d'expiration.
Machine à états de transfert et récupération après échec
Le cycle de vie d'un transfert est géré via_pop_done_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3036-3086:
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,
)Le transfert NIXL a trois états :DONE(terminé),PROC(en cours), autre (échec).
Le traitement des échecs se fait dans📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3103-3127:
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-3101Le commentaire de est crucial :
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 FalseUne erreur d'état ne garantit pas que le backend a arrêté le DMA. Si la libération échoue, il faut conserver le handle et le block jusqu'à ce que la libération réussisse. C'est un design typique de « plutôt fuir que mal utiliser ».
Traitement des blocks des requêtes en échec
Lorsqu'une réception échoue,📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2876-2891montre la logique de traitement :
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,
)
continueL'ID du block en échec est placé dans la file_invalid_block_ids, le Scheduler le récupère viaget_block_ids_with_load_errors 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3491-3504et décide s'il faut réessayer.
Éviction TTL des moteurs distants
Les instances de longue durée rencontrent continuellement de nouveaux moteurs distants ; sans nettoyage, la mémoire croîtrait indéfiniment.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3506-3532Le de_evict_stale_enginesimplémente l'éviction TTL :
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)La contrainte clé est l'ensemblebusy— les moteurs avec des transferts en cours ne peuvent pas être évincés.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546Le commentaire de explique la raison :
"""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.
"""Si la carte réseau du pair est défaillante, le transfert peut rester bloqué indéfiniment, le timestamp ne se rafraîchit pas, et le moteur semble inactif.busyL'ensemble protège explicitement ce cas.
Chronologie de la poignée de main et du transfert
Le diagramme de séquence ci-dessous montre les interactions clés depuis la requête jusqu'à la fin du transfert :
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. Réflexions sur le design : pourquoi ce choix
Pourquoi la poignée de main est-elle asynchrone ?
La poignée de main implique un aller-retour réseau, pouvant prendre plusieurs dizaines de millisecondes. Si elle était exécutée de manière synchrone, elle bloquerait la boucle principale du Scheduler, affectant l'ordonnancement de toutes les requêtes. La poignée de main asynchrone permet au Scheduler de traiter d'abord d'autres requêtes, puis d'être notifié par callback une fois la poignée de main terminée.
Mais l'asynchrone apporte aussi de la complexité :_handshake_futuresLe dictionnaire doit être protégé par un verrou, le callback doit gérer les cas de succès et d'échec, et il faut aussi éviter les poignées de main en double.
Pourquoi utiliser un lease plutôt qu'un comptage de références ?
Le comptage de références nécessite que l'instance decode notifie explicitement au prefill « j'ai fini de lire ». Mais si l'instance decode plante, la notification n'arrivera jamais, et le block du prefill fuira pour toujours.
Le lease est une solution plus robuste : même si le decode plante, le prefill récupère automatiquement après expiration du lease. Le mécanisme de heartbeat garantit le renouvellement du lease en conditions normales.
Pourquoi conserver le handle en cas d'échec ?
📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101Le commentaire de le dit clairement : une erreur d'état ne garantit pas l'arrêt du DMA. Si on libère le handle à ce moment, le DMA pourrait encore écrire dans la mémoire libérée, causant une corruption de données ou un crash. Plutôt fuir temporairement que prendre ce risque.
Pourquoi l'éviction TTL doit-elle vérifier busy ?
📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546Le commentaire de révèle un scénario de bug sournois : le timestamp est posé au lancement de la lecture, et n'est pas rafraîchi pendant la lecture. Si le transfert dépasse le TTL, le moteur semble inactif, mais il est en réalité encore en cours de lecture. Si on l'évince à ce moment, le transfert en cours échouera.
Pièges en environnement de production
1. Problème de contexte CUDA: la poignée de main s'exécute dans un thread d'arrière-plan, il faut explicitementset_device, sinon UCX désactivera silencieusement NVLink.
2. Incompatibilité de hash de compatibilité: la version vLLM, le modèle, le dtype, le KV layout et l'attention backend des instances P/D doivent être parfaitement identiques. En cas d'incompatibilité, la poignée de main échouera, et le message d'erreur indiquera comment désactiver la vérification (mais ce n'est pas recommandé).
3. Expiration du lease: si l'instance decode est très chargée, le heartbeat peut être retardé, entraînant l'expiration du lease. Un avertissement « Releasing expired KV blocks » apparaîtra dans les logs. On peut augmenterkv_lease_duration。
4. Incompatibilité TP: un TP hétérogène nécessite une disposition block-contiguous (comme LBHNC). Si une disposition non contiguë est utilisée, le TP hétérogène échouera.
5. Épuisement des UAR NIXL:📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:631-636Avertissement dans les commentaires de : chaque thread UCX alloue des UAR (doorbell pages) via DevX ; une utilisation excessive des UAR par NIXL épuise l'espace UAR de la NIC, ce qui provoque l'échec de NVSHMEM (utilisé par le noyau DeepEP) lors de l'initialisation RDMA.
---
Résumé de ce chapitre
Ce chapitre a approfondi les mécanismes essentiels du système KV Connector :
1. KVConnectorBase_V1définit l'abstraction à double rôle côté Scheduler et côté Worker, viaKVConnectorMetadataetKVConnectorTransferResultspour réaliser l'échange de métadonnées et le retour des résultats de transfert.
2. Le connecteur NIXLest l'implémentation la plus mature : il établit la connexion entre les instances P/D via un protocole de handshake ZMQ, utilise un hachage de compatibilité pour éviter les incompatibilités de configuration, et un pool de threads asynchrones pour éviter de bloquer la boucle principale.
3. Le mécanisme de lease et de heartbeatrésout le problème de timing de la libération des blocs : après l'envoi des KV par le prefill, ceux-ci ne sont pas libérés immédiatement, mais attendent le renouvellement du heartbeat ou l'expiration du lease côté decode.
4. La récupération après échecsuit le principe « plutôt fuir que mal utiliser » : en cas d'échec de libération, le handle est conservé, et l'ID du bloc en échec est signalé au Scheduler qui décide de réessayer.
5. L'éviction TTLempêche la croissance illimitée de l'état des moteurs distants lors d'exécutions prolongées, mais doit protéger les moteurs ayant des transferts en cours.
Dans le prochain chapitre, nous nous tournerons vers une autre direction pour éliminer les surcoûts : l'accélération de compilation et CUDA Graph. Une fois que la séparation PD a résolu le problème d'utilisation des ressources, le coût de lancement d'une seule passe avant devient le nouveau goulot d'étranglement — comment utiliser CUDA Graph pour compresser des centaines voire des milliers de lancements de noyaux en une seule relecture.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime la gestion des exceptions dans_try_release_xfer_handleet que l'on appelle directementrelease_xfer_handle, dans quels scénarios cela entraînerait-il une corruption des données ? Pourquoi ?
Analyse de référence:_try_release_xfer_handle 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101Le commentaire de indique explicitement : « A status error does not guarantee that the backend stopped DMA. » Si l'on supprime la gestion des exceptions, lorsquerelease_xfer_handlelève une exception, l'appelant considère que la libération a réussi et continue à libérer le bloc. Mais en réalité, le DMA du backend NIXL peut encore être en cours, en train d'écrire des données dans cette mémoire. Une fois le bloc réattribué à une autre requête, l'écriture DMA pollue le KV Cache de la nouvelle requête, entraînant un output corrompu ou des NaN. Pire encore, si le bloc est libéré vers le pool de mémoire GPU et réutilisé par d'autres tenseurs, le DMA peut écrire à une adresse invalide et provoquer un crash. La bonne pratique est de conserver le handle et le bloc, et de réessayer la libération lors du prochain_pop_done_transfers.
Q2: _reap_expired_send_leasesLe commentaire de dit « on ne peut pas arrêter le balayage parce qu'on rencontre la première requête non expirée ». Si l'on modifie le code pour faire un break dès qu'on rencontre une requête non expirée, dans quels scénarios cela déclencherait-il une fuite de blocs ?
Analyse de référence:_reqs_to_sendest un dict ordinaire, pas une file de priorité triée par temps d'expiration. Le traitement des heartbeats_handle_heartbeat 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034met à jour le temps d'expiration sur place :self._reqs_to_send[req_id] = max(old, new_expiry). Cela signifie qu'une requête ajoutée en premier peut avoir un temps d'expiration très tardif grâce à des heartbeats continus, tandis qu'une requête située après elle peut déjà être expirée. Si l'on fait un break dès qu'on rencontre la première requête non expirée, les requêtes expirées suivantes ne seront jamais récupérées, et leurs blocs continueront d'occuper la mémoire GPU. Dans des scénarios d'exécution prolongée avec des motifs de requêtes mixtes (certaines requêtes fréquemment renouvelées par heartbeat, d'autres dont l'instance decode a déjà crashé), cela s'accumule en une grave fuite de mémoire GPU.
Q3: _evict_stale_enginesutilise_engines_with_inflight_transferspour protéger les moteurs ayant des transferts en cours. Si l'on supprime cette protection, dans quels scénarios de panne réseau cela entraînerait-il un échec de transfert ?
Analyse de référence:_engines_with_inflight_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546Le commentaire de explique un scénario critique : « 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. » Supposons que la carte réseau du pair tombe en panne et qu'une opération de lecture NIXL reste suspendue au-delà du TTL (3600 secondes par défaut)._engine_last_activeLe timestamp est apposé au moment du lancement de la lecture et n'est pas rafraîchi pendant celle-ci, donc le moteur semble inactif. Si à ce moment-là_evict_stale_enginesévince ce moteur, il appellera_cleanup_remote_enginepour libérerdst_xfer_side_handleset supprimer le remote agent. Mais le DMA en cours utilise encore ces ressources, et la libération entraînera un échec de transfert voire un crash.busyL'ensemble protège explicitement ce cas, en garantissant que les moteurs ayant des transferts en cours ne soient pas évincés.
À ce stade, nous avons vu comment le KV Connector établit un canal de données fiable entre les instances de prefill et de decode, et comment il préserve la cohérence d'état grâce à des mécanismes de bail, de heartbeat et de reprise après échec. Mais le transfert inter-instances ne représente que la moitié de l'histoire de la séparation PD — une fois le KV Cache arrivé sur l'instance de decode, le moteur d'inférence doit encore exécuter efficacement chaque étape de calcul forward au sein d'une instance unique. Or, la surcharge de planification Python et de lancement des kernels constitue le prochain goulot d'étranglement limitant la latence par étape. Le chapitre suivant se tournera vers l'accélération par compilation et CUDA Graph, pour voir comment vLLM élimine ces surcoûts avec torch.compile et le backend piecewise, et fait coexister harmonieusement CUDA Graph avec les formes de batch dynamiques.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 10 : Accélération par compilation et CUDA Graph : éliminer les surcoûts de lancement et de planification
Dans le chapitre précédent, nous avons vu que le KV Connector, via des connecteurs tels que NIXL et Mooncake, transfère efficacement le KV cache entre les moteurs Prefill et Decode, permettant à l'architecture dissociée de réduire le TTFT tout en améliorant l'utilisation des ressources. Mais même avec un transfert très rapide, le décodage autorégressif conserve deux coûts fixes qu'aucun algorithme ne peut éliminer : la surcharge de planification de l'interpréteur Python et la surcharge de lancement des kernels GPU. Lorsque le forward du modèle est décomposé en centaines d'opérateurs, chacun nécessitant un appel de fonction Python et un lancement de kernel CUDA, la surcharge côté CPU suffit à faire tourner le GPU à vide entre deux calculs. Ce chapitre analyse comment vLLM utilise torch.compile pour fusionner les opérateurs en un graphe statique, puis CUDA Graph pour enregistrer toute la séquence de lancement des kernels en une seule relecture, réduisant ainsi ces deux types de surcoûts à un niveau proche de zéro.
Cache de compilation et couche d'adaptation du compilateur : permettre la réutilisation des résultats de compilation entre processus
Modèle intuitif
Le bénéfice de l'accélération par compilation est « compiler une fois, exécuter plusieurs fois », mais le coût est que la première compilation peut prendre plusieurs minutes. Sans cache, chaque redémarrage du service nécessite une recompilation, et le temps de démarrage à froid devient inacceptable.CompilerInterfaceCette couche résout précisément la question de savoir « comment sérialiser les artefacts de compilation, comment les identifier par hachage, et comment les retrouver précisément au prochain démarrage ». Sans elle, le désastre auquel le système fait face n'est pas un crash, mais une dégradation en « premier lancement » à chaque redémarrage — dans un environnement de production avec auto-scaling, cela signifie que les instances mises à l'échelle ne peuvent pas fournir de service à faible latence pendant plusieurs minutes.
Structures de données et contrat d'interface
CompilerInterfaceDéfinit le contrat abstrait de l'adaptateur de compilateur, dont le cœur est constitué de quatre méthodes :initialize_cacheResponsable de rediriger le répertoire de cache du compilateur lui-même vers le répertoire de cache de vLLM📎 vllm/compilation/compiler_interface.py:36-51;compute_hashCollecte les informations de configuration liées au compilateur pour générer un hachage📎 vllm/compilation/compiler_interface.py:53-62;compileExécute la compilation et renvoie un objet appelable ainsi qu'un handle📎 vllm/compilation/compiler_interface.py:64-95;loadRestaure l'artefact de compilation à partir du handle📎 vllm/compilation/compiler_interface.py:97-103。
La conception clé ici estcompileRenvoie un tuple de deux éléments(callable, handle)。callableEst le résultat de compilation directement appelable dans le processus courant ;handleEst le justificatif « utilisé pour restaurer au prochain démarrage », et la documentation exige explicitement qu'il soit un « plain Python object, preferably a string or a file path »📎 vllm/compilation/compiler_interface.py:81-81. Cette séparation permet au chemin de succès du cache et au chemin de première compilation d'emprunter des codes totalement différents — en cas de succès, il n'est pas nécessaire decompile, il suffit deload。
compile_rangeLe paramètre porte la sémantique des formes dynamiques. Le commentaire indique qu'il « could be concrete size (if compile_sizes is provided), e.g. [4, 4] or a range [5, 8] », et 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. C'est la contrainte centrale de la stratégie de compilation de vLLM : toutes les formes dynamiques sont réduites à une variable unique — le nombre de tokens.
Scénario guidé : le flux complet d'une requête de compilation
Supposons que le service démarre pour la première fois,InductorAdaptor.compileEst appelé. Il incrémente d'abord le compteur de compilation📎 vllm/compilation/compiler_interface.py:477-489, puis entre dans une pile de patches soigneusement construite.
La première étape est la copie profonde du graphe. Le commentaire indique que « inductor can inplace modify the graph, so we need to copy it »📎 vllm/compilation/compiler_interface.py:500-502, c'est une conception défensive — en cas d'échec de compilation, le graphe original reste utilisable pour une nouvelle tentative.
La deuxième étape consiste à installer une série de monkey-patchs.hijacked_compile_fx_innerEnveloppe la fonction de compilation interne d'Inductor, et après la compilation, récupère le hachage depuisinductor_compiled_graph._fx_graph_cache_keyRécupère le hachage📎 vllm/compilation/compiler_interface.py:512-536。hijack_compiled_fx_graph_hashIntercepte la fonction de calcul de hachage elle-même📎 vllm/compilation/compiler_interface.py:538-542. Pourquoi « détourner » le hachage ? Parce que vLLM doit compiler séparément en dehors du contexte de traçage de Dynamo, et que le calcul de hachage d'Inductor dépend de ce contexte.
La troisième étape est_check_can_cachepatch, il retourne directement sans effectuer aucune vérification📎 vllm/compilation/compiler_interface.py:544-551. Le commentaire explique la motivation : « Inductor refuse de mettre en cache le graphe en dehors du contexte de traçage Dynamo, et désactive également la mise en cache pour les graphes avec des opérations d'ordre supérieur. Pour vLLM, dans les deux cas, nous voulons mettre en cache le graphe »📎 vllm/compilation/compiler_interface.py:544-551。
La quatrième étape consiste à nettoyer le contexte de traçage. C'est l'endroit le plus subtil : vLLM appellePiecewiseCompileInterpreterdepuis l'intérieur decompile_fx, à ce moment leFakeTensorModede Dynamo et leFakeTensorModedes entrées du sous-graphe sont incohérents,detect_fake_mode()provoquera un échec d'assertion📎 vllm/compilation/compiler_interface.py:615-622. Le code sauvegardeTracingContextpuis le met à vide, et enregistre un callback pour le restaurer à la sortie📎 vllm/compilation/compiler_interface.py:623-630。
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 --> cleanupRéflexion de conception : AlwaysHitShapeEnv et cohérence du cache
AlwaysHitShapeEnvCette classe mérite une analyse séparée. Sa docstring énonce directement la motivation : vLLM n'exécute qu'une seule fois la compilation du bytecode Dynamo, mais doit exécuter plusieurs fois la compilation Inductor avec différentes formes plus une forme générique ; la compilation spécifique à une forme se produit en dehors du contexte Dynamo, où aucun shape environment n'est fourni à Inductor, ce qui entraîne l'échec de la recherche dans le cache de code Inductor📎 vllm/compilation/compiler_interface.py:114-131。
La solution consiste à fournir un faux shape environment qui « touche toujours » :evaluate_guards_expressionretourne constammentTrue 📎 vllm/compilation/compiler_interface.py:144-145,get_pruned_guardsretourne une liste vide📎 vllm/compilation/compiler_interface.py:144-145,produce_guards_expressionretourne une chaîne vide📎 vllm/compilation/compiler_interface.py:147-159. Le commentaire admet que ces méthodes sont « obtained by trial-and-error until it works »📎 vllm/compilation/compiler_interface.py:137-142— c'est un point de fragilité couplé à l'implémentation interne de PyTorch, et aussi l'endroit le plus susceptible de poser problème lors de la mise à jour de PyTorch.
La composition du hash de cache est tout aussi cruciale.get_inductor_factorscollecte trois catégories de facteurs : l'état du systèmeCacheBase.get_system(), l'état de PyTorchtorch_key(), ainsi que la configuration d'Inductor et de functorch📎 vllm/compilation/compiler_interface.py:165-185. Notez que la configuration de functorch est collectée dans le contextepatch(_get_vllm_functorch_config()), ce qui garantit que « la configuration à la compilation et la clé de cache sont toujours cohérentes » — le commentaire indique explicitement que c'est pour maintenir la cohérence entre📎 vllm/compilation/compiler_interface.py:188-189etset_functorch_config()get_inductor_factors(). Si ces deux endroits sont incohérents, il se produira un décalage du type « configuration A utilisée à la compilation, clé de cache calculée selon la configuration B », entraînant un chargement d'un artefact erroné malgré un hit de cache.📎 vllm/compilation/compiler_interface.py:147-159Pièges en production :
est un backport pour torch < 2.10.0_patch_standalone_compile_atomic_save. Il modifie📎 vllm/compilation/compiler_interface.py:205-243pour utiliserCompiledArtifact.save()en écriture au format binaire, le commentaire indiquant que l'objectif est de « preventing corrupt cache files when multiple processes compile concurrently »write_atomic. Dans un scénario de démarrage à froid simultané de plusieurs répliques, plusieurs processus écrivent concurremment dans le même fichier de cache ; une écriture non atomique produit un fichier tronqué, et les processus suivants lisant un artefact corrompu auront un comportement imprévisible.📎 vllm/compilation/compiler_interface.py:208-210PiecewiseBackend : compilation par paliers de forme et dispatch à l'exécution
Modèle intuitif
est le centre de调度 entre compilation et exécution. Il compile « un sous-graphe FX » en « plusieurs objets appelables par paliers de forme », et sélectionne le plus approprié à l'exécution en fonction du nombre réel de tokens. Sans lui, soit toutes les formes passeraient par une même compilation générique (performance sous-optimale), soit chaque forme serait compilée séparément (explosion du temps de compilation).
PiecewiseBackendStructure de données : RangeEntry et plage de compilation
La structure de données centrale est
, qui lie le flagRangeEntryetcompile_range、compiledensemblerunnablemaintient un📎 vllm/compilation/piecewise_backend.py:80-83。PiecewiseBackendLa construction de la plage de compilation se fait en deux étapes. D'abord, traiterrange_entries: dict[Range, RangeEntry] 📎 vllm/compilation/piecewise_backend.py:166-171。
(tailles exactes), chaque taille génère un intervalle ponctuelcompile_sizesdeRange(start=size, end=size). Notez qu'ici, pour la chaîne📎 vllm/compilation/piecewise_backend.py:166-171, une exception"cudagraph_capture_sizes"est directement levée, avec la mention « should be handled inNotImplementedError— c'est une déclaration explicite de frontière de responsabilité. Ensuite, traiterpost_init_cudagraph_sizes" 📎 vllm/compilation/piecewise_backend.py:166-171(intervalles), chaque intervalle génère une entréecompile_rangessupporte deux modes mutuellement exclusifs, le constructeur imposant cela par une assertion XOR📎 vllm/compilation/piecewise_backend.py:173-173。
PiecewiseBackend: le mode compilation (avec graph, sans compiled_runnables) passe par📎 vllm/compilation/piecewise_backend.py:117-119; le mode précompilé (sans graph, avec compiled_runnables) passe parcompile_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194. Cette conception permet au démarrage à froid et au démarrage à chaud de partager la même classe, seule la source des données diffère.load_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194Pilotage par scénario : de la compilation au dispatch à l'exécution
Phase de compilation
parcourt toutes les range entries, et pour chaque entrée non compilée appelle:compile_all_rangesenregistre l'événement de traçage_log_compile_start. La branche clé se situe dans la construction des paramètres : s'il s'agit d'une taille ponctuelle, appeler📎 vllm/compilation/piecewise_backend.py:252-256pour générer un FakeTensor de forme concrètecreate_concrete_args; sinon appeler📎 vllm/compilation/piecewise_backend.py:258-261pour réutiliser directement les métadonnées de placeholder du grapheget_fake_args_from_graphL'implémentation de📎 vllm/compilation/piecewise_backend.py:262-263。
create_concrete_argsrévèle les détails de la concrétisation des formes symboliques. Il construit unShapeEnvavecFakeTensorMode 📎 vllm/compilation/piecewise_backend.py:54, puis parcourt les nœuds placeholder. Pour les entrées de typeSymInt, utiliserconcretizepour remplacer tous les symboles libres parsize 📎 vllm/compilation/piecewise_backend.py:47-52; pour le typeTensor, il faut concrétiser simultanément shape, stride, storage_offset, et utilisercompute_required_storage_lengthpour calculer la longueur de stockage requise, puis reconstruire le tenseur viaas_strided📎 vllm/compilation/piecewise_backend.py:64-73. Pourquoi ne pas simplement modifier la shape ? Parce que stride et storage_offset peuvent aussi contenir des symboles, et les trois doivent être cohérents, sinonas_stridedprovoquera un dépassement de limites.
Dispatch à l'exécution:__call__est un chemin critique. S'il existesym_shape_indices, extraire la forme d'exécutionargs, puis appeler📎 vllm/compilation/piecewise_backend.py:357-362pour la recherche. La logique de recherche a des priorités : d'abord vérifier si une correspondance exacte avec_find_range_for_shapeest trouvée, si oui retourner cet intervalle ponctuelcompile_sizes; sinon parcourir📎 vllm/compilation/piecewise_backend.py:342-355pour trouver l'intervalle contenant cette formecompile_rangesCopie📎 vllm/compilation/piecewise_backend.py:342-355。
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〔Inférence de conception et compromis architecturaux〕
to_bytes: lorsque pickle rencontrereducer_override, il appelle d'abordCachingAutotunerpuis sérialiseobj.prepare_for_pickle(). Pourquoi ce hook est-il nécessaire ?📎 vllm/compilation/piecewise_backend.py:209-218détient en interne les artefacts de compilation Triton et l'état d'exécution ; un pickle direct pourrait échouer ou produire des objets non réutilisables ;CachingAutotunerconvertit évidemment l'objet en une forme purement sérialisable.prepare_for_pickleLors de la sérialisation, on active aussi temporairement
, ce qui fait écho à la logique dansbundled_autograd_cache 📎 vllm/compilation/piecewise_backend.py:222— lorsque_get_vllm_functorch_confign'est pas activé, cette configuration estVLLM_USE_MEGA_AOT_ARTIFACT, et lors de la sérialisation elle est forcée àFalse 📎 vllm/compilation/compiler_interface.py:160-161, garantissant que les artefacts sont empaquetés.Trueest le chemin de démarrage à chaud ; il vérifie que chaque range peut être trouvé dans
load_all_rangesavec la clé correspondante, sinon il lève une erreur contenant la liste des clés disponiblescompiled_runnables. Ce message d'erreur est conçu de manière très pratique — il liste directement les clés disponibles, facilitant le diagnostic des incompatibilités de version de cache.📎 vllm/compilation/piecewise_backend.py:329-339Wrapper CUDA Graph : capture, rejeu et dispatch imbriqué
Modèle intuitif
CUDA Graph enregistre « une séquence de lancements de kernels » sous forme d'un graphe statique, puis chaque rejeu ne nécessite qu'un seul appel API.
est l'exécutant de l'enregistrement et du rejeu. Le défi central auquel il fait face est : la taille de batch de vLLM est dynamique, alors que CUDA Graph exige des adresses d'entrée fixes. La solution est « la capture par paliers selon le batch descriptor » — une image est enregistrée par palier de forme, et à l'exécution on consulte la table par descriptor pour rejouer.CUDAGraphWrapperStructure de données : CUDAGraphEntry et contrat de dispatch
détient trois champs clés :
CUDAGraphEntrycomme clé de dispatchbatch_descriptorest l'objet graphe capturé📎 vllm/compilation/cuda_graph.py:128-135、cudagraphest la sortie au moment de la capture (conservée par référence faible pour économiser la mémoire)📎 vllm/compilation/cuda_graph.py:128-135、outpututilisé uniquement en mode debug pour vérifier la cohérence des adresses d'entrée lors du rejeu📎 vllm/compilation/cuda_graph.py:128-135。input_addressesLa documentation de classe décrit précisément le contrat de dispatch : à l'initialisation, allouer un runtime mode (FULL ou PIECEWISE)📎 vllm/compilation/cuda_graph.py:128-135。
CUDAGraphWrapper; à l'exécution, recevoir runtime_mode et batch_descriptor depuis le forward context et « blindly trust them »📎 vllm/compilation/cuda_graph.py:158-158; si runtime_mode est NONE ou ne correspond pas, appeler directement📎 vllm/compilation/cuda_graph.py:158-158; sinon exécuter la capture ou le rejeu📎 vllm/compilation/cuda_graph.py:158-158La documentation déclare aussi explicitement une limite : « CUDAGraphWrapper does not store persistent buffers or copy any runtime inputs into that buffers for replay »📎 vllm/compilation/cuda_graph.py:158-158。
. Cela signifie que la gestion des buffers d'entrée est la responsabilité de l'appelant — le wrapper ne s'occupe que du graphe lui-même.📎 vllm/compilation/cuda_graph.py:164-164Scénario guidé : une capture et un rejeu
Chemin de capture
: lorsqueest déclenché et que runtime_mode correspond, vérifier d'abord si le forward context est disponible. S'il ne l'est pas (comme le forward de l'encodeur visuel), appeler directement la fonction sous-jacente__call__. C'est la branche clé du scénario multimodal — le forward ViT ne passe pas par CUDA Graph.📎 vllm/compilation/cuda_graph.py:232-233Ensuite, prendre
etbatch_descriptor. Si le mode est NONE ou ne correspond pas, appeler directementcudagraph_runtime_mode 📎 vllm/compilation/cuda_graph.py:242-244. Cette conception « pas de correspondance, passage direct » permet la coexistence de wrappers imbriqués : le wrapper FULL à l'extérieur, le wrapper PIECEWISE à l'intérieur, un seul étant activé à l'exécution.📎 vllm/compilation/cuda_graph.py:246-256Si le
de l'entry est None, entrer en capture. Appeler d'abordcudagraphpour valider la légalitévalidate_cudagraph_capturing_enabled(), puis enregistrer les adresses d'entrée📎 vllm/compilation/cuda_graph.py:279, créer📎 vllm/compilation/cuda_graph.py:281-284. Dans le contexte de capture, il y a plusieurs opérations clés. Sitorch.cuda.CUDAGraph() 📎 vllm/compilation/cuda_graph.py:285。
est activé, patchergc_disableetgc.collect. Le commentaire explique la raison : en mode piecewise, chaque couche doit capturer un graphe, et des GC répétés rendraient la capture extrêmement lente, donc « only run gc for the first graph, and disable gc for the rest »torch.accelerator.empty_cache 📎 vllm/compilation/cuda_graph.py:288-303. Ensuite, définir le graph pool id📎 vllm/compilation/cuda_graph.py:289-294, et synchroniser le flux de copie de l'offloader📎 vllm/compilation/cuda_graph.py:305-308. La capture réelle s'exécute dans le contexte📎 vllm/compilation/cuda_graph.py:310-312。
torch.cuda.graph(cudagraph, pool=..., stream=...). Après la capture, appelerself.runnable(*args, **kwargs) 📎 vllm/compilation/cuda_graph.py:315-321pour éviter les erreurs de flux non joinget_offloader().join_after_forward(). Si📎 vllm/compilation/cuda_graph.py:322-326est activé, convertir l'output en référence faible pour économiser la mémoireweak_ref_output. Enfin, l'entry sauvegarde la référence faible de l'output et l'objet graphe📎 vllm/compilation/cuda_graph.py:327-334, mais📎 vllm/compilation/cuda_graph.py:338-339retourne l'output original et non la référence faible— le commentaire souligne que c'est pour permettre à PyTorch de gérer correctement la mémoire pendant la captureChemin de rejeu📎 vllm/compilation/cuda_graph.py:343-346。
: si l'entry a déjà un graphe, en mode debug vérifier la cohérence des adresses d'entrée, puis synchroniser l'offloader📎 vllm/compilation/cuda_graph.py:348-357, appeler📎 vllm/compilation/cuda_graph.py:359-361et retournerentry.cudagraph.replay() 并返回 entry.output 📎 vllm/compilation/cuda_graph.py:362-363。
Réflexion de conception : pourquoi la sortie doit être une référence faible, tandis que le retour doit être une référence forte
C'estCUDAGraphWrapperle point le plus contre-intuitif. Lors de la capture,outputest géré par le cudagraph pool de PyTorch📎 vllm/compilation/cuda_graph.py:320. Si l'entrée référence fortement la sortie, la mémoire GPU occupée par ce graphe ne pourra jamais être libérée ; mais si on la convertit en référence faible pendant la capture, PyTorch pourrait récupérer la mémoire avant la fin de la capture, entraînant l'échec de celle-ci. Le code utilise donc une référence faible📎 vllm/compilation/cuda_graph.py:334dans le bloc de capture, stocke une référence faible📎 vllm/compilation/cuda_graph.py:338dans l'entrée, mais la valeur de retour de la fonction est une référence forte📎 vllm/compilation/cuda_graph.py:346. Cet « état de triple référence » est un équilibre précis entre sécurité mémoire et efficacité de la mémoire GPU.
Une autre conception notable est_all_instancesceWeakSet 📎 vllm/compilation/cuda_graph.py:173-176. Il permet àclear_all_graphsde vider en une seule fois les graphes de tous les wrappers📎 vllm/compilation/cuda_graph.py:173-176, pour une récupération d'urgence en cas de tension de mémoire GPU. L'utilisation deWeakSetplutôt qu'un ensemble ordinaire vise à ne pas empêcher le wrapper d'être GC——sinon le wrapper lui-même fuiterait.
Pièges en production :__getattr__l'implémentation de📎 vllm/compilation/cuda_graph.py:211-217lève, en mode débogage, une erreur contextuelle pour un attribut inexistantAttributeError. Cela semble anodin, mais lors du diagnostic de « pourquoi un appel de méthode échoue », pouvoir voir la description sous forme de chaîne du runnable encapsulé par le wrapper est bien plus utile qu'un simple
Réflexion de conception : découplage entre compilation et CUDA Graph
Le document de conception consigne explicitement la motivation de cette refonte. La compilation piecewise initiale visait à prendre en charge la capture piecewise CUDA Graph, en excluant les opérateurs non compatibles CUDA Graph (principalement attention)📎 docs/design/cuda_graphs.md:25. Par la suite, le support full CUDA Graph a été ajouté, mais « this tight coupling between compilation and cudagraph capture led to an all-or-nothing experience with little flexibility »📎 docs/design/cuda_graphs.md:25。
Après refonte, quatre objectifs : distinguer explicitement les lots prefill/mixed et uniform-decode et les capturer séparément📎 docs/design/cuda_graphs.md:25-25; découpler la logique de capture CUDA Graph de la compilation, afin de « capturing piecewise and full cudagraphs using the same compiled graph »📎 docs/design/cuda_graphs.md:25-25; dispatcher à l'exécution selon la composition du lot📎 docs/design/cuda_graphs.md:25-25; centraliser le contrôle pour réduire la complexité📎 docs/design/cuda_graphs.md:25-25。
BatchDescriptorest la structure centrale de la clé de dispatch, contenantnum_tokens、num_reqs、uniform、has_loraquatre champs📎 docs/design/cuda_graphs.md:86-93。uniformLe flag est particulièrement critique——de nombreux backends attention ne supportent full CUDA Graph que lorsque le lot est uniform📎 docs/design/cuda_graphs.md:95-95. Le document annonce aussi que cette structure pourrait être étendue, par exemple en ajoutantuniform_query_lenpour supporter plusieurs longueurs uniform decode📎 docs/design/cuda_graphs.md:95-95。
La priorité de dispatch estFULL > PIECEWISE > None, et si la clé de dispatch n'existe pas, on retombe en mode NONE pour une exécution eager📎 docs/design/cuda_graphs.md:112-115. Cette stratégie de « dégradation plutôt qu'erreur » garantit que toute combinaison de lots peut s'exécuter, seule la performance diffère.
AttentionCGSupportl'énumération quantifie la capacité CUDA Graph du backend, avec les valeursALWAYS=3 > UNIFORM_BATCH=2 > UNIFORM_SINGLE_TOKEN_DECODE=1 > NEVER=0 📎 docs/design/cuda_graphs.md:153-162. Les modèles à attention mixte (comme mamba mixer) prennent le minimum des capacités de tous les backends, et dégradent le mode CUDA Graph en conséquence📎 docs/design/cuda_graphs.md:173-175. Cette conception découple « déclaration de capacité » et « sélection de mode »——ajouter un backend ne nécessite que de déclarer sa capacité, la stratégie de dégradation s'applique automatiquement.
Résumé de ce chapitre
Réflexions et auto-évaluation de ce chapitre
Q1 : si l'on retire le_check_can_cachepatch (📎 vllm/compilation/compiler_interface.py:544-551), laissant Inductor décider lui-même de mettre en cache ou non, dans quels scénarios cela entraînerait-il l'invalidation du cache de compilation ? Pourquoi le commentaire dit-il « Inductor refuses to cache the graph outside of Dynamo tracing context » ?
Analyse de référence:_check_can_cacheretourne directement, sans aucune vérification, le commentaire indique qu'Inductor refuse la mise en cache dans deux cas : hors du contexte de traçage Dynamo, et lorsque le graphe contient des opérateurs d'ordre supérieur📎 vllm/compilation/compiler_interface.py:544-551. Le flux de compilation de vLLM se situe précisément hors du contexte Dynamo (compile_fxest appelé parPiecewiseCompileInterpreter, et le code vide explicitementTracingContext 📎 vllm/compilation/compiler_interface.py:623-625). Si l'on retire le patch, Inductor jugera « non cachable », recompilant à chaque démarrage, faisant passer le temps de démarrage à froid de quelques secondes à plusieurs minutes. Plus insidieux encore, comme vLLM dépend dehijacked_compile_fx_innerpour récupérerhash_str, si le chemin de cache est ignoré,hash_strpourrait être None, déclenchant le RuntimeError de📎 vllm/compilation/compiler_interface.py:640-652. Cela explique pourquoi le commentaire souligne « vLLM today assumes and requires the monkey-patched functions to get hit »📎 vllm/compilation/compiler_interface.py:596-598。
Q2: CUDAGraphWrapperconvertit output en référence faible lors de la capture et la stocke dans l'entrée (📎 vllm/compilation/cuda_graph.py:338), mais retourne une référence forte (📎 vllm/compilation/cuda_graph.py:346). Si l'on changeait aussi la valeur de retour en référence faible, dans quels scénarios cela planterait-il ?
Analyse de référence: pendant la captureoutputest géré par le cudagraph pool de PyTorch📎 vllm/compilation/cuda_graph.py:320. Si la valeur de retour est une référence faible, l'objet obtenu par l'appelant peut être immédiatement récupéré par le GC après la sortie du bloc de capture — car à ce moment-là, aucune référence forte ne le maintient en vie. PyTorch a besoin que l'output reste vivant pendant la capture pour établir correctement la relation de mappage du pool mémoire ; une fois récupéré, lors du replay ultérieurentry.outputla référence faible pointée est déjà invalide,replay()l'objet retourné après peut avoir été écrasé ou libéré. Le commentaire indique explicitement « 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. Cette conception est un équilibre précis entre « référence forte pendant la capture, référence faible pendant le stockage ».
Q3 : DansPiecewiseBackend._find_range_for_shape(📎 vllm/compilation/piecewise_backend.py:342-355), la recherche par taille exacte est prioritaire sur la recherche par intervalle. Supposonscompile_sizes=[8]、compile_ranges=[Range(1,16)], avec un shape=8 à l'exécution, quelle entry sera touchée ? Si l'on inverse la priorité, quelles en seraient les conséquences ?
Analyse de référence: La logique actuelle vérifie d'abordruntime_shape in self.compile_sizes, et en cas de correspondance retourneRange(start=8, end=8)l'entry ponctuelle de📎 vllm/compilation/piecewise_backend.py:342-355. Cette entry est compilée aveccreate_concrete_args, la forme est entièrement concrétisée, le noyau Triton peut effectuer la spécialisation maximale (par exempleset_inductor_configles tailles ponctuelles activentmax_autotune 📎 vllm/compilation/compiler_interface.py:747-754). Si l'on inverse la priorité, shape=8 toucherait l'entry de l'intervalleRange(1,16)— c'est une version générique compilée avec des formes symboliques, aux performances sous-optimales. Plus grave encore,compile_sizesprovient généralement decudagraph_capture_sizes, ces tailles sont précisément les paliers que CUDA Graph doit capturer ; si à l'exécution on dispatche vers l'entry générique, le graphe capturé par CUDA Graph et le runnable dispatché seraient incohérents, ce qui pourrait provoquer une incompatibilité de forme lors du replay. La priorité à l'exact n'est donc pas seulement un choix de performance, mais une exigence de correction.
Le chapitre suivant abordera la quantification et les noyaux personnalisés, pour voir comment vLLM intervient dès la phase de chargement des poids pour contrôler la précision, et comment des opérateurs hautement spécialisés transforment réellement les gains de quantification en amélioration du débit.
Ce chapitre a analysé les deux niveaux de mécanisme de l'accélération de compilation de vLLM. Le premier niveau est CompilerInterface et PiecewiseBackend : le premier définit le contrat d'adaptation du compilateur et la stratégie de hachage du cache, en utilisant AlwaysHitShapeEnv pour contourner le problème d'absence de contexte Dynamo ; le second compile un seul sous-graphe FX en plusieurs paliers de forme, et dispatche à l'exécution selon le nombre de tokens. Le second niveau est CUDAGraphWrapper : il capture les CUDA Graph par paliers selon BatchDescriptor, et réalise un dispatch imbriqué via la correspondance du runtime mode, permettant aux modes FULL et PIECEWISE de coexister sur le même graphe compilé. Le découplage des deux est le cœur de cette refonte — les artefacts de compilation peuvent être réutilisés par les deux modes CUDA Graph, et CUDA Graph peut également fonctionner indépendamment de la compilation. Cependant, la compilation et la capture de graphe résolvent les surcoûts d'ordonnancement ; la précision des poids du modèle lui-même et l'efficacité des opérateurs restent une autre ligne d'optimisation. Le chapitre suivant abordera la quantification et les noyaux personnalisés, pour voir comment vLLM analyse les configurations de quantification, effectue la conversion de formats tels que FP8/INT4/AWQ/GPTQ lors du chargement des poids, et exploite davantage les performances matérielles grâce à _custom_ops et aux noyaux Triton.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 11 : Quantification et noyaux personnalisés : du chargement des poids aux opérateurs haute performance
Dans le chapitre précédent, nous avons vu que torch.compile et CUDA Graph poussent à l'extrême la réduction des surcoûts d'ordonnancement Python et de lancement de noyaux. Mais peu importe la rapidité de l'ordonnancement, si les poids eux-mêmes sont en FP16 et que la multiplication matricielle utilise un GEMM générique, la puissance de calcul matérielle reste entravée par la bande passante mémoire et les opérateurs inefficaces. La quantification et les noyaux personnalisés constituent une autre ligne d'optimisation orthogonale : la première réduit la précision dès la phase de chargement des poids, la seconde transforme réellement les gains de quantification en débit. Ce chapitre part du point d'entrée de l'analyse de la configuration de quantification, et va jusqu'à l'enregistrement des opérateurs de _custom_ops et l'ordonnancement des noyaux Triton.
11.1 Configuration de quantification : de la chaîne CLI à QuantKey
Modèle intuitif
Le rôle du module de configuration de quantification ressemble à un traducteur de menu dans un restaurant. L'utilisateur dit au comptoir « je veux fp8_per_tensor » (chaîne CLI), tandis que la cuisine a besoin du numéro précis de la recette (QuantKey). Le traducteur doit gérer trois types d'entrées : l'abréviation CLI pure, les métadonnées de quantification fournies par le checkpoint, et les scénarios combinés des deux. Sans cette couche de traduction, la cuisine recevrait une série de chaînes ambiguës et ne pourrait pas décider quel kernel appeler.
Structures de données et disposition mémoire
Les structures de données centrales sontQuantSpecetQuantizationConfigArgs. La première décrit les clés de quantification des poids et activations d'un type de couche (linear ou MoE), la seconde est la configuration de niveau supérieur visible par l'utilisateur.
📎 vllm/config/quantization.py:73-99
@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)weightetactivationsont tous deux optionnelsQuantKey。NoneLa sémantique de est « revenir aux valeurs par défaut de la classe de méthode elle-même » — généralement héritées du checkpoint ; dans un scénario de quantification en ligne, cela signifie ne pas quantifier📎 vllm/config/quantization.py:74-74。QuantKeyest en soi un type complexe contenant les déclarationsNamedTupleetClassVar[GroupShape], que pydantic ne peut pas introspecter directement ; l'auteur a donc utiliséGetPydanticSchemapour injecter un validateur personnalisé_coerce_quant_key, qui normalise uniformément les chaînes de caractères ouQuantKey📎 vllm/config/quantization.py:60-69。
QuantizationConfigArgsLa disposition des champs de mérite attention📎 vllm/config/quantization.py:102-126:
linear/moe: ils s'appliquent respectivement aux couchesLinearBaseetFusedMoEFactory;ignore: liste des noms de couches à ignorer pour la quantification ; la quantification en ligne prend également en charge les jokers fnmatch ;targets: surcharge de quantification en ligne couche par couche ; la clé peut être un nom de couche exact, une expression régulière préfixée parre:, ou un motif fnmatch ; la valeur est mutuellement exclusive aveclinear/moe.
targetsetlinear/moesont rendus mutuellement exclusifs parmodel_validatorqui l'impose📎 vllm/config/quantization.py:172-179. Cette contrainte n'est pas du formalisme :targetsemprunte le chemin de surcharge couche par couche,linear/moeemprunte le chemin des valeurs par défaut globales ; si les deux coexistent, il devient indécidable de savoir « quel spec s'applique à une couche donnée ».
Étape par étape : une résolution de--quantization fp8_per_tensor
Mise en situation : l'utilisateur passe en ligne de commande--quantization fp8_per_tensor, et spécifie simultanément via--quantization-configla quantification d'activation des couches MoE.
Première étape,resolve_quantization_configest appelé avec comme arguments la chaîne CLI et le dictionnaire de configuration📎 vllm/config/quantization.py:233-235. Il vérifie d'abord siquantizationse trouve dansONLINE_QUANT_SHORTHAND_NAMES— ce tuple contient tous les noms abrégés plus un"online" 📎 vllm/config/quantization.py:216-222。
Deuxième étape,fp8_per_tensorcorrespond à la table des abréviations,baseest résolu en_ONLINE_SHORTHANDS["fp8_per_tensor"], c'est-à-dire que linear et moe utilisent tous deuxkFp8StaticTensorSym 📎 vllm/config/quantization.py:188-190。
Troisième étape,quantization_configest non vide et est construit comme un objetQuantizationConfigArgs. On entre ensuite dans la logique de fusion📎 vllm/config/quantization.py:267-268: chaque champ est déterminé parquantization_config.xxx or base.xxx— les champs explicitement définis par l'utilisateur sont prioritaires, les champs non définis héritent des valeurs par défaut de l'abréviation. Ici, l'utilisation deorplutôt queif is not Noneest intentionnelle :QuantSpecet une liste vide sont tous deux falsy ; sémantiquement, « non défini » et « vide » sont équivalents.
Quatrième étape, siquantizationne figure pas dans la table des abréviations (par exemple s'il s'agit duawqpropre au checkpoint), et quequantization_configvautNone, la fonction retourne directementNone 📎 vllm/config/quantization.py:256-257. Cela signifie « ne pas superposer de quantification en ligne » ; la méthode de quantification du checkpoint reste dominante.
Il existe une branche facile à négliger :_DEFERRED_ONLINE_SHORTHANDScontientmxfp4etmxfp8 📎 vllm/config/quantization.py:233-235. Ces deux noms sont à la fois des abréviations CLI et des noms de méthodes de quantification de checkpoint. Lorsque l'utilisateur ne passe que--quantization mxfp4sansquantization_config, la fonction retourneNoneplutôt quebase 📎 vllm/config/quantization.py:267-268, reportant la décision aux métadonnées du checkpoint — ce n'est que lorsque le checkpoint ne contient aucune information de quantification que l'on revient à l'abréviation en ligne.
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_mergedRéflexions de conception et pièges
_coerce_specLe validateur gère un scénario subtil : lorsquelinearoumoereçoit une chaîne de caractères, il consulte d'abord_ONLINE_SHORTHANDS; en cas de correspondance, il extrait le spec du champ correspondant ; sinon, il la traite comme un seul nomQuantKey📎 vllm/config/quantization.py:130-139. Cela signifie quelinear="fp8_per_tensor"etlinear="fp8_per_tensor_static"empruntent deux chemins différents — le premier est une abréviation de configuration complète, le second une clé de quantification unique. Si dans l'abréviation ce champ vautNone(par exempleint8_per_channel_weight_onlyn'a pas de champlinear), uneValueErrorexplicite est levée plutôt qu'un retour silencieux deNone 📎 vllm/config/quantization.py:130-139。
Un piège courant en production :targetsles clés d'expression régulière de sont précompilées et validées dans_validate_targets📎 vllm/config/quantization.py:166-167, mais les clés de motif fnmatch ne sont pas validées. Si l'utilisateur écrit un motif fnmatch qui ne correspondra jamais à aucune couche, aucune erreur n'est signalée ; cette couche reste simplement non quantifiée — lors du diagnostic, il faut vérifier si les noms de couches correspondent réellement.
11.2 _custom_ops: enregistrement des opérateurs et implémentations fake
Modèle intuitif
_custom_ops.pyest la couche d'adaptation entre vLLM et les opérateurs CUDA/C++ sous-jacents, comme une douane. L'espace de nomstorch.ops._Cde PyTorch contient les opérateurs C++ compilés, mais les appeler directement pose trois problèmes : les ensembles d'opérateurs diffèrent selon la plateforme (CUDA/ROCm/CPU/XPU),torch.compilenécessite des implémentations fake pour déduire les formes de sortie, et certains opérateurs nécessitent un prétraitement des paramètres côté Python._custom_opsencapsule uniformément ces problèmes.
Structures de données et mécanisme d'enregistrement
Au chargement du module, on appelle d'abordcurrent_platform.import_kernels() 📎 vllm/_custom_ops.py:25-26, pour donner à la couche plateforme l'occasion d'importer sa propre bibliothèque d'opérateurs. On définit ensuiteregister_fake— sousTYPE_CHECKINGc'est un décorateur vide ; à l'exécution, on importe depuistorch.library📎 vllm/_custom_ops.py:25-26。
Le rôle principal des implémentations fake est de permettre àtorch.compilede connaître la forme de sortie et le dtype de l'opérateur pendant la phase de traçage, sans l'exécuter réellement. Prenonsscaled_fp4_quantcomme exemple :
📎 vllm/_custom_ops.py:90-100
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)Noter la gardehasattr: l'implémentation fake n'est définie que si la plateforme a réellement enregistré_C::scaled_fp4_quant. Cela garantit que l'import du module sur CPU ou sur un ancien GPU ne plantera pas à cause d'un opérateur manquant.
create_fp4_output_tensorsillustre les détails de disposition mémoire de la sortie de quantification FP4📎 vllm/_custom_ops.py:69-87. Lorsqueis_sf_swizzled_layout=True, le tenseur de scale doit être disposé selon les tuiles 128x4 exigées par les Tensor Cores : le nombre de lignes est arrondi au multiple de 128 supérieur, le nombre de colonnes (n // 16) est arrondi au multiple de 4 supérieur, et chaque groupe de 4 float8_e4m3 est empaqueté dans un int32📎 vllm/_custom_ops.py:55-64. Le commentaire indique explicitement que le noyau de quantification NVFP4 met explicitement à zéro toutes les entrées de scale de padding, ce qui rend inutile un kernel d'initialisation à zéro séparé📎 vllm/_custom_ops.py:60-61。
Étape par étape : le flux d'appel d'un GEMM AWQ
Mise en situation : le modèle a chargé des poids quantifiés AWQ ; lors de la propagation avant, il faut effectuer une multiplication matricielle entre les activations et les poids quantifiés.
Première étape, appel deawq_gemm 📎 vllm/_custom_ops.py:587-592. La fonction vérifie d'abord la variable d'environnementVLLM_USE_TRITON_AWQ. Si elle est vraie, elle importe paresseusementawq_gemm_tritonet l'appelle — c'est un chemin d'implémentation purement Triton, utilisé pour les plateformes ne prenant pas en charge les opérateurs CUDA ou pour le débogage.
Deuxième étape, le chemin par défaut appelletorch.ops._C.awq_gemm, en passant input, qweight, scales, qzeros etsplit_k_iters 📎 vllm/_custom_ops.py:598-598。
Troisième étape, sitorch.ops._C.awq_gemmexiste, l'implémentation fake est enregistrée📎 vllm/_custom_ops.py:601-616. La forme retournée par fake est(split_k_iters, num_in_feats, qweight.size(1) * 8)puis.sum(0)— cela simule précisément la forme des résultats intermédiaires du split-K et la forme finale après réduction.qweight.size(1) * 8Provient du mode d'empaquetage d'AWQ : chaque int32 stocke 8 poids de 4 bits.
Quatrième étape,awq_dequantizesuit un chemin similaire📎 vllm/_custom_ops.py:553-559, mais l'implémentation fake déduit une forme différente :out_c = qout_c * 8, car après déquantification le nombre de colonnes est multiplié par 8📎 vllm/_custom_ops.py:587-592。
La fonction repack de la série Marlin illustre un autre schéma.gptq_marlin_repackL'implémentation fake de calculepack_factor = 32 // num_bits, la forme de sortie est(size_k // 16, size_n * 16 // pack_factor) 📎 vllm/_custom_ops.py:1103-1119. Ici16est la taille de tuile Marlin,size_k // 16indique que la dimension K est découpée par tuile. La version MoE degptq_marlin_moe_repackappelle en boucle au niveau Python le repack mono-expert pour chaque expert📎 vllm/_custom_ops.py:1154-1172, et affirmesize_k % 16 == 0— c'est une contrainte stricte du format Marlin.
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 --> outputRéflexions de conception et pièges
L'implémentation fake doit être parfaitement cohérente avec la forme de sortie de l'opérateur réel, sinontorch.compilele graphe tracé par présentera des incompatibilités de forme à l'exécution.create_fp4_output_tensorsLe commentaire de souligne particulièrement « Must match the C++ scaled_fp4_quant_func allocation exactly when padded_n is None »📎 vllm/_custom_ops.py:69-74. C'est un point propice aux erreurs : si le côté C++ modifie la logique d'allocation sans que le fake soit synchronisé, le graphe compilé plantera lors du rejeu du CUDA Graph.
Un autre piège esttorch.library.custom_opla règle d'alias de .safeFusedQuantizeNvLe commentaire de indique que torch 2.12+ n'autorise pas la sortie d'un opérateur personnalisé à aliaser une quelconque entrée, c'est pourquoi l'auteur a transformé le tenseur de retour en paramètre in-place📎 vllm/_custom_ops.py:4650-4655. Cette approche consistant à « modifier la forme de l'API pour contourner les limitations du framework » est courante dans la couche d'adaptation des opérateurs ; lors du diagnostic, il faut vérifier si la déclarationmutates_argsest cohérente avec le comportement réel.
CPUDNNLGEMMHandlerprésente un autre mode de gestion des ressources : le pointeur de handler est stocké dans un tenseur int64,__del__lors de appellerelease_dnnl_matmul_handlerpour libérer📎 vllm/_custom_ops.py:3708-3717. Stocker le pointeur dans un tenseur vise à éviter qu'il soit optimisé par l'inlining des entiers Python — c'est une technique classique de liaison bas niveau.
11.3 Planification des kernels Triton :KernelOverrideet reliaison inter-modules
Modèle intuitif
Le rôle du planificateur de kernels Triton ressemble à un système de remplacement de postes dans une entreprise. Lorsqu'une plateforme (par exemple ROCm) doit remplacer un kernel Triton du cœur de vLLM par sa propre implémentation, elle ne peut pas modifier directement le code cœur — cela polluerait l'upstream.dispatcherpermet à une plateforme d'enregistrer un remplaçant, puis remplace discrètement toutes les références pointant vers le kernel original par le remplaçant. Sans ce mécanisme, chaque plateforme devrait maintenir un fork, avec des conflits incessants lors de la fusion des changements upstream.
Structures de données et disposition mémoire
La structure de données centrale est_registryle dictionnaire etKernelOverridela classe📎 vllm/triton_utils/dispatcher.py:29-36。
KernelOverrideles champs clés de📎 vllm/triton_utils/dispatcher.py:50-61:
_impl: fonction d'implémentation de la plateforme ;arg_names: tuple des noms de paramètres miroir du kernel original, utilisé pour la liaison par mot-clé au lancement ;constexprs: déclarations constexpr héritées du kernel original ;func: pointe vers la fonction d'implémentation, pour l'introspection du warmup ;_forward_by_name: indicateur booléen déterminant si les paramètres sont transmis par mot-clé ou par position au lancement.
_forward_by_nameLa logique de calcul de est : comparerinspect.signature(impl).parametersavec le du kernel originalarg_namespour vérifier s'ils sont parfaitement égaux📎 vllm/triton_utils/dispatcher.py:50-61. Si égaux, cela signifie que les noms de paramètres de l'implémentation correspondent au kernel et la transmission par mot-clé est sûre ; sinon, la transmission doit se faire par position selon l'ordre des paramètres du kernel original.
Step-by-Step : uneregister_kernelsreliaison de
Mise en situation : la plateforme ROCm appelle lors de l'initialisationregister_kernels({"vllm.v1.sample.rejection_sampler.expand_kernel": my_expand_impl})。
Première étape,register_kernelsparcourt les overrides, et pour chaque nom appelle_resolve_kernel 📎 vllm/triton_utils/dispatcher.py:162-166。_resolve_kernelpour découper le nom selon le dernier.en nom de module et nom d'attribut📎 vllm/triton_utils/dispatcher.py:83-94. Si la première lettre du dernier segment du nom de module est en majuscule, cela signifie que le kernel appartient à une classe (JIT warmup owner) ; il faut d'abord importer le module parent puisgetattrrécupérer la classe, et retourner(类, 属性名); sinon importer le module lui-même et retourner(模块, 属性名)。
Deuxième étape, après avoir obtenu l'objet kernel original, construireKernelOverridele wrapper, et l'enregistrer dans_registry 📎 vllm/triton_utils/dispatcher.py:167-169。
Troisième étape,_rebind_kernelsexécute un balayage de tous les modules📎 vllm/triton_utils/dispatcher.py:97-144. Il parcourtsys.modulesles de tous les modules dans__dict__, et effectue une comparaison d'identité sur chaque valeur d'attribut — attention, c'estiset non==, car certaines valeurs d'attribut (commePlaceholderModulela sentinelle) déclenchent des imports ou des exceptions lors du hash/eq📎 vllm/triton_utils/dispatcher.py:116-123。
Quatrième étape, pour les attributs correspondant au kernel original, directementsetattrremplacer par le wrapper📎 vllm/triton_utils/dispatcher.py:125-135. Pour le JIT warmup owner (objet dont l'attribut d'instancekernelpointe vers le kernel original), remplacervalue.kernelet effacer le cache de_kernel_arg_names, pour que la liaison au lancement soit redéduite depuis le wrapper📎 vllm/triton_utils/dispatcher.py:138-139。
Cinquième étape,_rebind_kernelsune fois terminé, remplacer également l'attribut à l'endroit de la définition par le wrapper📎 vllm/triton_utils/dispatcher.py:170-174. Le commentaire explique l'importance de l'ordre : si l'on remplace d'abord l'endroit de la définition, le kernel original ne sera plus trouvable lors du balayage📎 vllm/triton_utils/dispatcher.py:170-171。
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: 注册完成Réflexions de conception et pièges
KernelOverride.__getitem__retourneself._launch, rendantkernel[grid](**kwargs)cette syntaxe de lancement Triton standard transparente pour le wrapper📎 vllm/triton_utils/dispatcher.py:63-74。_launchLa logique de transmission de se divise en trois cas📎 vllm/triton_utils/dispatcher.py:63-74: s'il y a des arguments positionnels, transmission directe ;_forward_by_namesi vrai, transmission par mot-clé ; sinon vérifier si kwargs contient des noms de paramètres inconnus du kernel original, si oui leverRuntimeError, sinon extraire les valeurs dans l'ordre des paramètres du kernel original et transmettre par position.
CeRuntimeErrorest une défense importante : si les noms de paramètres implémentés par la plateforme ne correspondent pas à ceux du noyau, et que l'appelant transmet des paramètres que l'implémentation ne reconnaît pas, un ignoré silencieux conduirait à des résultats erronés difficiles à diagnostiquer. Un signalement explicite d'erreur expose le problème dès la phase d'enregistrement.
Un piège en environnement de production :_rebind_kernelsle balayage est en O(nombre de modules × nombre d'attributs × nombre de noyaux). Pour les grands modèles,sys.modulesil peut y avoir des milliers de modules, chacun avec des centaines d'attributs. Bien que cela ne s'exécute qu'une seule fois à l'initialisation, si de nombreux noyaux sont enregistrés, le temps de démarrage augmente sensiblement.lookupla fonction utilise un balayage linéaire plutôt qu'une recherche par hachage, et le commentaire explique pourquoi — certaines valeurs d'attributs ne sont pas hachables📎 vllm/triton_utils/dispatcher.py:116-123. C'est un compromis typique de « la correction prime sur la performance ».
Un autre piège :_resolve_kerneldétermine s'il s'agit d'un attribut de classe par « la première lettre du dernier segment du nom de module est en majuscule »📎 vllm/triton_utils/dispatcher.py:83-94. Si un nom de module commence justement par une majuscule (ce qui ne respecte pas les conventions de nommage Python mais est syntaxiquement légal), il sera pris à tort pour une classe. C'est une conception où la convention prime sur la configuration, qui repose sur les normes de nommage internes de vLLM.
Réflexions sur la conception
Les deux mécanismes que sont la configuration de quantification et l'enregistrement d'opérateurs constituent ensemble la surface de réglage « précision-performance » de vLLM.QuantizationConfigArgsla conception reflète la séparation entre « l'intention de l'utilisateur » et « les valeurs par défaut de la méthode » :Nonece n'est pas « ne pas quantifier », mais « laisser la classe de méthode décider elle-même ». Cette décision différée permet à une même configuration de s'adapter à deux scénarios : la quantification du checkpoint et la quantification en ligne.
_custom_opsle mode d'implémentation fake esttorch.compileun standard de l'écosystème, mais la particularité de vLLM réside danshasattrl'usage généralisé des gardes. Cela permet à un même module d'être importé sans plantage sur CUDA, ROCm, CPU et XPU, au prix de trois emplacements de code par opérateur : le wrapper Python, l'implémentation fake, et la garde de plateforme.
La reliaison inter-modules du dispatcher Triton est une approche radicale. Elle ne repose pas sur les hooks d'import de Python ni sur__getattr__, mais scanne et remplace directement toutes les références. L'avantage de cette méthode est son exhaustivité — peu importefrom mod import kernelcombien de fois le noyau est copié, il peut être remplacé ; l'inconvénient est sa fragilité — toute nouvelle façon de détenir une référence au noyau (comme une capture par closure) peut échapper au balayage.
Résumé de ce chapitre
Réflexions et auto-évaluation de ce chapitre
Q1 : Dansresolve_quantization_config, si l'on supprime la branche_DEFERRED_ONLINE_SHORTHANDS(c'est-à-dire lorsquequantization in _DEFERRED_ONLINE_SHORTHANDSon retournebaseau lieu deNone), que se passe-t-il lors du chargement d'un modèle dont le checkpoint possède son proprequant_method: "mxfp4"et où l'utilisateur ne transmet que--quantization mxfp4?
Analyse de référence:_DEFERRED_ONLINE_SHORTHANDSl'intention de conception est de donner la priorité à la méthode de quantification du checkpoint📎 vllm/config/quantization.py:233-235. Si l'on supprime cette branche,mxfp4on tombera sur_ONLINE_SHORTHANDSet on retournerabase(c'est-à-direQuantSpec(weight=kMxfp4Static))📎 vllm/config/quantization.py:198-210. À ce moment, la configuration de quantification en ligne écrasera la méthode de quantification du checkpoint, alors que les poids du checkpoint sont stockés au formatmxfp4— si lekMxfp4Staticde la configuration en ligne ne correspond pas exactement au format réel du checkpoint (par exemple une disposition des scales différente), le chargement des poids échouera ou produira des résultats erronés. Un cas plus insidieux : lemxfp4du checkpoint peut utiliser un group size ou un scale dtype différents, et les valeurs par défaut de la configuration en ligne ne correspondent pas, entraînant une baisse de précision d'inférence sans erreur signalée.
Q2: KernelOverride._launchdans, si_forward_by_namevautFalseet que les kwargs transmis par l'appelant contiennent un nom de paramètre que le noyau d'origine ne reconnaît pas, le code lèveraRuntimeError. Si l'on supprime cette vérification pour ignorer silencieusement les paramètres inconnus, dans quel scénario cela conduirait-il à des problèmes difficiles à diagnostiquer ?
Analyse de référence:_forward_by_namevautFalsesignifie que les noms de paramètres de l'implémentation de plateforme ne correspondent pas à ceux du noyau d'origine, et qu'il faut transmettre par position📎 vllm/triton_utils/dispatcher.py:50-61. Si l'appelant transmet un paramètre que le noyau d'origine ne reconnaît pas (par exemple un nouveau paramètre optionnel ajouté en amont), l'ignoré silencieux entraînera la perte de la valeur de ce paramètre. Dans le cas d'un noyau Triton, cela signifie généralement qu'un constexpr ou une dimension de grid n'est pas transmis, et le noyau peut démarrer avec des valeurs par défaut — le résultat peut être un calcul erroné plutôt qu'un plantage. Comme les résultats erronés d'un noyau Triton se manifestent souvent par des écarts numériques plutôt que par des exceptions, le diagnostic est extrêmement difficile. UnRuntimeErrorexplicite expose le problème dès le premier launch📎 vllm/triton_utils/dispatcher.py:63-74。
Q3: _rebind_kernelsaprès avoir remplacé la propriétékerneldu propriétaire du JIT warmup, on exécutevalue.__dict__.pop("_kernel_arg_names", None). Si l'on supprime cette ligne, dans quel cas cela conduirait-il à une erreur de liaison au launch ?
Analyse de référence: le propriétaire du JIT warmup met en cache_kernel_arg_names, utilisé au launch pour lier les kwargs aux paramètres du noyau📎 vllm/triton_utils/dispatcher.py:138-139. Après avoir remplacékernelpar le wrapper, learg_namesdu wrapper peut différer de celui du noyau d'origine (si les noms de paramètres de l'implémentation de plateforme diffèrent, learg_namesdu wrapper reflète toujours le noyau d'origine, mais_forward_by_namepeut valoirFalse). Si l'on ne vide pas le cache, le mécanisme de warmup continuera d'utiliser l'ancienne liste de noms de paramètres pour la liaison, alors que la logique de launch du wrapper peut attendre une méthode de liaison différente. Concrètement,KernelOverride._launchlorsque_forward_by_namevautFalse, extrait les valeurs dans l'ordreself.arg_names, et si le📎 vllm/triton_utils/dispatcher.py:79-80mis en cache ne correspond pas au_kernel_arg_namesdu wrapper, l'ordre des paramètres extraits sera erroné, et le noyau recevra des valeurs de paramètres incorrectes.arg_namesLe chapitre suivant se tournera vers les fonctionnalités d'inférence avancées, pour voir comment le cache de préfixes réutilise les KV blocks, comment le décodage spéculatif accélère les grands modèles avec de petits modèles, et comment LoRA permet de basculer dynamiquement les adaptateurs sans modifier les poids de base.
下一章将转向高级推理特性,看前缀缓存如何复用 KV block、投机解码如何用小模型加速大模型、以及 LoRA 如何在不改基座权重的前提下动态切换适配器。
Ce chapitre analyse les deux couches d'infrastructure de la quantification et des kernels personnalisés de vLLM. La première couche est l'analyse de la configuration de quantification : QuantSpec et QuantizationConfigArgs normalisent uniformément les chaînes CLI, les métadonnées de checkpoint et les surcharges par couche en QuantKey ; resolve_quantization_config gère l'expansion des abréviations et la fusion des champs ; _DEFERRED_ONLINE_SHORTHANDS résout les scénarios de conflit de noms. La seconde couche est l'adaptation des opérateurs : _custom_ops réalise l'enregistrement d'opérateurs multiplateformes via des gardes hasattr et register_fake ; l'implémentation fake reproduit précisément les formes de sortie des opérateurs réels pour prendre en charge torch.compile ; le dispatcher réalise le remplacement multiplateforme des kernels Triton via KernelOverride et un balayage complet des modules. Ensemble, elles soutiennent la concrétisation des gains de quantification, du chargement des poids au calcul forward. Nous nous tournerons ensuite vers les fonctionnalités avancées d'inférence qui améliorent le débit et réduisent la latence : comment le cache de préfixe automatique réutilise les KV entre requêtes, comment le décodage spéculatif accélère la génération avec un modèle draft, et comment LoRA commute dynamiquement les adaptateurs.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 12 : Fonctionnalités avancées d'inférence : cache de préfixe, décodage spéculatif et LoRA
Dans le chapitre précédent, nous avons approfondi le système de quantification et l'infrastructure d'opérateurs personnalisés de vLLM, en voyant comment la configuration de quantification est analysée et comment les kernels correspondants sont sélectionnés, ainsi que la manière dont les schémas FP8, INT4, AWQ, GPTQ, etc. effectuent la conversion lors du chargement des poids. En parallèle, nous avons élucidé comment _custom_ops enregistre les opérateurs CUDA, le mécanisme d'ordonnancement des kernels Triton, et comment les kernels fusionnés MoE réduisent les allers-retours en mémoire. Ces capacités de bas niveau ouvrent la voie à des optimisations d'inférence plus avancées. Ce chapitre se concentrera sur les trois principales fonctionnalités avancées d'inférence de vLLM : le cache de préfixe automatique (APC), le décodage spéculatif et LoRA. Bien qu'elles semblent indépendantes, elles partagent en réalité la même infrastructure sous-jacente — le hachage des blocs KV, l'allocation de slots par l'ordonnanceur, et l'injection dynamique de poids lors de l'exécution du modèle. La clé pour les comprendre est de comprendre comment elles poussent la « réutilisation » à l'extrême sans briser la sémantique de pagination de PagedAttention.
12.1 Cache de préfixe : comment le block hash empreinte une préfixe
Modèle intuitif
Le cache de préfixe ressemble à un « recueil d'extraits communs » de bibliothèque : deux étudiants rédigent une dissertation, et leurs débuts citent le même passage ancien ; le professeur n'a besoin de corriger ce passage qu'une seule fois, puis examine séparément les parties différentes qui suivent. Sans cela, chaque requête devrait préremplir l'intégralité du prompt depuis le début, et dans les scénarios de questions-réponses sur de longs documents, la puissance de calcul serait consommée plusieurs fois de manière répétée.
Structure de données : mapping des tokens vers le block hash
Le cœur du cache de préfixe est de « déterminer si deux requêtes ont le même préfixe ». La réponse de vLLM est : découper la séquence de tokens en blocs, et calculer un hachage chaîné pour chaque bloc. Le chaînage signifie que le hachage du N-ième bloc contient le hachage des N-1 blocs précédents ; ainsi, un block hash empreinte de manière unique l'intégralité du préfixe « du début de la séquence jusqu'à la fin de ce bloc ».
Le support du hachage estBlockHash, défini commebytesdeNewType, et non unbytesnu, afin d'empêcher au niveau du typage toute utilisation abusive de📎 vllm/v1/core/kv_cache_utils.py:59-62. Lorsqu'il faut combiner le block hash avec le KV cache group id pour former une clé de dictionnaire, vLLM n'utilise pas de tuple, mais concatène directement le group id de 4 octets en big-endian à la fin des octets du hash📎 vllm/v1/core/kv_cache_utils.py:75-76:
def make_block_hash_with_group_id(block_hash, group_id):
return BlockHashWithGroupId(block_hash + group_id.to_bytes(4, "big", signed=False))Il s'agit d'une optimisation typique « pour éviter l'allocation de tuples » : sur le chemin critique, chaque recherche de bloc doit construire une clé ; un tuple entraînerait une allocation supplémentaire d'objets Python et un surcoût de hachage, tandis que la concaténation d'octets s'effectue au niveau C, et la chaîne d'octets elle-même est hachable. Lors de la récupération, on utilise le découpagekey[:-4]etint.from_bytes(key[-4:])pour restaurer📎 vllm/v1/core/kv_cache_utils.py:87-89。
La fonction de hachage elle-même est assurée parhash_block_tokens, qui alimente la fonction de hachage avec le hash du bloc parent, le tuple des token id du bloc courant, ainsi que des clés supplémentaires📎 vllm/v1/core/kv_cache_utils.py:650-680. Notez que le hash parent du premier bloc n'est pasNone, mais le globalNONE_HASH:
if not parent_block_hash:
parent_block_hash = NONE_HASH📎 vllm/v1/core/kv_cache_utils.py:674-675。NONE_HASHLe choix de la graine de"vllm-none-hash"recèle une conception de sécurité : pour les hachages cryptographiques comme SHA-256, la graine est fixe📎 vllm/v1/core/kv_cache_utils.py:105-126。resolve_none_hash_seed, ce qui permet à différents processus vLLM de calculer le même hash pour un même contenu, et donc de partager le cache de préfixe entre nœuds ; tandis que pour les hachages non cryptographiques comme xxhash, la graine est aléatoire par processus, car une graine prévisible permettrait à un attaquant de précalculer hors ligne des blocs en collisionPYTHONHASHSEEDimplémente cette bifurcation :os.urandom(32) 📎 vllm/v1/core/kv_cache_utils.py:132-145。
La variable d'environnement est prioritaire ; sinon, les hachages cryptographiques utilisent une graine fixe et les hachages non cryptographiques utilisent
Supposons qu'une requête arrive avec 128 tokens, avec une taille de bloc de 16.get_request_block_hasherLa closure retournée est chargée du calcul incrémental📎 vllm/v1/core/kv_cache_utils.py:802-861:
Première étape, déterminer où commencer le calcul.start_token_idx = len(request.block_hashes) * hash_block_size 📎 vllm/v1/core/kv_cache_utils.py:812-812, c'est-à-dire le nombre de blocs déjà calculés multiplié par la taille de bloc. Si les tokens restants ne suffisent pas pour un bloc, retourner directement vide📎 vllm/v1/core/kv_cache_utils.py:812-812。
Deuxième étape, traiter le décalage multimodal. Si la position de départ tombe à l'intérieur d'une entrée multimodale, il faut utiliserget_mm_features_in_windowpour repositionnercurr_mm_idx 📎 vllm/v1/core/kv_cache_utils.py:823-832. Cela est dû au fait que le token placeholder de l'entrée multimodale ne porte pas de sémantique en lui-même ; il faut incorporer l'identifiant de caractéristique mm et son décalage dans le bloc comme clés supplémentaires dans le hachage.
Troisième étape, calculer chaque bloc en boucle.generate_block_hash_extra_keyscollecter toutes les clés supplémentaires📎 vllm/v1/core/kv_cache_utils.py:611-647, incluant le nom LoRA, les clés multimodales, le cache salt, le hachage des prompt embeds. Le cache salt ne prend effet que dans le premier bloc📎 vllm/v1/core/kv_cache_utils.py:633-635, c'est intentionnel : le rôle du salt est d'isoler tout l'espace de nommage du cache, il suffit de l'injecter une fois au point de départ de la chaîne.
Quatrième étape,hash_block_tokenshacher ensemble le hash parent, le tuple de tokens et les clés supplémentaires, le résultat servant de hash parent pour le bloc suivant📎 vllm/v1/core/kv_cache_utils.py:851-857. La structure en chaîne se forme ainsi.
Conversion de granularité multi block size
Lorsqu'un modèle possède plusieurs groupes de KV cache avec des tailles de bloc différentes, la granularité de hachage et la granularité de bloc du groupe peuvent être incohérentes.BlockHashListWithBlockSizerésout ce problème : il ne recalcule pas le hachage, mais exploite la propriété du hachage en chaîne — le hash d'un target block est le hash de son dernier hash block interne📎 vllm/v1/core/kv_cache_utils.py:2781-2851. Par exemple, avec un hash block de 16 et un target block de 32, le hash des tokens 0-31 est le deuxième hash de taille 16 (qui couvre déjà 0-31 en chaîne)📎 vllm/v1/core/kv_cache_utils.py:2794-2806。_get_value_atl'implémentation estself.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851。
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 --> checkRéflexions de conception et pièges rencontrés
Pourquoi utiliser un hachage en chaîne plutôt qu'un hachage indépendant ?Le hachage indépendant ne peut pas distinguer le cas où « le même bloc apparaît à différentes positions de préfixe ». Le hachage en chaîne permet au block hash d'empreinter de manière unique tout le préfixe, ce qui est précisémentfind_longest_cache_hitla condition préalable pour réutiliser le KV en toute sécurité.
Le piège inter-processus du hachage non cryptographique.Si l'on utilise xxhash sans définirPYTHONHASHSEED, leNONE_HASHde chaque processus est différent, entraînant une défaillance complète du cache de préfixe inter-instances.init_none_hashaffichera un avertissement📎 vllm/v1/core/kv_cache_utils.py:161-169. En production, si l'on déploie plusieurs instances partageant un cache, il faut explicitement définirPYTHONHASHSEEDou passer à sha256.
La subtilité du décalage multimodal. _gen_mm_extra_hash_keysutiliser(mm_identifier, offset - start_token_idx)comme clé supplémentaire📎 vllm/v1/core/kv_cache_utils.py:552. Le décalage est relatif au début du bloc, ainsi le même élément mm apparaissant à différentes positions de bloc aura un hash différent, évitant les faux positifs.
12.2 Décodage spéculatif : synergie entre brouillon et vérification
Modèle intuitif
Le décodage spéculatif ressemble à un secrétaire qui rédige d'abord plusieurs versions de réponse pour le dirigeant, qui n'a plus qu'à cocher rapidement celle qui convient. Le modèle brouillon (drafter) prédit plusieurs tokens candidats à très faible coût, le modèle cible (target) vérifie ces candidats en parallèle en une seule passe avant, acceptant les parties correspondantes. Sans cela, le modèle cible ne peut générer les tokens qu'en série un par un, et l'utilisation du GPU est extrêmement faible pendant la phase de decode.
Structure de données : annotation des EAGLE group
Le problème central du décodage spéculatif dans la gestion du KV cache est : comment regrouper les couches KV du modèle brouillon et celles du modèle cible ?_annotate_eagle_groupsutilise deux règles pour identifier le groupe brouillon📎 vllm/v1/core/kv_cache_utils.py:2134-2189:
Règle un, pilotée par spec :non_causal_multi_token_decodele flag est déclaré surMLAAttentionSpec, défini par la couche d'attention brouillon exécutant un décodage multi-token non causal, et peut survivre à l'opérationmerge📎 vllm/v1/core/kv_cache_utils.py:2175-2177。
Règle deux, repli par position : les drafters MTP (comme DeepseekV4/V4.1 DSpark) réutilisent les propres couches decoder du modèle cible, sans marquage sur spec, mais leurs couches d'attention brouillon sont toujours enregistrées après toutes les couches cibles, donc on annote le groupe qui détient la dernière couche enregistrée📎 vllm/v1/core/kv_cache_utils.py:2183-2184. Cette règle ne prend effet que lorsque le groupe partitionne exactementkv_cache_spectoutes les couches📎 vllm/v1/core/kv_cache_utils.py:2183-2184。
Piloté par scénario : allocation KV du décodage spéculatif
Lorsquespeculative_configest activé etuse_eagle_block_drop()est vrai,_annotate_eagle_groupsest appelé📎 vllm/v1/core/kv_cache_utils.py:2175-2177. Le résultat d'annotationis_eagle_groupinfluence la stratégie d'allocation de blocs ultérieure — les blocs du groupe brouillon peuvent être jetés après vérification.
Dansget_kv_cache_groupsdu chemin principal, l'annotation se produit après le regroupement📎 vllm/v1/core/kv_cache_utils.py:2364-2365. Si aucun groupe n'est annoté comme groupe brouillon,_warn_if_unannotated_eagle_mambaémettra un avertissement📎 vllm/v1/core/kv_cache_utils.py:2192-2222。
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: 丢弃被拒绝的草稿 blockRéflexions de conception et pièges rencontrés
Pourquoi le groupe brouillon nécessite-t-il une annotation séparée ?Les tokens générés par le modèle brouillon peuvent être rejetés après vérification, et les KV correspondants doivent être jetés. Si les KV brouillon et les KV cible sont mélangés dans le même groupe, l'opération de rejet endommagerait par erreur les KV cible. L'annotation permet au planificateur de récupérer précisément.
La fragilité de la règle de repli par position.La règle deux dépend de la convention « la couche brouillon est enregistrée en dernier », les commentaires indiquent explicitement qu'il s'agit d'un hacky check et laissent un FIXME📎 vllm/v1/core/kv_cache_utils.py:2158-2159. Lorsque le cache de queue du brouillon s'étend sur plusieurs groupes, cette règle n'annote que le groupe détenant la dernière couche, et doit être généralisée.
Contraintes supplémentaires du modèle Mamba.Si le décodage spéculatif est activé mais qu'aucun groupe n'est identifié comme groupe brouillon, et qu'un groupe Mamba existe, un avertissement est déclenché📎 vllm/v1/core/kv_cache_utils.py:2211-2213. Cela signifie généralement que le spec de la couche brouillon ne peut pas être distingué de la couche cible, il faut vérifier l'ordre d'enregistrement du modèle.
12.3 LoRA : adaptateurs dynamiques sans rechargement du modèle de base
Modèle intuitif
LoRA, c'est comme changer de coque pour le même téléphone : le corps du téléphone (modèle de base) reste inchangé, on change la coque (adaptateur) et il devient un style différent. Sans cela, chaque tâche de fine-tuning devrait charger une copie complète des poids, ce que la mémoire GPU ne pourrait pas supporter.
Structure de données : double cache LRU et tableau de slots
LoRAModelManagerDeux caches LRU gèrent le cycle de vie des adaptateurs📎 vllm/lora/model_manager.py:115-120:
self._registered_adapters: AdapterLRUCache[LoRAModel] = AdapterLRUCache(
self.capacity, self.deactivate_adapter
)
self._active_adapters: AdapterLRUCache[None] = AdapterLRUCache(
self.lora_slots, self._deactivate_adapter
)capacityest le nombre total d'adaptateurs pouvant être mis en cache côté CPU (max_cpu_loras)📎 vllm/lora/model_manager.py:340-342,lora_slotsest le nombre d'adaptateurs pouvant être activés simultanément côté GPU (max_loras)📎 vllm/lora/model_manager.py:345-346。_registered_adapterslorsqu'il est retiré, déclenche le rappeldeactivate_adaptercallback📎 vllm/lora/model_manager.py:71-74, garantissant que lorsque le cache CPU évince une entrée, la copie sur le GPU est également nettoyée.
lora_index_to_idest un tableau de longueurlora_slotsqui mappe l'index de slot GPU vers l'id d'adaptateur📎 vllm/lora/model_manager.py:122. Ce tableau est l'index central utilisé par le punica wrapper pour le calcul LoRA par lots.
Piloté par scénario : activation d'adaptateur
Lorsqu'une requête arrive avec un adaptateur LoRA,activate_adapterest appelé📎 vllm/lora/model_manager.py:352-409:
Première étape, vérifier s'il est déjà activé, si oui retourner directement📎 vllm/lora/model_manager.py:352-354。
Deuxième étape, chercher un slot libre. Parcourirlora_index_to_idpour trouver le premierNone 📎 vllm/lora/model_manager.py:362-362. Si aucun slot libre, leverValueError("No free lora slots") 📎 vllm/lora/model_manager.py:368-368。
Troisième étape, mettre à jour l'état et parcourir tous les modules wrappés, appelermodule.set_lora(index, lora_a, lora_b)pour copier les poids dans le stacked buffer du GPU📎 vllm/lora/model_manager.py:377-401. Si un module n'a pas de poids LoRA correspondant, appelerreset_lora(index)pour remettre à zéro📎 vllm/lora/model_manager.py:378-385。
Quatrième étape, si aucun poids n'a été appliqué, imprimer un log de débogage unique📎 vllm/lora/model_manager.py:411-416. C'est le comportement attendu en pipeline parallèle ou expert parallèle — certains ranks ne détiennent pas les couches adaptées.
Wrapping de module : de nn.Linear à BaseLayerWithLoRA
_create_lora_modulesparcourt tous les modules nommés du modèle📎 vllm/lora/model_manager.py:462-606. Logique clé :
- Ignorer
PPMissingLayer📎vllm/lora/model_manager.py:473-474。 - Filtrer selon
target_modules: si non spécifié, utiliseris_supported_lora_modulepour juger, sinon utiliser_match_target_modules📎vllm/lora/model_manager.py:479-493。 - Gérer les modules alias : un même module sous-jacent peut être accédé via plusieurs chemins (par exemple le gate MoE est à la fois sur le block et dans le runner). Dans ce cas, rediriger la propriété alias vers le même wrapper, mais sans réenregistrer, sinon
activate_adapterappelleraitreset_lorasur l'alias et effacerait les poids tout juste définis📎vllm/lora/model_manager.py:512-527。 - Utiliser
from_layerpour créer le wrapper et remplacer le module original📎vllm/lora/model_manager.py:546-553。
Réflexions de conception et pièges
Un changement de disposition des slots déclenche une mise à jour du mapping. set_adapter_mappingne compare pas seulement si le mapping a changé, mais aussi le snapshot de tuple delora_index_to_id📎 vllm/lora/model_manager.py:1323-1331. La raison est clairement indiquée en commentaire : unadd_lora()hors bande peut déclencher une éviction LRU et réattribuer un slot, alors que le batch en cours d'exécution et son mapping n'ont pas changé📎 vllm/lora/model_manager.py:1323-1331. Si l'on ne regarde que le mapping, les métadonnées punica utiliseraient une disposition de slots obsolète.
Découpage EP de MoE.Lorsque le parallélisme expert est activé, le checkpoint détient les poids de tous les experts globaux, mais chaque rank ne possède quelocal_num_experts._stack_moe_lora_weightsd'abord selonglobal_num_expertsreshape, puis découper[expert_start:expert_end] 📎 vllm/lora/model_manager.py:966-977. Hors EP, le découpage est un no-op.
Timing de pin_memory.L'empaquetage des poids (commepack_moe) peut invalider l'allocation pin_memory, donc pin_memory est exécuté après la fusion de tous les poids📎 vllm/lora/model_manager.py:916-934. Le commentaire indique explicitement deux raisons : le nombre élevé de poids LoRA dans les modèles MoE rend le pin prématuré coûteux ; l'empaquetage peut invalider l'allocation📎 vllm/lora/model_manager.py:916-921。
Réflexion de conception : le point de convergence des trois
Les trois fonctionnalités convergent au niveau de la gestion du KV cache. Le cache de préfixe réutilise le KV via le block hash ; le décodage spéculatif utiliseis_eagle_grouppour marquer et distinguer le KV brouillon ; LoRA utilise_gen_lora_extra_hash_keyspour intégrer le nom de l'adaptateur dans le block hash📎 vllm/v1/core/kv_cache_utils.py:568-581, garantissant que des séquences de tokens identiques avec des adaptateurs différents ne se trompent pas de KV.
generate_block_hash_extra_keysplace la clé LoRA en tête de la liste de clés supplémentaires📎 vllm/v1/core/kv_cache_utils.py:640-642, formant avec les clés multimodales, le cache salt et les clés prompt embeds l'entrée de hachage complète. Cela garantit que même si deux requêtes ont des tokens identiques, tant que leurs adaptateurs LoRA diffèrent, leurs block hash diffèrent et les KV ne sont pas mélangés.
Résumé du chapitre
Réflexions et auto-évaluation du chapitre
Q1 : Si l'on supprime la logique de graine aléatoire du hachage non cryptographique dansinit_none_hashet qu'on utilise toujours une graine fixe, dans quels scénarios cela introduirait-il un risque de sécurité ? Pourquoi le commentaire source insiste-t-il particulièrement sur le fait que xxhash nécessite une graine secrète ?
Analyse de référence: le code source dans_NON_CRYPTO_HASH_FUNCTIONSliste explicitement xxhash et xxhash_cbor comme algorithmes non résistants aux collisions📎 vllm/v1/core/kv_cache_utils.py:125-126。resolve_none_hash_seedretourneos.urandom(32).hex() 📎 vllm/v1/core/kv_cache_utils.py:143-144pour ce type d'algorithmes. Si l'on passait à une graine fixe, un attaquant pourrait précalculer hors ligne des blocks en collision avec le préfixe cible, construire des requêtes avec le même hash mais un contenu différent, et ainsi atteindre et lire le KV cache d'autrui — c'est une fuite d'informations entre requêtes. La résistance aux collisions de SHA-256 ne dépend pas du secret de la graine, donc une graine fixe n'affecte que la reproductibilité, pas la sécurité📎 vllm/v1/core/kv_cache_utils.py:97-111。
Q2: _create_lora_moduleslors du traitement des modules alias, si l'on supprime la logique « ne pas réenregistrer » et qu'on appelle directementregister_moduleaussi sur l'alias, dansactivate_adapterque se passe-t-il ? Veuillez analyser en combinantreset_lorale chemin d'appel de
Analyse de référence:activate_adapterparcourtself.moduleset appelle pour chaque moduleset_loraoureset_lora 📎 vllm/lora/model_manager.py:377-401. Si l'alias et le nom canonique sont tous deux enregistrés, le même wrapper sous-jacent sera accédé deux fois. Sous le chemin du nom canonique,_get_lora_layer_weightspeut trouver les poids et appelerset_lorapour écrire ; sous le chemin de l'alias, en raison de la non-concordance des noms,_get_lora_layer_weightsrenvoie None, déclenchantreset_lora(index) 📎 vllm/lora/model_manager.py:378-385, ce qui remet à zéro les poids qui viennent d'être écrits. Les commentaires du code source signalent explicitement ce piège📎 vllm/lora/model_manager.py:519-523. La bonne pratique consiste à rediriger l'attribut d'alias vers le même wrapper sans réenregistrer📎 vllm/lora/model_manager.py:531-537。
Q3: BlockHashListWithBlockSizedépend de la propriété selon laquelle « le hachage du target block est égal au hachage de son dernier hash block interne ». Si la fonction de hachage n'est pas chaînée (c'est-à-dire que chaque block est haché indépendamment), cette classe peut-elle encore fonctionner correctement ? Dans quels cas produirait-elle des hits de cache erronés ?
Analyse de référence: Non._get_value_atrenvoie directementself.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851, ce qui suppose que le hachage du dernier hash block couvre déjà en chaîne tous les tokens qui le précèdent. Si les hachages sont indépendants, cette valeur n'empreinte que le contenu du dernier hash block, et non l'ensemble du target block. Deux target blocks peuvent différer dans leur première partie mais avoir le même dernier hash block, provoquant une collision de hachage,find_longest_cache_hitréutiliserait à tort un KV non correspondant. Les commentaires du code source indiquent explicitement « Each hash_block_size hash is already chained over its entire prefix »📎 vllm/v1/core/kv_cache_utils.py:2787-2792。
Le chapitre suivant se tournera vers le système de plugins et l'extensibilité, pour voir comment vLLM prend en charge des formes de déploiement diversifiées via l'abstraction de plateforme, les processeurs IO et l'extension d'endpoints.
Ce chapitre analyse les mécanismes sous-jacents des trois principales fonctionnalités avancées d'inférence de vLLM. Le cœur du cache de préfixe est le hachage de block chaîné : hash_block_tokens hache ensemble le hachage parent, le tuple de tokens et les clés supplémentaires, et la stratégie de graine NONE_HASH arbitre entre partage inter-processus et sécurité contre les collisions. Le décodage spéculatif distingue les groupes KV de brouillon via l'annotation is_eagle_group. LoRA gère le cycle de vie des adaptateurs via un double cache LRU et un tableau de slots, et intègre le nom de l'adaptateur dans le block hash pour isoler le cache. Ces fonctionnalités illustrent ensemble la profondeur et la flexibilité de vLLM en matière d'optimisation d'inférence. Ensuite, nous nous tournerons vers le système de plugins et l'extensibilité de vLLM, pour voir comment les plugins de plateforme s'adaptent aux nouveaux matériels, comment les plugins IO processor interviennent dans le traitement des entrées multimodales, et comment les plugins d'endpoints injectent des routes API personnalisées. Comprendre l'ordre de chargement de l'enregistrement et de la découverte des plugins révélera comment étendre les capacités de vLLM sans modifier le code cœur.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 13 : Système de plugins et extensibilité : plateformes, processeurs IO et extensions d'endpoints
Dans le chapitre précédent, nous avons vu que des fonctionnalités avancées telles que le cache de préfixe, le décodage spéculatif et LoRA sont profondément couplées au cœur du planificateur, de la gestion KV et de l'exécution du modèle. Mais pour qu'un moteur d'inférence atteigne véritablement la production, la performance seule ne suffit pas — il doit répondre à une question plus épineuse : lorsque la communauté souhaite intégrer un nouveau matériel, un nouveau format d'entrée multimodal ou une route HTTP personnalisée, comment y parvenir sans forker le code cœur ? C'est précisément la raison d'être du système de plugins. L'architecture de vLLM est naturellement multi-processus : le processus frontal API Server, le processus EngineCore, et le processus Worker correspondant à chaque rang TP/PP. Si le mécanisme de plugins se contentait simplement d'« exécuter du code à l'import », il s'exécuterait soit de manière répétée dans chaque processus, entraînant une accumulation d'effets de bord, soit uniquement dans le processus principal, empêchant les Workers d'obtenir l'extension. Ce que ce chapitre décompose, c'est la manière dont vLLM utilise le mécanisme standard Python entry_points, combiné à la triple contrainte groupe + frontière de processus + moment de chargement, pour construire un système de plugins capable à la fois de couvrir tous les processus et de contrôler précisément la surface exposée. Nous nous concentrons sur trois axes principaux : les plugins de plateforme (adaptation au nouveau matériel), les plugins IO processor (intervention dans le traitement des entrées multimodales), et les plugins d'endpoints (injection de routes API personnalisées). Les stratégies de chargement de ces trois éléments sont radicalement différentes ; comprendre cette différence, c'est comprendre la philosophie d'arbitrage de vLLM entre « capacité d'extension » et « frontière de sécurité ».
I. Découverte et chargement des plugins : le contrat de regroupement des entry_points
Modèle intuitif : les « canaux de diffusion » des plugins
Imaginez le système de plugins de vLLM comme un ensemble de canaux de diffusion. Chaque package de plugin, lors de l'installation, viasetup.pydeentry_points« enregistre » auprès d'un canal son indicatif d'appel (plugin name) et sa fonction de réponse (plugin value). vLLM scanne ces canaux au démarrage et décide quels canaux sont « écoutés » dans quels processus.
Sans ce mécanisme, étendre vLLM ne pourrait se faire qu'en modifiant le code source — chaque ajout de matériel par la communauté nécessiterait la maintenance d'un fork, aboutissant à une fragmentation des versions. La valeur du mécanisme de regroupement réside dans :Un même paquet de plugin peut être enregistré uniquement dans un canal spécifique, ce qui le limite à un chargement dans un processus particulier。
Structure de données : cinq constantes de groupe et un indicateur global
vLLM dansvllm/plugins/__init__.pydéfinit en tête cinq constantes de groupe entry point, chaque constante correspondant à une stratégie de chargement :
📎 vllm/plugins/__init__.py:16-30
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"Les commentaires contiennent des informations clés :DEFAULT_PLUGINS_GROUPdansTous les processuschargement (process0, engine core, worker) ;IO_PROCESSOR_PLUGINS_GROUP uniquement dans process0;PLATFORM_PLUGINS_GROUPchargé dans tous les processus, mais le déclenchement se fait lors ducurrent_platformpremier accès ;STAT_LOGGER_PLUGINS_GROUPuniquement dans process0 et en mode asynchrone ;ENDPOINT_PLUGINS_GROUPuniquement dans le processus frontal API Server.
Immédiatement après se trouve une variable globale au niveau du moduleplugins_loaded = False 📎 vllm/plugins/__init__.py:32-33, qui est un garde pour le chargement idempotent — le commentaire indique explicitement « make sure one process only loads plugins once ».
Step-by-Step : unload_plugins_by_groupflux d'appel complet
Mise en situation : l'utilisateur danssetup.pya enregistrévllm.general_pluginssousregister_dummy_model, maintenant vLLM démarre, un processus appelleload_general_plugins()。
Première étape : garde d'idempotence. load_general_pluginsvérifie d'abordplugins_loaded, si déjàTrueretourne directement📎 vllm/plugins/__init__.py:77-90. Notez une subtilité ici : le garde est positionnéavantle chargement, ce qui signifie que même si le chargement ultérieur lève une exception, il n'y aura pas de nouvelle tentative. C'est intentionnel — un échec de chargement de plugin ne doit pas entraîner des tentatives répétées du processus.
Deuxième étape : découverte.entre dansload_plugins_by_group, viaimportlib.metadata.entry_points(group=group)obtient tous les entry points installés sous ce groupe📎 vllm/plugins/__init__.py:36-45. Si vide, enregistre un log debug puis retourne un dictionnaire vide.
Troisième étape : gradation des logs.Le code source distingue le niveau de log entre les groupes par défaut et non par défaut :is_default_groupsi vrai, utiliselogger.debug, sinon utiliselogger.info 📎 vllm/plugins/__init__.py:47-54. La motivation est pratique —vllm.general_pluginssous
se trouvent généralement de nombreux plugins d'enregistrement de modèles, utiliser INFO saturerait les logs ; tandis que les plugins de plateforme/endpoint sont peu nombreux et importants, méritant d'être visibles en INFO.Quatrième étape : filtrage par liste blanche.envs.VLLM_PLUGINSlitNone, si📎 vllm/plugins/__init__.py:62-70alors charge tout, sinon ne charge que les plugins dont le nom est dans la listeplugin.load(). Notez que📎 vllm/plugins/__init__.py:68-72。
est enveloppé dans un try/except, l'échec de chargement d'un seul plugin n'enregistre qu'un log exception, sans affecter les autres pluginsCinquième étape : exécution.load_general_pluginsretourne àfunc() 📎 vllm/plugins/__init__.py:77-90, pour chaque fonction chargée appelle directement. C'est pourquoi la documentation insiste sur le fait que les fonctions de plugin doivent êtreréentrantes (re-entrant)
— elles peuvent être appelées plusieurs fois dans plusieurs processus.load_plugins_by_groupLe diagramme de flux ci-dessous décrit
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 字典"]Réflexion de conception : pourquoi utiliser entry_points plutôt qu'un fichier de configuration
Choisirentry_pointsplutôt qu'un fichier de configuration personnalisé, la motivation principale estde distribuer les plugins avec le paquet Python. Après que l'utilisateurpip install vllm-add-dummy-platform, le plugin apparaît automatiquement dans le groupe correspondant, sans édition manuelle de la configuration de vLLM. Cela s'inscrit dans la lignée de l'écosystème de plugins d'outils comme pytest, flake8. Le coût est que la découverte de plugins dépend des métadonnées du paquet ; si le paquet de plugin est installé de manière incomplète (par exemple, seul le répertoire source a été copié sans passer par pip), entry_points ne le détectera pas.
---
II. Plugins de plateforme : couche d'abstraction pour l'adaptation matérielle
Modèle intuitif : la plateforme est un « traducteur de dialectes matériels »
PlatformLa classeest leseul traducteurcurrent_platform.get_attn_backend_cls()、current_platform.is_cuda_alike()de tout vLLM dialoguant avec le matériel. Le code du modèle n'appelle queimport torch.cudades méthodes abstraites commeif device == "xpu", jamais directement
. Sans cette couche d'abstraction, chaque nouveau matériel supporté nécessiterait d'ajouter des branches
Platformdans le code du modèle, aboutissant à des spaghettis.vllm/platforms/interface.pyStructure de données : disposition des champs de la classe de base Platform📎 vllm/platforms/interface.py:135-179:
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_enumCopierPlatformEnumestis_cuda()、is_rocm()une valeur d'énumération, déterminant📎 vllm/platforms/interface.py:69-78。device_control_env_varetc. pour jugerCUDA_VISIBLE_DEVICESest une abstraction « variable d'environnement de visibilité des dispositifs » indépendante de la plateforme — CUDA est📎 vllm/platforms/interface.py:151-152。_global_graph_pool, les autres plateformes définissent chacuneget_global_graph_poolest un cache de pool de mémoire CUDA graph au niveau de la classe, initialisé paresseusement via📎 vllm/platforms/interface.py:1210-1215。
Il est à noter que__getattr__la logique de repli de📎 vllm/platforms/interface.py:1189-1208: lors de l'accès à un attribut inexistant sur Platform, il tente de transférer depuis l'espace de nomstorch.<device_type>. Cela permet au code de plateforme d'écrirecurrent_platform.memory_allocated()tout en appelant réellementtorch.cuda.memory_allocated(). Mais le code source exclut délibérément les méthodes dunder — sinon la vérification pickle__getstate__obtiendraitNoneet tenterait de l'appeler📎 vllm/platforms/interface.py:1182-1185。
Step-by-Step : conversion à trois espaces de noms des ID de dispositif
Le piège le plus courant dans l'abstraction de plateforme estl'espace de noms des ID de dispositif. Les commentaires du code source listent explicitement trois📎 vllm/platforms/interface.py:275-283:
- logical: le local rank interne à vLLM, indexant
_assigned_physical_gpu_ids - visible: le numéro torch/CUDA du processus actuel après remappage par
CUDA_VISIBLE_DEVICES: l'ID GPU global utilisé par les API de topologie comme NVML, non affecté par les variables d'environnement - physicalMise en situation : un processus Worker s'est vu attribuer le GPU physique
, variable d'environnement[4, 5], maintenant il faut convertir local rank 0 enCUDA_VISIBLE_DEVICES=4,5Première étape : logical → physical.torch.device("cuda:0")。
vérifie d'abord device_id_to_physical_device_id(0), si déjà défini, retourne directement l'index_assigned_physical_gpu_ids. Si non défini, prend le premier élément de la liste séparée par des virgules de4 📎 vllm/platforms/interface.py:296-297. Notez que le code source traite délibérémentdevice_control_env_varla chaîne vide📎 vllm/platforms/interface.py:305-311comme non définie — c'est une configuration légitime lorsque Ray démarre le moteur sur un placement group purement CPU空字符串当作未设置处理——这是 Ray 在纯 CPU placement group 上启动引擎时的合法配置 📎 vllm/platforms/interface.py:296-297。
Deuxième étape : physical → visible. logical_device_id_to_visible_device_id(0)Après avoir obtenu physical4, on décompose les variables d'environnement en[4, 5], on trouve4l'index de0et on retourne📎 vllm/platforms/interface.py:316-339. Si le physical ID n'est pas dans la liste visible, on lèveRuntimeError— c'est une protection stricte contre l'utilisation abusive d'un appareil invisible entre processus.
set_assigned_physical_gpu_idsLa conception idempotente deRuntimeError 📎 vllm/platforms/interface.py:38-56mérite également attention : répéter la même valeur est une opération nulle, tandis que définir une valeur différente lève
. Cela empêche l'écrasement accidentel du mapping d'appareils dans un environnement multithread.
Enregistrement et injection de configuration des plugins de plateformevllm.platform_pluginsLes plugins de plateforme sont enregistrés via le groupeNone, la fonction du plugin retourne le nom qualifié complet de la classe de plateforme (ou📎 docs/design/plugin_system.md:50-50pour indiquer que l'environnement actuel n'est pas pris en charge)📎 docs/design/plugin_system.md:100-100:
_enum. L'implémentation minimale donnée dans la documentation exigePlatformEnum.OOT(out-of-tree)device_typeest généralement défini àcheck_and_update_configretourne la chaîne de type d'appareil reconnue par PyTorchest appelé au début de l'initialisation de vLLM,worker_clsget_attn_backend_clsil faut définir iciget_device_communicator_clsretourne le nom de classe du backend d'attention
check_and_update_configretourne le nom de classe du communicateur📎 vllm/platforms/interface.py:583-592est le hook le plus critique du plugin de plateformeVllmConfig. Il reçoit📎 docs/design/plugin_system.md:105-105par référence et le modifie sur place, permettant d'ajuster le block size, le graph mode, etc. La documentation souligne que « le plus important est que worker_cls doit être défini ici »
— car vLLM doit savoir quelle classe Worker utiliser pour instancier le processus de travail.
Réflexion de conception : stratégie en trois phases pour l'alignement du block sizeupdate_block_size_for_backend 📎 vllm/platforms/interface.py:666-708La logique la plus complexe de l'interface de plateforme est
Phase 1. Elle se divise en trois phases pour garantir la compatibilité du block size avec le backend d'attention :--block-size: si l'utilisateur n'a pas explicitement spécifié_preferred_block_size_for_backends, appeler📎 vllm/platforms/interface.py:687-697pour sélectionner le plus petit block size pris en charge par tous les backends📎 vllm/platforms/interface.py:622-663。
Phase 2. Cette fonction énumère les valeurs candidates par LCM (plus petit commun multiple), car certains backends (comme CPU_MLA) n'acceptent que des tailles exactes et non des multiples📎 vllm/platforms/interface.py:699-702。
Phase 3: les modèles hybrides (attention + mamba) doivent aligner le block et le mamba page size📎 vllm/platforms/interface.py:704-708。
〔Inférence de conception et compromis architecturaux〕
---
Cette conception par phases reflète la réalité à laquelle vLLM fait face : différents matériels, différents schémas de quantification et différentes architectures de modèles imposent des contraintes de block size mutuellement conflictuelles, impossibles à résoudre par une formule unique. La division en phases permet de traiter chaque contrainte indépendamment, puis de prendre la solution satisfaisant toutes les contraintes.
III. IO Processor et plugins d'endpoint : traitement des entrées et extension de l'API
Modèle intuitif : IO Processor est une « couche de traduction multimodale »
L'entrée d'un modèle multimodal (comme LLaVA) n'est pas du texte pur, mais un mélange de texte + images. Le plugin IO Processor est chargé de convertir les données multimodales brutes en tenseurs que le modèle peut consommer, puis de reconvertir la sortie du modèle en format lisible par l'humain. Il agit comme un traducteur des douanes : la langue étrangère entrante (image/audio) est traduite vers la langue maternelle du modèle, et la langue maternelle du modèle sortante est retraduite vers la langue étrangère.
Étape par étape : découverte et instanciation de l'IO Processorio_processor_pluginMise en situation : charger un modèle avec un HF config contenant le champ
. get_io_processorPremière étape : déterminer le nom du plugin.plugin_from_initOn utilise en priorité lehf_configexplicitement passé, sinon on lit le champio_processor_pluginde📎 vllm/plugins/io_processors/__init__.py:42-50. Si les deux sont vides, on retourneNone— cela signifie que le modèle n'a pas besoin d'IO processor📎 vllm/plugins/io_processors/__init__.py:52-54。
Deuxième étape : charger tous les plugins installés.On appelleload_plugins_by_group(IO_PROCESSOR_PLUGINS_GROUP)pour obtenir tous les plugins de ce groupe📎 vllm/plugins/io_processors/__init__.py:59-61。
Troisième étape : construire le mapping chargeable.On parcourt chaque plugin, on appelle sa fonction pour obtenirprocessor_cls_qualname, si ce n'est pasNoneon l'enregistre dansloadable_plugins 📎 vllm/plugins/io_processors/__init__.py:66-76. Noter que l'appel de fonction de chaque plugin est également entouré d'un try/except, un échec individuel n'affectant pas les autres.
Quatrième étape : validation et instanciation.Si le nombre de plugins chargeables est 0, on lèveValueErroren indiquant « un plugin IOProcessor est requis mais aucun n'est installé »📎 vllm/plugins/io_processors/__init__.py:66-76. Si le nom de plugin requis par le modèle n'est pas dans la liste chargeable, on lèveValueErroret on liste tous les noms de plugins disponibles📎 vllm/plugins/io_processors/__init__.py:80-81. Enfin, viaresolve_obj_by_qualnameon résout le nom de classe et on l'instancie📎 vllm/plugins/io_processors/__init__.py:80-81。
Plugins d'endpoint : posture de sécurité par refus par défaut
Les plugins d'endpoint sont la catégorie la plus particulière de ce chapitre, car ilsne sont pas chargés par défaut。load_endpoint_pluginsLa docstring deload_plugins_by_groupexplique clairement la raison : les plugins d'endpoint ajoutent des routes HTTP au API Server, élargissant la surface d'exposition réseau, d'où une posture de « refus par défaut » plus stricte que📎 vllm/plugins/__init__.py:93-94。
La règle concrète est : un plugin n'est chargé que si son nomapparaît explicitement dansVLLM_PLUGINS,et que sonrequired_tasksestNoneou a une intersection avec les tasks supportées par le serveur📎 vllm/plugins/__init__.py:108-108。
Mise en situation : l'utilisateur a installé un plugin d'endpoint mais a oublié de définirVLLM_PLUGINS。
Première étape : vérifier si VLLM_PLUGINS n'est pas défini.Sienvs.VLLM_PLUGINS is None, on découvre d'abord les plugins de ce groupe, et s'il y en a, on enregistre un warning indiquant « allowlist explicite requise »📎 vllm/plugins/__init__.py:126-126. Noter que le commentaire du code source précise particulièrement :VLLM_PLUGINS=""est interprété comme[""]et nonNone, donc considéré comme une « allowlist ne correspondant à aucun plugin », et non comme « non défini »📎 vllm/plugins/__init__.py:108-108. Cette distinction de cas limite est importante — une chaîne vide est un « ne rien charger » explicite, tandis queNoneest « non configuré ».
Deuxième étape : charger et instancier.Après avoir obtenu la fonction factory viaload_plugins_by_group, on appelle une par unefactory()pour instancier📎 vllm/plugins/__init__.py:133-141. Un échec d'instanciation enregistre une exception et continue.
Troisième étape : contrôle par task.On vérifieplugin.required_tasks, si ce n'est pasNoneet qu'il y a une intersection avecsupported_tasksAucune intersection, ignorer ce plugin📎 vllm/plugins/__init__.py:144-145. Cela permet à un même package de plugin d'enregistrer différents points de terminaison pour différentes tâches (comme embedding vs generation).
Le diagramme de séquence ci-dessous décrit l'interaction complète du plugin de point de terminaison, de la découverte au chargement :
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 列表
endRéflexion de conception : la frontière de processus détermine la stratégie de chargement
La différence entre les stratégies de chargement des trois types de plugins est essentiellement unefrontière de processus:
| Type de plugin | Processus de chargement | Comportement par défaut | Motivation |
|---|---|---|---|
| general | Tous les processus | Chargement complet | L'enregistrement du modèle doit être visible dans chaque Worker |
| platform | Tous les processus | Chargement complet | L'abstraction matérielle est dépendante de tous les processus |
| io_processor | Uniquement process0 | Chargement complet | Le traitement d'entrée ne se produit qu'au niveau du frontend |
| stat_logger | Uniquement process0 (asynchrone) | Chargement complet | Les journaux ne sont collectés que dans le processus principal |
| endpoint | Uniquement API Server | Refus par défaut | Élargit la surface d'exposition réseau, nécessite une autorisation explicite |
Le « refus par défaut » du plugin de point de terminaison est une pratique standard en ingénierie de sécurité : toute extension élargissant la surface d'attaque doit être opt-in. Les autres plugins sont chargés par défaut car ils n'exposent pas directement d'interfaces réseau, et l'écosystème communautaire a besoin d'une expérience d'intégration à faible friction.
Piège en production : dégradation silencieuse en cas d'échec de chargement d'un plugin
load_plugins_by_groupPour chaque plugin,plugin.load()est enveloppé dans un try/except, en cas d'échec seule une exception est journalisée📎 vllm/plugins/__init__.py:68-72. Cela signifie queun plugin défectueux n'empêchera pas vLLM de démarrer, mais ne donnera pas non plus d'erreur explicite — l'utilisateur peut être perplexe quant à « pourquoi mon plugin ne fonctionne pas ».
Conseil de dépannage : passez le niveau de journalisation à DEBUG, recherchez"Failed to load plugin". Si le plugin est dans le groupevllm.general_plugins, le niveau de journalisation par défaut est DEBUG, il faut l'activer explicitement pour voir les détails du chargement📎 vllm/plugins/__init__.py:49-50。
Un autre piège est le moment de positionnement du gardeplugins_loaded📎 vllm/plugins/__init__.py:77-90: il est positionné avant le chargementTrue. Si le premier chargement échoue pour une raison quelconque (comme une anomalie de scan des entry_points), les appels suivants retourneront directement sans réessayer. Cela peut provoquer dans les environnements de test le phénomène étrange de « plugins qui fonctionnent par intermittence ».
---
Résumé de ce chapitre
Le système de plugins de vLLM est construit sur Pythonentry_points, viacinq constantes de groupepour diviser les types d'extension, viala frontière de processuspour déterminer la portée de chargement, viaVLLM_PLUGINSune liste blanchepour contrôler l'ensemble de chargement. Les plugins de plateforme utilisent la classe de basePlatformpour abstraire les différences matérielles, leur conversion à trois espaces de noms d'ID de périphérique (logical/visible/physical) est au cœur de la gestion des périphériques inter-processus ; les plugins IO processor sont déclenchés via le champio_processor_pluginde la config HF, responsables de la traduction des entrées multimodales ; les plugins de point de terminaison adoptent une posture de « refus par défaut », n'étant chargés que lorsqu'ils figurent explicitement dans l'allowlist et que la tâche correspond, afin de contrôler la surface d'exposition réseau.
Les trois lignes directrices partagent le même mécanisme de découverte, mais les différences de stratégie de chargement reflètent le compromis de vLLM entre « facilité d'extension » et « frontière de sécurité » : les plugins n'exposant pas le réseau sont chargés par défaut, ceux exposant le réseau doivent être opt-in.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on retire le try/except deload_plugins_by_groupdansplugin.load(), laissant l'échec de chargement se propager directement, quel impact cela aurait-il sur le démarrage multiprocessus de vLLM ? Dans quels scénarios cela serait-il au contraire une meilleure conception ?
Analyse de référence: L'implémentation actuelle📎 vllm/plugins/__init__.py:68-72fait que l'échec de chargement d'un plugin individuel est silencieusement absorbé, seule une exception est journalisée. Si l'on retire le try/except, l'échec de chargement se propagera vers le haut jusqu'àload_general_plugins, interrompant ainsi le démarrage du processus. Dans un scénario multiprocessus, cela entraînerait : si le chargement d'un plugin dans un processus Worker échoue, tout le moteur ne peut pas démarrer — cela peut être une bonne chose (échec rapide, évitant qu'un processus partiellement défaillant provoque une incohérence d'état), ou une mauvaise chose (un bug dans un plugin optionnel fait s'effondrer tout le service). Une meilleure conception pourrait introduire une variable d'environnementVLLM_PLUGINS_STRICT: par défaut permissif (comportement actuel), en mode strict l'échec de chargement lève une exception. Ainsi, l'environnement de production peut exiger que « tous les plugins déclarés soient chargés avec succès », tandis que l'environnement de développement reste tolérant.
Q2: load_endpoint_pluginsDansVLLM_PLUGINS="", quelle est la différence de comportement entreVLLM_PLUGINSetNonenon défini (
〔Inférence de conception et compromis architecturaux〕Analyse de référenceVLLM_PLUGINS="": Les commentaires du code source indiquent explicitement que[""]est interprété commeNoneet non📎 vllm/plugins/__init__.py:108-108, donc considéré comme une « allowlist ne correspondant à aucun plugin »VLLM_PLUGINS is None. Lorsqueload_endpoint_plugins,[]retourne directement📎 vllm/plugins/__init__.py:126-126et journalise un warningVLLM_PLUGINS=""; tandis que lorsqueload_plugins_by_group, le code continue jusqu'à, mais comme la chaîne vide ne correspond à aucun nom de plugin, il retourne finalement une liste vide. Les deux ont unrésultat identique(aucun plugin de point de terminaison n'est chargé), mais une:Nonesémantique différente"":
Q3: device_id_to_physical_device_idsignifie « l'utilisateur n'a pas configuré, nous refusons activement et avertissons »,device_control_env_varsignifie « l'utilisateur a explicitement configuré une allowlist vide, nous respectons son intention sans avertir ». Cette distinction permet aux opérations de « désactiver silencieusement tous les plugins de point de terminaison » en définissant une chaîne vide, sans avoir à subir le bruit des warnings à chaque démarrage.📎 vllm/platforms/interface.py:302-308Dans
, pourquoi le code source traite-t-il unvide comme non défini📎 vllm/platforms/interface.py:296-297? Si l'on retire cette vérification de chaîne vide, que se passerait-il dans le scénario de placement group CPU-only de Ray ?!= ""Analyse de référencedevice_ids = "".split(","): Les commentaires du code source expliquent qu'une variable d'environnement vide est une configuration légitime lorsque Ray démarre un placement group CPU-only sur un nœud GPU[""]. Si l'on retire la vérificationdevice_ids[device_id], le code entrerait dans la brancheint(""), obtiendraitValueError. Cela entraîne l'échec du démarrage du moteur avec une configuration Ray légitime. Après avoir conservé la vérification, une variable d'environnement vide emprunte laelsebranche et retourne directementdevice_id, c'est-à-dire en supposant que l'ID logique est égal à l'ID physique — ce qui est sûr dans un scénario CPU-only, puisqu'aucun GPU n'a besoin d'être mappé. Ce cas illustre que « non défini » et « défini comme vide » ont des sémantiques différentes dans les systèmes d'orchestration distribués, et que le code doit les traiter explicitement.
---
Le chapitre suivant se tournera vers les compromis architecturaux, les pièges en production et l'évolution future ; nous rassemblerons les mécanismes décomposés dans les treize chapitres précédents pour examiner les arbitrages de vLLM entre performance, maintenabilité et extensibilité, et nous envisagerons les directions d'évolution des moteurs d'inférence.
À ce stade, nous avons clairement vu comment vLLM, grâce au mécanisme de regroupement des entry_points, au moment de chargement sensible aux frontières de processus, ainsi qu'aux stratégies différenciées des trois types de plugins — plateforme, IO processor et endpoint —, ouvre des surfaces d'extension tout en maintenant la stabilité du code cœur. Ce système de plugins permet à de nouveaux matériels, de nouveaux formats d'entrée et de nouvelles routes API de s'intégrer de manière non intrusive, mais l'extensibilité elle-même implique davantage de dimensions à arbitrer. Le chapitre suivant clôturera l'ouvrage en organisant systématiquement les tensions dans les décisions de conception clés de vLLM — continuous batching et fragmentation de la mémoire GPU, CUDA Graph et formes dynamiques, déploiement disaggregated et surcoût réseau — et fournira une liste de pièges en production ainsi qu'un parcours de diagnostic, tout en esquissant les tendances d'évolution vers un frontend Rust, une couche IR et le matériel hétérogène.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Chapitre 14 : Compromis architecturaux, pièges en production et évolution future
Dans le chapitre précédent, nous avons décomposé le mécanisme d'extension par plugins de vLLM, en voyant comment les plugins de plateforme, les plugins IO processor et les plugins endpoint permettent au moteur de s'adapter à de nouveaux matériels, de nouveaux modalités et de nouvelles API sans modifier le code cœur. Cette extensibilité permet à vLLM d'embrasser rapidement le changement, mais plus les points d'extension sont nombreux, plus les chemins d'interaction en production deviennent complexes. Lorsque la fragmentation de la mémoire GPU, les échecs de handshake NCCL, l'invalidation du cache de compilation et la gigue réseau surviennent simultanément, les mécanismes présentés dans les treize chapitres précédents s'entrechoquent et révèlent des tensions invisibles en environnement idéal. Ce chapitre n'introduit pas de nouveau mécanisme cœur, mais rassemble ces mécanismes, en s'appuyant sur la documentation officielle de troubleshooting comme point d'ancrage, et en combinant la conception de l'outil de bench du frontend Rust, pour examiner les arbitrages entre performance et opérabilité, et fournir un parcours de diagnostic actionnable.
I. Niveaux d'optimisation : un contrat explicite entre temps de démarrage et performance d'exécution
Modèle intuitif
Les niveaux d'optimisation ressemblent aux « modes de scène » d'un appareil photo : le mode automatique (-O2) convient à la plupart des situations, mais lorsque vous devez capturer rapidement (débogage), passer en mode manuel (-O0) permet une réponse immédiate, au prix d'une baisse de qualité d'image (performance). vLLM fait de cet arbitrage un contrat explicite à quatre niveaux, plutôt que de le cacher dans des dizaines de flags booléens que l'utilisateur doit assembler lui-même.
Disposition des champs des quatre niveaux
vLLM fournit-O0à-O3quatre niveaux📎 docs/design/optimization_levels.md:5-5. Le principe de conception fondamental est :les flags explicitement définis par l'utilisateur ont priorité sur les valeurs par défaut du niveau d'optimisation 📎 docs/design/optimization_levels.md:5-5. Cela signifie que le niveau d'optimisation n'est qu'un ensemble de valeurs par défaut, et non une contrainte stricte.
-O0désactive tout : pas d'autotuning, pas de compilation, pas de cudagraph📎 docs/design/optimization_levels.md:32-33. Concrètement, cela se traduit par quatre interrupteurs :cudagraph_mode=NONE、mode=NONE, toutes les fusions désactivées,enable_flashinfer_autotune=False 📎 docs/design/optimization_levels.md:37-40。
-O1est le point d'équilibre pour les scénarios de développement : activation dePIECEWISEcudagraph et du modeVLLM_COMPILE. Notez un détail subtil :📎 docs/design/optimization_levels.md:50-51etfuse_norm_quantne sont activés que lorsqu'un des opérateurs utilise un kernel personnalisé, sinon l'auto-fusion d'Inductor donne de meilleurs résultatsfuse_act_quant. C'est un jugement de conception typique de « ne pas empiéter sur le travail du compilateur ».📎 docs/design/optimization_levels.md:61est la valeur par défaut, orientée production
-O2. Elle ajoute à📎 docs/design/optimization_levels.md:66-67les-O1cudagraph etFULL_AND_PIECEWISEactuellement équivalent àfuse_allreduce_rms 📎 docs/design/optimization_levels.md:72-73。-O3, réservant-O2pour des optimisations expérimentales plus agressives à l'avenir📎 docs/design/optimization_levels.md:80-81。
Processus de sélection piloté par le scénario
Lorsqu'un utilisateur exécutevllm serve model -O1, que se passe-t-il en interne ? Le diagramme de flux ci-dessous montre comment le niveau d'optimisation interagit avec les flags utilisateur :
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 --> doneLa clé de ce processus réside dans la branchecheck_user: un paramètre explicitement défini par l'utilisateur a toujours priorité📎 docs/design/optimization_levels.md:5-5. Cela évite des problèmes difficiles à diagnostiquer tels que « le niveau d'optimisation a silencieusement écrasé mon flag de débogage ».
Réflexions de conception et pièges
Le piège de production le plus courant lié aux niveaux d'optimisation estun temps de démarrage trop long. La documentation recommande explicitement : en cas de temps de démarrage trop long, utiliser-O0ou-O1 📎 docs/design/optimization_levels.md:87. Mais il y a un coût caché —-O0sans cudagraph, le surcoût de lancement CPU de chaque kernel devient visible, et le débit peut chuter de plusieurs facteurs dans des scénarios à forte concurrence.
Un autre piège estles erreurs de compilation。-O2: lesFULL_AND_PIECEWISEcudagraph de-O2imposent des hypothèses plus fortes sur la structure du modèle ; certains modèles personnalisés échouent à la compilation sous-O1mais fonctionnent normalement sousdebug_dump_path. La documentation recommande d'utiliser📎 docs/design/optimization_levels.md:88pour obtenir plus d'informations de débogage-O0. Le parcours de diagnostic devrait être : d'abord utiliser-O1、-O2pour confirmer la correction fonctionnelle, puis monter progressivement jusqu'à
〔Inférence de conception et compromis architecturaux〕--enforce-eagerC'est la même méthodologie : d'abord confirmer la justesse avec la configuration la plus conservatrice, puis activer progressivement les optimisations, en isolant le problème sur la plus petite différence de configuration.
---
II. Liste des pièges en production : chemin de diagnostic du symptôme à la cause racine
Modèle intuitif
Le dépannage en production ressemble au triage aux urgences : vous ne pouvez pas faire passer une batterie complète d'examens à tous les patients, vous devez d'abord réduire rapidement le périmètre selon les symptômes (OOM, hang, crash), puis approfondir de manière ciblée. La documentation de troubleshooting de vLLM est essentiellement un manuel de triage.
Classification des symptômes et outils de diagnostic
La documentation classe les problèmes courants en plusieurs grandes catégories ; nous les présentons par difficulté de diagnostic croissante.
Première catégorie : blocage lors du téléchargement/chargement du modèle.Le symptôme est une absence de réponse prolongée après le démarrage. La cause racine est généralement un réseau lent ou un système de fichiers partagé lent📎 docs/usage/troubleshooting.md:11-11. Le moyen de diagnostic est--load-format dummyde sauter le chargement des poids, pour isoler s'il s'agit d'une lenteur de téléchargement ou de chargement📎 docs/usage/troubleshooting.md:23-23. C'est une technique typique d'« isolation par dichotomie ».
Deuxième catégorie : OOM de mémoire GPU.La documentation pointe directement vers la documentation de configuration conserving_memory📎 docs/usage/troubleshooting.md:23. Mais en production, l'OOM n'est souvent pas dû à un modèle trop grand, mais à la fragmentation du KV cache ou à un nombre de requêtes concurrentes supérieur aux attentes.
Troisième catégorie : changement de la qualité de génération.C'est un piège facile à négliger. La v0.8.0 a changé la source des paramètres d'échantillonnage par défaut : des valeurs neutres par défaut de vLLM vers celles de l'auteur du modèlegeneration_config.json 📎 docs/usage/troubleshooting.md:23-23. Dans la plupart des cas, cela améliore la qualité, mais pour certains modèles, la configuration est au contraire moins bonne📎 docs/usage/troubleshooting.md:23-23. La méthode de diagnostic consiste à revenir à--generation-config vllmpour comparer📎 docs/usage/troubleshooting.md:23-23。
Quatrième catégorie : blocage (hang).C'est la catégorie la plus difficile à diagnostiquer. La documentation fournit un ensemble progressif de variables d'environnement de débogage📎 docs/usage/troubleshooting.md:41-41:
VLLM_LOGGING_LEVEL=DEBUG: activer les logs détaillésVLLM_LOG_STATS_INTERVAL=1.: sortie à haute fréquence de l'état de la file d'attente et des hits de cacheCUDA_LAUNCH_BLOCKING=1: localiser quel CUDA kernel pose problèmeNCCL_DEBUG=TRACE: activer les logs détaillés NCCLVLLM_TRACE_FUNCTION=1: enregistrer tous les appels de fonction, mais ralentit de plus de 100 fois📎docs/usage/troubleshooting.md:41
Il y a ici une discipline d'exploitation importante : après le débogage, il faut impérativement désactiver ces variables d'environnement, ou ouvrir directement un nouveau shell, sinon la configuration de débogage résiduelle continuera de ralentir le système📎 docs/usage/troubleshooting.md:11-11。
Le piège des frontières de processus dans le débogage par points d'arrêt
L'architecture multiprocessus de vLLM rend les points d'arrêt classiquespdbinefficaces — si un point d'arrêt s'exécute dans un sous-processus, il lèveBdbQuit 📎 docs/usage/troubleshooting.md:45-54. Deux solutions : utiliserforked-pdb 📎 docs/usage/troubleshooting.md:57-61, ou définirVLLM_ENABLE_V1_MULTIPROCESSING=0pour garder le scheduler dans le même processus📎 docs/usage/troubleshooting.md:63-68。
La seconde méthode est certes pratique, mais elle modifie le modèle d'exécution — en mode monoprocessus, EngineCore et API Server ne communiquent plus via des files d'attente, et certains bugs de concurrence peuvent ne plus être reproductibles. Elle convient donc pour localiser des erreurs logiques, mais pas pour reproduire des problèmes de concurrence.
Diagnostic de la communication distribuée
Le déploiement distribué dispose d'une documentation de diagnostic dédiée. La recommandation centrale est :définir les variables d'environnement lors de la création du cluster, car les variables se propagent à tous les nœuds ; tandis que les définir dans le shell n'affecte que le nœud local📎 docs/serving/distributed_troubleshooting.md:16-16。
Un problème fréquent estNo available node types can fulfill resource request, qui apparaît même si le cluster dispose de suffisamment de GPU📎 docs/serving/distributed_troubleshooting.md:16-16. La cause racine est généralement que le nœud possède plusieurs IP et que vLLM en choisit une mauvaise. La solution est d'utiliserVLLM_HOST_IPpour spécifier explicitement, etray statuspour vérifier📎 docs/serving/distributed_troubleshooting.md:16-16。
Script de diagnostic en cas d'échec d'initialisation NCCL
La documentation fournit un script de diagnostic complet, qui valide la pile de communication couche par couche📎 docs/usage/troubleshooting.md:89-150. Sa conception est très hiérarchisée :
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 成功"]La subtilité de ce script réside dans son isolation couche par couche : il valide d'abord le NCCL PyTorch le plus bas niveau, puis le GLOO côté CPU, puis l'encapsulation PyNcclCommunicator propre à vLLM, et enfin la communication au sein du CUDA Graph📎 docs/usage/troubleshooting.md:90-146. Chaque échec de couche pointe vers une cause racine différente.
Un détail notable dans le script :pynccl.disabled = Falseest destiné à la rétrocompatibilité avec les versions 0.6.4 et inférieures📎 docs/usage/troubleshooting.md:121-125. La 0.6.5+ l'active par défaut, mais conserver cette ligne évite de perdre les utilisateurs qui lisent la documentation la plus récente.
Lors des tests multi-nœuds, la documentation utilise délibérément--rdzv_backend=staticplutôt quec10d, carc10déchoue en multi-nœuds à cause d'un échec de résolution DNS📎 docs/usage/troubleshooting.md:168-168. C'est une configuration typique que l'on ne connaît qu'après avoir « trébuché ».
Réflexions de conception et pièges rencontrés
Échec d'initialisation NCCL(ncclCommInitRanksignale une unhandled system error) pointe généralement vers deux causes racines : absence deIPC_LOCKcapability ou/dev/shmnon monté📎 docs/usage/troubleshooting.md:311-311. Ce sont deux pièges classiques du déploiement conteneurisé.
Incompatibilité de la chaîne d'outils CUDA PTX(the provided PTX was compiled with an unsupported toolchain) indique que le PTX dans le wheel a été compilé avec une version plus récente du CUDA toolkit📎 docs/usage/troubleshooting.md:325-327. La solution est d'activer la compatibilité ascendante CUDA : sous Docker, ajouter-e VLLM_ENABLE_CUDA_COMPATIBILITY=1 📎 docs/usage/troubleshooting.md:325-327, en bare metal, installer le paquetcuda-compatet définirVLLM_CUDA_COMPATIBILITY_PATH 📎 docs/usage/troubleshooting.md:325-327。
Problème connu de surcoût mémoire NCCL:vLLM >= 0.4.3, <= 0.10.1.1définitNCCL_CUMEM_ENABLE=0pour contourner un bug NCCL ; les processus externes se connectant à vLLM doivent aussi définir cette variable, sinon ils se bloquent ou plantent📎 docs/usage/troubleshooting.md:375. Après correction dans NCCL 2.22.3, les nouvelles versions ont supprimé cette surcharge pour permettre l'optimisation des performances📎 docs/usage/troubleshooting.md:375. Ce cas montre que :le contrat de variables d'environnement inter-processus est une dépendance implicite des systèmes distribués, et doit être synchronisé lors des mises à niveau.
---
III. Frontend Rust : la philosophie de conception zéro-copie de l'outil bench
Modèle intuitif
Si le frontend Python est un couteau suisse « complet mais lourd », l'outil bench Rust est un scalpel « conçu uniquement pour les tests de charge ». Son objectif de conception n'est pas la couverture fonctionnelle, mais de réduire au minimum le surcoût du client lui-même sous forte concurrence, afin que les chiffres mesurés reflètent réellement les performances du serveur.
Structures de données et disposition mémoire
La structure de données centrale de l'outil bench estRequestFuncInput 📎 rust/src/bench/src/backends/mod.rs:59-89. Il utilise massivementArc<str>etArc<[u32]>plutôt queString/Vec, ce qui constitue le cœur de la conception zéro-copie.
Examinons quelques champs clés :prompt: Arc<str> 📎 rust/src/bench/src/backends/mod.rs:50-52——plusieurs requêtes concurrentes peuvent partager la même chaîne prompt, évitant ainsi de cloner une copie pour chaque requête.prompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77——les token ID précalculés sont envoyés directement au serveur, contournant la tokenization côté serveur📎 rust/src/bench/src/backends/mod.rs:74-76。
Le plus ingénieux estmulti_modal_content: Option<Arc<[Arc<str>]>> 📎 rust/src/bench/src/backends/mod.rs:81. Le commentaire explique : le contenu multimodal est traité comme un fragment JSON pré-sérialisé, que le chat backend concatène directement dans le flux d'octets du payload, évitant toute analyse ou copie profonde des données d'image base64📎 rust/src/bench/src/backends/mod.rs:78-80. Il s'agit d'une structure à double niveauArc: le niveau externeArc<[...]>partage l'ensemble du tableau, le niveau interneArc<str>partage un fragment individuel.
chat_messages_json: Option<Arc<str>>a la priorité la plus élevée, il est concaténé tel quel directement dans le payload📎 rust/src/bench/src/backends/mod.rs:82-85。
Désérialisation sans allocation
L'analyse des réponses en streaming SSE est un autre point critique de performance. Le commentaire indique explicitement : utiliser la désérialisation typée pour éviter de construire un arbre completserde_json::Value, en n'extrayant que les champs nécessaires📎 rust/src/bench/src/backends/mod.rs:20-24。
CompletionChunkne conserve quechoicesetusagedeux champs📎 rust/src/bench/src/backends/mod.rs:20-24,ChatChunkDe même📎 rust/src/bench/src/backends/mod.rs:33-37。#[serde(default)]fait que le champchoicesmanquant prend par défaut un tableau vide📎 rust/src/bench/src/backends/mod.rs:20-24, ce qui est le cas courant pour les réponses en streaming.
Flux de requêtes piloté par les scénarios
Lorsqu'une requête de test de charge est émise, comment les données circulent-elles ? Le diagramme de flux de données ci-dessous illustre la transformation de l'entrée vers la sortie :
flowchart LR
input["RequestFuncInput<br/>Arc<str> 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"]BackendL'énumération utilise la répartition statique pour éviter le problème des async trait object📎 rust/src/bench/src/backends/mod.rs:150-154。send_requestviamatchest réparti vers l'implémentation concrète📎 rust/src/bench/src/backends/mod.rs:158-168。get_backenden fonction deBackendKindretourne le backend correspondant📎 rust/src/bench/src/backends/mod.rs:172-181。
Un détail :API_KEYutiliseOnceLockpour la mise en cache, évitant un appel système de variable d'environnement à chaque requête📎 rust/src/bench/src/backends/mod.rs:186-188。build_headersinsère successivement Content-Type, Authorization, extra headers, request-id📎 rust/src/bench/src/backends/mod.rs:191-215。
Réflexions de conception et pièges rencontrés
La conception zéro-copie de l'outil bench Rust reflète un jugement important :la surcharge côté client de l'outil de test de charge devient une source d'erreur de mesure. Si chaque requête clone le prompt, analyse le JSON complet et copie profondément les images base64, alors la latence mesurée intègre la surcharge client et ne peut pas refléter fidèlement les performances du serveur. UtiliserArcpour partager des données immuables et la désérialisation typée pour ignorer les champs non pertinents revient essentiellement à réduire la surcharge client à presque zéro.
RequestFuncOutputLa conception des champs dettft(time to first token)、itl(tableau inter-token latency),tpot(time per output token)📎 rust/src/bench/src/backends/mod.rs:93-105. Ces trois métriques correspondent à différentes dimensions de performance : TTFT reflète le prefill et la latence de mise en file d'attente, ITL reflète la stabilité du decode, TPOT reflète le débit global. Lors d'un test de charge, ne regarder que la latence moyenne masquerait la gigue de l'ITL.
---
Réflexion de conception : la logique sous-jacente des compromis architecturaux
En mettant côte à côte les mécanismes de ce chapitre et des treize précédents, on distingue plusieurs axes de compromis fondamentaux de vLLM.
Continuous batching vs fragmentation de la mémoire vidéo.Le continuous batching permet de recombiner le lot à chaque étape, augmentant considérablement le débit, mais au prix d'allocations et libérations extrêmement fréquentes du KV cache. Le mécanisme de table de blocs de PagedAttention vise précisément à gérer cette allocation à haute fréquence——des blocs de taille fixe éliminent la fragmentation externe, mais introduisent la surcharge d'adressage indirect de la table de blocs et la fragmentation interne (le dernier bloc pouvant être incomplet). C'est un compromis typique « échanger le taux de fragmentation contre une couche d'indirection », dans le même esprit que la pagination de la mémoire virtuelle des systèmes d'exploitation.
CUDA Graph vs formes dynamiques.CUDA Graph exige des formes statiques, mais la taille de lot du continuous batching change à chaque étape. La solution de vLLM estPIECEWISEet le modeFULL_AND_PIECEWISE📎 docs/design/optimization_levels.md:50,72——capturer en graphe les parties staticisables, et garder les parties dynamiques en eager.-O0désactiver complètement cudagraph sert au débogage,-O2tout activer sert à la production, et-O1au milieu est un compromis.
Déploiement dissocié vs surcharge réseau.KV Connector permet de séparer prefill et decode sur différentes instances, mais le transfert du KV cache entre instances introduit de la latence réseau. Les exigences de configuration de GPUDirect RDMA dans la documentation (IPC_LOCK、/dev/shm)📎 docs/usage/troubleshooting.md:311-311) montrent que ce chemin impose des contraintes matérielles strictes sur l'infrastructure. La gigue réseau peut provoquer un timeout du transfert KV, déclenchant alors des retentatives ou une dégradation.
Opérabilité vs performance.Les niveaux d'optimisation, les variables d'environnement de débogage, les scripts de diagnostic, tout cela représente un coût payé pour l'opérabilité.VLLM_TRACE_FUNCTION=1ralentit 100 fois📎 docs/usage/troubleshooting.md:41, mais c'est le dernier recours pour localiser un problème de hang. Un moteur mature doit fournir ces outils « lents mais qui permettent de voir clair ».
---
Résumé de ce chapitre
Ce chapitre clôture l'ouvrage, en réexaminant sous l'angle de la production les mécanismes des treize chapitres précédents.
Les niveaux d'optimisation (-O0à-O3) constituent un contrat explicite entre temps de démarrage et performance d'exécution, les flags utilisateur ayant toujours priorité sur les valeurs par défaut des niveaux📎 docs/design/optimization_levels.md:5-5. La liste des pièges en production couvre le chemin de diagnostic complet, du chargement du modèle, de l'OOM de mémoire vidéo, des changements de qualité de génération jusqu'aux échecs de communication distribuée, la méthodologie centrale étant « l'isolation par dichotomie » et « la validation couche par couche ». L'outil bench Rust utiliseArcle partage et la désérialisation typée pour réduire la surcharge client à presque zéro, garantissant que les chiffres du test de charge reflètent fidèlement les performances du serveur.
Trois axes de compromis fondamentaux traversent tout l'ouvrage : le continuous batching face à la fragmentation de la mémoire GPU, les CUDA Graphs face aux formes dynamiques, et le déploiement disaggregated face aux surcoûts réseau. Comprendre ces tensions est plus important que de mémoriser n'importe quel mécanisme pris isolément — car chaque optimisation en production consiste essentiellement à trouver un point d'équilibre entre ces tensions.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on remplace le-O2deFULL_AND_PIECEWISEcudagraph par le-O1dePIECEWISE, dans quels scénarios déclencherait-on une régression de performance ? Pourquoi ?
Analyse de référence:-O2En complément de-O1, on ajouteFULL_AND_PIECEWISEle mode cudagraph📎 docs/design/optimization_levels.md:72。FULLLe mode capture l'intégralité de la propagation avant en un seul graphe, tandis quePIECEWISEne capture que les fragments statiquement déterminables. Dans un scénario de production où la forme des lots est stable,FULLle mode élimine davantage de surcoûts de lancement de kernels, offrant un débit plus élevé. Mais si le modèle contient un flux de contrôle dynamique (comme le routage de tokens d'un MoE),FULLle mode peut ne pas parvenir à capturer, ou présenter un comportement anormal après capture ; dans ce cas,PIECEWISEs'avère plus stable. La régression de performance apparaît lorsque : la taille de lot change fréquemment, empêchantFULLle graphe d'être atteint, ou lorsque la structure du modèle déclencheFULLle chemin de fallback du mode. La méthode de diagnostic consiste d'abord à utiliser-O1pour confirmer la ligne de base, puis à monter vers-O2pour comparer, et à utiliserVLLM_LOG_STATS_INTERVAL=1.pour observer l'état de la file📎 docs/usage/troubleshooting.md:41-41。
Q2 : Dans le script de diagnostic, pourquoi faut-il tester PyTorch GLOO avant de tester le vLLM PyNcclCommunicator ? Que manquerait-on si l'on sautait le test GLOO pour tester directement PyNccl ?
Analyse de référence: L'ordre d'exécution du script est PyTorch NCCL → PyTorch GLOO → vLLM PyNccl → CUDA Graph📎 docs/usage/troubleshooting.md:90-146. GLOO teste la communication côté CPU📎 docs/usage/troubleshooting.md:106-112, tandis que lePyNcclCommunicatorde vLLM nécessite un groupe GLOO comme bootstrap📎 docs/usage/troubleshooting.md:120. Si l'on saute le test GLOO, lorsque l'initialisation de PyNccl échoue, on ne peut pas distinguer s'il s'agit d'un problème de NCCL lui-même ou d'un problème de bootstrap GLOO. GLOO dépend de la configuration de l'interface réseau (GLOO_SOCKET_IFNAME)📎 docs/usage/troubleshooting.md:81-81, ce qui constitue un point de défaillance fréquent dans les environnements réseau complexes. La valeur du test couche par couche réside dans l'isolation de la défaillance au plus petit écart de configuration.
Q3 : L'outil de bench Rust utiliseArc<str>pour partager le prompt. Si le scénario de stress test nécessite d'envoyer un prompt différent pour chaque requête, cette conception devient-elle caduque ? Pourquoi ?
Analyse de référence:Arc<str>L'objectif de conception de📎 rust/src/bench/src/backends/mod.rs:50-52est de permettre à plusieurs requêtes concurrentes de partager la même chaîne immuableArc. Si le prompt de chaque requête est différent,Arc<str>l'avantage du partage deArc<str>disparaît effectivement — chaque requête doit construire son propreString. Mais la conception n'est pas caduque :prompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77par rapport àArcévite toujours les clonages multiples lors du cheminement de la requête (par exemple de la file d'entrée vers le backend, puis vers la construction du payload). La véritable optimisation zero-copy réside dansArc<str>— même si le texte du prompt diffère, le tableau de token IDs précalculé peut toujours être partagé viaArc<[u32]>pendant le cycle de vie de la requête, évitant les allocations répétées. L'hypothèse de conception de l'outil de stress test est « même prompt à haute concurrence » ou « token IDs précalculés » ; le premier utilise
---
pour partager le texte, le second utilise
pour partager la séquence de tokens.
Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé
Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.
⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes
Pour comprendre un projet complexe, tout ce dont vous avez besoin est un bon livre
Compilé automatiquement par AiReadCode en scannant le dépôt officiel avec ancrage permanent des commits.