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

10d. TensorSharp : pilote d’inférence LLM native .NET

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

  1. Distinguer un moteur natif .NET d’un binding C++ et d’un runtime ONNX.
  2. Interroger le serveur TensorSharp depuis un notebook .NET Interactive.
  3. Mesurer latence, débit brut et qualité sans recopier un benchmark tiers.
  4. 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.

using System.Diagnostics;
using System.Net.Http;
using System.Text;
using System.Text.Json;

var tensorSharpBaseUrl = Environment.GetEnvironmentVariable("TENSORSHARP_BASE_URL")
    ?? "http://127.0.0.1:5000";
var http = new HttpClient { BaseAddress = new Uri(tensorSharpBaseUrl), Timeout = TimeSpan.FromMinutes(5) };

Console.WriteLine($"Runtime .NET : {Environment.Version}");
Console.WriteLine($"Serveur TensorSharp : {tensorSharpBaseUrl}");
Console.WriteLine("Client HTTP configuré sans secret : endpoint local uniquement.");
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.

var modelsResponse = await http.GetAsync("/v1/models");
var modelsJson = await modelsResponse.Content.ReadAsStringAsync();
modelsResponse.EnsureSuccessStatusCode();

var modelsDoc = JsonDocument.Parse(modelsJson);
var modelIds = modelsDoc.RootElement.GetProperty("data")
    .EnumerateArray()
    .Select(item => item.GetProperty("id").GetString()!)
    .ToArray();
var tensorSharpModel = modelIds.First();

Console.WriteLine($"HTTP /v1/models : {(int)modelsResponse.StatusCode}");
Console.WriteLine($"Modèles servis ({modelIds.Length}) : {string.Join(", ", modelIds)}");
Console.WriteLine($"Modèle retenu automatiquement : {tensorSharpModel}");
HTTP /v1/models : 200
Modèles servis (1) : gemma-4-E4B-it-Q8_0
Modèle retenu automatiquement : gemma-4-E4B-it-Q8_0

2. Appel OpenAI-compatible depuis C

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.

async Task<(string Content, int Tokens, double Seconds)> SendChatAsync(
    string prompt,
    int maxTokens = 128)
{
    var request = new
    {
        model = tensorSharpModel,
        messages = new[] { new { role = "user", content = prompt } },
        temperature = 0.2,
        max_tokens = maxTokens,
        stream = false
    };
    using var body = new StringContent(
        JsonSerializer.Serialize(request), Encoding.UTF8, "application/json");
    var stopwatch = Stopwatch.StartNew();
    var response = await http.PostAsync("/v1/chat/completions", body);
    var json = await response.Content.ReadAsStringAsync();
    stopwatch.Stop();
    response.EnsureSuccessStatusCode();

    using var doc = JsonDocument.Parse(json);
    var content = doc.RootElement.GetProperty("choices")[0]
        .GetProperty("message").GetProperty("content").GetString() ?? "";
    var tokens = doc.RootElement.TryGetProperty("usage", out var usage)
        && usage.TryGetProperty("completion_tokens", out var completionTokens)
        ? completionTokens.GetInt32()
        : 0;
    return (content, tokens, stopwatch.Elapsed.TotalSeconds);
}

Console.WriteLine("Helper SendChatAsync prêt : réponse, tokens et temps mural.");
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 160
foreach (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, 4
foreach (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 falsifiable

Console.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.

Sources : TensorSharp, LLamaSharp, ONNX Runtime GenAI, issue #12353.

Retour au sommet