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:
parent
f0ffd9644e
commit
6574d8e4a8
2 changed files with 97 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<RecommendedModelKey, RecommendedModel> = {
|
|||
"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<void> {
|
||||
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<void> {
|
||||
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<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++) {
|
||||
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<BenchmarkSa
|
|||
const latencyMs = performance.now() - startedAt;
|
||||
const rssDeltaBytes = process.memoryUsage().rss - rssBefore;
|
||||
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) {
|
||||
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)`,
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue