Index
7 min de lecture

570 000 lignes de code LLM qui compilaient parfaitement. 20 171 fois plus lent que SQLite.

Un développeur a benchmarké une réimplémentation Rust de SQLite entièrement écrite par un LLM. L'écart entre du code qui semble juste et du code qui l'est vraiment s'est avéré couvrir cinq ordres de grandeur.

Une réimplémentation de SQLite en Rust, entièrement générée par un LLM, a récemment fait l’objet d’un benchmark sérieux. Le code compilait. Les tests passaient. La structure était propre, idiomatique, lisible. Sur une simple recherche par clé primaire, elle était 20 171 fois plus lente que SQLite.

Ce chiffre m’a arrêté net. Pas parce que du code généré par LLM soit lent par nature, mais à cause de l’origine précise de cette lenteur. Le code n’était faux d’aucune façon qu’un compilateur ou une suite de tests aurait pu détecter. Le B-tree était correctement implémenté. Le query planner existait. Le moteur de stockage fonctionnait. Chaque composant était individuellement défendable. Le système dans son ensemble était quasiment inutilisable.

J’ai passé du temps à lire l’analyse du benchmark et le code source. Les patterns que j’y ai trouvés reviennent constamment dans les projets générés par LLM, et je pense qu’ils révèlent quelque chose de fondamental sur la façon dont ces modèles écrivent du code.

Le B-tree était là. Le query planner l’ignorait.

Dans SQLite, une recherche par PRIMARY KEY emprunte le chemin du B-tree et se termine en O(log n). Quatre lignes dans where.c vérifient la présence d’un iPKey et redirigent la requête directement vers l’arbre. C’est le genre de micro-optimisation qui n’a de sens que si l’on comprend comment tout le système s’articule.

La version LLM avait elle aussi une implémentation de B-tree. Elle fonctionnait correctement en isolation. Le problème : le query planner ne l’appelait jamais pour les recherches par clé primaire. La fonction is_rowid_ref() ne reconnaissait que trois chaînes littérales : “rowid”, “rowid” et “oid”. Si vous déclariez une colonne id INTEGER PRIMARY KEY, le planner ne la reconnaissait pas comme alias de rowid. Chaque requête déclenchait un full table scan à la place.

Le calcul est brutal. Pour 100 lignes interrogées 100 fois, le chemin B-tree nécessite environ 700 étapes de comparaison. Le chemin full scan en nécessite plus de 10 000. Mais le vrai dégât vient de la complexité algorithmique : O(log n) par lookup devient O(n), et sur l’ensemble du benchmark, ça se compose pour produire ce facteur 20 171.

C’est exactement le type de bug qu’aucun test unitaire ne détecte, à moins d’écrire un benchmark spécifiquement pour ça. Le B-tree fonctionne. Le scan fonctionne. Le planner choisit le mauvais. Tout passe au vert.

Les valeurs par défaut sûres se composent comme des intérêts

Ce qui rend ce cas plus intéressant qu’un simple bug de routage, c’est ce qui reste une fois le problème du query planner corrigé. Même après correction, la réimplémentation était encore environ 2 900 fois plus lente. L’écart résiduel provenait d’un empilement de décisions individuellement raisonnables.

Chaque exécution de requête clonait l’AST complet et le recompilait en bytecode. SQLite réutilise les prepared statement handles. Les deux approches sont valides, mais cloner un AST à chaque exécution coûte cher à l’échelle.

Chaque lecture de page allouait un nouveau buffer de 4 Ko sur le tas. Le page cache de SQLite retourne un pointeur direct vers la mémoire déjà chargée. La version LLM a choisi le chemin sûr et évident : allouer, lire, retourner. Ça fonctionne. C’est simplement plusieurs ordres de grandeur plus lent quand on lit des milliers de pages par requête.

Chaque commit reconstruisait le schéma entier depuis zéro. SQLite compare une seule valeur entière, le cookie de schéma. Si le cookie n’a pas changé, le schéma est toujours valide. La réimplémentation n’avait pas ce concept, donc elle refaisait tout le travail à chaque fois.

Chaque statement déclenchait un appel sync_all() pour vider toutes les métadonnées du fichier sur le disque. SQLite utilise fdatasync(), qui ne vide que les données du fichier et ignore la synchronisation des métadonnées. La différence est énorme sur les workloads en écriture intensive.

J’appelle ça l’effet de composition des valeurs par défaut défensives. Chaque choix pris isolément a une justification raisonnable. Cloner l’AST évite la complexité d’ownership en Rust. Allouer des buffers frais prévient les use-after-free. Reconstruire le schéma évite les problèmes de cache périmé. Appeler sync_all() offre la garantie de durabilité la plus forte.

Mais les coûts de performance se multiplient, ils ne s’additionnent pas. Quand quatre pénalités de 10x s’empilent, on n’obtient pas 40x plus lent. On obtient 10 000x plus lent. Un LLM ne raisonne pas sur cette composition parce qu’il génère chaque fonction en isolation relative. Il optimise localement et paye globalement.

82 000 lignes pour remplacer un one-liner cron

L’autre projet LLM du même développeur illustrait le même pattern sous une forme différente. Le problème : les artefacts de build dans le répertoire target/ de Rust consomment de l’espace disque au fil du temps. La solution du LLM : un daemon Rust de 82 000 lignes avec sept dashboards et un moteur de scoring bayésien pour décider quels artefacts supprimer.

La solution existante, c’est find ./target -type f -atime +30 -delete, une seule ligne dans un cron job. Zéro dépendance. Ou cargo-sweep, un outil communautaire officiel qui existe déjà et gère les cas limites que le daemon ignore.

Le projet généré par LLM tirait 192 dépendances. Pour référence, ripgrep, l’un des outils de recherche les plus sophistiqués de l’écosystème Rust, en utilise 61.

C’est un pattern que je vois constamment : les LLMs construisent ce qu’on leur demande, pas ce dont on a besoin. Si vous promptez “construis un système qui gère intelligemment les artefacts de build Rust avec monitoring et scoring”, vous obtenez exactement ça. Le modèle n’a aucun mécanisme pour prendre du recul et demander si le problème nécessite un système du tout. Il ne sait pas que la taille du répertoire target/ est une plainte récurrente dans la communauté Rust avec des solutions bien connues. Il ne tient pas compte du coût de maintenance de 192 dépendances contre zéro.

La recherche pointe dans la même direction

Je me demandais si ces deux projets étaient des cas isolés. J’ai donc regardé la littérature plus large. Ils ne le sont pas.

METR a conduit un essai contrôlé randomisé avec 16 développeurs open-source expérimentés. Le groupe utilisant des outils IA a terminé les tâches 19% plus lentement que le groupe contrôle. Ce qui m’a frappé : après l’expérience, le groupe IA croyait avoir été 20% plus rapide. L’expérience subjective de la productivité était l’inverse de la réalité mesurée.

GitClear a analysé 210 millions de lignes de code et constaté que le code copié-collé a dépassé le code refactorisé pour la première fois. La tendance corrèle directement avec l’adoption des outils de coding IA. Du code est ajouté plus vite qu’il n’est amélioré.

Le rapport DORA 2024 de Google a trouvé qu’une augmentation de 25% de l’adoption de l’IA corrélait avec une baisse de 7,2% de la stabilité des déploiements. Plus de code généré par IA en production, plus d’incidents en sortie.

Le benchmark Mercury de NeurIPS 2024 a ajouté des métriques d’efficacité aux benchmarks de coding standards. Quand on mesure non seulement “produit-il le bon output” mais “produit-il le bon output sans gaspiller des ressources”, les taux de réussite sont tombés sous 50%.

Rien de tout cela ne signifie que les LLMs sont inutiles pour coder. Je les utilise constamment. Mais ça signifie que “compile et passe les tests” est une barre dangereusement basse. L’espace entre code plausible et code correct, c’est là que se passe le vrai travail d’ingénierie.

Ce que ça exige réellement des développeurs

Le problème fondamental n’est pas que les LLMs écrivent du mauvais code. Ils écrivent du code qui est localement cohérent et globalement incohérent. Chaque fonction a du sens. Le système, non. C’est exactement le mode d’échec que les tests traditionnels ratent, parce que les tests vérifient le comportement local.

Ce qu’il faut, c’est une évaluation qui cible ces angles morts. Des benchmarks, pas seulement des tests. Des budgets de performance dans la CI, pas seulement des vérifications de correction. Une revue architecturale qui demande “pourquoi ce module existe-t-il” avant de vérifier s’il fonctionne. Des audits de dépendances qui comparent la complexité de la solution à la complexité du problème.

La question n’est pas “ce code a-t-il l’air juste ?”. C’est “comment prouve-t-on qu’il l’est ?”. Et le prouver exige le type de réflexion systémique qui manque actuellement aux LLMs.

L’espace entre ce qu’on a demandé et ce que la production exige, c’est là que vit le jugement d’ingénierie. Sans mesure, la génération de code n’est que de la génération de tokens.

Rejoindre la newsletter

Recevez des mises à jour sur mes derniers projets, articles et expériences en IA et développement web.