Le problème : « le GPU est à 30 %, pourquoi ? »
C’est l’une des questions les plus frustrantes en production d’inférence. La carte coûte cher, un H100 SXM facturé à l’heure dépasse les 2 € l’heure chez la plupart des fournisseurs cloud, la charge est là, et nvidia-smi affiche 30 % d’occupation. Mathématiquement, vous payez 70 % de la carte pour ne rien faire. Le GPU n’est pas le goulot ; quelque chose en amont ne le nourrit pas assez vite, et l’inférence avance au rythme du composant le plus lent.
Le réflexe est de regarder le GPU plus en détail : examiner les kernels CUDA, ajuster la taille des batches, chercher si une couche est mal optimisée. Cette piste est rarement la bonne quand le GPU plafonne à 30 %. Un GPU sous-utilisé est, dans l’immense majorité des cas, un problème côté hôte : le CPU qui prépare les batches plus lentement que le GPU ne les consomme, la mémoire qui pagine, le disque qui ne livre pas les données à temps, le réseau qui plafonne en amont. Le débit de bout en bout est borné par l’étage le plus lent du pipeline. Optimiser l’étage GPU pendant qu’un étage CPU bouchonne ne déplace pas le goulot.
Et le côté hôte, c’est précisément ce qu’ eBPF extended Berkeley Packet Filter. Mécanisme du noyau Linux qui exécute de petits programmes vérifiés à l'intérieur du noyau, attachés à des événements (appels système, fonctions kernel, sondes utilisateur). Permet d'observer une application sans la modifier ni la redémarrer. Approfondir dans le glossaire sait rendre visible, sans modifier l’application, sans la redémarrer, sans pénalité de performance significative en régime normal.
Ce qu’eBPF est, en une phrase
eBPF permet d’exécuter de petits programmes vérifiés à l’intérieur du noyau Linux, attachés à des événements : appels système, tracepoints, sondes sur des fonctions noyau (kprobes) ou utilisateur (uprobes). Quand l’événement se déclenche (un appel à read(), une page fault Interruption déclenchée par le CPU quand un processus accède à une page mémoire absente de la RAM physique. Un page fault mineur est résolu rapidement par l'OS ; un page fault majeur exige un accès disque et gèle le calcul pendant des millisecondes. , un changement de contexte), le programme eBPF s’exécute dans le noyau, agrège ce qu’il observe dans des structures partagées (map BPF), et un outil en espace utilisateur vient lire le résultat. Le mot vérifié compte : avant de charger le programme, le noyau prouve qu’il termine en temps borné et n’accède qu’à de la mémoire valide : c’est ce qui permet de l’exécuter en contexte noyau sans risquer de planter la machine. L’application surveillée n’est ni recompilée, ni redémarrée, ni même au courant qu’elle est observée.
Pour une stack d’inférence, c’est l’outil idéal : on ne veut pas instrumenter vLLM ou llama.cpp (recompiler avec un profileur intégré rouvre la porte à des biais de mesure et à des cycles de release) ; on veut savoir ce que le système d’exploitation leur fait subir pendant qu’ils tournent normalement.
Ce qu’eBPF voit, et ce qu’il ne voit pas
C’est la distinction la plus importante de cet article, et celle que la confusion entre « observer » et « profiler » fait régulièrement rater.
| Couche | Outil | Ce qu'on y voit |
|---|---|---|
| Ordonnancement CPU | eBPF / perf | Latence de run-queue, préemption |
| Mémoire hôte | eBPF | Page faults, allocations, mmap |
| I/O disque & réseau | eBPF | Latence, débit, blocages |
| Frontière hôte → GPU | eBPF | Appels ioctl au driver, leur latence |
| Intérieur du GPU | Nsight / rocprof | Occupation SM, kernels, HBM |
Les quatre angles morts d’une stack d’inférence
Quand le GPU est affamé, la cause se loge presque toujours dans l’un de ces quatre endroits, tous visibles par eBPF, tous invisibles à nvidia-smi.
L’ordonnancement CPU. Le thread qui prépare les batches (tokenisation, mise en forme du tenseur d’entrée, copie vers le GPU) n’a pas le CPU au moment où il en a besoin. Il a fini son attente sur le GPU précédent, il est prêt à courir, mais l’ordonnanceur Linux a donné le cœur à un autre thread (un worker concurrent, un thread kernel, un démon de monitoring). Le thread attend dans la run-queue (la file des threads prêts mais sans cœur pour eux), et le GPU attend ce thread.
Sur un système multi-tenant ou un nœud qui co-loue son CPU avec d’autres services, la latence d’attente peut dépasser le millième de seconde, assez pour faire chuter de 20 % le débit d’un GPU qui voulait traiter des batches toutes les 5 ms. C’est une cause classique et la cause la plus invisible sans traçage : aucun compteur côté application ne la révèle, parce que le thread pense avoir tourné dès qu’il a le CPU.
Les page faults. Un modèle chargé en mmap (le mode normal de llama.cpp) pagine depuis le disque à la demande : le noyau ne charge en RAM que les pages effectivement lues, et libère silencieusement celles qui n’ont pas servi récemment. Sous pression mémoire (quand un KV cache Mémoire des vecteurs clé et valeur déjà calculés pour chaque token traité par un LLM. Évite de recalculer l'attention sur tout l'historique, au prix d'une consommation mémoire qui croît avec le contexte.
Approfondir dans le glossaire
qui grossit ou un autre processus revendique de la RAM), le noyau évince des pages du modèle, qui seront rechargées au prochain accès. Chaque page fault majeur (le qualificatif majeur signifie qu’il faut un accès disque, par opposition au mineur qui se résout en RAM) gèle le calcul le temps d’une lecture I/O : quelques dizaines de microsecondes sur un NVMe, plusieurs millisecondes sur un disque réseau. eBPF compte ces fautes par processus en s’attachant aux tracepoints page_fault_user et page_fault_kernel, mais ces deux-là comptent toutes les fautes, majeures comme mineures. Pour n’isoler que les fautes majeures (celles qui touchent réellement le disque), il faut suivre le chemin d’I/O, par exemple les tracepoints mm_filemap_* / vmscan, ou le compteur de fautes majeures de la tâche. Une montée de ces fautes majeures donne instantanément l’image d’un modèle qui thrashe en mémoire au lieu de tourner en RAM.
L’I/O disque et réseau. Pour un service d’inférence exposé via HTTP ou gRPC, la latence réseau d’admission des requêtes peut dépasser le temps de calcul lui-même : quelques millisecondes de réseau ajoutées à une inférence de 50 ms changent peu, mais s’ajoutent à 5 ms d’inférence elles doublent la latence end-to-end. Pour un démarrage à froid, c’est la lecture initiale du modèle depuis le stockage qui domine : plusieurs gigaoctets de poids lus avant la première inférence. Dans les deux cas, eBPF mesure la latence réelle du côté noyau (file d’attente bloc, syscalls recv/send), pas la latence supposée annoncée par l’application qui, elle, observe un temps déjà filtré par ses propres buffers.
La frontière hôte → GPU. Le driver NVIDIA ou AMD est piloté depuis l’espace utilisateur par des appels ioctl (l’appel système générique d’envoi de commandes à un pilote) : c’est par cette interface que CUDA ou ROCm pousse des kernels et des transferts mémoire vers la carte. Tracer leur fréquence et leur latence révèle un schéma direct : si le débit d’ioctl est bas (le GPU ne reçoit pas assez de commandes) ou si leur latence côté noyau est haute (le driver attend une ressource), c’est que le GPU passe son temps à attendre des ordres, pas à calculer. C’est le dernier endroit où l’hôte peut étrangler le GPU avant que l’exécution lui soit déléguée, et c’est invisible à tout outil qui regarde au-dessus du driver.
En pratique : bpftrace, perf, ftrace
La boîte à outils est mûre et tient en trois noms. bpftrace pour des sondes ad hoc en une ligne (un langage de script qui compile vers eBPF, idéal pour le diagnostic exploratoire). perf pour l’échantillonnage CPU et la génération de flame graphs, l’outil de référence quand on cherche dans quelles fonctions le CPU passe son temps. ftrace pour le traçage fin de fonctions noyau, plus intrusif mais plus précis. La collection BCC (BPF Compiler Collection, le projet historique d’outils eBPF prêts à l’emploi) ajoute runqlat, biolatency, tcplife et une centaine d’autres scripts qu’on lance sans écrire de code.
Un exemple concret de bpftrace en action : mesurer la latence d’ordonnancement, combien de temps un thread reste dans la run-queue après avoir été réveillé, avant qu’on lui donne le CPU. C’est la mesure directe du premier angle mort.
# Latence de run-queue : un thread est-il prêt mais en attente du CPU ?
# Un GPU "à 30 %" est typiquement un thread hôte coincé ici.
bpftrace -e '
tracepoint:sched:sched_wakeup { @qt[args->pid] = nsecs; }
tracepoint:sched:sched_switch /@qt[args->next_pid]/ {
@runq_us = hist((nsecs - @qt[args->next_pid]) / 1000);
delete(@qt[args->next_pid]);
}' L’histogramme @runq_us montre la distribution des temps d’attente en microsecondes : combien de threads attendent moins de 10 µs, combien attendent entre 100 µs et 1 ms, combien dépassent 10 ms. Si la queue de votre processus d’inférence s’étale vers les millisecondes alors que les inférences elles-mêmes durent quelques millisecondes, le GPU n’est pas en cause : l’ordonnanceur l’est, et la solution passe par un chrt, un cpuset, ou la révision de la cohabitation sur le nœud. La même logique s’applique aux page faults (tracepoint:exceptions:page_fault_user), à l’I/O (kprobe:vfs_read), aux ioctl (tracepoint:syscalls:sys_enter_ioctl) : une sonde, un histogramme, une réponse, et un mécanisme qui devient visible.
La règle : eBPF pour l’hôte, les profileurs constructeur pour le GPU
Inverser cet ordre fait perdre des heures. Profiler les kernels CUDA d’un GPU qui, de toute façon, attend son CPU ne déplace pas le goulot : le gain mesuré côté GPU est absorbé par l’attente côté hôte, et le débit observé ne bouge pas. Le bon réflexe est l’inverse : caractériser l’hôte d’abord avec eBPF, et ne dégainer Nsight qu’une fois que le GPU est réellement saturé sur la charge nominale.
Conclusion
L’inférence LLM se raisonne comme un problème de GPU jusqu’au moment où elle devient un problème de système. À partir de là, le système ne se devine pas, il se trace. La question pratique pour un service en production n’est donc pas « mon GPU est-il assez rapide » mais « mon hôte sait-il le nourrir au débit nominal de la carte », et eBPF est l’outil qui répond, sans modifier le service, sans le redémarrer. Pour quiconque a un GPU qui plafonne à 30 % et un budget qui pousse à comprendre pourquoi : commencez par runqlat, puis biolatency, puis comptez les page_fault_user par PID. Les pourcents perdus se récupèrent dans cet ordre.
L’étape d’après, une fois le diagnostic posé : agir sur l’ordonnanceur lui-même, avec sched-ext appliqué à l’inférence.
Sources et méthode
Scripts reproductibles. Le toolkit eBPF/bpftrace de cet article (runqlat, fautes majeures, latence I/O, et le traceur ioctl côté GPU) est open-source : llm-host-observability.
eBPF, bpftrace, perf et ftrace : documentation publique du noyau Linux et des projets bpftrace et BCC. L’exemple runqlat.bt reprend la logique de l’outil runqlat de la collection bpftrace/BCC ; il est volontairement simplifié pour la lisibilité et illustre le principe, il ne reproduit pas toutes les options de l’outil complet. La frontière entre observabilité hôte (eBPF) et profilage GPU (Nsight Systems côté NVIDIA, ROCm rocprof côté AMD) est une propriété d’architecture, documentée par les constructeurs concernés. Article de synthèse : il ne rapporte pas de mesure de banc, mais une méthode.