Là où l’article eBPF s’arrêtait

Notre dossier eBPF et perf répondait à la question « le GPU est à 30 %, pourquoi ? » et désignait un coupable récurrent : le thread hôte qui alimente le GPU est prêt à courir (il a fini d’attendre le résultat précédent), mais l’ordonnanceur Linux a donné le cœur à un autre thread. Il patiente dans la run-queue, la file des threads prêts sans cœur disponible, et le GPU patiente avec lui. runqlat rendait cette attente visible : un histogramme qui s’étale vers la milliseconde alors que les inférences durent quelques millisecondes.

La solution qu’on évoquait alors était à la hache : chrt pour passer le thread en priorité temps réel, cpuset pour l’épingler à des cœurs réservés, ou la révision de la cohabitation sur le nœud. Ces outils marchent, mais ils sont aveugles à ce que fait votre charge. chrt en priorité temps réel peut affamer les autres threads, y compris ceux dont le GPU a aussi besoin ; cpuset fige un placement qui ne s’adapte ni à la phase d’inférence, ni à la pression mémoire, ni au voisinage. Vous avez deux réglages binaires là où votre problème est dynamique.

C’est exactement l’écart que sched_ext comble : remplacer le réglage grossier par une politique, écrite dans le langage de votre charge.

Ce que fait sched_ext

sched_ext (ou SCX) est une classe d’ordonnancement du noyau Linux qui délègue les décisions d’ordonnancement à un programme eBPF. Intégrée à la version 6.12 du noyau (fin 2024) et stabilisée depuis (au 12 juin 2026, le noyau stable courant est la 7.0, la 7.1 étant imminente), elle se branche via le mécanisme struct_ops : votre programme eBPF implémente une poignée de callbacks (« quel cœur pour ce thread qui se réveille », « dans quel ordre piocher dans la file ») et le noyau les appelle à la place de sa logique habituelle.

Deux garde-fous rendent la chose praticable sur un système réel, et ils méritent qu’on s’y arrête parce qu’ils renversent le rapport au risque. Le premier est le vérificateur eBPF : avant de charger le programme, le noyau prouve qu’il termine en temps borné et n’accède qu’à de la mémoire valide. Le second est un chien de garde : si l’ordonnanceur eBPF cesse de replanifier les threads dans un délai imparti (bug, boucle, oubli d’un cas), le noyau le désactive d’office et rebascule sur l’ordonnanceur par défaut, l’EEVDF qui a remplacé le vieux CFS. Un mauvais ordonnanceur sched_ext dégrade donc les performances, ou se fait débrancher. Il ne plante pas la machine. C’est ce qui autorise à tester un ordonnanceur en production, à le charger et le retirer à chaud, sans recompiler le noyau ni redémarrer le nœud.

Ce n’est pas un gadget de recherche : Meta le déploie à grande échelle, Google s’y est engagé, et la collection d’ordonnanceurs scx est maintenue en parallèle du noyau.

Pourquoi l’inférence est un cas d’école

L’ordonnanceur par défaut d’un noyau généraliste poursuit un objectif : l’équité. Donner à chaque thread une part proportionnée du CPU, sans privilégier personne. C’est le bon défaut pour un système qui ignore ce que ses threads font. C’est le mauvais défaut pour un service d’inférence, parce qu’une inférence n’est pas une charge homogène : c’est deux charges aux contraintes opposées qui partagent les mêmes threads.

Le prefill Phase initiale d'une inférence LLM : tous les tokens du prompt sont traités d'un coup. Intensité arithmétique élevée, le GPU sature ses Tensor Cores. C'est l'inverse du decode qui suit. Approfondir dans le glossaire traite le prompt entier d’un coup : calcul dense, par à-coups, qui sature le GPU pendant une fraction de seconde. Côté hôte, c’est une rafale : préparer un gros tenseur, le pousser, attendre. Le decode Phase de génération autorégressive d'un LLM : un token est produit à la fois, en relisant tout le KV cache. Intensité arithmétique très basse, le GPU passe l'essentiel du temps à attendre la mémoire. Un service d'inférence réel est presque toujours dominé par le decode. Approfondir dans le glossaire génère ensuite les tokens un par un : à chaque pas, le GPU relit tout le 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 , le calcul est minime, et le rythme est régulier. La métrique qui compte n’est plus le débit brut mais la latence inter-token, l’intervalle entre deux tokens que l’utilisateur voit s’afficher. Un decode qui hoquette parce que son thread d’alimentation a raté son créneau CPU se traduit directement par une réponse qui saccade.

Voici le piège : sur un nœud chargé, l’ordonnanceur générique ne voit ni le prefill ni le decode. Il voit des threads qui se réveillent et se rendorment, et il les traite à égalité. Quand une rafale de prefill d’une autre requête tombe au moment où votre decode a besoin de son cœur, l’ordonnanceur équitable peut très bien servir le prefill d’abord. Il ne sait pas que le decode, lui, tient une horloge. Le résultat est la latence inter-token qui se dégrade sous charge sans qu’aucun compteur applicatif n’explique pourquoi : le temps est perdu dans la run-queue, exactement là où eBPF le débusque. C’est, à l’échelle d’un thread, la même tension prefill/decode que la disaggregation résout à l’échelle du cluster. Ici, le levier n’est pas un pool de GPU dédié : c’est l’ordonnanceur du cœur sur lequel tournent vos threads.

Ce qu’un ordonnanceur d’inférence encoderait

La valeur de sched_ext, c’est de pouvoir traduire votre connaissance de la charge en politique. Trois décisions qu’un ordonnanceur d’inférence prendrait, et qu’aucun réglage générique ne sait prendre :

  • Servir le decode en priorité. Le thread qui alimente une boucle de génération porte une contrainte de latence ; il doit obtenir le CPU dès son réveil, avant les threads de préparation de prefill qui, eux, tolèrent quelques microsecondes d’attente. Pas une priorité temps réel absolue qui affamerait tout le reste, mais une priorité qui connaît la phase.
  • Garder le thread d’alimentation collé à son nœud NUMA. Le thread qui pousse les commandes au GPU et la mémoire qu’il touche doivent vivre sur le même nœud NUMA que la carte. Un ordonnanceur conscient de la topologie évite de migrer ce thread sur un cœud distant, où chaque accès mémoire traverse l’interconnexion CPU et ajoute de la latence à chaque token.
  • Empêcher une rafale de prefill de préempter un decode en cours. Quand les deux phases coexistent, c’est l’arbitrage qui fait la latence. L’ordonnanceur peut réserver des cœurs au decode, ou refuser qu’un prefill préempte un decode déjà engagé sur son créneau.

Concrètement, un ordonnanceur sched_ext est un programme eBPF qui implémente ces callbacks. Le squelette tient en quelques fonctions :

sched_ext inference_sched.bpf.c C
/* Squelette illustratif d'un ordonnanceur sched_ext (struct_ops).
   L'API évolue avec le noyau ; voir la collection scx pour une base réelle. */

s32 BPF_STRUCT_OPS(infsched_select_cpu, struct task_struct *p, s32 prev, u64 wake_flags)
{
    /* Réveiller le thread d'alimentation du decode sur un cœur NUMA-local au GPU,
       idéalement le cœur précédent s'il est libre (cache chaud). */
    if (is_decode_feeder(p))
        return pick_idle_local_cpu(p, prev);
    return prev;
}

void BPF_STRUCT_OPS(infsched_enqueue, struct task_struct *p, u64 enq_flags)
{
    /* Le decode passe devant : file prioritaire, slice court pour rester réactif.
       Le prefill va dans la file normale, il tolère l'attente. */
    u64 dsq = is_decode_feeder(p) ? DSQ_DECODE : DSQ_NORMAL;
    scx_bpf_dsq_insert(p, dsq, SLICE_NS, enq_flags);
}

L’essentiel n’est pas la syntaxe (elle évolue d’une version de noyau à l’autre), mais le fait que cette politique vit dans votre code, se charge à chaud, et se mesure.

Partir d’un ordonnanceur scx existant

Écrire un ordonnanceur de production de zéro est un vrai projet. La bonne entrée en matière est de partir d’un ordonnanceur de la collection scx, qui en fournit plusieurs orientés production, et de l’adapter.

OrdonnanceurOrientationPertinence pour l'inférence
scx_lavdSensible à la latenceLe decode et sa latence inter-token
scx_rustyMulti-domaine, conscient NUMAPlacement du thread d'alimentation près du GPU
scx_layeredPartition en couches à politiques distinctesSéparer prefill et decode en couches
scx_bpflandTâches interactivesFavoriser les threads réactifs sous charge
Tableau 1 : quelques ordonnanceurs de la collection scx comme points de départ. Descriptions d'orientation ; la documentation de référence vit dans le dépôt sched-ext/scx.

Pour un service d’inférence, scx_layered est souvent le point de départ le plus naturel : il partitionne les tâches en couches auxquelles on applique des politiques distinctes, une couche pour le decode, une pour le prefill, une pour le reste. La logique métier (« ce thread alimente un decode ») se traduit en appartenance à une couche, et la politique de la couche fait le reste.

La boucle : observer, agir, mesurer

sched_ext n’a de sens que refermé sur la boucle de l’article eBPF. L’ordre compte.

Cette discipline est ce qui sépare l’optimisation de la superstition. sched_ext rend trivial le test d’une hypothèse d’ordonnancement (charger, mesurer, retirer), là où, avant, changer d’ordonnanceur voulait dire recompiler le noyau et redémarrer. Le coût d’une expérience s’effondre ; ce qui reste, c’est l’exigence de la mesurer.

Quand s’en abstenir

Conclusion

L’inférence en production se raisonne d’abord comme un problème de GPU, puis comme un problème de système. Le système, on l’a vu, ne se devine pas : il se trace. sched_ext ajoute le verbe qui manquait à cette grammaire : après observer, agir. Non plus subir l’ordonnanceur générique et le corriger à la hache, mais écrire la politique que votre charge réclame, dans un programme qu’on charge et retire comme on teste une hypothèse.

La direction où cela mène est claire. À mesure que le serving se spécialise, prefill et decode déjà séparés en pools de matériel distincts dans les stacks de disaggregation, la même spécialisation descend jusqu’à l’ordonnanceur de l’hôte. La question pour 2027 n’est pas de savoir si les runtimes d’inférence embarqueront un ordonnanceur sched_ext dédié, mais lequel le fera en premier, et s’il sera assez bon pour qu’on le charge sans même savoir qu’il est là.

Sources et méthode

Cet article est une synthèse de méthode arrêtée au 12 juin 2026 ; il ne rapporte pas de mesure de banc mais un mécanisme et une démarche. Étiquettes : fait vérifié = source primaire citable ; estimation crédible = cohérent mais non mesuré ici ; hypothèse = raisonnement explicite.

sched_ext et la collection scx

  • Intégration de sched_ext au noyau Linux 6.12 (fin 2024), stabilisation et déploiement en production (Meta à grande échelle, Google engagé) : documentation du noyau et dépôt sched-ext/scx (fait vérifié). Au 12 juin 2026, le noyau stable courant est la 7.0 (sortie le 12 avril 2026) ; la 7.1 paraît le 14 juin (fait vérifié).
  • Mécanisme struct_ops, vérificateur eBPF et chien de garde rebasculant sur l’ordonnanceur par défaut : documentation sched_ext du noyau (fait vérifié). L’ordonnanceur par défaut depuis la 6.6 est EEVDF, successeur de CFS (fait vérifié).
  • Ordonnanceurs scx_lavd, scx_rusty, scx_layered, scx_bpfland : collection sched-ext/scx (fait vérifié pour leur existence ; les orientations décrites au Tableau 1 sont des résumés, la documentation de chaque ordonnanceur fait foi).

Inférence et ordonnancement

  • La tension prefill/decode et la latence inter-token comme métrique du decode : voir notre dossier disaggregation prefill/decode et le choix de runtime (synthèse interne).
  • Le diagnostic de la latence de run-queue (runqlat), des page faults et des appels driver côté hôte : notre article eBPF et perf, dont le présent article est le prolongement « côté action ». Le lien entre GPU sous-utilisé et coût est développé dans le coût d’inférence.

À manier avec prudence

  • Aucune mesure de gain chiffrée n’est avancée : les bénéfices d’un ordonnanceur sur mesure dépendent de la charge, du niveau de contention et de la qualité de l’implémentation. La seule affirmation forte est conditionnelle : sched_ext n’aide que si le diagnostic eBPF établit une contention d’ordonnancement côté hôte.
  • Extrait de code illustratif. Le squelette inference_sched.bpf.c montre la forme d’un ordonnanceur sched_ext ; l’API struct_ops évolue d’une version de noyau à l’autre. Partez de la collection scx pour une base à jour.

Image d’en-tête

  • Image d’en-tête. Processeur (boîtier à grille de broches) photographié par Daniel Garcia, Wikimedia Commons, sous licence CC BY-SA 2.0, recadré pour l’en-tête ; les cœurs de ce silicium sont ce que l’ordonnanceur distribue.