Sommaire
Bonjour, aujourd'hui j'ai fait un essai qui n'est pas du tout scientifique : que valent les LLM qui tournent sur mon Dell d'inférence pour coder une fonction Python très simple ?
Le prompt donné aux modèles : « Je voudrais que tu me fasses une fonction en Python pour parser un fichier CSV et le transformer en JSON. »
Un même prompt, six modèles différents, tous exécutés en local via Ollama. L'objectif : voir comment chaque LLM interprète une demande simple et comparer la qualité, la performance et la concision du code produit.
Le matériel utilisé
Les modèles ont été exécutés sur un serveur dédié avec la configuration suivante :
| Composant | Détail |
|---|---|
| CPU | Intel Xeon W-2125 @ 4.00 GHz (4 cœurs / 8 threads) |
| RAM | 32 Go DDR4 |
| GPU | 2× NVIDIA Quadro P5000 (16 Go VRAM chacune) |
| OS | Ubuntu 24.04.4 LTS |
| Runtime | Ollama dans Docker |
Le protocole de test
Les bouts de code ont été obtenus via Open WebUI et Ollama, puis copiés-collés dans un script de benchmark Python. Pour chaque fonction générée, le script effectue :
- Test fonctionnel — La fonction est appelée avec un fichier CSV de 10 lignes. Le JSON produit (fichier ou retour mémoire) est validé : structure, clés, nombre de lignes, correspondance exacte avec les données source.
- Benchmark de vitesse — 1 000 exécutions consécutives, temps moyen mesuré en microsecondes.
-
Mesure mémoire — Pic de consommation mémoire mesuré via
tracemalloc.
Le CSV de test :
nom,prenom,age,ville,email
Dupont,Jean,34,Paris,jean.dupont@email.fr
Martin,Sophie,28,Lyon,sophie.martin@email.fr
...
Les résultats
| Modèle | Fonctionnel | Temps moyen | Mémoire pic | Note |
|---|---|---|---|---|
| Nemotron-cascade-2 | ✓ | 46.9 µs | 34.0 KB | 8/10 |
| devstral-small-2 | ✓ | 61.7 µs | 38.0 KB | 7/10 |
| Gemma4:26b | ✓ | 65.0 µs | 38.1 KB | 6/10 |
| qwen3.5:35b | ✓ | 74.0 µs | 38.0 KB | 6/10 |
| ministral-3:14b | ✓ | 74.5 µs | 38.0 KB | 7/10 |
| Gemma4:31b | ✓ | 124.8 µs | 38.1 KB | 5/10 |
Tous les modèles produisent un code fonctionnel. Les différences se jouent sur l'architecture, la flexibilité et l'efficacité.
Analyse modèle par modèle
1. Nemotron-cascade-2 — 8/10
def csv_to_json(csv_path, json_path=None, encoding="utf-8"):
csv_path = Path(csv_path)
if not csv_path.is_file():
raise FileNotFoundError(f"Le fichier CSV n'existe pas : {csv_path}")
with csv_path.open(newline='', mode='r', encoding=encoding) as f:
reader = csv.DictReader(f, delimiter=',')
data = [row for row in reader]
json_str = json.dumps(data, ensure_ascii=False, indent=2)
if json_path:
json_path = Path(json_path)
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(json_str, encoding=encoding)
return data
Le meilleur du lot. C'est le seul à proposer les trois modes d'utilisation : écriture fichier, retour en mémoire, ou les deux. Il utilise pathlib (plus moderne que os.path), crée automatiquement les répertoires parents et offre un paramètre encoding configurable.
Point faible : il sérialise systématiquement en JSON string (json.dumps) même quand on ne demande que le retour en mémoire — une opération inutile qui gaspille du CPU. Malgré cela, il reste le plus rapide en mode écriture fichier, grâce à pathlib.write_text() qui est plus efficient que le pattern open()/json.dump() pour les petits fichiers.
2. ministral-3:14b — 7/10
def csv_to_json(csv_file_path, json_file_path=None):
data = []
with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
for row in csv_reader:
data.append(row)
if json_file_path:
with open(json_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
return None
else:
return json.dumps(data, indent=4, ensure_ascii=False)
Bonne surprise pour un « petit » modèle (14b). C'est le seul autre modèle (avec Nemotron) à proposer un double mode : écriture fichier ou retour en mémoire. Le code est propre, sans fioritures.
Point faible : pas d'utilisation de pathlib, pas de gestion d'erreur. Mais l'absence de gestion d'erreur est ici un choix acceptable — mieux vaut laisser les exceptions remonter naturellement que de les avaler avec des print(). Pour un modèle de cette taille, c'est un excellent résultat : il a bien compris l'ambiguïté du prompt et a fait un choix intelligent.
3. devstral-small-2 — 7/10
def csv_to_json(csv_file_path, json_file_path):
data = []
with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
for row in csv_reader:
data.append(row)
with open(json_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
Le plus minimaliste — et c'est une qualité. Pas de try/except inutile, pas de print(), pas de if __name__. Juste la fonction, propre et directe. C'est exactement ce qu'on demandait. En bonus, c'est le deuxième plus rapide du benchmark.
Point faible : aucune flexibilité (pas de mode retour mémoire, pas de paramètre encoding). Mais le prompt ne le demandait pas, donc c'est une lecture correcte de la consigne.
4. Gemma4:26b — 6/10
def csv_to_json(csv_filepath, json_filepath):
data = []
try:
with open(csv_filepath, encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
for row in csv_reader:
data.append(row)
with open(json_filepath, 'w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
print(f"Conversion réussie ! Fichier créé : {json_filepath}")
except FileNotFoundError:
print(f"Erreur : Le fichier '{csv_filepath}' est introuvable.")
except Exception as e:
print(f"Une erreur est survenue : {e}")
Correct mais sans valeur ajoutée. Structurellement quasi identique à Gemma4:31b. La boucle for row / append est plus verbeuse que list(reader) sans bénéfice. La gestion d'erreur par print() est un anti-pattern en production — on préfère lever des exceptions pour laisser l'appelant décider.
5. qwen3.5:35b — 6/10
def csv_to_json(input_file_path, output_file_path):
data = []
try:
with open(input_file_path, mode='r', newline='', encoding='utf-8') as csv_file:
dict_reader = csv.DictReader(csv_file)
data = list(dict_reader)
with open(output_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, ensure_ascii=False, indent=4)
print(f"Conversion réussie : {input_file_path} -> {output_file_path}")
return True
except FileNotFoundError:
print("Erreur : Le fichier spécifié est introuvable.")
return False
except Exception as e:
print(f"Erreur lors du traitement : {e}")
return False
Seul modèle à ajouter newline='' dans l'appel à open(), ce qui est la bonne pratique selon la documentation du module csv (évite les problèmes de saut de ligne sur Windows). Bon point. Le retour booléen est une idée intéressante pour du scripting.
Point faible : la gestion d'erreur par print() + return False rend le débogage difficile — on perd le traceback. Pour 35 milliards de paramètres, on aurait pu espérer une approche plus sophistiquée.
6. Gemma4:31b — 5/10
def csv_to_json(csv_file_path, json_file_path):
try:
with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
data = list(csv_reader)
with open(json_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
print(f"Succès : Le fichier a été converti et enregistré sous {json_file_path}")
except FileNotFoundError:
print("Erreur : Le fichier CSV source est introuvable.")
except Exception as e:
print(f"Une erreur est survenue : {e}")
Le plus gros modèle… mais le résultat le plus basique. Le code fonctionne, mais c'est le plus lent du benchmark (124.8 µs, presque 3× plus lent que Nemotron). Précision importante : cette lenteur vient du code Python généré (notamment la gestion d'erreur et les print()), pas du temps d'inférence du modèle lui-même. Aucune flexibilité : pas de retour en mémoire, pas de paramètre d'encoding. La gestion d'erreur par print() avale les exceptions silencieusement.
Ironie : c'est le modèle le plus volumineux (31b) qui produit le code le moins intéressant. La taille du modèle n'est clairement pas corrélée à la qualité du code généré sur ce type de tâche.
Ce qu'on retient
La taille du modèle ne fait pas tout
Le classement final bouscule les attentes :
- Nemotron-cascade-2 — Le plus rapide, le plus flexible, le mieux typé
- ministral-3:14b — Un « petit » modèle qui a compris la subtilité du prompt
- devstral-small-2 — Minimaliste et efficace, zéro superflu
-
qwen3.5:35b — Correct avec un bon détail technique (
newline='') - Gemma4:26b — Fonctionnel mais générique
- Gemma4:31b — Le plus lourd, le plus lent, le moins inspiré
Les tendances communes
Tous les modèles ont produit du code fonctionnel utilisant csv.DictReader + json.dump. Les différences se jouent sur :
-
La gestion d'erreur : 4 modèles sur 6 utilisent
try/exceptavecprint(), un anti-pattern. Seuls Nemotron (qui lève uneFileNotFoundError) et devstral (qui ne gère pas les erreurs, ce qui est mieux que de les avaler) font un choix correct. - La flexibilité : seuls 2 modèles sur 6 (Nemotron et ministral) proposent un double mode fichier/mémoire. C'est pourtant une interprétation naturelle du prompt « transformer en JSON » qui ne précise pas la destination.
-
Le style :
list(reader)vsfor row: append— les deux fonctionnent, maislist()est plus idiomatique et marginalement plus rapide.
Conseil pratique
Pour du code utilitaire simple, un modèle léger (14b) bien entraîné peut rivaliser avec des modèles 2× plus gros. Sur un serveur avec 2× Quadro P5000, les modèles jusqu'à ~35b tournent confortablement. Le meilleur rapport qualité/ressources ici : ministral-3:14b, qui donne un excellent résultat avec une empreinte mémoire GPU minimale.
Le script de benchmark
Pour ceux qui veulent reproduire les tests, voici le script complet. Les fonctions de chaque modèle y sont intégrées telles quelles :
"""
Benchmark des fonctions csv_to_json générées par différents LLMs.
Mesure : temps d'exécution, mémoire utilisée, validité du résultat.
"""
import csv
import json
import os
import sys
import time
import traceback
import tracemalloc
from io import StringIO
from pathlib import Path
from contextlib import redirect_stdout
# ── Fichiers de test ──────────────────────────────────────────────
CSV_FILE = "donnees.csv"
JSON_EXPECTED_KEYS = {"nom", "prenom", "age", "ville", "email"}
NUM_ROWS = 10
# ── Lecture du CSV attendu pour validation ────────────────────────
with open(CSV_FILE, encoding="utf-8") as f:
EXPECTED_DATA = list(csv.DictReader(f))
# ══════════════════════════════════════════════════════════════════
# Définition de chaque fonction générée par les LLMs
# ══════════════════════════════════════════════════════════════════
def gemma4_31b(csv_file_path, json_file_path):
try:
with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
data = list(csv_reader)
with open(json_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
print(f"Succès : Le fichier a été converti et enregistré sous {json_file_path}")
except FileNotFoundError:
print("Erreur : Le fichier CSV source est introuvable.")
except Exception as e:
print(f"Une erreur est survenue : {e}")
def nemotron_cascade_2(csv_path, json_path=None, encoding="utf-8"):
csv_path = Path(csv_path)
if not csv_path.is_file():
raise FileNotFoundError(f"Le fichier CSV n'existe pas : {csv_path}")
with csv_path.open(newline='', mode='r', encoding=encoding) as f:
reader = csv.DictReader(f, delimiter=',')
data = [row for row in reader]
json_str = json.dumps(data, ensure_ascii=False, indent=2)
if json_path:
json_path = Path(json_path)
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(json_str, encoding=encoding)
print(f"Fichier JSON écrit dans {json_path}")
return data
def gemma4_26b(csv_filepath, json_filepath):
data = []
try:
with open(csv_filepath, encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
for row in csv_reader:
data.append(row)
with open(json_filepath, 'w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
print(f"Conversion réussie ! Fichier créé : {json_filepath}")
except FileNotFoundError:
print(f"Erreur : Le fichier '{csv_filepath}' est introuvable.")
except Exception as e:
print(f"Une erreur est survenue : {e}")
def devstral_small_2(csv_file_path, json_file_path):
data = []
with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
for row in csv_reader:
data.append(row)
with open(json_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
def ministral_3_14b(csv_file_path, json_file_path=None):
data = []
with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
csv_reader = csv.DictReader(csv_file)
for row in csv_reader:
data.append(row)
if json_file_path:
with open(json_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, indent=4, ensure_ascii=False)
return None
else:
return json.dumps(data, indent=4, ensure_ascii=False)
def qwen3_5_35b(input_file_path, output_file_path):
data = []
try:
with open(input_file_path, mode='r', newline='', encoding='utf-8') as csv_file:
dict_reader = csv.DictReader(csv_file)
data = list(dict_reader)
with open(output_file_path, mode='w', encoding='utf-8') as json_file:
json.dump(data, json_file, ensure_ascii=False, indent=4)
print(f"Conversion réussie : {input_file_path} -> {output_file_path}")
return True
except FileNotFoundError:
print("Erreur : Le fichier spécifié est introuvable.")
return False
except Exception as e:
print(f"Erreur lors du traitement : {e}")
return False
# ══════════════════════════════════════════════════════════════════
# Harness de test
# ══════════════════════════════════════════════════════════════════
MODELS = [
("Gemma4:31b", gemma4_31b, "file"),
("Nemotron-cascade-2", nemotron_cascade_2, "both"),
("Gemma4:26b", gemma4_26b, "file"),
("devstral-small-2", devstral_small_2, "file"),
("ministral-3:14b", ministral_3_14b, "both"),
("qwen3.5:35b", qwen3_5_35b, "file"),
]
ITERATIONS = 1000
def validate_json_file(path):
"""Vérifie que le fichier JSON produit est correct."""
with open(path, encoding="utf-8") as f:
data = json.load(f)
assert isinstance(data, list), "Le résultat n'est pas une liste"
assert len(data) == NUM_ROWS, f"Attendu {NUM_ROWS} lignes, obtenu {len(data)}"
for row in data:
assert JSON_EXPECTED_KEYS == set(row.keys()), f"Clés incorrectes: {row.keys()}"
assert data == EXPECTED_DATA, "Les données ne correspondent pas au CSV source"
return True
def validate_return_data(data):
"""Vérifie que les données retournées en mémoire sont correctes."""
if isinstance(data, str):
data = json.loads(data)
assert isinstance(data, list), "Le résultat n'est pas une liste"
assert len(data) == NUM_ROWS, f"Attendu {NUM_ROWS} lignes, obtenu {len(data)}"
for row in data:
assert JSON_EXPECTED_KEYS == set(row.keys()), f"Clés incorrectes: {row.keys()}"
assert data == EXPECTED_DATA, "Les données ne correspondent pas au CSV source"
return True
def run_test(name, func, mode):
"""Exécute le test pour une fonction donnée."""
json_out = f"output_{name.replace(':', '_').replace('.', '_')}.json"
results = {"nom": name, "mode": mode}
# ── Test fonctionnel ──
try:
captured = StringIO()
with redirect_stdout(captured):
if mode == "both":
ret = func(CSV_FILE, json_out)
validate_json_file(json_out)
ret_mem = func(CSV_FILE)
validate_return_data(ret_mem)
else:
func(CSV_FILE, json_out)
validate_json_file(json_out)
results["fonctionnel"] = "OK"
except Exception as e:
results["fonctionnel"] = f"ERREUR: {e}"
results["temps_moyen_us"] = None
results["memoire_pic_kb"] = None
print(f" ✗ {name}: {e}")
traceback.print_exc()
return results
# ── Benchmark temps (N itérations, écriture fichier) ──
start = time.perf_counter_ns()
captured = StringIO()
with redirect_stdout(captured):
for _ in range(ITERATIONS):
func(CSV_FILE, json_out)
elapsed_ns = time.perf_counter_ns() - start
results["temps_moyen_us"] = elapsed_ns / ITERATIONS / 1000
# ── Benchmark mémoire (une seule exécution) ──
tracemalloc.start()
captured = StringIO()
with redirect_stdout(captured):
func(CSV_FILE, json_out)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
results["memoire_pic_kb"] = peak / 1024
# Nettoyage
if os.path.exists(json_out):
os.remove(json_out)
return results
def main():
print("=" * 70)
print(" BENCHMARK DES FONCTIONS CSV→JSON GÉNÉRÉES PAR LLMs")
print("=" * 70)
print(f" Fichier CSV : {CSV_FILE} ({NUM_ROWS} lignes)")
print(f" Itérations : {ITERATIONS}")
print()
all_results = []
for name, func, mode in MODELS:
print(f" ▶ Test de {name}...")
r = run_test(name, func, mode)
all_results.append(r)
if r["fonctionnel"] == "OK":
print(f" ✓ Fonctionnel: OK")
print(f" ⏱ Temps moyen: {r['temps_moyen_us']:.1f} µs")
print(f" 💾 Mémoire pic: {r['memoire_pic_kb']:.1f} KB")
print()
# ── Tableau récapitulatif ──
print("=" * 70)
print(f" {'Modèle':<25} {'Status':<10} {'Temps (µs)':<14} {'Mém. (KB)':<12}")
print("-" * 70)
for r in all_results:
status = "OK" if r["fonctionnel"] == "OK" else "ERREUR"
t = f"{r['temps_moyen_us']:.1f}" if r["temps_moyen_us"] is not None else "N/A"
m = f"{r['memoire_pic_kb']:.1f}" if r["memoire_pic_kb"] is not None else "N/A"
print(f" {r['nom']:<25} {status:<10} {t:<14} {m:<12}")
print("=" * 70)
# ── Export résultats en JSON ──
with open("resultats_benchmark.json", "w", encoding="utf-8") as f:
json.dump(all_results, f, indent=2, ensure_ascii=False)
print("\nRésultats exportés dans resultats_benchmark.json")
if __name__ == "__main__":
main()
Tests réalisés avec Python 3.13 sur un Intel Xeon W-2125 / 32 Go RAM / 2× Quadro P5000. Benchmark : 1 000 itérations par fonction, mesure mémoire via tracemalloc.

# Reproductibilité
Posté par François Chaix (Mastodon) . Évalué à 2 (+1/-0).
C'est intéressant.
Ce qui serait intéressant à ajouter (mais c'est du travail j'en conviens), ce serait de faire des répétitions de la même expérience.
Étant donné le caractère stochastique des LLM (un prompt peut produire une infinité de réponses), ce serait intéressant de voir si le classement que tu produits est stable, ou bien si à chaque itération de l'expérience, les modèles peuvent se retrouver plus ou moins bien notés…
[^] # Re: Reproductibilité
Posté par Ecran Plat (site web personnel) . Évalué à 2 (+0/-0).
Oui, à la main c'est du boulot (surtout de l'attente), mais un petit script Python lancé à intervalles réguliers et c'est réglé.
Cela dit, il y a deux problèmes de mon côté.
Premièrement, la consommation: pendant une inférence, la machine monte à environ 550 W (je mesure et je graphe). Pour un usage ponctuel ce n'est pas grave, mais pour des tests en continu, ça finit par coûter cher sur la durée.
Deuxièmement, depuis que j'ai changé mon ordinateur principal, je me suis offert un Ultra Core 7 (Lunar Lake) pour remplacer mon bon vieux Dell qui commençait à montrer des signes de fatigue. Du coup ma machine d'inférence ne me sert plus autant. L'Ultra Core 7 a un GPU, un NPU, et les 32 Go de RAM intégrés à la puce sont partagés.
Il m'arrive de faire tourner de petits modèles dessus si besoin. Résultat, la machine d'inférence je ne l'utilise plus qu'occasionnellement (j'ai même essayé de la vendre, mais elle est trop vieille).
Je peux l'allumer à distance si nécessaire.
Après, j'ai fait ce test parce que je voulais voir ce que valait Gemma 4, que Google a sorti hier. Et surtout parce que c'est férié (Vendredi saint) en Suisse.
Envoyer un commentaire
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.