Durée estimée : 55 minutes Prérequis : C# asynchrone, API OpenAI-compatible, notions de quantification GGUF Matériel du run de référence : GPU NVIDIA 16 Go ; modèle GGUF identifié dans les sorties
Objectifs d’apprentissage
Distinguer un moteur natif .NET d’un binding C++ et d’un runtime ONNX.
Interroger le serveur TensorSharp depuis un notebook .NET Interactive.
Mesurer latence, débit brut et qualité sans recopier un benchmark tiers.
Refuser un onboarding quand HTTP 200 masque un défaut de décodage.
Verdict du pilote : RECOVERABLE-LOCAL. Le vrai serveur TensorSharp CUDA charge le GGUF recommandé et répond via l’API OpenAI-compatible, mais la génération Gemma 4 contient des jetons <pad> répétés. Les débits bruts sont donc diagnostiques, pas des performances utiles. Qwen3-4B échoue séparément pendant le warm-up. Le bake-off LLamaSharp/ORT GenAI reste une étape distincte de l’investigation #12353.
Runtime .NET : 10.0.11
Serveur TensorSharp : http://127.0.0.1:5000
Client HTTP configuré sans secret : endpoint local uniquement.
1. Trois voies pour héberger un LLM en .NET
Moteur
Architecture
Format principal
Surface d’intégration
Statut du pilote
TensorSharp
moteur d’inférence écrit en .NET, kernels CUDA/GGML
GGUF
CLI + serveur OpenAI/Ollama
exécuté ici
LLamaSharp
binding .NET de llama.cpp
GGUF
NuGet, API in-process
baseline du bake-off suivant
ORT GenAI
extension Microsoft d’ONNX Runtime
ONNX
NuGet, API in-process
option si modèle ONNX équivalent
Le pilote ne transforme pas cette table qualitative en benchmark. Une comparaison valable doit utiliser le même modèle, la même quantification, le même GPU et les mêmes prompts.
Le serveur et le notebook communiquent par un contrat standard. La fonction suivante mesure le temps mural et lit le nombre de tokens de sortie renvoyé par le serveur. Le débit est calculé à partir de cette réponse, jamais inscrit à la main.
Helper SendChatAsync prêt : réponse, tokens et temps mural.
2.1 Exemple guidé : génération séquentielle
Un échauffement réduit l’effet du chargement initial. La seconde requête porte la mesure de référence. Le texte retourné sert aussi de contrôle qualitatif minimal : le serveur doit répondre au sujet demandé, pas seulement rendre HTTP 200.
_ = await SendChatAsync("Réponds uniquement par le mot PRET.",32);var sequential = await SendChatAsync("Explique en français, en trois phrases, la différence entre quantification et distillation d'un LLM.",160);var sequentialRate = sequential.Seconds>0? sequential.Tokens/ sequential.Seconds:0;var padCount = sequential.Content.Split("<pad>").Length-1;var usefulContent = sequential.Content.Replace("<pad>","").Trim();var qualitativeStatus = padCount ==0&& usefulContent.Length>=40?"VALIDE":"DECODE_DEFECT";Console.WriteLine($"Tokens générés (brut serveur) : {sequential.Tokens}");Console.WriteLine($"Temps mural : {sequential.Seconds:F2} s");Console.WriteLine($"Débit brut observé : {sequentialRate:F2} tok/s");Console.WriteLine($"Jetons <pad> détectés : {padCount}");Console.WriteLine($"Contrôle qualitatif : {qualitativeStatus}");Console.WriteLine("Contenu utile après diagnostic :");Console.WriteLine(usefulContent);
Tokens générés (brut serveur) : 160
Temps mural : 2,47 s
Débit brut observé : 64,82 tok/s
Jetons <pad> détectés : 159
Contrôle qualitatif : DECODE_DEFECT
Contenu utile après diagnostic :
Here
Lecture du résultat
L’échauffement précède la mesure : une première requête (« PRET », 32 tokens) absorbe l’effet du chargement initial, puis la seconde porte la mesure de référence — 160 tokens en 2,47 secondes, soit un débit brut de 64,82 tok/s, mesuré par le client lui-même et donc reproductible. Ce temps et ce débit restent propres à l’exécution courante : GPU, modèle, quantification, longueur du prompt et état du cache les influencent. Surtout, le débit brut n’est pas un débit utile lorsque DECODE_DEFECT apparaît : le contrôle qualitatif détecte 159 jetons <pad> répétés — comptés par le serveur, mais sans aucune information. La sonde prouve l’intégration locale .NET et rend le défaut falsifiable ; elle ne valide pas encore l’onboarding TensorSharp.
Exercice 1 : budget de génération
Objectif : observer l’effet de max_tokens sur la durée et la complétude.
Étape 1 : choisissez deux budgets.
Étape 2 : appelez SendChatAsync avec le même prompt.
Étape 3 : comparez tokens, durée et fin de réponse.
int[] budgets = Array.Empty<int>();// TODO étudiant : par exemple 48 et 160foreach(var budget in budgets){// TODO étudiant : appeler SendChatAsync avec un prompt identique Console.WriteLine($"Budget à tester : {budget}");}if(budgets.Length==0){ Console.WriteLine("Exercice 1 à compléter : définissez deux budgets de génération.");}
Exercice 1 à compléter : définissez deux budgets de génération.
3. Concurrence : ce que mesure réellement le client
Quatre requêtes sont envoyées simultanément. Le débit agrégé mesure le travail terminé par seconde vu du client. Avec un seul petit lot, il s’agit d’une sonde pédagogique, pas d’un benchmark de capacité maximale.
var batchPrompts =new[]{"Définis le cache KV en deux phrases.","Définis le continuous batching en deux phrases.","Définis GGUF en deux phrases.","Définis une quantification Q4 en deux phrases."};var batchStopwatch = Stopwatch.StartNew();var batchResults = await Task.WhenAll( batchPrompts.Select(prompt =>SendChatAsync(prompt,96)));batchStopwatch.Stop();var batchTokens = batchResults.Sum(result => result.Tokens);var batchPadCount = batchResults.Sum( result => result.Content.Split("<pad>").Length-1);var aggregateRate = batchStopwatch.Elapsed.TotalSeconds>0? batchTokens / batchStopwatch.Elapsed.TotalSeconds:0;Console.WriteLine($"Requêtes terminées : {batchResults.Length}/{batchPrompts.Length}");Console.WriteLine($"Tokens cumulés (brut serveur) : {batchTokens}");Console.WriteLine($"Jetons <pad> cumulés : {batchPadCount}");Console.WriteLine($"Temps du lot : {batchStopwatch.Elapsed.TotalSeconds:F2} s");Console.WriteLine($"Débit agrégé brut : {aggregateRate:F2} tok/s");for(var index =0; index < batchResults.Length; index++){ Console.WriteLine($" Requête {index + 1} : {batchResults[index].Tokens} tokens, {batchResults[index].Seconds:F2} s");}
Requêtes terminées : 4/4
Tokens cumulés (brut serveur) : 384
Jetons <pad> cumulés : 383
Temps du lot : 6,51 s
Débit agrégé brut : 59,01 tok/s
Requête 1 : 96 tokens, 6,44 s
Requête 2 : 96 tokens, 6,51 s
Requête 3 : 96 tokens, 6,51 s
Requête 4 : 96 tokens, 6,51 s
Lecture du résultat concurrent
Le lot révèle le parallélisme : chaque requête de 96 tokens s’achève en 6,44–6,51 s et le lot entier en 6,51 s — des temps quasi identiques, signe que les quatre tâches Task.WhenAll s’exécutent dans la même fenêtre, pour un débit agrégé brut de 59,01 tok/s. Mais ce chevauchement n’est utile qu’en apparence : 383 des 384 jetons comptés sont des <pad>. Le débit agrégé brut ne représente donc pas une capacité utile et ne peut pas valider le continuous batching. Après correction du décodage, il faudra répéter plusieurs charges en contrôlant modèle, backend et longueur de sortie.
Exercice 2 : facteur de concurrence
Objectif : construire une courbe charge-débit.
Étape 1 : testez 1, 2 puis 4 requêtes.
Étape 2 : gardez le prompt et le budget constants.
Étape 3 : tracez ou affichez le débit agrégé pour chaque charge.
int[] concurrencyLevels = Array.Empty<int>();// TODO étudiant : 1, 2, 4foreach(var level in concurrencyLevels){// TODO étudiant : créer level tâches SendChatAsync et mesurer le lot Console.WriteLine($"Niveau de concurrence à mesurer : {level}");}if(concurrencyLevels.Length==0){ Console.WriteLine("Exercice 2 à compléter : définissez les niveaux de concurrence.");}
Exercice 2 à compléter : définissez les niveaux de concurrence.
4. Décision d’onboarding proportionnée
Le pilote valide trois faits : un artefact Windows CUDA officiel existe, le serveur charge le GGUF recommandé et /v1/models répond. Il invalide toutefois l’adoption immédiate : la réponse de chat contient des jetons <pad> répétés, tandis que Qwen3-4B déclenche une NullReferenceException pendant le warm-up sur les backends ggml_cuda et cuda.
Axe
Verdict actuel
Prochaine preuve requise
Texte / serveur local
RECOVERABLE-LOCAL
corriger ou confirmer le décodage Gemma, puis réexécuter
API .NET in-process
non évaluée
charger les assemblies ou documenter l’API publique
Modèles ONNX
non comparable dans ce run
modèle équivalent ORT GenAI
Image / vidéo
non évalué
essai séparé avec modèle et VRAM adaptés
Adoption curriculum
NO-GO actuel
sortie textuelle valide + stabilité API + bake-off reproductible
Exercice 3 : protocole de bake-off
Objectif : préparer une comparaison TensorSharp / LLamaSharp qui évite les conclusions trompeuses.
Étape 1 : choisissez les variables à maintenir constantes.
Étape 2 : définissez les métriques de préfill et de décodage.
Étape 3 : indiquez un critère de décision falsifiable.
var controlledVariables =new List<string>();// TODO étudiant : modèle, quantification, GPU, prompt...var decisionCriterion ="";// TODO étudiant : critère falsifiableConsole.WriteLine(controlledVariables.Count==0?"Exercice 3 à compléter : listez les variables contrôlées.": $"Variables contrôlées : {string.Join(",", controlledVariables)}");Console.WriteLine(string.IsNullOrWhiteSpace(decisionCriterion)?"Critère de décision à compléter.": $"Critère : {decisionCriterion}");
Exercice 3 à compléter : listez les variables contrôlées.
Critère de décision à compléter.
Conclusion
TensorSharp franchit ici le seuil d’une investigation reproductible, pas celui d’un onboarding : vrai binaire CUDA, vrai GGUF, vrai serveur et vrai client .NET Interactive, mais sortie textuelle dégradée. Le notebook conserve cette preuve négative parce qu’un HTTP 200 et un débit élevé ne suffisent pas à établir une inférence utile.
À retenir :
un moteur natif .NET peut exposer une API OpenAI-compatible sans transformer le notebook en simple client cloud ;
une métrique de tokens doit être croisée avec un contrôle qualitatif ;
RECOVERABLE-LOCAL impose de réparer ou d’isoler le défaut avant promotion ;
le choix entre TensorSharp, LLamaSharp et ORT GenAI doit se faire par axe et par format de modèle ;
le prochain bake-off doit conserver modèle, quantification, GPU et prompts identiques.