Training SLMs (3) - LoRA et entraînement.
LoRA, tout cette VRAM que je n'ai pas
Rentrons maintenant dans le vif du sujet avec l’entraînement de nos SLMs. On ne part pas de zéro : nous allons simplement rajouter notre touche perso à des modèles déjà pré-entraînés. Et pour spoiler la fin de l’article : la moitié du problème posé dans l’article précédent se règle en dix minutes de GPU, l’autre moitié va nous occuper quelques articles de plus.
LoRA, ou comment entraîner un modèle sans louer un datacenter
Les modèles IA sont très gourmands en mémoire vive, et tout particulièrement en VRAM, la mémoire de nos chères cartes graphiques, ultra rapide et très intégrée au GPU. C’est vrai pour l’inférence, et c’est encore plus vrai pour l’entraînement. Attention au raccourci sur les raisons, parce que je l’ai fait moi-même : ce n’est pas le volume de données qui remplit la carte, les données défilent par petits lots. Ce qui la remplit, c’est tout ce qu’il faut garder en mémoire en plus des poids du modèle pour pouvoir les corriger : les gradients, les états de l’optimiseur, et les activations conservées pour la rétropropagation. Compter deux à quatre fois la taille du modèle en plus du modèle lui-même est un ordre de grandeur raisonnable.
C’est exactement ce que la méthode LoRA (Low-Rank Adaptation) évite. Au lieu de mettre à jour les poids du modèle, on les gèle, et on greffe à côté de chaque couche visée deux petites matrices, beaucoup plus fines, qui sont les seules à être entraînées. Le modèle d’origine ne bouge pas d’un octet, et l’apprentissage tient dans un adaptateur de quelques mégaoctets. Je ne rentre pas dans le détail des matrices, d’autres le font très bien. Le point important pour nous est que le nombre de paramètres à entraîner tombe de quelques centaines de millions à quelques millions, et que les gradients et les états de l’optimiseur suivent la même pente.
J’ai poussé le raisonnement un cran plus loin en utilisant QLoRA, pour Quantized Low-Rank Adaptation. L’idée est de charger le modèle de base en 4 bits au lieu de ses 16 bits d’origine, ce qui divise son empreinte par quatre. Attention à la nuance qui compte : ce sont les poids gelés qui sont quantifiés, pas les adaptateurs, qui restent en précision haute puisque ce sont eux qu’on entraîne. On dégrade donc la partie qu’on ne touche pas, et on apprend en pleine précision sur la partie qui apprend. La bibliothèque BitsAndBytes pour PyTorch nous aidera en ce sens.
A noter que vue la taille de nos modèles, mes 8 Go de VRAM en furie n’étaient pas du tout la contrainte que je croyais. L’entraînement de SmolLM2-135M a culminé à 0,94 Go, celui de Qwen 3.5 0.8B à 2,87 Go. J’aurais pu m’en passer. QLoRA est resté quand même, parce que c’est la configuration qui me permettra plus tard de faire tenir un modèle de 4 milliards de paramètres sur cette carte, et que ce jour-là la contrainte va mordre pour de bon. Mais nous n’en sommes pas là.
Premier test, Hello Model
Avant de me lancer dans un vrai entraînement avec le dataset construit dans l’article précédent, j’ai voulu faire un test façon Hello World pour m’assurer de la viabilité de mon environnement et de la chaîne d’entraînement. Une première passe sur deux modèles seulement, avec 120 paires (diff, message) extraites de Fulgur et 8 paires de test, suffit largement. Ce Hello Model est automatisé par un script Python. Quelque 90 secondes d’entraînement par modèle, et voici le résultat :
| Gemma 3 270M avant | après | SmolLM2 360M avant | après | |
|---|---|---|---|---|
| Format de message valide | 0/8 | 7/8 | 0/8 | 8/8 |
| Type correct | 0/8 | 1/8 | 0/8 | 3/8 |
Quatre-vingt-dix secondes pour passer de zéro message exploitable à 7/8 et 8/8. C’est la thèse du projet en une ligne de tableau, et c’est aussi le premier avertissement : les messages sont désormais fluides, bien formés, affirmatifs… et souvent faux. Gemma qualifie de fix(share): add a button to the share file un commit que j’avais typé refactor(share_file). La forme est une convention de surface et elle tombe immédiatement. Le type demande de comprendre ce que le diff a fait, et c’est une tout autre affaire.
Les deux ratés sont plus instructifs que les réussites. L’unique échec de format de Gemma est **doc-ref:** patch(deny): rsa- Marvin Attack : il a compris qu’un message de commit est une ligne courte avec un préfixe avant d’avoir compris quels préfixes existent. Et une réponse de SmolLM2 part en boucle sur perf(sync): sync-sync-sync-..., ce qui à 360M après trois époques sur 120 exemples n’est pas un bug mais un décodage sous-entraîné.
Ce Hello Model a parfaitement rempli son office car il aura fallu deux précédents essais pour arriver à ce résultat. Mais pour comprendre le premier échec, il faut d’abord un mot sur la seule chose qu’on regarde pendant qu’un entraînement tourne : la loss. C’est la mesure de l’erreur que le modèle commet sur ses propres exemples d’entraînement, recalculée à chaque étape et tracée tout au long du run. Elle descend, le modèle apprend, tout va bien. C’est le seul retour en direct dont on dispose, et c’est très exactement là qu’est le piège.
Ma première mesure donnait donc 0/8 avant entraînement et 0/8 après, sur les deux modèles et sur les deux lignes, le tout avec une loss qui descendait magnifiquement. La cause est bête et instructive : dans un exemple d’entraînement, le message de commit à apprendre se trouve après le diff. Or mon budget de 3000 caractères de diff, hérité tel quel des conditions d’inférence, poussait la réponse au-delà de la longueur maximale de l’exemple pour 58% du jeu d’entraînement. Le modèle s’entraînait donc sur des exemples amputés de leur réponse, et il apprenait consciencieusement la seule chose qu’il lui restait à apprendre : continuer un diff. La loss descendait parce qu’il y devenait réellement meilleur. Un budget d’inférence n’est pas un budget d’entraînement, et le script refuse désormais de démarrer si plus de 10% des exemples perdent leur réponse.
La deuxième version, elle, n’avait pas de chiffres du tout : Gemma renvoyait seize réponses vides, huit avant et huit après. J’y reviens plus bas. Aucun des deux runs n’a levé la moindre erreur. Dans cette pile logicielle, rien ne vous prévient que ça s’est mal passé : seule une comparaison avant/après sur des données jamais vues vous le dit.
Et bien sûr, 3/8 contre 1/8, c’est une différence de deux cas sur huit, sur un seul run, sur un seul dépôt. On ne conclut rien avec ça. Justement, passons aux choses sérieuses.
Premier tour : trois modèles, corpus humain
Le premier tour d’entraînement est réalisé sur un modèle de chaque architecture : Gemma 3 270M, SmolLM2 360M et Qwen 3.5 0.8B. Pour répondre à la question laissée ouverte dans l’article précédent, chaque modèle est entraîné sur 25%, 50% et 100% du jeu d’entraînement humain de 467 entrées, puis noté sur les 30 commits mis de côté. Neuf runs, environ deux heures de GPU au total, un script d’entraînement et un script d’évaluation.
La forme, c’est réglé
Commençons par la bonne nouvelle, celle que l’article précédent attendait. Nos deux plus petits modèles ne produisaient pas un seul message valide sur 30. Après entraînement sur un quart du dataset, tous les trois sont à 30/30. Pas 28, pas 29 : trente sur trente, et ça ne bouge plus ensuite.
Attention cepdendant: le format Conventional Commits est un motif de surface, quelques dizaines d’exemples suffisent à l’installer. Mais ça règle définitivement la question posée dans l’article 2, celle de savoir si un modèle de 270 millions de paramètres peut produire une ligne exploitable. Oui, et il n’en a même pas besoin de beaucoup pour ça. Au passage, la prédiction de l’article précédent se vérifie : les feat (desktop template added) et autres espaces parasites avant la parenthèse ont bien été effacés par le fine-tuning. Mon setup d’entraînement n’est donc pas cassé, ce qui, vu ce qui suit, méritait vérification.
Tout le problème restant tient donc dans une seule colonne.
Le type, c’est une autre paire de manche
La baseline de l’article précédent passait par Ollama en quantification Q8_0, alors que tout passe maintenant par la pile transformers, donc on mesure de nouveau cete baseline (qui change très légèrement).
| Données d’entraînement | Qwen 3.5 0.8B | Gemma 3 270M | SmolLM2 360M |
|---|---|---|---|
| 0% (base) | 5/30 - feat x24 |
0/30 | 0/30 |
| 25% | 17/30 - 6 types | 7/30 - fix x28 |
8/30 - fix x20 |
| 50% | 20/30 - 7 types | 9/30 - 3 types | 10/30 - 3 types |
| 100% | 21/30 - 7 types | 15/30 - 5 types | 15/30 - 7 types |
La deuxième colonne de chaque case est le nombre de types distincts que le modèle a effectivement proposés, et elle est là pour une raison précise. Regardez Gemma à 25% : 7/30. Répondre chore à absolument tout, sans rien lire, en rapporte exactement 7 aussi. Le modèle n’a donc strictement rien appris d’utile ici, il a juste déplacé son effondrement : il répond fix 28 fois sur 30 sur un jeu qui contient 7 vrais fix. C’est le même phénomène que le feat compulsif du Qwen non entraîné de l’article précédent, avec une autre étiquette. Sans la répartition des prédictions à côté, ce 7/30 ressemble à un début d’apprentissage, et c’est un mirage complet. Morale, encore une fois : un score agrégé ne dit pas ce que le modèle fait.
Un chiffre indispensable pour lire la suite : en réentraînant deux fois la même configuration, avec la même graine et les mêmes données, l’écart est d’environ un cas, soit 3,3 points sur 30, à cause de l’ordre non déterministe des noyaux CUDA. Toute différence inférieure à ça n’est pas un résultat, c’est du bruit. Ce qui nous donne deux tendances, et une seule est solide :
- Qwen fait un bond immédiat dès 25% du dataset, puis plafonne. Il gagne +1 sur le dernier doublement, ce qui est très exactement le plancher de bruit. Son plateau commence donc à 50%, pas à 100%. Logique : il savait déjà lire un
diffavant qu’on y touche, il n’avait qu’un dialecte à apprendre, et c’est fait. - Gemma et SmolLM2 grimpent encore à 100%, et à rythme constant. Il y a un piège de lecture ici, et je suis tombé dedans en rédigeant : ils gagnent +2 cas sur le premier palier, puis +6 et +5 sur le second, ce qui ressemble beaucoup à une accélération. Sauf que les deux paliers n’ajoutent pas la même quantité de données. Le dataset fait 467 paires, donc les trois points sont à 117, 234 et 467 commits : le premier palier ajoute 117 commits, le second en ajoute 233, soit deux fois plus. Ramené à quantité ajoutée égale, le rythme est de 2 cas puis 3 pour Gemma, 2 puis 2,5 pour SmolLM2. Un cas d’écart, c’est-à-dire du bruit. La progression est donc linéaire, et c’est bien suffisant : l’important n’est pas la forme exacte de la droite, c’est qu’aucun rendement décroissant n’est visible à 100%. Ces deux-là n’ont pas fini d’apprendre, et la donnée est bien leur facteur limitant.
Et n’allons pas plus loin que ça dans l’analyse de courbe : trois points, un plancher de bruit d’un cas, et un premier point qui pour Gemma correspond à un modèle effondré sur fix plutôt qu’à un modèle qui a appris quelque chose. On peut affirmer que ça monte encore. On ne peut pas ajuster une équation.
C’est la réponse à la question que je laissais ouverte dans l’article précédent, et elle est plus intéressante que ce que j’espérais : “faut-il plus de données ?” n’a pas de réponse unique. C’est non pour Qwen et oui pour les deux petits. Un objectif de volume fixé à l’avance, comme mes 1000 à 2000 paires initiales, m’aurait fait passer à côté de cette distinction.
Deuxième tour : quatre modèles, corpus augmenté
Pour ce deuxième tour, je me contente des datasets entiers, et j’ajoute au passage SmolLM2-135M, le plus petit modèle de la série. Trois jeux d’entraînement sont comparés : les 467 paires humaines du premier tour, les 428 commits nouvellement typés lors de la passe de typage décrite dans l’article précédent, et la combinaison des deux, soit 855 paires après nettoyage et dédoublonnage. Les 428 seuls servent de comparaison à volume à peu près égal avec les 467 humaines, ce qui est la seule façon de savoir si ces nouvelles données valent les anciennes.
| Humain @467 | Typé @428 | Combiné @855 | |
|---|---|---|---|
| Qwen 3.5 0.8B | 21/30 | 19/30 | 20/30 |
| Gemma 3 270M | 15/30 | 14/30 | 15/30 |
| SmolLM2 360M | 15/30 | 12/30 | 16/30 |
| SmolLM2 135M | 16/30 | 13/30 | 18/30 |
Plusieurs observations, en gardant en tête le plancher de bruit de un cas :
- le plateau de Qwen se confirme, et Gemma s’y installe à son tour : doubler le volume ne lui rapporte rien du tout,
- SmolLM2 360M plafonne aussi, son +1 sur le corpus combiné étant tout juste du bruit,
- SmolLM2 135M fait au moins aussi bien que son aîné deux fois plus gros, et mieux que Gemma, au point de venir titiller Qwen sur le plus gros dataset. Deux tiers de moins de paramètres, deux points de plus. On y reviendra longuement,
- le corpus typé seul est constamment moins bon que le corpus humain, sur les quatre modèles, sans exception.
Ce dernier point mérite qu’on résiste à la conclusion facile. Deux lectures l’expliquent aussi bien l’une que l’autre : soit ces paires supplémentaires sont de moins bonne qualité parce qu’elles ont été partiellement typées à la machine, soit ajouter du volume ne sert simplement plus à rien à ce stade pour ces modèles. Mes données ne permettent pas de trancher, parce que les paires ajoutées sont à la fois plus nombreuses et typées automatiquement, et que ces deux hypothèses prédisent exactement le même tableau. La seule expérience qui départagerait les deux serait un lot de données supplémentaires typées à la main, et il n’existe pas. Je note donc l’ambiguïté plutôt que de choisir la version qui m’arrange.
Et cette passe de typage n’a pas été inutile pour autant : elle n’a rien apporté sur le jeu de test, mais elle a nettement amélioré les résultats sur les diffs TypeScript que le modèle n’a jamais vus à l’entraînement. C’est un tout autre sujet, et c’est un article à lui tout seul.
Où sont les erreurs, exactement ?
Un score global de 15 ou 21 sur 30 ne dit pas grand-chose tant qu’on ne l’a pas ouvert. Voici le détail par type sur le corpus combiné, avec le nombre de bonnes réponses par modèle :
| Type | Jeu de test | Qwen | Gemma | SmolLM2 360M | SmolLM2 135M |
|---|---|---|---|---|---|
| chore | 7 | 6 | 5 | 6 | 6 |
| fix | 7 | 5 | 5 | 5 | 6 |
| test | 2 | 2 | 2 | 2 | 2 |
| docs | 2 | 2 | 2 | 2 | 2 |
| perf | 2 | 2 | 0 | 0 | 0 |
| feat | 6 | 2 | 1 | 1 | 2 |
| refactor | 3 | 1 | 0 | 0 | 0 |
| style | 1 | 0 | 0 | 0 | 0 |
Cette table change complètement la nature du problème. chore, fix, test et docs sont essentiellement résolus, y compris par le modèle à 135 millions de paramètres. Le style à 0 partout était annoncé dans l’article précédent : un seul exemple d’entraînement, donc une erreur garantie, et il ne faut pas la mettre sur le dos des modèles. Tout ce qui reste à gagner est concentré dans feat et refactor, et une seule confusion domine : un feat est reconnu comme fix 15 fois et l’inverse 7 fois, tous modèles confondus, soit plus de la moitié des erreurs.
La conséquence pratique est nette, et elle oriente le prochain article : il faut optimiser pour feat et refactor, ou ne pas optimiser du tout. Un réglage générique qui améliorerait chore ne peut rien rapporter, chore est déjà à 6 sur 7.
Reste à comprendre pourquoi ces deux-là résistent. Voici trois commits que les quatre modèles ratent, unanimement, de la même façon :
feat(shutdown): truncate WAL on SIGTERM/Ctrl+C -> tous répondent fix
feat(db): set 30s SQLite busy_timeout on contended writes -> tous répondent fix
feat(sse): add absolute read deadline to detect dead conns -> tous répondent fix
Lus dans le diff, ces trois-là ressemblent effectivement à des réparations. Ils sont typés feat parce que l’auteur, c’est-à-dire moi, savait qu’il s’agissait d’un comportement nouveau et voulu. Prenez set 30s SQLite busy_timeout on contended writes : c’est une mesure préventive et donc une fonctionnalité, ou bien c’est la réponse à des utilisateurs qui attendaient que SQLite se réveille et donc un correctif. Les deux se défendent devant le même diff, et seule l’intention tranche. Sauf que l’intention, elle, n’est pas dans le code.
L’explication qui vient donc naturellement est que l’information manquante n’est pas dans le diff, que le plafond est atteint, et qu’on peut ranger le matériel. Gardez cette conclusion sous le coude, elle est confortable, elle généralise trois exemples à tout un résidu d’erreurs, et elle est en bonne partie fausse. J’ai fini par la mesurer au lieu de la supposer, et le résultat m’a obligé à revenir sur ce paragraphe. Ce sera pour un prochain article.
Et maintenant ?
Le bilan de cette campagne d’entraînements est plus tranché que prévu. La forme est acquise, gratuitement ou presque. Quatre types sur huit sont au plafond. Augmenter encore le dataset ne profiterait qu’à un seul modèle, SmolLM2-135M, dont la courbe est de loin la plus raide du projet. Et le meilleur rapport qualité/taille du lot est tenu par le plus petit modèle de la série, ce qui n’était pas franchement le pronostic de départ.
Dans le prochain article, je m’attaque aux hyperparamètres pour essayer de gratter ces fameux feat et refactor. Je vous préviens tout de suite : cette campagne d’optimisation est un échec. Un échec utile, mais un échec.
Trois pièges qui n’apparaissent dans aucun tutoriel
Avant de se quitter, je reviens sur quelques soucis qui ont fait que le premier entraînement s’est très bien passé… au troisième essai (décidément !). On va entrer dans des points assez techniques ici.
Gemma ne sait pas calculer en fp16, et ne le dit pas. La RTX 2060 Super est une carte Turing, une architecture qui ne connaît pas le format bf16 et travaille donc en fp16. Or Gemma 3 a été entraîné en bf16 et ses activations ne tiennent pas dans la plage du fp16 : le modèle renvoie des logits intégralement NaN et génère du <pad> en boucle. Pas de crash, pas d’avertissement, juste des réponses vides. Le même prompt en fp32 donne un résultat parfaitement sain. C’est un vrai conflit matériel/modèle, Turing n’a pas de bf16 et Gemma n’a pas de fp16, et la seule sortie est d’entraîner ce modèle-là en fp32. C’est abordable uniquement parce que les poids de base restent en 4 bits et que le modèle fait 270M ; ça ne le serait pas à 4B. Si mes lecteurs veulent se cotiser pour que je puisse accéder à une RTX 5090 et éviter ce genre de soucis, merci d’avance !
Un adaptateur LoRA dont les clés ne correspondent pas au modèle se charge sans erreur. Celui-là m’a coûté le premier run de Qwen. En laissant la bibliothèque d’entraînement choisir elle-même la classe du modèle, elle a instancié un wrapper multimodal dont les couches texte vivent sous model.language_model.layers.*, et l’adaptateur a été sauvegardé avec ces clés. Côté évaluation, AutoModelForCausalLM expose model.layers.*. Résultat : plus rien ne correspond, et comme l’une des deux matrices LoRA est initialisée à zéro par construction, les 186 couches se sont chargées à zéro. L’adaptateur était donc mathématiquement l’identité, et le modèle “fine-tuné” répondait octet pour octet comme le modèle de base sur les 30 cas de test. Le tout derrière une courbe de loss qui descendait proprement de 3,0 à 2,18.
Ce qui rend ce piège vicieux, c’est que le résultat est plausible : un petit modèle qui ne gagne pas grand-chose à un fine-tuning, c’est exactement ce à quoi on s’attend. Je ne l’ai repéré que parce que la distribution des types prédits était identique et pas seulement proche. La leçon rejoint celle des labels tronqués du Hello Model : la courbe de loss ne prouve pas que le run a fait quelque chose. Il faut vérifier l’artefact produit, pas la métrique. Le script d’évaluation refuse désormais de noter un adaptateur dont toutes les matrices sont nulles.
Une valeur finie n’est pas une valeur saine. Un run de SmolLM2 360M s’est terminé avec un training_loss de 3415,8. Fini, donc, pas NaN, donc mon garde-fou l’a laissé passer et l’adaptateur a été sauvegardé. Il score 6/30 en répondant perf quatorze fois. C’est l’échelle de loss du fp16 qui s’est effondrée en cours de route, la fameuse instabilité de Turing. Le run identique relancé derrière, même graine et mêmes données, termine à 2,475 et score 16/30 : c’est un tirage à pile ou face, pas un défaut de configuration. Le script rejette maintenant toute loss supérieure à 20, sachant qu’une prédiction uniforme sur un vocabulaire de 50 000 tokens tourne autour de 10,8. Prévoyez un run de rattrapage par branche fp16 un peu longue.
Ce qui m’amène à une remarque que vous vous faites peut-être depuis trois paragraphes. Je viens de vous présenter la loss comme le seul indicateur qu’on surveille pendant un entraînement, et elle n’apparaît dans aucun tableau de résultats de cet article. Ce n’est pas un oubli, c’est la conclusion. La loss sert à détecter qu’un run a déraillé, et elle fait ça très bien quand elle vaut 3415,8. Elle ne sert pas à dire si le modèle est bon, parce qu’elle ne mesure que l’écart entre ses prédictions et les exemples qu’on lui a donnés à réviser. Deux des trois échecs ci-dessus ont produit une loss parfaitement présentable, et le troisième n’a été rattrapé que parce qu’une valeur de 3415,8 finit par se remarquer. Les seuls chiffres que je publie sont donc ceux du jeu de test, sur des commits qu’aucun modèle n’a jamais vus. La loss est un voyant d’alerte au tableau de bord, pas le compteur de vitesse. Le prochain article ira même un cran plus loin : on y verra des runs dont la loss est la plus basse et le score sur le jeu de test parmi les plus mauvais. Non seulement ces deux indicateurs ne disent pas la même chose, mais il leur arrive de pointer dans des directions opposées.
TL;DR
- LoRA gèle les poids du modèle et n’entraîne que deux petites matrices greffées à côté, ce qui fait tomber l’entraînement de centaines de millions de paramètres à quelques millions. QLoRA y ajoute un modèle de base en 4 bits, les adaptateurs restant eux en précision haute.
- 90 secondes de fine-tuning sur 120 exemples suffisent à faire passer un modèle de zéro message valide sur 8 à sept ou huit sur huit.
- Sur le vrai dataset, les trois modèles atteignent 30/30 sur le format avec un quart des données. Le format est un motif de surface et il tombe immédiatement. Tout le problème restant est le type.
- “Faut-il plus de données ?” n’a pas de réponse unique : Qwen plafonne dès 50% du dataset, là où Gemma et SmolLM2 progressent encore linéairement à 100%, sans le moindre rendement décroissant.
- Trois pièges silencieux, chacun capable de produire un tableau de résultats crédible et faux : Gemma qui renvoie des NaN en fp16, un adaptateur LoRA mal nommé qui se charge comme une identité derrière une belle courbe de loss, et une loss finie mais divergée à 3415,8. La courbe de loss ne prouve jamais que le run a fait quelque chose.
chore,fix,testetdocssont résolus. Tout ce qui reste est dansfeatetrefactor, et plus de la moitié des erreurs sont une seule confusion,featpris pourfix.
Photo originale par Frédéric Loridant, Free Art License 1.3.