En 2023, une tendance éphémère sur les réseaux sociaux consistait à fournir une image de base à un modèle de langage et à lui demander de la rendre "plus quelque chose" de façon itérative. Le résultat final finissait souvent par converger vers un style cosmique répétitif, illustrant la notion de "bouillie IA". Cette expérience a inspiré une question technique : que se passe-t-il si l'on applique la même méthode itérative simple pour générer du code ?
Contrairement aux images générées par IA, le code produit par les LLMs est souvent fonctionnel, car il obéit à des règles strictes. Si des itérations successives de prompts vagues comme "écrire un meilleur code" pouvaient réellement améliorer la qualité et la performance du code, cela représenterait un gain de productivité considérable. Pour explorer cette idée, une expérience a été menée avec un LLM.
Une tâche de programmation classique
Le prompt initial soumet un problème de type entretien technique, simple à implémenter mais offrant de la marge pour l'optimisation. Pour garantir l'originalité, le problème est conçu pour ne pas provenir de plateformes de codage connues.
Écrire du code Python pour résoudre ce problème :
Étant donné une liste de 1 million d'entiers aléatoires compris entre 1 et 100 000, trouver la différence entre le plus petit et le plus grand nombre dont la somme des chiffres est égale à 30.
L'implémentation initiale produite par le LLM est correcte et typique d'un code novice. Elle utilise une fonction pour calculer la somme des chiffres en convertissant le nombre en chaîne, et parcourt la liste en mettent à jour les valeurs min et max.
import random
def somme_chiffres(n):
"""Calcule la somme des chiffres d'un nombre."""
return sum(int(ch) for ch in str(n))
def trouver_difference():
nombres = [random.randint(1, 100000) for _ in range(1000000)]
min_val = float('inf')
max_val = float('-inf')
for num in nombres:
if somme_chiffres(num) == 30:
min_val = min(min_val, num)
max_val = max(max_val, num)
if min_val == float('inf'):
return "Aucun nombre trouvé avec une somme de chiffres de 30"
return max_val - min_val
Ce code a servi de référence de performance.
Itération #1 : "écrire un meilleur code"
Après une première itération de prompt, le LLM a refactorisé le code en une classe et introduit deux améliorations algorithmiques notables : le calcul de la somme des chiffres via des opérations entières, et le pré-calcul de toutes les sommes possibles dans un tableau pour éviter les recalculs.
class RechercheurSommeChiffres:
def __init__(self, cible: int = 30, borne_sup: int = 100_000, nb_elements: int = 1_000_000):
self.cible = cible
self.borne_sup = borne_sup
self.nb_elements = nb_elements
self.table_sommes = self._precalculer()
def _precalculer(self) -> array:
table = array('B', [0] * (self.borne_sup + 1))
for num in range(1, self.borne_sup + 1):
somme = 0
n_temp = num
while n_temp:
somme += n_temp % 10
n_temp //= 10
table[num] = somme
return table
def trouver_difference(self) -> Tuple[int, Optional[int], Optional[int]]:
min_val = float('inf')
max_val = float('-inf')
compteur = 0
for _ in range(self.nb_elements):
num = random.randint(1, self.borne_sup)
if self.table_sommes[num] == self.cible:
compteur += 1
if num < min_val: min_val = num
if num > max_val: max_val = num
if compteur == 0:
return 0, None, None
return max_val - min_val, min_val, max_val
Ces améliorations ont accéléré l'exécution d'un facteur 2,7 par rapport à la version de référence.
Itération #2 : Introduire le parallélisme et la vectorisation
Une seconde itération de prompt a poussé le LLM à utiliser le parallélisme et la vectorisation. Le code a été restructuré pour utiliser numpy et le module concurrent.futures. Une méthode de calcul vectorisé de la somme des chiffres a été implémentée.
def precalculer_sommes_vectorisees(borne_sup: int) -> array:
chiffres = np.arange(borne_sup + 1)
sommes = np.zeros(borne_sup + 1, dtype=np.uint8)
while chiffres.any():
sommes += chiffres % 10
chiffres //= 10
return array('B', sommes)
def traiter_bloc(bloc_taille: int, table_sommes: array, cible: int) -> Iterator[int]:
nombres = np.random.randint(1, 100001, bloc_taille, dtype=np.int32)
masque = np.frombuffer(table_sommes, dtype=np.uint8)[nombres] == cible
return nombres[masque]
def analyser(nb_blocs: int, taille_bloc: int) -> Resultat:
with ProcessPoolExecutor() as executor:
futures = [executor.submit(traiter_bloc, taille_bloc) for _ in range(nb_blocs)]
min_val, max_val = float('inf'), float('-inf')
for fut in as_completed(futures):
for num in fut.result():
min_val = min(min_val, num)
max_val = max(max_val, num)
return Resultat(min_val, max_val)
Malgré la nécessité de corrections mineures (gestion des types, sérialisation), la performance a été multipliée par 5,1.
Itération #3 et #4 : La loi des rendements décroisants et la compilation JIT
Les itérations suivantes ont introduit un sur-ingénierie (ajout de classes, journalisation complexe) sans gain algorithmique majeur, confirmant la loi des rendements décroissants. Cependant, l'introduction de la compilation Just-In-Time (JIT) via la bibliothèque numba a apporté un saut spectaculaire de performance.
@jit(nopython=True, parallel=True)
def calculer_sommes_numba(nombres: ArrayInt) -> ArrayInt:
result = np.zeros_like(nombres)
for i in prange(len(nombres)):
num = nombres[i]
total = 0
while num:
total += num % 10
num //= 10
result[i] = total
return result
Cette version, combinée avec une gestion asynchrone, a permis une accélération de 100x par rapport à l'implémentation de base.
Le rôle crucial de l'ingénierie de prompt
La première série d'expériences a montré que des prompts vagues pouvaient améliorer le code, mais de manière imprévisible et parfois en introduisant une complexité inutile. Une seconde série a été conduite avec une ingénierie de prompt rigoureuse, utilisant des instructions système claires définissant l'optimisation et demandant explicitement une planification préalable.
Système: "Tout code que tu écris DOIT être entièrement optimisé. L'optimisation inclut : maximiser l'efficacité algorithmique (Big-O), utiliser la parallélisation/vectorisation, suivre les conventions de style, et ne pas écrire de code superflu. Si le code n'est pas optimisé, tu seras pénalisé."
Utilisateur: "Écrire du code Python... Avant d'écrire le code, planifie toutes les optimisations nécessaires."
Avec cette approche, le LLM a immédiatement choisi d'utiliser numpy et numba avec une approche par calcul entier, produisant un code plus propre et plus rapide dès le premier essai. Les itérations suivantes, guidées par des critiques spécifiques, ont conduit à des améliorations plus ciblées, bien que le LLM ait parfois proposé des solutions complexes ou erronées (comme l'utilisation de décalages de bits pour le calcul en base 10).
Enseignements et limites
Demander à un LLM "d'écrire un meilleur code" de manière itérative peut effectivement améliorer la performance, surtout si l'on passe d'une approche naïve à l'utilisation de bibliothèques comme numba. Cependant, cette méthode manque de direction et peut mener à du code sur-ingénierié. Une ingénierie de prompt précise et orientée performance donne des résultats plus rapides, cohérents et proches du théoriquement optimal.
Une conclusion importante est que les LLMs, bien qu'ils puissent générer du code défaillant, sont excellents pour suggérer des outils et des idées (comme l'utilisation de numba) qu'un développeur pourrait ne pas connaître. Ils ne remplaceront pas les ingéneiurs logiciels, car discerner les bonnes pratiques et gérer les contraintes du monde réel reste un domaine où l'expertise humaine est essentielle. Néanmoins, le gain de performance obtenu (jusqu'à 100x) grâce à une boucle itérative simple et ciblée justifie pleinement l'intégration de ces techniques dans un flux de travail de développement.