Aller au contenu
EN LIGNE·MISSIONS T4 2026 · CRÉNEAUX OUVERTS·ONDINE v1.10.1·--:-- UTC
← Retour aux articles
OSSSGLangvLLM

Contribuer à SGLang sans connaître encore la base de code

4 septembre 2026·9 min de lecture

Instruments de mesure sur une forme d'onde : l'évidence plutôt que l'opinion.

Le benchmark de SGLang contredisait les logs de son propre moteur depuis quatre mois. J'ai découvert pourquoi dès mon premier jour dans le dépôt, avec en bonus un segfault et un import d'une seconde.

Premier jour dans le dépôt : pytest termine par un segfault, un pin de torch casse en trente secondes et un import de fonction coûte 1,1 seconde. Le benchmark, lui, contredit les logs du moteur depuis quatre mois. Je commence par lire les deux calculs.

Dix ans dans la donnée m’ont laissé un réflexe utile : mesurer avant de croire. J’ai choisi la couche de serving précisément parce que c’est la partie de la stack LLM que je connais le moins.

Ce journal ne raconte que ce que j’ai exécuté, mesuré ou lu ce jour-là. Le claim, la première PR, l’audit du benchmark et la validation vLLM sont publics.

Protocole en cinq étapes : choisir un travail demandé, mesurer avant d'affirmer, publier le claim avec numéros et lignes de code, livrer la plus petite unité honnête, attendre l'accord.
Le pari : compenser le manque de familiarité par un travail qu’un mainteneur peut vérifier vite.

Faire tourner un dépôt CUDA-only sur un Mac

SGLang déclare des dépendances réservées à CUDA, compile des extensions Rust et fixe torch==2.13.0. Sur un Mac arm64, pip install -e python/ échoue dès la première wheel CUDA. Sa CI CPU fournit la sortie : ne pas installer le package, mais l’exécuter depuis les sources avec PYTHONPATH.

cd sglang uv venv --python 3.12 uv pip install -p .venv/bin/python pytest pytest-cov pre-commit \ "torch==2.13.0" "torchvision==0.28.*" "transformers==5.12.1" \ "tokenizers==0.22.2" xgrammar==0.1.33 # plus ~25 lighter deps PYTHONPATH=python .venv/bin/python -m pytest test/registered/unit/parser -q

Trois pins m'ont mordu, par ordre d'embarras : un torchvision flottant a silencieusement mis torch à jour en 2.14.0 et cassé le pin du dépôt ; le dernier transformers entre en collision avec les patches de SGLang (ValueError : 'qwen3_asr' déjà enregistré), donc 5.12.1 ; et la couverture a segfaulté. Ce dernier cas était intéressant.

Les runs pytest ordinaires passaient au vert. Dès que j'ai ajouté --cov, l'interpréteur est mort avec Segmentation fault quelque part dans torch. La pile racontait l'histoire : le hook d'import de pytest-cov appelle find_spec sur chaque module pour décider de le tracer. Cet appel a déclenché le premier import de sglang, qui exécute ses platform stubs darwin, qui importent torch, dont les extensions natives étaient en cours d'initialisation par un autre point d'entrée. La machinerie d'import s'est ré-entrée elle-même, et la couche C a cédé. Pendant une minute, j'ai cru que c'était le moment où on demande poliment aux utilisateurs de Mac de sortir.

Le correctif tient en une ligne, hors du dépôt : un sitecustomize.py qui pré-importe sglang avant que les hooks de couverture n'existent.

# ~/oss/cov-prelude/sitecustomize.py import sglang # then, always: PYTHONPATH=~/oss/cov-prelude:python .venv/bin/python -m pytest \ test/registered/unit/parser --cov=sglang.srt.parser --cov-branch \ --cov-report=term-missing -q

La leçon se généralise : quand un outil de test plante, lisez la pile de bas en haut et demandez-vous ce qui est importé depuis l'intérieur du hook de quelqu'un d'autre.

Deux panneaux : la boucle d'import ré-entrante qui provoque le segfault de la couverture, et le sitecustomize qui la corrige.
Le plantage : le hook d'import de la couverture ré-entre dans le premier import de sglang, qui traverse les platform stubs jusqu'au chargeur natif de torch en pleine initialisation. Le correctif : pré-importer sglang au démarrage de l'interpréteur.

Trouver du vrai travail dans un fichier déjà testé

L’issue de suivi des tests unitaires liste srt/parser. La couverture de branches invalide aussitôt mon hypothèse : le module compte déjà 1 600 lignes de tests. Pourtant, son plus gros fichier reste le moins couvert. L’issue indiquait une zone ; la mesure a trouvé le travail.

Couverture de branches par fichier, avant les nouveaux tests :

Fichier dans srt/parserAvantAprès (19 tests)
code_completion_parser.py98%98%
harmony_parser.py96%96%
conversation.py92%92%
jinja_template_utils.py92%92%
template_detection.py89%89%
inkling_tokenizer.py85%85%
inkling_renderer.py81%81%
reasoning_parser.py64%77%

Couverture de branches, main @ 99b9109, mesurée avec --cov-branch. Le total de la suite est passé de 82 % à 86 %.

Couverture par fichier du module parser : reasoning_parser passe de 64 à 77 pour cent avec les nouveaux tests.
Les mêmes chiffres en barres : reasoning_parser.py est l'histoire, 64 % avec toute la classe MuseGlimmerDetector non testée.

À l'intérieur, une classe n'avait aucun test : MuseGlimmerDetector, le parser côté raisonnement d'un protocole de canaux où le contenu to=self est la réflexion et to=user la réponse visible. Deux cents lignes de machine à états : routage des sinks, rétention des marqueurs coupés entre les chunks, et un chemin finish() qui promeut le raisonnement en contenu quand un tour n'a rien produit de visible. Un piège mérite d'être nommé : un détecteur d'appels d'outils portant le même nom dans un autre package est bien testé. Même nom, classe différente. Si j'avais greppé au lieu de mesuré, le commentaire de claim aurait été faux en public.

J'ai donc claimé avant d'écrire une ligne, avec les chiffres : 64 %, 286 statements non couverts, les plages de lignes, la note de collision de noms, et la promesse de coller la couverture avant/après dans la PR. Claimer d'abord, c'est une assurance anti-collision et ça vous force à énoncer le périmètre à voix haute, là où quelqu'un peut vous corriger.

Puis j'ai écrit 19 tests, un comportement chacun : routage des canaux, marqueurs coupés entre les chunks qui doivent être retenus et jamais fuiter, l'interaction avec le détecteur d'appels d'outils qui préserve le framing, la sémantique de promotion au finish(). Vert en 0,73 seconde. Le fichier est passé de 64 % à 77 %, la suite de 82 % à 86 %. La PR #37974 est ouverte au moment où j'écris.

Un bug de benchmark vieux de quatre mois, lu des deux côtés

L'issue #3050 dormait depuis mai : le benchmark rapporte environ la moitié du débit de décodage affiché par le moteur, quelqu'un a supposé des causes, et le fil s'est enlisé. Le mainteneur avait demandé l'aide de la communauté et personne n'avait répondu avec du code. Les deux métriques vivent dans le dépôt, donc j'ai lu les deux.

Ce ne sont pas deux mesures de la même chose. Le chiffre du benchmark est une moyenne TPOT, sur toute l'exécution, côté client : elle inclut l'attente en file, la montée en charge et la traîne après le dernier token. Le chiffre du moteur est num_generated_tokens divisé par le temps depuis la dernière ligne de log (metrics_reporter.py:799). L'une est une moyenne sur toute l'exécution. L'autre est proche d'un débit instantané. L'incohérence rapportée vit dans cette comparaison, pas dans l'une ou l'autre des métriques.

Une même exécution, deux fenêtres : le benchmark moyenne toute l'exécution côté client, le moteur mesure un intervalle court côté serveur.
Une même exécution, deux fenêtres. Bench : moyenne client sur toute l'exécution. Moteur : intervalles de log courts côté serveur, près du régime établi. Les deux chiffres échantillonnent des fenêtres différentes.
  • itl est enregistré par événement de stream, pas par token. Un événement portant plusieurs tokens produit un seul échantillon de latence, donc les percentiles d'ITL du backend completions confondent latence d'événement et latence de token. Une correction par retokenisation existe, mais seulement pour les backends sglang-oai.
  • output_len retombe silencieusement sur la longueur demandée quand la réponse n'a pas de stats d'usage, ce qui rend le TPOT trop petit pour les requêtes arrêtées tôt.
  • La théorie du cold-start par flush_cache du fil ne s'applique que sous CI ou avec le flag explicite (serving.py:1467-1471). Elle ne peut pas expliquer un run par défaut.

J'ai écrit cinq tests unitaires qui épinglent l'arithmétique avec une horloge simulée : timestamps exacts en entrée, invariants en sortie. sum(itl) plus la traîne [DONE] égale latency - ttft, au tick près. L'ITL moyen dépasse le TPOT pour la même requête, par les seuls dénominateurs.

PYTHONPATH=python .venv/bin/python -m pytest \ test/registered/unit/bench/test_serving_timing_invariants.py -q 5 passed in 3.33s

Les tests ne corrigent pas le bug ; ils permettent à la prochaine personne de raisonner dessus sans faire confiance à la mémoire de quiconque, y compris la mienne. Ils sont ouverts dans la PR #37973.

Une seconde d'import pour un appel de fonction

Le dernier artefact de la journée était gratuit. Une PR ouverte sur le coût d'import attendait sans commentaire depuis deux mois, donc j'ai profilé le main courant sur ma machine : import sglang prend 5,3 secondes à chaud, 6,4 à froid, sur 3 479 modules. Le haut de la colonne cumulée :

PYTHONPATH=python python -X importtime -c "import sglang" # 5.3s warm, 6.4s cold, 3,479 modules
PackageCumulé (à chaud)
torch1.51s
transformers1.13s
torchvision (one function: decode_jpeg)1.11s

La ligne torchvision est la plus intéressante. Elle existe parce que srt/utils/common.py:96 importe decode_jpeg depuis torchvision.io à la racine du module. Une fonction, inconditionnellement, pour 1,1 seconde à chaque import sglang. J'ai posté les chiffres sur cette PR avec l'observation, sans correctif attaché. Les chiffres voyagent plus loin que les suggestions.

Le tableau de score, honnêtement

Rien n'était mergé au moment de la publication. Ce soir-là : un claim sur l'issue de suivi des tests avec la PR derrière, cinq tests d'invariants sur leur branche, deux commentaires de preuve sur des fils dormants, et un claim de validation vLLM pour gemma-4-E4B-it. Six fils, deux projets, zéro merge.

Une journée d’activité ne prouve pas une contribution durable. Pire : les nouveaux aidés par l’IA peuvent submerger les mainteneurs de changements plausibles mais coûteux à vérifier. SGLang dit rejeter les PR qui semblent générées automatiquement. J’utilise un agent pour les tâches mécaniques — lire le code, suivre les imports, lancer la couverture — mais je choisis chaque test et dois pouvoir le défendre. Le merge viendra, ou non. Les affirmations, elles, sont déjà falsifiables.

La lane vLLM est tombée après le premier jet de cet article : google/gemma-4-E4B-it validé sur les deux cellules d'exécution avec une variance inter-runs nulle, résultats publiés et la PR de doc #55343 ouverte.

Mon seul tableau de score : un mainteneur a-t-il reçu quelque chose qu’il peut vérifier plus vite qu’il ne peut en douter ? Le merge reste sa décision, à son rythme.

Voilà l'échange que je suis venu faire.