Navigation : Index | << Précédent | Suivant >>

10f. ONNX Runtime GenAI : jambe finale du bake-off .NET

Durée estimée : 45 minutes Prérequis : notebooks 10d et 10e, C# asynchrone, notions ONNX Matériel du run de référence : GPU NVIDIA RTX 3080 Ti Laptop 16 Go ; modèle ONNX Qwen3-4B int4 (block 128, ~2,8 Go) ; .NET Interactive (C#)

Objectifs d’apprentissage

  1. Charger un modèle LLM au format ONNX officiel (pas GGUF) avec Microsoft.ML.OnnxRuntimeGenAI et prouver que le backend CUDA est réellement actif.
  2. Mesurer latence de chargement, TTFT (time to first token), débit décodé (tok/s) et taux de jetons <pad> — les quatre axes du bake-off #12353.
  3. Reproduire le protocole exact des Phases 1 (TensorSharp, #12645) et 2 (LLamaSharp, #12759) : mêmes quatre invites, décodage greedy, budget de 96 jetons.
  4. Compléter le tableau comparatif à trois moteurs et statuer go/no-go par axe.

Contexte

Ce notebook est la Phase 3 du bake-off #12353. Rappel des jambes déjà livrées (mesures committées, nous ne les recopions pas sans source) :

Phase Moteur Livrable Résultat clé (mesure committée)
1 TensorSharp 3.2.1.0 (serveur HTTP distant, Gemma 4 E4B Q8_0) 10d, PR #12645 ~50 tok/s côté serveur mais 159/160 jetons <pad> → inférence inutilisable en l’état (RECOVERABLE-LOCAL)
2 LLamaSharp 0.27.0 (binding llama.cpp, Qwen3-4B Q4_K_M in-process) 10e, PR #12759 353 jetons propres, 0 jeton <pad> — 14,14 tok/s sur CPU, puis 36,57 tok/s sur GPU une fois la détection CUDA réparée (cf. 10e §1bis)
3 ONNX Runtime GenAI (ce notebook) — mesures ci-dessous

Différence de conditions à connaître avant de comparer : la jambe LLamaSharp de référence (PR #12759) n’a pas tourné sur le GPU. Le notebook 10e l’annonçait sur la RTX 3080 Ti (« 35 couches sur GPU ») ; la vérification du 2026-09-01 — backend_ptrs.size() = 1, 0/36 couches assignées au GPU, VRAM plate pendant toute l’inférence — a montré que ses 14,14 tok/s étaient un chiffre CPU : le paquet Backend.Cuda12 était installé, sa variante native jamais chargée.

Ce que la suite a établi : cette absence de GPU était un défaut réparable, pas une limite du moteur. LLamaSharp dérive la disponibilité de CUDA du toolkit (CUDA_PATH, clé libcublas de version.json) et non du pilote ; sur une machine pilote-seul il n’énumère donc jamais le candidat cuda12, et le fait sans émettre le moindre avertissement. Une fois la détection réparée (chaîne de causes complète dans 10e §1bis), la même jambe rend 36,57 tok/s avec 37/37 couches sur CUDA0, soit ×3,06 sur un contrôle CPU mesuré dans le même run — le même exécutable relancé sur les mêmes invites, dans la même minute, avec pour seule différence le retrait de CUDA_PATH.

Comparer les 14,14 tok/s à ceux mesurés ici mesurerait donc le périphérique, pas le moteur. Le chiffre GPU, lui, autorise enfin une comparaison à périphérique constant — c’est celle que conduit le §5, avec ses réserves de format et de charge.

1. Le modèle : ONNX officiel GenAI-ready

ORT GenAI ne lit pas les GGUF (format llama.cpp) : il consomme un dossier ONNX contenant le graphe (model.onnx + poids externes model.onnx.data), un genai_config.json (provider, hyperparamètres de recherche) et le tokenizer. Nous utilisons le dépôt Hugging Face onnx-community/Qwen3-4B-ONNX, variante cuda-int4-kld-block-128 — la même famille Qwen3-4B que la Phase 2, quantifiée int4 par blocs de 128 pour l’exécution accélérée GPU.

Téléchargement manuel équivalent (si la cellule suivante ne peut pas télécharger) :

huggingface-cli download onnx-community/Qwen3-4B-ONNX `
  --include "onnxruntime/cuda/cuda-int4-kld-block-128/*" `
  --local-dir models/qwen3-4b-ortgenai-cuda-int4

La cellule suivante installe le package NuGet, résout le dossier du modèle (variable d’environnement ORTGENAI_MODEL_DIR pour un emplacement externe, sinon chemin relatif models/…, ignoré par git) et télécharge uniquement les fichiers manquants ou tronqués.

#r "nuget: Microsoft.ML.OnnxRuntimeGenAI.Cuda, 0.15.2"

using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using Microsoft.ML.OnnxRuntimeGenAI;

// Windows peut résoudre C:\Windows\System32\onnxruntime.dll (ORT 1.17) avant
// la dépendance NuGet 1.28 requise par GenAI 0.15.2. Charger explicitement la
// bonne DLL en premier rend la résolution native déterministe dans le kernel.
string nugetRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string ortNativeDir = Path.Combine(nugetRoot, ".nuget", "packages",
    "microsoft.ml.onnxruntime.gpu.windows", "1.28.0", "runtimes", "win-x64", "native");
string ortDll = Path.Combine(ortNativeDir, "onnxruntime.dll");
if (!File.Exists(ortDll))
    throw new FileNotFoundException("La dépendance native ORT 1.28 installée par NuGet est introuvable.", ortDll);
NativeLibrary.Load(ortDll);
Console.WriteLine("ORT natif 1.28 préchargé depuis le cache NuGet utilisateur.");

long VramUtiliseeMib()
{
    var psi = new ProcessStartInfo("nvidia-smi", "--query-gpu=memory.used --format=csv,noheader,nounits")
        { RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true };
    using var p = Process.Start(psi)!;
    string sortie = p.StandardOutput.ReadToEnd().Trim();
    p.WaitForExit();
    return long.Parse(sortie);
}

string modelDir = Environment.GetEnvironmentVariable("ORTGENAI_MODEL_DIR")
    ?? Path.Combine("models", "qwen3-4b-ortgenai-cuda-int4");

var attendus = new (string Nom, long MinOctets)[] {
    ("model.onnx",            400_000),
    ("model.onnx.data",   2_800_000_000),
    ("genai_config.json",         500),
    ("config.json",               500),
    ("tokenizer.json",       11_000_000),
    ("tokenizer_config.json",     300),
    ("chat_template.jinja",     1_000),
};
const string UrlBase = "https://huggingface.co/onnx-community/Qwen3-4B-ONNX/resolve/main/onnxruntime/cuda/cuda-int4-kld-block-128";

Directory.CreateDirectory(modelDir);
int manquants = 0;
using (var http = new HttpClient())
{
    http.Timeout = TimeSpan.FromMinutes(30);
    http.DefaultRequestHeaders.UserAgent.ParseAdd("CoursIA-10f-notebook");
    foreach (var (nom, minOctets) in attendus)
    {
        string chemin = Path.Combine(modelDir, nom);
        bool ok = File.Exists(chemin) && new FileInfo(chemin).Length >= minOctets;
        if (!ok)
        {
            manquants++;
            Console.WriteLine($"téléchargement : {nom}");
            string tmp = chemin + ".tmp";
            using var resp = await http.GetAsync($"{UrlBase}/{nom}", HttpCompletionOption.ResponseHeadersRead);
            resp.EnsureSuccessStatusCode();
            using (var src = await resp.Content.ReadAsStreamAsync())
            using (var dst = File.Create(tmp))
            {
                await src.CopyToAsync(dst);
            }
            File.Move(tmp, chemin, overwrite: true);
        }
    }
}
Console.WriteLine(manquants == 0
    ? $"Modèle complet : {modelDir} ({attendus.Length} fichiers vérifiés)"
    : $"Modèle réparé ({manquants} fichier(s) téléchargé(s)) : {modelDir}");
foreach (var (nom, _) in attendus)
    Console.WriteLine($"  {nom,-24} {new FileInfo(Path.Combine(modelDir, nom)).Length / 1024.0 / 1024.0,9:F1} Mo");
Console.WriteLine($"VRAM au départ : {VramUtiliseeMib()} MiB");
Installing Packages
  • Microsoft.ML.OnnxRuntimeGenAI.Cuda
ORT natif 1.28 préchargé depuis le cache NuGet utilisateur.
Modèle complet : models\qwen3-4b-ortgenai-cuda-int4 (7 fichiers vérifiés)
  model.onnx                     0,5 Mo
  model.onnx.data             2681,5 Mo
  genai_config.json              0,0 Mo
  config.json                    0,0 Mo
  tokenizer.json                10,9 Mo
  tokenizer_config.json          0,0 Mo
  chat_template.jinja            0,0 Mo
VRAM au départ : 0 MiB

2. Chargement et preuve du backend CUDA

Une erreur classique des notebooks GPU est d’inférer « ça tourne sur GPU » d’un débit élevé — sans vérifier quel execution provider (EP) a réellement servi l’inférence. ONNX Runtime charge ses providers dynamiquement : si l’EP CUDA est activée, le module natif onnxruntime_providers_cuda.dll apparaît dans la liste des modules chargés du processus ; sinon, l’inférence retombe silencieusement sur CPU.

Nous mesurons donc trois choses indépendantes : la latence de chargement, la VRAM consommée (nvidia-smi), et la présence du module provider CUDA.

long vramAvant = VramUtiliseeMib();
var chronoChargement = Stopwatch.StartNew();
Model modele = new Model(modelDir);
Tokenizer tokenizer = new Tokenizer(modele);
chronoChargement.Stop();
long vramApres = VramUtiliseeMib();

var modules = Process.GetCurrentProcess().Modules.Cast<ProcessModule>()
    .Select(m => Path.GetFileName(m.FileName))
    .Where(n => n.Contains("onnxruntime", StringComparison.OrdinalIgnoreCase)
             || n.Contains("cuda", StringComparison.OrdinalIgnoreCase)
             || n.Contains("cudnn", StringComparison.OrdinalIgnoreCase))
    .Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();

Console.WriteLine($"Chargement Model + Tokenizer : {chronoChargement.Elapsed.TotalSeconds:F1} s");
Console.WriteLine($"VRAM : {vramAvant} MiB -> {vramApres} MiB (delta {vramApres - vramAvant} MiB)");
Console.WriteLine("Modules natifs onnxruntime/cuda chargés dans le processus :");
foreach (var m in modules) Console.WriteLine($"  - {m}");
Console.WriteLine(modules.Any(m => m.Equals("onnxruntime_providers_cuda.dll", StringComparison.OrdinalIgnoreCase))
    ? "=> EP CUDA ACTIVE (module provider chargé)"
    : "=> ATTENTION : module provider CUDA absent => inférence sur CPU");
Chargement Model + Tokenizer : 11,1 s
VRAM : 0 MiB -> 3300 MiB (delta 3300 MiB)
Modules natifs onnxruntime/cuda chargés dans le processus :
  - Microsoft.ML.OnnxRuntimeGenAI.dll
  - nvcuda.dll
  - nvcuda64.dll
  - nvcudart_hybrid64.dll
  - onnxruntime-genai-cuda.dll
  - onnxruntime-genai.DLL
  - onnxruntime.dll
  - onnxruntime_providers_cuda.dll
  - onnxruntime_providers_shared.dll
=> EP CUDA ACTIVE (module provider chargé)

Lecture du résultat : chargement et backend

Trois preuves indépendantes se lisent dans la sortie ci-dessus :

Indice Ce qu’il prouve
onnxruntime_providers_cuda.dll dans les modules du processus l’EP CUDA a bien été instanciée (chargement dynamique — le module n’existe dans la liste que si la session l’a demandé)
Le delta VRAM après chargement les poids int4 (~2,8 Go) résident sur le GPU, avec les buffers d’exécution
La latence de chargement l’ordre de grandeur d’un cold/warm start du dossier ONNX

C’est la preuve explicite demandée par le bake-off : on ne déduit pas le GPU du débit, on l’observe dans le processus. À comparer avec la Phase 1 (TensorSharp), où le serveur HTTP distant rendait ce diagnostic impossible depuis le notebook client.

3. Exemple guidé : génération et raisonnement natif de Qwen3

Qwen3 est un modèle à raisonnement natif : son gabarit de chat (chat_template.jinja) ouvre un bloc <think>…</think> avant la réponse. Nous construisons le prompt à la main pour rendre ce gabarit visible — c’est exactement ce que fait tokenizer.ApplyChatTemplate sous le capot.

La fonction Generer ci-dessous sera réutilisée par tout le reste du notebook : elle compte les jetons générés, mesure le TTFT (premier appel GenerateNextToken, qui embarque le préfill), le temps total et les occurrences du jeton spécial <pad> — l’indicateur de dégénérescence qui a coulé la Phase 1.

record ResultatGen(string Texte, int Jetons, double Secondes, double TtftMs, int Pads);

ResultatGen Generer(string question, int budgetJetons, bool raisonnement,
                    bool echantillonner = false, double temperature = 0.6, int topK = 20)
{
    string debutAssistant = raisonnement
        ? "<|im_start|>assistant\n"
        : "<|im_start|>assistant\n<think>\n\n</think>\n\n";
    string prompt = $"<|im_start|>user\n{question}<|im_end|>\n{debutAssistant}";

    var seqs = tokenizer.Encode(prompt);
    int promptTokens = seqs[0].Length;
    using var gp = new GeneratorParams(modele);
    gp.SetSearchOption("max_length", promptTokens + budgetJetons + 2);
    gp.SetSearchOption("do_sample", echantillonner);
    if (echantillonner)
    {
        gp.SetSearchOption("temperature", temperature);
        gp.SetSearchOption("top_k", topK);
    }

    var chrono = Stopwatch.StartNew();
    using var generateur = new Generator(modele, gp);
    generateur.AppendTokenSequences(seqs);
    string texte = "";
    int jetons = 0;
    double ttft = -1;
    while (!generateur.IsDone())
    {
        generateur.GenerateNextToken();
        if (jetons == 0) ttft = chrono.Elapsed.TotalMilliseconds;
        var seq = generateur.GetSequence(0);
        texte += tokenizer.Decode(new int[] { seq[seq.Length - 1] });
        jetons++;
    }
    chrono.Stop();
    return new ResultatGen(texte, jetons, chrono.Elapsed.TotalSeconds, ttft,
                           texte.Split("<pad>").Length - 1);
}

var exemple = Generer("Explique en une phrase, en français, ce qu'est un KV cache.", 180, raisonnement: true);
Console.WriteLine($"[exemple guidé, raisonnement ON] {exemple.Jetons} jetons en {exemple.Secondes:F2} s, TTFT {exemple.TtftMs:F0} ms, pads = {exemple.Pads}");
Console.WriteLine("--- sortie brute du modèle ---");
Console.WriteLine(exemple.Texte);
[exemple guidé, raisonnement ON] 182 jetons en 3,28 s, TTFT 1232 ms, pads = 0
--- sortie brute du modèle ---
<think>
Okay, the user is asking for a one-sentence explanation in French of what a KV cache is.

First, I need to recall what a KV cache is. From what I remember, KV cache stands for Key-Value cache. It's a type of cache that stores data in key-value pairs, allowing for quick retrieval of data based on the key.

Now, translating this into a single, concise sentence in French. The key terms to include are "cache", "clé-valeur", and "stockage rapide".

Putting it all together, the sentence should explain that a KV cache is a type of cache that stores data in key-value pairs, allowing for quick retrieval of data based on the key.

Now, translating this into a single, concise French sentence. The sentence should be clear and to the point, as per the user's request.

So, the final sentence in French would be

Lecture du résultat : le bloc <think>

La sortie brute montre la structure propre à Qwen3 : un bloc de délibération <think>…</think> (en anglais, même pour une question française — comportement du modèle base), puis la réponse. Deux enseignements :

  1. Coût en jetons : la délibération consomme une grande part du budget. Pour un bake-off qui compare des moteurs (et non des modèles), il faut neutraliser cette variance — d’où le commutateur logiciel <think>\n\n</think> (documenté par Qwen) que la section suivante active.
  2. Cohérence du décodage : aucun jeton <pad> ne s’immisce dans le texte, là où la Phase 1 (TensorSharp) produisait 99,4 % de <pad>. L’indicateur reste mesuré à chaque génération ci-dessous.

Note technique : le TTFT du tout premier appel inclut la compilation CUDA du graphe de calcul (warm-up) ; les appels suivants mesurent le préfill réel.

4. Bake-off : protocole identique aux Phases 1 et 2

Pour que la jambe ORT GenAI soit comparable aux mesures committées, nous reproduisons le protocole de la PR #12759 :

  • les quatre mêmes thèmes (KV cache, continuous batching, GGUF, quantization Q4) ;
  • décodage greedy (do_sample = false, équivalent du Temperature = 0.0 de la Phase 2) ;
  • budget de 96 jetons par invite, raisonnement désactivé ;
  • compte des jetons <pad> sur chaque sortie.

Rappel d’honnêteté : la Phase 2 a en réalité décodé sur CPU — son backend CUDA n’a jamais été chargé, vérifié le 2026-09-01 (10e §1bis) — tandis que la présente jambe tourne sur GPU avec l’EP CUDA prouvée par module chargé.

La cause a depuis été réparée, et 10e porte une mesure GPU contrôlée : 36,57 tok/s (37/37 couches sur CUDA0), contre 11,95 tok/s obtenus dans le même run en retirant la seule variable CUDA_PATH (10e §1quater). Le tableau final (section 5) porte désormais les deux régimes en deux lignes distinctes : la mesure CPU committée en Phase 2 (14,14 tok/s) et cette mesure GPU. La comparaison qui en résulte est à périphérique constant, pas à charge identique : formats distincts (ONNX int4 block-128 contre GGUF Q4_K_M), tokenizers et critères d’arrêt distincts, nombre de jetons émis différent.

string[] themesBakeOff = { "KV cache", "Continuous batching", "GGUF", "Quantization Q4" };
var resultatsBakeOff = new List<ResultatGen>();

foreach (var theme in themesBakeOff)
{
    var r = Generer($"Explique en deux phrases, en français, la notion de {theme}.", 96, raisonnement: false);
    resultatsBakeOff.Add(r);
    Console.WriteLine($"--- {theme,-20} {r.Jetons,3} jetons | TTFT {r.TtftMs,5:F0} ms | {r.Secondes,5:F2} s | {r.Jetons / r.Secondes,6:F1} tok/s | pads = {r.Pads}");
    string reponse = r.Texte.Trim();
    Console.WriteLine($"    {reponse[..Math.Min(160, reponse.Length)]}");
}

int totalJetons10f = resultatsBakeOff.Sum(r => r.Jetons);
int totalPads10f = resultatsBakeOff.Sum(r => r.Pads);
double totalSecondes10f = resultatsBakeOff.Sum(r => r.Secondes);
Console.WriteLine($"\nAGRÉGAT jambe ORT GenAI : {totalJetons10f} jetons en {totalSecondes10f:F2} s = {totalJetons10f / totalSecondes10f:F2} tok/s | {totalPads10f} jeton(s) <pad> au total");
Console.WriteLine($"VRAM après la campagne : {VramUtiliseeMib()} MiB");
--- KV cache              41 jetons | TTFT    48 ms |  0,43 s |   94,8 tok/s | pads = 0
    Le KV cache est une technique utilisée dans les modèles de langage pour accélérer l'inference en stockant les représentations des tokens clés (K) et valeurs (V)
--- Continuous batching   40 jetons | TTFT    48 ms |  0,63 s |   63,3 tok/s | pads = 0
    Le continuous batching est une méthode de production où les lots de matière première sont constamment ajoutés à la machine. Cela permet d'obtenir une production
--- GGUF                  37 jetons | TTFT    48 ms |  0,49 s |   75,5 tok/s | pads = 0
    Le GGUF est un format de fichier open-source conçu pour stocker des modèles de langage. Il permet une utilisation flexible et efficace de ces modèles..
--- Quantization Q4       42 jetons | TTFT    47 ms |  0,71 s |   59,1 tok/s | pads = 0
    La quantisation Q4 est une méthode de compression d'images, qui réduit la précision des couleurs. Elle conserve une qualité acceptable tout en réduisant la tail

AGRÉGAT jambe ORT GenAI : 160 jetons en 2,27 s = 70,63 tok/s | 0 jeton(s) <pad> au total
VRAM après la campagne : 4396 MiB

Lecture du résultat : débit et qualité

Ce que montre la sortie ci-dessus (les valeurs font foi, la prose ne les recopie pas — elles bougeraient à la re-exécution) :

  1. Débit décodé : l’agrégat de ce run dépasse les deux régimes de la jambe LLamaSharp — son plancher CPU (14,14 tok/s, la valeur committée dans 10e) comme son débit GPU (36,57 tok/s, mesuré après réparation de la détection CUDA, cf. 10e §1quater) ; le rapport exact se lit dans la sortie ci-dessus, il bougerait avec elle. Seule la comparaison au régime GPU oppose deux moteurs à périphérique constant : celle au plancher CPU mélange moteur, périphérique et journées de mesure — le contrôle CPU de 10e a rendu 11,95 tok/s le jour de sa mesure GPU, contre 14,14 le 2026-08-24, la machine étant plus lente des deux côtés ce jour-là. C’est exactement l’erreur que ce notebook met en garde de commettre. Les réserves qui subsistent portent sur le format (ONNX int4 block-128 contre GGUF Q4_K_M), les tokenizers et critères d’arrêt distincts, et la charge (le nombre de jetons émis par l’agrégat ci-dessus, contre 353 côté LLamaSharp) — plus sur le matériel.
  2. TTFT : les quatre appels de la campagne s’établissent entre 47 et 48 ms — le warm-up CUDA a déjà été payé par l’exemple guidé de la section 3 (TTFT de 1232 ms à son premier appel), pas par le premier thème de cette campagne. Le préfill d’un prompt court est négligeable devant le décodage.
  3. Qualité syntaxique : zéro jeton <pad> sur les quatre sorties — la pathologie de la Phase 1 est absente.
  4. Qualité sémantique — à lire avec les yeux : les réponses sont en français grammatical, mais le modèle base (non instruct) se trompe de domaine sur certains thèmes (le batching continu décrit comme une chaîne de production, la quantification Q4 comme de la compression d’images). C’est une limite du modèle, pas du moteur : la variante Qwen3-4B-Instruct-2507-ONNX existe sur le même dépôt.

Un moteur d’inférence se juge sur la fidélité du décodage (jetons propres, débit, latence) ; la justesse factuelle appartient au modèle choisi.

Exercice 1 : budget de génération et courbe débit/latence

Contexte : le budget max_length plafonne la longueur générée. Court budget = réponse tronquée mais latence maîtrisée ; long budget = réponse complète mais temps total linéaire en jetons.

Objectif : renseigner budgetsEssai (au moins trois valeurs croissantes) puis relancer la cellule, et observer comment évoluent TTFT (constant ? croissant ?) et débit.

# Indice : le TTFT mesure le préfill, qui dépend du prompt, pas du budget.
# Étape 1 : choisir par exemple new[] { 16, 48, 96, 192 }
# Étape 2 : comparer le débit (jetons/seconde) entre la première et la dernière ligne.
int[] budgetsEssai = Array.Empty<int>(); // TODO étudiant : trois budgets croissants, ex. new[] { 16, 48, 96, 192 }

if (budgetsEssai.Length == 0)
{
    Console.WriteLine("Exercice à compléter : renseigne budgetsEssai (trois valeurs croissantes) puis relance cette cellule.");
}
else
{
    foreach (var budget in budgetsEssai)
    {
        var r = Generer("Explique en deux phrases, en français, la notion de KV cache.", budget, raisonnement: false);
        Console.WriteLine($"budget {budget,3} -> {r.Jetons,3} jetons | TTFT {r.TtftMs,5:F0} ms | débit {r.Jetons / r.Secondes,6:F1} tok/s | pads = {r.Pads}");
    }
}
Exercice à compléter : renseigne budgetsEssai (trois valeurs croissantes) puis relance cette cellule.

5. Tableau comparatif à trois moteurs

Les colonnes TensorSharp et LLamaSharp reproduisent les mesures committées (10d/PR #12645 ; 10e Phase 2 CPU/PR #12759 et régime GPU/PR #14364) ; la colonne ORT GenAI injecte les valeurs mesurées par la cellule bake-off ci-dessus. Les conditions (VRAM, modèle exact, in-process vs serveur) sont rappelées ligne par ligne : comparer des tok/s sans lire cette ligne est la première erreur de bake-off.

Note d’attribution (cf #14157 (a), vérification 2026-09-01) : la ligne LLamaSharp « CPU » rapporte la mesure committée en Phase 2 — 14,14 tok/s, un débit CPU (backend CUDA jamais chargé sur le harnais de référence), et non GPU comme la version antérieure l’annonçait. Le débit reste authentique ; seule l’attribution avait changé.

Depuis : la cause a été réparée — LLamaSharp déduisait CUDA du toolkit et non du pilote — et la même jambe rend 36,57 tok/s sur GPU, 37/37 couches sur CUDA0, mesurés le 2026-09-02 avec un contrôle CPU du même run (11,95 tok/s, seule variable retirée : CUDA_PATH) (10e §1quater). Le tableau ci-dessous porte désormais les deux régimes en deux lignes distinctes.

Réserves avant de lire les débits : les moteurs ne servent ni le même format (GGUF Q4_K_M côté LLamaSharp contre ONNX int4 block-128 côté ORT GenAI), ni la même charge (353 jetons émis côté LLamaSharp contre ~160 côté ORT — tokenizers et critères d’arrêt distincts). Ce tableau compare des débits, pas un benchmark à charge contrôlée ; seuls les deux régimes LLamaSharp se lisent à charge strictement identique (mêmes 353 jetons, échantillonnage déterministe).

// Valeurs committées : 10d (PR #12645), 10e Phase 2 CPU (PR #12759), 10e régime GPU (PR #14364).
// Valeurs ORT : run courant (re-exécution end-to-end de ce notebook, cf #14365).
// Note attribution GPU (cf #14157 (a), verification 2026-09-01) : la ligne LLamaSharp CPU ci-dessous a ete corrigee
// de "in-process, RTX 3080 Ti, 6,2 Go VRAM libres" vers "in-process, CPU (CUDA jamais charge)"
// car la mesure sur le meme harnais a montre `backend_ptrs.size() = 1` (CPU seul), 0/36 couches
// sur GPU, VRAM plate pendant l'inference, debit reproduit 13,90 et 11,51 tok/s. La jambe n'a
// jamais utilise le GPU. Le debit 14,14 tok/s reste authentique ; c'est son attribution qui etait
// fausse. La cellule [c11-lecture-bakeoff] corrige la meme assertion en miroir.
// Régime GPU LLamaSharp (mesure committée 10e §1bis, run du 2026-09-02, PR #14364, même harnais) :
// `backend_ptrs.size() = 2`, 37/37 couches offloaded vers CUDA0, VRAM +3163 MiB pendant l'inférence,
// exactement les mêmes 353 jetons que le régime CPU (Temperature = 0.0, échantillonnage déterministe), 0 <pad>.
// Réserves du tableau : les moteurs ne servent ni le même format (ONNX int4 block-128 contre GGUF Q4_K_M)
// ni la même charge (tokenizers et critères d'arrêt distincts) — on compare des débits, pas des charges identiques.
var lignes = new (string Moteur, string Modele, string Conditions, string Jetons, string Debit, string Pads, string Qualite)[]
{
    ("TensorSharp 3.2.1.0", "Gemma 4 E4B Q8_0 (GGUF)", "serveur HTTP distant, RTX 3080 16 Go",
     "160", "~50 tok/s (côté serveur)", "159/160 (99,4 %)", "dégénérée (<pad>)"),
    ("LLamaSharp 0.27.0", "Qwen3-4B Q4_K_M (GGUF)", "in-process, CPU (CUDA jamais chargé ; voir #14157 (a))",
     "353", "14,14 tok/s (CPU)", "0 (0 %)", "propre, français"),
    ("LLamaSharp 0.27.0", "Qwen3-4B Q4_K_M (GGUF)", "in-process, RTX 3080 Ti (37/37 couches CUDA0 ; voir #14364)",
     "353", "36,57 tok/s (GPU)", "0 (0 %)", "propre, français"),
    ("ORT GenAI 0.15.2", "Qwen3-4B int4 (ONNX)", "in-process, RTX 3080 Ti, 16 Go VRAM libres",
     $"{totalJetons10f}", $"{totalJetons10f / totalSecondes10f:F2} tok/s", $"{totalPads10f} ({100.0 * totalPads10f / totalJetons10f:F1} %)", "propre, français"),
};
Console.WriteLine($"| {"Moteur",-20} | {"Modèle",-24} | {"Conditions",-38} | {"Jetons",6} | {"Débit",-22} | {"Pads",-18} | {"Qualité",-18} |");
Console.WriteLine($"|{"",-22}|{"",-26}|{"",-40}|{"",-8}|{"",-24}|{"",-20}|{"",-20}|");
foreach (var l in lignes)
    Console.WriteLine($"| {l.Moteur,-20} | {l.Modele,-24} | {l.Conditions,-38} | {l.Jetons,6} | {l.Debit,-22} | {l.Pads,-18} | {l.Qualite,-18} |");
Console.WriteLine($"\nLecture : les moteurs n'ont PAS servi le même format de modèle (GGUF Q4_K_M contre ONNX int4 block-128), ni le même déploiement (distant vs in-process), ni la même charge ({totalJetons10f} jetons émis côté ORT contre 353 côté LLamaSharp, tokenizers et critères d'arrêt distincts). Seuls les deux régimes LLamaSharp se comparent à charge identique (mêmes 353 jetons, échantillonnage déterministe) ; partout ailleurs ce tableau compare des débits, pas des charges. Le taux de jetons <pad> reste le seul axe strictement comparable entre les trois moteurs.");
| Moteur               | Modèle                   | Conditions                             | Jetons | Débit                  | Pads               | Qualité            |
|                      |                          |                                        |        |                        |                    |                    |
| TensorSharp 3.2.1.0  | Gemma 4 E4B Q8_0 (GGUF)  | serveur HTTP distant, RTX 3080 16 Go   |    160 | ~50 tok/s (côté serveur) | 159/160 (99,4 %)   | dégénérée (<pad>)  |
| LLamaSharp 0.27.0    | Qwen3-4B Q4_K_M (GGUF)   | in-process, CPU (CUDA jamais chargé ; voir #14157 (a)) |    353 | 14,14 tok/s (CPU)      | 0 (0 %)            | propre, français   |
| LLamaSharp 0.27.0    | Qwen3-4B Q4_K_M (GGUF)   | in-process, RTX 3080 Ti (37/37 couches CUDA0 ; voir #14364) |    353 | 36,57 tok/s (GPU)      | 0 (0 %)            | propre, français   |
| ORT GenAI 0.15.2     | Qwen3-4B int4 (ONNX)     | in-process, RTX 3080 Ti, 16 Go VRAM libres |    160 | 70,63 tok/s            | 0 (0,0 %)          | propre, français   |

Lecture : les moteurs n'ont PAS servi le même format de modèle (GGUF Q4_K_M contre ONNX int4 block-128), ni le même déploiement (distant vs in-process), ni la même charge (160 jetons émis côté ORT contre 353 côté LLamaSharp, tokenizers et critères d'arrêt distincts). Seuls les deux régimes LLamaSharp se comparent à charge identique (mêmes 353 jetons, échantillonnage déterministe) ; partout ailleurs ce tableau compare des débits, pas des charges. Le taux de jetons <pad> reste le seul axe strictement comparable entre les trois moteurs.

Lecture du résultat : trois moteurs, trois profils

  • TensorSharp (Phase 1) : le seul à proposer un serveur HTTP avec batching continu intégré — mais la jambe locale a produit une sortie dégénérée, ce qui disqualifie l’onboarding en l’état (verdict RECOVERABLE-LOCAL dans 10d, réparation toujours ouverte).
  • LLamaSharp (Phase 2) : binding mature de llama.cpp, écosystème GGUF immense, sortie propre. Attribution (cf #14157 (a)) : les 14,14 tok/s du tableau §5 sont un débit CPU — la vérification du 2026-09-01 sur le même harnais (mêmes GGUF, 3 runs) a montré backend_ptrs.size() = 1, 0/36 couches assignées au GPU, VRAM plate pendant l’inférence. Depuis, la détection CUDA a été réparée et la jambe rend 36,57 tok/s avec backend_ptrs.size() = 2 et 37/37 couches sur CUDA0 (10e §1quater) — le tableau §5 porte désormais les deux régimes en deux lignes. Le débit CPU restait authentique ; c’était son attribution GPU qui était fausse.
  • ORT GenAI (ce notebook) : un NuGet Microsoft, un dossier ONNX officiel, l’EP CUDA prouvée par module chargé, sortie propre, et un débit in-process dans les conditions les plus favorables des trois jambes.

La ligne « Qualité » est le discriminant du bake-off : deux moteurs sur trois produisent du texte exploitable ; c’est entre eux (LLamaSharp vs ORT GenAI) que se joue la décision. Depuis la réparation GPU de la jambe 2, cet arbitrage se pose enfin à périphérique constant : l’agrégat ORT de ce run (cellule §4 ci-dessus) contre 36,57 tok/s pour LLamaSharp, sur la même RTX 3080 Ti, in-process, décodage greedy — un écart qui appartient cette fois aux moteurs et à leurs formats, non au matériel. Deux réserves bornent la lecture : les formats et quantizations diffèrent (ONNX int4 block-128 contre GGUF Q4_K_M) et la charge diffère (nombre de jetons émis différent, tokenizers et critères d’arrêt distincts) — c’est une comparaison de débits, pas un benchmark à charge identique.

Exercice 2 : étendre le protocole à un cinquième thème

Contexte : un bake-off sur quatre thèmes est un échantillon minuscule. Ajouter un thème (par exemple « paged attention », « speculative decoding », ou un thème de votre cursus) teste la stabilité du pad ratio sur du vocabulaire nouveau.

Objectif : renseigner themesExtra puis relancer ; vérifier que les réponses restent sans <pad> et en français.

# Indice : Generer(...) est déjà paramétrée pour ce cas (greedy, 96 jetons, sans raisonnement).
# Étape 1 : new[] { "Paged attention", "Speculative decoding" }
# Étape 2 : lire chaque ligne pads = ... et la réponse affichée.
string[] themesExtra = Array.Empty<string>(); // TODO étudiant : au moins un thème supplémentaire

if (themesExtra.Length == 0)
{
    Console.WriteLine("Exercice à compléter : renseigne themesExtra puis relance cette cellule.");
}
else
{
    foreach (var theme in themesExtra)
    {
        var r = Generer($"Explique en deux phrases, en français, la notion de {theme}.", 96, raisonnement: false);
        string reponse = r.Texte.Trim();
        Console.WriteLine($"--- {theme,-24} {r.Jetons,3} jetons | {r.Jetons / r.Secondes,6:F1} tok/s | pads = {r.Pads}");
        Console.WriteLine($"    {reponse[..Math.Min(160, reponse.Length)]}");
    }
}
Exercice à compléter : renseigne themesExtra puis relance cette cellule.

6. Verdict go/no-go par axe (Phase 3 du bake-off)

Axe Verdict Preuve
Acquisition d’un modèle officiel GenAI-ready GO Dépôt onnx-community/Qwen3-4B-ONNX, variante CUDA int4 — 7 fichiers vérifiés par la cellule §1, téléchargement idempotent
Chargement .NET in-process GO new Model(dossier) + Tokenizer (cellule §2) — aucune compilation, aucun binaire hors NuGet
Backend GPU réellement actif GO onnxruntime_providers_cuda.dll chargé dans le processus + delta VRAM (cellule §2) — preuve directe, pas une inférence
Débit décodé in-process GO Agrégat bake-off (cellule §4) mesuré sur GPU, EP CUDA prouvée par module chargé. La jambe LLamaSharp dispose désormais d’un débit GPU sur la même carte — 36,57 tok/s, contrôlé dans son propre run (10e §1quater) : la comparaison se fait donc à périphérique constant, ×1,93 en faveur d’ORT (70,63 contre 36,57 tok/s). Elle reste bornée par le format (ONNX int4 block-128 contre GGUF Q4_K_M) et la charge (160 jetons émis contre 353, tokenizers et critères d’arrêt distincts) ; le tableau §5 porte désormais les deux régimes LLamaSharp, CPU et GPU
Qualité textuelle (décodage) GO 0 jeton <pad> sur les 4 invites du protocole (cellule §4), vs 99,4 % TensorSharp Phase 1
Qualité sémantique MITIGÉ Réponses grammaticales mais parfois hors-domaine : limite du modèle base non-instruct (cellule §4), pas du moteur — la variante Instruct-2507-ONNX existe
API .NET GO Model/Tokenizer/GeneratorParams/Generator, streaming jeton à jeton (GetSequence), recherche greedy et échantillonnage — MIT
Onboarding GO Un #r "nuget:…" + un dossier de modèle ; à comparer au binaire TensorSharp de 653 Mo sans NuGet officiel
Reproductibilité notebook local GO Exécuté de bout en bout dans le kernel .NET Interactive local (le blocage AppLocker documenté en 10e n’est plus actif sur cette machine)
Parité axes GenAI Image/Video NON ÉVALUÉ Hors périmètre de cette jambe Texte — gated sur vérification multimodale firsthand (cf. #12353)
Adoption curriculum DÉCISION COORDINATEUR + USER Les deux jambes exploitables (LLamaSharp, ORT GenAI) sont toutes deux GO sur le technique ; le choix final, la réparation #8369 et d’éventuels runs multi-seed restent à arbitrer — ce notebook fournit la jambe manquante du tableau

Lecture du verdict : ORT GenAI coche tous les axes techniques du cahier des charges #12353 pour l’axe Texte. Le point d’attention n’est pas le moteur mais le modèle base utilisé pour la comparabilité avec la Phase 2 : pour un notebook de curriculum, passer à la variante Instruct est le chemin naturel.

Exercice 3 : greedy contre échantillonnage

Contexte : tout le bake-off tourne en greedy (do_sample = false) pour être déterministe et comparable. Mais les modèles de raisonnement sont conçus pour être échantillonnés (Qwen3 recommande temperature 0,6 / top_k 20 / top_p 0,95).

Objectif : renseigner plansEssai avec au moins le greedy et un plan échantillonné, relancer, et comparer la variabilité de deux runs du même plan.

# Indice : Generer accepte echantillonner, temperature, topK.
# Étape 1 : ("greedy", false, 0, 0) puis ("temp 0.6", true, 0.6, 20)
# Étape 2 : lancer deux fois le même plan échantillonné — compare les textes jeton à jeton.
# Étape 3 : observer l'effet sur les pads et la longueur produite.
var plansEssai = new List<(string Nom, bool Echantillonner, double Temperature, int TopK)>(); // TODO étudiant

if (plansEssai.Count == 0)
{
    Console.WriteLine("Exercice à compléter : renseigne plansEssai (greedy + au moins un plan échantillonné) puis relance cette cellule.");
}
else
{
    foreach (var plan in plansEssai)
    {
        var r = Generer("Explique en deux phrases, en français, la notion de quantization Q4.",
                        96, raisonnement: false,
                        echantillonner: plan.Echantillonner, temperature: plan.Temperature, topK: plan.TopK);
        string reponse = r.Texte.Trim();
        Console.WriteLine($"--- {plan.Nom,-12} {r.Jetons,3} jetons | {r.Jetons / r.Secondes,6:F1} tok/s | pads = {r.Pads}");
        Console.WriteLine($"    {reponse[..Math.Min(160, reponse.Length)]}");
    }
}
Exercice à compléter : renseigne plansEssai (greedy + au moins un plan échantillonné) puis relance cette cellule.

Conclusion

Ce notebook fermait la jambe manquante du bake-off #12353. Récapitulatif qualitatif (les valeurs chiffrées vivent dans les sorties des cellules et dans les PR committées) :

Aspect TensorSharp (10d) LLamaSharp (10e) ORT GenAI (10f)
Packaging binaire prébuilt hors NuGet NuGet mature NuGet Microsoft
Format modèle GGUF GGUF (écosystème le plus large) ONNX officiel GenAI-ready
Déploiement testé serveur HTTP distant in-process ; CPU au run de référence, GPU (37/37 couches) après réparation in-process, EP CUDA prouvée
Sortie du protocole commun dégénérée (99,4 % <pad>) propre (0 %) propre (0 %)
Diagnostic GPU depuis le notebook impossible (distant) absent jusqu’au 2026-09-01 — le débit avait été lu comme une preuve de GPU ; instrumenté depuis (§1bis/§1ter de 10e) direct (modules + VRAM)

Points à retenir :

  1. Prouver le backend GPU se fait par observation (module provider chargé, VRAM), jamais par déduction du débit. La jambe LLamaSharp (10e) en est le contre-exemple coûteux : GpuLayerCount = 99 était demandé puis réaffiché, le débit paraissait plausible, et l’inférence tournait pourtant sur CPU. Un paramètre relu n’est pas une mesure ; seuls backend_ptrs.size(), le placement des couches et la VRAM tranchent.
  2. Un bake-off compare des moteurs à protocole constant — mais les conditions réelles (VRAM disponible, format de modèle, in-process ou distant) font partie du résultat et doivent voyager avec lui.
  3. Le taux de jetons <pad> est un détecteur de dégénérescence quasi gratuit : quelques lignes de code qui ont disqualifié un moteur entier.
  4. La qualité sémantique appartient au modèle : sur le même moteur, passer de Qwen3-4B base à la variante Instruct est le levier évident pour un notebook de curriculum.

Limites : run unique, machine unique, modèle base (choisi pour la comparabilité avec la Phase 2) — aucune statistique multi-seed. Le périphérique, lui, n’est plus une limite : la jambe LLamaSharp dispose depuis sa réparation d’un débit GPU sur la même carte (36,57 tok/s), ce qui rend la comparaison enfin homogène de ce côté ; ce sont désormais le format de modèle et le nombre de jetons émis qui la bornent. La décision d’adoption (et la réparation de #8369) revient au coordinateur et au user, comme prévu par la Phase 3 de l’issue.

Sources : ONNX Runtime GenAI · onnx-community/Qwen3-4B-ONNX · bake-off #12353 — Phase 1 10d / PR #12645, Phase 2 10e / PR #12759 · LLamaSharp · TensorSharp (voir 10d pour la référence du binaire)

Retour au sommet