fix(experiments): ajoute un warm-up et des logs itératifs au benchmark LLM

- LocalLlmStepAnalyzer.warmUp() : force le coût caché du tout premier appel
  d'inférence (spin-up threads llama.cpp, cache KV, tokenizer) avant le
  benchmark, plutôt que de laisser la première phrase l'absorber — constaté
  sur des runs réels (Qwen/Llama) où fr-multi-action montait jusqu'à ~28s
  contre ~5s pour ses autres répétitions.
- initialize() et runBenchmark() journalisent maintenant chaque sous-étape
  (résolution du modèle, chargement des poids, contexte, grammaire, puis
  chaque répétition avec son résultat immédiat) au lieu de rester muets
  plusieurs minutes avant le récapitulatif final.
- RECOMMENDED_MODELS / README corrigés suite aux runs réels de l'utilisateur :
  Qwen2.5-1.5B s'est montré systématiquement plus rapide que Llama-3.2-1B
  sur les deux machines testées, contredisant l'hypothèse a priori du README
  ("moins de paramètres = plus rapide") — gardé comme résultat empirique
  plutôt que corrigé silencieusement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-21 19:32:37 +02:00
parent f0ffd9644e
commit 6574d8e4a8
2 changed files with 97 additions and 14 deletions

View file

@ -40,8 +40,16 @@ Le script télécharge automatiquement (une seule fois, mis en cache dans
| Valeur (défaut en gras) | Modèle | Pourquoi | | Valeur (défaut en gras) | Modèle | Pourquoi |
|---|---|---| |---|---|---|
| **`qwen2.5-1.5b`** | Qwen2.5-1.5B-Instruct, `Q4_K_M` | Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON à taille comparable — recommandation par défaut vu que "robustesse FR/EN" est un critère explicite de ce PoC. | | **`qwen2.5-1.5b`** | Qwen2.5-1.5B-Instruct, `Q4_K_M` | Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement (voir `Résultats obtenus` ci-dessous), aussi la latence la plus basse des deux sur ce benchmark, malgré ses ~50 % de paramètres en plus. |
| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins (plus rapide/plus léger), FR officiellement supporté, mais structuration JSON moins fiable à 1B — utile en comparaison "latence d'abord". | | `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins, FR officiellement supporté, mais structuration JSON moins fiable à 1B — et pas plus rapide non plus dans les runs obtenus jusqu'ici. Conservé comme point de comparaison, pas comme choix "latence d'abord" (l'hypothèse initiale du README). |
> **Résultats obtenus** (Windows, backend Vulkan, une machine) : sur les 7
> phrases de `TEST_SENTENCES`, Qwen2.5-1.5B a été systématiquement plus
> rapide que Llama-3.2-1B malgré sa taille plus grande — l'inverse de
> l'hypothèse a priori "moins de paramètres = plus rapide". Résultat gardé
> ici tel quel plutôt que la doc pré-run corrigée après coup silencieusement
> — mais un seul run sur une seule machine/un seul backend ne généralise pas
> forcément (CUDA/CPU pur donneraient possiblement un classement différent).
```bash ```bash
LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench
@ -57,7 +65,12 @@ résolution/téléchargement Hugging Face.
pnpm bench pnpm bench
``` ```
Charge le modèle, puis lance 3 répétitions sur chacune des 7 phrases de test Charge le modèle, fait un appel de warm-up (chronométré et affiché à part —
le tout premier appel d'inférence sur un contexte fraîchement créé paie un
coût caché de plusieurs secondes que le chargement du modèle ne couvre pas ;
sans ce warm-up c'est la première phrase du benchmark qui l'absorbe,
faussant sa latence par rapport aux six autres), puis lance 3 répétitions
sur chacune des 7 phrases de test
(4 FR + 3 EN, voir `TEST_SENTENCES` dans (4 FR + 3 EN, voir `TEST_SENTENCES` dans
[`src/llm-tech-step-poc.ts`](./src/llm-tech-step-poc.ts)) — les 3 premières [`src/llm-tech-step-poc.ts`](./src/llm-tech-step-poc.ts)) — les 3 premières
couvrent le cas courant (actions enchaînées, dont le cas piège documenté dans couvrent le cas courant (actions enchaînées, dont le cas piège documenté dans

View file

@ -221,14 +221,19 @@ interface RecommendedModel {
* d'entraînement nettement plus multilingue que la famille Llama à * d'entraînement nettement plus multilingue que la famille Llama à
* taille comparable, et meilleur suivi d'instructions de structuration * taille comparable, et meilleur suivi d'instructions de structuration
* (extraction JSON, function calling) dans les benchmarks publiés par * (extraction JSON, function calling) dans les benchmarks publiés par
* Qwen le compromis précision/latence le plus favorable ici vu que le * Qwen. Contrairement à l'hypothèse initiale ("plus de paramètres, donc
* critère "robustesse FR/EN" est un objectif explicite du PoC. * plus lent"), les runs de ce benchmark (Windows, backend Vulkan) le
* montrent aussi systématiquement PLUS RAPIDE que Llama-3.2-1B sur les 7
* phrases de test, malgré ses ~50 % de paramètres en plus le premier
* choix sur les deux axes ici, pas seulement sur la robustesse FR/EN.
* - **Llama-3.2-1B-Instruct** (alternative) : ~35 % de paramètres en * - **Llama-3.2-1B-Instruct** (alternative) : ~35 % de paramètres en
* moins, donc plus rapide et plus léger en RAM ; le FR fait partie de ses * moins, et le FR fait partie de ses langues officiellement supportées,
* langues officiellement supportées, mais avec un suivi d'instructions de * mais avec un suivi d'instructions de structuration plus fragile à
* structuration plus fragile à cette taille dans la pratique utile * cette taille dans la pratique et, empiriquement (voir ci-dessus), pas
* comme point de comparaison "latence d'abord" plutôt que comme premier * plus rapide non plus sur ce benchmark. Gardé comme point de comparaison
* choix. * plutôt que retiré : la latence relative entre les deux dépend du
* backend d'inférence (CUDA/Vulkan/CPU pur) et du matériel, un résultat
* obtenu sur une seule machine ne généralise pas forcément.
* *
* Les deux sont quantisés en `Q4_K_M` le compromis taille/qualité standard * Les deux sont quantisés en `Q4_K_M` le compromis taille/qualité standard
* pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une * pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une
@ -239,12 +244,12 @@ const RECOMMENDED_MODELS: Record<RecommendedModelKey, RecommendedModel> = {
"qwen2.5-1.5b": { "qwen2.5-1.5b": {
hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M", hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M",
rationale: rationale:
"Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON à taille comparable.", "Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement sur ce benchmark, aussi la latence la plus basse malgré la taille plus grande.",
}, },
"llama-3.2-1b": { "llama-3.2-1b": {
hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M",
rationale: rationale:
"Plus petit/plus rapide ; FR officiellement supporté mais structuration JSON moins fiable à 1B.", "Plus petit, structuration JSON moins fiable à 1B, et pas plus rapide qu'un Qwen 1.5B sur ce benchmark — conservé comme point de comparaison, pas comme choix latence.",
}, },
}; };
@ -308,15 +313,51 @@ export class LocalLlmStepAnalyzer {
* crée son contexte d'inférence et compile la grammaire JSON la partie * crée son contexte d'inférence et compile la grammaire JSON la partie
* coûteuse (souvent plusieurs secondes, dominée par le chargement des * coûteuse (souvent plusieurs secondes, dominée par le chargement des
* poids depuis disque), à faire une seule fois avant tout `analyzeStep`. * poids depuis disque), à faire une seule fois avant tout `analyzeStep`.
*
* Journalise chaque sous-étape (`console.info`, autorisé par
* `biome.json` voir `suspicious.noConsole`) : cette méthode reste muette
* pendant plusieurs secondes à secondes-longues sans ça (résolution/
* téléchargement du modèle, chargement des poids, création du contexte,
* compilation de la grammaire), et rien ne dit à l'utilisateur laquelle
* de ces sous-étapes est en cours.
*/ */
public async initialize(modelKey: RecommendedModelKey): Promise<void> { public async initialize(modelKey: RecommendedModelKey): Promise<void> {
console.info(`[poc] résolution du modèle "${modelKey}"...`);
const modelPath = await resolveModelPath(modelKey); const modelPath = await resolveModelPath(modelKey);
console.info(`[poc] modèle : ${modelPath}`);
console.info("[poc] initialisation de node-llama-cpp...");
this._llama = await getLlama(); this._llama = await getLlama();
console.info("[poc] chargement des poids en mémoire...");
this._model = await this._llama.loadModel({ modelPath }); this._model = await this._llama.loadModel({ modelPath });
console.info("[poc] création du contexte d'inférence...");
this._context = await this._model.createContext({ contextSize: 4096 }); this._context = await this._model.createContext({ contextSize: 4096 });
console.info("[poc] compilation de la grammaire JSON...");
this._grammar = await this._llama.createGrammarForJsonSchema(KITCHEN_ACTIONS_JSON_SCHEMA); this._grammar = await this._llama.createGrammarForJsonSchema(KITCHEN_ACTIONS_JSON_SCHEMA);
} }
/**
* Force un premier appel d'inférence factice, séparément de
* `initialize()` et avant tout appel mesuré par le benchmark même rôle
* que `TechStepClassifierService.warmUp()` côté `node-nlp`
* (`tech-step-matcher.ts`) : le tout premier `session.prompt()` sur un
* contexte fraîchement créé paie un coût caché que `initialize()` ne
* couvre pas (spin-up du pool de threads llama.cpp, allocation du cache
* KV, initialisation paresseuse du tokenizer) mesuré ici entre 15 et
* 20+ secondes selon le modèle/matériel, contre quelques secondes pour
* les appels suivants sur la même phrase. Sans cet appel, c'est la
* première phrase du benchmark qui absorbe ce coût, faussant sa latence
* moyenne/max sans rapport avec le coût réel d'une inférence en régime
* établi (voir l'écart min/max observé sur `fr-multi-action` avant ce
* correctif : ~5s en moyenne, jusqu'à ~28s sur un run).
*/
public async warmUp(): Promise<void> {
await this.analyzeStep("Faites chauffer une poêle.");
}
/** /**
* Analyse une étape de recette et renvoie sa séquence ordonnée d'actions. * Analyse une étape de recette et renvoie sa séquence ordonnée d'actions.
* *
@ -480,11 +521,25 @@ interface BenchmarkSample {
* Une erreur sur une répétition est journalisée et n'interrompt pas les * Une erreur sur une répétition est journalisée et n'interrompt pas les
* suivantes un run de benchmark qui plante entièrement à la première * suivantes un run de benchmark qui plante entièrement à la première
* réponse mal formée serait bien moins utile qu'un rapport partiel. * réponse mal formée serait bien moins utile qu'un rapport partiel.
*
* Journalise chaque répétition au fur et à mesure (avant ET après) plutôt
* que de rester muet jusqu'au récapitulatif final : un run complet peut
* prendre plusieurs minutes (7 phrases × 3 répétitions), et savoir on en
* est quelle phrase, quelle répétition, le résultat qui vient de tomber
* vaut largement le bruit de sortie supplémentaire pour ce script de
* benchmark (contrairement au code applicatif, `console` est réservé à
* `LoggerService` n'existe pas ici, PoC autonome sans app autour).
*/ */
async function runBenchmark(analyzer: LocalLlmStepAnalyzer): Promise<BenchmarkSample[]> { async function runBenchmark(analyzer: LocalLlmStepAnalyzer): Promise<BenchmarkSample[]> {
const samples: BenchmarkSample[] = []; const samples: BenchmarkSample[] = [];
for (const sentence of TEST_SENTENCES) { const totalRuns = TEST_SENTENCES.length * REPETITIONS_PER_SENTENCE;
let runIndex = 0;
for (const [sentenceIndex, sentence] of TEST_SENTENCES.entries()) {
for (let repetition = 1; repetition <= REPETITIONS_PER_SENTENCE; repetition++) { for (let repetition = 1; repetition <= REPETITIONS_PER_SENTENCE; repetition++) {
runIndex++;
console.info(
`[poc] (${runIndex}/${totalRuns}) phrase ${sentenceIndex + 1}/${TEST_SENTENCES.length} "${sentence.id}" (${sentence.locale}) — répétition ${repetition}/${REPETITIONS_PER_SENTENCE}...`,
);
const rssBefore = process.memoryUsage().rss; const rssBefore = process.memoryUsage().rss;
const startedAt = performance.now(); const startedAt = performance.now();
try { try {
@ -492,8 +547,11 @@ async function runBenchmark(analyzer: LocalLlmStepAnalyzer): Promise<BenchmarkSa
const latencyMs = performance.now() - startedAt; const latencyMs = performance.now() - startedAt;
const rssDeltaBytes = process.memoryUsage().rss - rssBefore; const rssDeltaBytes = process.memoryUsage().rss - rssBefore;
samples.push({ sentence, latencyMs, rssDeltaBytes, analysis }); samples.push({ sentence, latencyMs, rssDeltaBytes, analysis });
console.info(
`[poc] -> ${latencyMs.toFixed(0)} ms, ${analysis.actions.length} action(s) détectée(s), RSS ${rssDeltaBytes >= 0 ? "+" : ""}${(rssDeltaBytes / (1024 * 1024)).toFixed(1)} Mo`,
);
} catch (err) { } catch (err) {
console.error(`[poc] échec sur "${sentence.id}" (répétition ${repetition})`, err); console.error(`[poc] -> échec sur "${sentence.id}" (répétition ${repetition})`, err);
} }
} }
} }
@ -598,6 +656,18 @@ async function main(): Promise<void> {
`[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`, `[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`,
); );
// Absorbe ici le coût caché du tout premier appel d'inférence (voir
// LocalLlmStepAnalyzer.warmUp) plutôt que de laisser la première phrase
// du benchmark le payer — sans ça, sa latence n'est pas comparable aux
// six autres.
const warmUpStartedAt = performance.now();
try {
await analyzer.warmUp();
} catch (err) {
console.error("[poc] échec du warm-up — le benchmark continue quand même", err);
}
console.info(`[poc] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
try { try {
const benchmarkStartedAt = performance.now(); const benchmarkStartedAt = performance.now();
const samples = await runBenchmark(analyzer); const samples = await runBenchmark(analyzer);