From 6574d8e4a8b13997dd29c67e1482fb1c3390f14b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 19:32:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(experiments):=20ajoute=20un=20warm-up=20et?= =?UTF-8?q?=20des=20logs=20it=C3=A9ratifs=20au=20benchmark=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- experiments/llm-tech-step-poc/README.md | 19 +++- .../src/llm-tech-step-poc.ts | 92 ++++++++++++++++--- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/experiments/llm-tech-step-poc/README.md b/experiments/llm-tech-step-poc/README.md index 607735a..38a3d35 100644 --- a/experiments/llm-tech-step-poc/README.md +++ b/experiments/llm-tech-step-poc/README.md @@ -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 | |---|---|---| -| **`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. | -| `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". | +| **`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, 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 LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench @@ -57,7 +65,12 @@ résolution/téléchargement Hugging Face. 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 [`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 diff --git a/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts index 8fd062f..ab9f37c 100644 --- a/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts +++ b/experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts @@ -221,14 +221,19 @@ interface RecommendedModel { * d'entraînement nettement plus multilingue que la famille Llama à * taille comparable, et meilleur suivi d'instructions de structuration * (extraction JSON, function calling) dans les benchmarks publiés par - * Qwen — le compromis précision/latence le plus favorable ici vu que le - * critère "robustesse FR/EN" est un objectif explicite du PoC. + * Qwen. Contrairement à l'hypothèse initiale ("plus de paramètres, donc + * 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 - * moins, donc plus rapide et plus léger en RAM ; le FR fait partie de ses - * langues officiellement supportées, mais avec un suivi d'instructions de - * structuration plus fragile à cette taille dans la pratique — utile - * comme point de comparaison "latence d'abord" plutôt que comme premier - * choix. + * moins, et le FR fait partie de ses langues officiellement supportées, + * mais avec un suivi d'instructions de structuration plus fragile à + * cette taille dans la pratique — et, empiriquement (voir ci-dessus), pas + * plus rapide non plus sur ce benchmark. Gardé comme point de comparaison + * 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 * pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une @@ -239,12 +244,12 @@ const RECOMMENDED_MODELS: Record = { "qwen2.5-1.5b": { hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M", 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": { hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", 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 * coûteuse (souvent plusieurs secondes, dominée par le chargement des * 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 { + console.info(`[poc] résolution du modèle "${modelKey}"...`); const modelPath = await resolveModelPath(modelKey); + console.info(`[poc] modèle : ${modelPath}`); + + console.info("[poc] initialisation de node-llama-cpp..."); this._llama = await getLlama(); + + console.info("[poc] chargement des poids en mémoire..."); 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 }); + + console.info("[poc] compilation de la grammaire JSON..."); 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 { + await this.analyzeStep("Faites chauffer une poêle."); + } + /** * 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 * 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. + * + * 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 où 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, où `console` est réservé à + * `LoggerService` — n'existe pas ici, PoC autonome sans app autour). */ async function runBenchmark(analyzer: LocalLlmStepAnalyzer): Promise { 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++) { + 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 startedAt = performance.now(); try { @@ -492,8 +547,11 @@ async function runBenchmark(analyzer: LocalLlmStepAnalyzer): Promise ${latencyMs.toFixed(0)} ms, ${analysis.actions.length} action(s) détectée(s), RSS ${rssDeltaBytes >= 0 ? "+" : ""}${(rssDeltaBytes / (1024 * 1024)).toFixed(1)} Mo`, + ); } 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 { `[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 { const benchmarkStartedAt = performance.now(); const samples = await runBenchmark(analyzer);