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 |
|
| 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
|
||||||
|
|
|
||||||
|
|
@ -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 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[]> {
|
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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue