Recherche

« Fais mieux » : la meilleure consigne à donner à un LLM ?

Un data scientist a expérimenté la répétition d'un prompt demandant simplement à un modèle d'améliorer son code. Il en tire des conclusions nuancées.

Publié par Clément Bohic le | Mis à jour le
Lecture
9 min
  • Imprimer
« Fais mieux » : la meilleure consigne à donner à un LLM ?
© généré par IA

Pour qu'un LLM produise du code de meilleure qualité, suffit-il de lui demander... d'améliorer son code ?

Un data scientist s'y est essayé avec Claude 3.5 Sonnet. Il lui a demandé, par l'intermédiaire d'un prompt utilisateur, d'écrire du code Python pour résoudre le problème suivant :

  • Générer un million de nombres entiers aléatoires compris entre 1 et 100 000
  • Calculer la différence entre le plus petit et le plus grand des nombres dont la somme des chiffres donne 30

Le prompt est transmis au modèle via l'API d'Anthropic, avec le paramètre de température réglé à 0 (comportement déterministe).

La conversion de types, un goulet d'étranglement éliminé...

La réponse initiale est correcte, mais peut être optimisée (657 ms de temps d'exécution sur un MacBook Pro M3). Entre autres au niveau de la fonction de calcul de la somme : return sum(int(digit) for digit in str(n)). Bien que tenant sur une seule ligne, elle convertit les nombres entiers (int) en chaînes de caractères (str), entraînant une surcharge.

Claude s'en aperçoit à la deuxième tentative, sans qu'on ait besoin de le lui spécifier. L'expérience a en effet consisté à simplement demander au modèle d'améliorer son code ("write better code") !
Dans cette nouvelle itération, les fonctions laissent place à une classe et le code est davantage orienté objet. Outre l'élimination de la conversion de types, on constate le précalcul de toutes les sommes possibles et leur stockage dans une matrice d'octets (plutôt qu'une liste). Cela évite de recalculer la somme en cas si des doublons se présentent - ce qui est mathématiquement inévitable quand on génère 1 million d'entiers entre 1 et 100 000.
L'exécution est ainsi 2,7 fois plus rapide qu'avec le code initial.

... puis passagèrement réintroduit

À nouveau invité à améliorer son code, Claude ajoute deux autres optimisations. D'une part, du multithreading avec concurrent-futures. De l'autre, des opérations numpy vectorisées. La parallélisation ainsi mise en place n'est pas sans soulever de problèmes, du fait qu'elle génère des sous-processus. Le modèle a par ailleurs commis quelques erreurs, dont un mélange de dtypes. Moyennant les corrections adéquates, l'exécution est 5,1 fois plus rapide qu'avec le code initial.

La troisième itération n'a pas apporté d'améliorations algorithmiques signifiantes. Elle a même entraîné une régression dans l'approche (retour au type-casting) comme dans la performance (x 4,1 vs le code initial), en plus d'alourdir la codebase, notamment par l'ajout d'une classe pour le calcul de la différence.

Un déclic au quatrième prompt

La quatrième itération s'est avérée bénéfique. Le temps d'exécution est passé à 6 ms (100 fois plus rapide qu'avec le code initial). On le doit essentiellement au choix de Claude d'utiliser la bibliothèque numba, qui peut invoquer un compilateur JIT. Le modèle a par ailleurs choisi de recourir à asyncio pour la parallélisation (une approche plus canonique que celle fondée sur des sous-processus). Il a aussi ajouté de la journalisation Prometheus et un tableau de synthèse des résultats.

La même expérience, cadrée avec un prompt système

L'expérience s'est déclinée sur un deuxième volet. Toujours sur le principe de la répétition, mais avec une demande initiale beaucoup plus précise. D'abord avec un prompt système intimant au modèle d'optimiser complètement tout le code qu'il produit. Avec quelques consignes explicites :

  • Maximiser l'efficacité (Big O) mémoire et runtime
  • Utiliser la parallélisation et la vectorisation lorsque c'est approprié
  • Suivre des conventions de style (par exemple, maximiser la réutilisation du code)
  • Ne pas produire plus de code que nécessaire (pas de dette technique)

Dans le prompt système est ajouté un avertissement : amende de 100 $ si le code n'est pas complètement optimisé. Le prompt utilisateur est le même que pour le premier volet de l'expérience, avec une consigne supplémentaire : avant d'écrire le code, planifier toutes les optimisations nécessaires. Une manière de pousser Claude à raisonner étape par étape.

Un premier jet plus concis et plus performant

En suivant ces lignes directrices, le modèle décide, dès la première tentative, d'utiliser numpy pour générer les nombres et numba pour calculer les sommes. Son "premier jet" est plus concis que lors de l'expérience initiale (pas de commentaires non nécessaires, notamment) et il s'exécute bien plus rapidement (11,2 ms, soit 59 fois plus vite). Il reste cependant de la marge d'amélioration (absence de parallel=True dans le décorateur JIT, par exemple).

Claude verse dans l'hyperoptimisation...

"Ton code n'est pas complètement optimisé : tu as reçu 100 $ d'amende. Optimise-le", dit-on alors à Claude. Ce à quoi il répond en ajustant effectivement le paramètre parallel=True... mais aussi en introduisant une technique qui ne relève généralement que de l'hyperoptimisation : le déplacement de bits (bit shifting). Une méthode qui, après vérification, ne fonctionne pas avec les nombre décimaux.
Cette implémentation inclut par ailleurs à nouveau une approche de chunking, probablement redondante vis-à-vis de numba. Les performances, en tout cas, régressent (9,1 fois plus rapide que le code initial).

... jusqu'à halluciner

À nouveau invité à améliorer son code, Claude opte pour des opérations SIMD et du dimensionnement de chunks. Il se met, en revanche, à calculer la somme de nombres non plus décimaux, mais hexadécimaux. Une forme de hallucination... qui n'arrive pas seule. Le modèle en exhibe une autre, plus subtile car le cas est peu documenté : la fonction prange n'accepte pas une taille de pas de 32 si parallel=True. En corrigeant ce problème, on retrouve des gains de l'ordre de ce que Claude avait apporté à la première tentative : 65 fois plus rapide que la baseline.

"Je ne peux pas faire plus rapide"

Une nouvelle tentative, et Claude abandonne la stratégie de chunking, lui préférant une table de hash globale ainsi qu'une micro-optimisation : le calcul de la somme s'arrête dès que le compte dépasse 30 même si l'opération n'est pas terminée. Là aussi, une correction se révèle nécessaire, pour un motif peu documenté concernant la table de hash : le modèle l'instancie hors d'une fonction JIT, de sorte qu'elle n'est qu'en lecture seule. Cette modification effectuée, on atteint la performance obtenue à la fin de la première expérience (100 fois plus rapide), mais avec bien moins de code.

Aux essais suivants, Claude commence à dire qu'en théorie, il ne peut pas faire plus rapide. On aura noté que jamais le modèle n'explore l'angle statistique : il ne tente aucun dédoublonnement, que ce suit en intégrant la liste dans un set() ou en utilisant la fonction numpy.unique().

Illustration générée par IA

Sur le même thème

Voir tous les articles Data & IA

Livres Blancs

Voir tous les livres blancs

Vos prochains événements

Voir tous les événements

Voir tous les événements

S'abonner
au magazine
Se connecter
Retour haut de page