From 06b2b1db744726fb449e4a3c0c9a42d5dec47d1e Mon Sep 17 00:00:00 2001 From: James Burton Date: Fri, 7 Aug 2026 22:36:04 +0100 Subject: [PATCH] bench(mtp): add standalone MTP self-speculative decode benchmark harness (#253) CLI/server wiring for MtpSpeculativeDecoder was left as explicit follow-up in PR #285. Rather than thread it through TextGenerator's two speculative decode loops under time pressure, add a standalone `mtp-bench` profile subcommand (mirrors the existing profile-cuda-decode / profile-vulkan-* pattern in DotLLM.Benchmarks) that drives MtpSpeculativeDecoder directly against a real loaded CPU model for measurement: interleaved A/B trials of plain greedy decode vs MTP self-speculative decode, reporting tok/s, prefill tok/s, and acceptance rate. Smoke-tested against the cached Qwen3-0.6B fixture (correctly falls back to baseline-only when SupportsMtp is false). Usage: dotnet run --project benchmarks/DotLLM.Benchmarks -- mtp-bench --model [--threads N] [--prefill N] [--decode N] [--k N] [--repeats N] --- .../Profile/MtpBenchProfile.cs | 278 ++++++++++++++++++ benchmarks/DotLLM.Benchmarks/Program.cs | 4 + 2 files changed, 282 insertions(+) create mode 100644 benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs diff --git a/benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs b/benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs new file mode 100644 index 00000000..ac9ff9ba --- /dev/null +++ b/benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs @@ -0,0 +1,278 @@ +using System.Diagnostics; +using System.Numerics.Tensors; +using DotLLM.Core.Configuration; +using DotLLM.Core.Tensors; +using DotLLM.Engine; +using DotLLM.Engine.KvCache; +using DotLLM.Engine.Samplers; +using DotLLM.HuggingFace; +using DotLLM.Models; +using DotLLM.Models.Gguf; +using DotLLM.Tokenizers.Bpe; + +namespace DotLLM.Benchmarks.Profile; + +/// +/// Standalone CPU benchmark harness for MTP (Multi-Token Prediction) self-speculative decoding +/// (issue #253). Drives directly against a real loaded model — +/// there is no CLI/server wiring for MTP yet (tracked as follow-up in docs/SPECULATIVE.md), so this +/// exists purely for real-model measurement without touching TextGenerator's production +/// decode loops. +/// +/// Runs interleaved A/B trials: plain greedy decode (baseline) vs. MTP self-speculative decode, +/// alternating so both share the same thermal/scheduling conditions, then reports median tok/s and +/// the MTP acceptance rate. +/// +/// Usage: dotnet run -- mtp-bench --model <path.gguf> [--threads N] [--decode N] [--k N] [--repeats N] +/// +internal static class MtpBenchProfile +{ + private const string DefaultPrompt = + "The capital of France is Paris. The capital of Germany is Berlin. The capital of Italy is Rome. " + + "The capital of Spain is Madrid. The capital of Portugal is Lisbon. Write a short paragraph " + + "describing how speculative decoding accelerates large language model inference."; + + public static int Run(string[] args) + { + string? modelPath = GetOption(args, "--model") ?? Environment.GetEnvironmentVariable("DOTLLM_BENCH_MODEL_PATH"); + if (string.IsNullOrEmpty(modelPath) || !File.Exists(modelPath)) + { + Console.Error.WriteLine("Usage: mtp-bench --model [--threads N] [--prefill N] [--decode N] [--k N] [--repeats N] [--warmup N]"); + Console.Error.WriteLine($" (or set DOTLLM_BENCH_MODEL_PATH). Got: '{modelPath}'"); + return 2; + } + + int threads = GetIntOption(args, "--threads", 0); // 0 = auto (all cores) + int prefillTokens = GetIntOption(args, "--prefill", 256); + int decodeTokens = GetIntOption(args, "--decode", 32); + int k = GetIntOption(args, "--k", 4); + int repeats = GetIntOption(args, "--repeats", 1); + int warmup = GetIntOption(args, "--warmup", 0); + string prompt = GetOption(args, "--prompt") ?? DefaultPrompt; + + var threading = new ThreadingConfig(threads); + Console.WriteLine($"Model: {modelPath}"); + Console.WriteLine($"Threads: {threading.EffectiveThreadCount} (requested {threads})"); + + var loadSw = Stopwatch.StartNew(); + using var gguf = GgufFile.Open(modelPath); + var config = GgufModelConfigExtractor.Extract(gguf.Metadata); + var tokenizer = GgufBpeTokenizerFactory.Load(gguf.Metadata); + using var typedModel = ModelLoader.CreateCpuModelFromGguf(gguf, config, threading); + loadSw.Stop(); + + Console.WriteLine($"Architecture: {config.Architecture} Layers={config.NumLayers} Hidden={config.HiddenSize} Vocab={config.VocabSize}"); + Console.WriteLine($"Load time: {loadSw.Elapsed.TotalSeconds:F1}s"); + Console.WriteLine($"SupportsMtp: {typedModel.SupportsMtp}"); + + if (!typedModel.SupportsMtp) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("ERROR: model.SupportsMtp is false — no MTP head (nextn.* tensors) was detected in this GGUF,"); + Console.Error.WriteLine("or the architecture dispatch didn't route to a model that implements it. MTP benchmark cannot run."); + Console.Error.WriteLine("Baseline-only greedy decode numbers below are still valid for the no-MTP comparison point."); + } + + int[] promptTokens = tokenizer.Encode(prompt).ToArray(); + int prefillLen = Math.Min(promptTokens.Length, prefillTokens); + int[] prefill = promptTokens[..prefillLen]; + Console.WriteLine($"Prompt tokens: {promptTokens.Length} (using {prefillLen} for prefill)"); + Console.WriteLine($"Decode tokens per trial: {decodeTokens} K (MTP candidates): {k} Repeats: {repeats} Warmup trials: {warmup}"); + Console.WriteLine(); + + int cacheCapacity = prefillLen + decodeTokens + k + 8; + + var baselineResults = new List(); + var mtpResults = new List(); + + int totalTrials = repeats + warmup; + for (int trial = 0; trial < totalTrials; trial++) + { + bool isWarmup = trial < warmup; + string tag = isWarmup ? "warmup" : $"trial {trial - warmup + 1}/{repeats}"; + + // Interleaved A/B: baseline then MTP each round, same prompt/params, same process. + var baseline = RunBaseline(typedModel, tokenizer, prefill, prefillLen, decodeTokens, cacheCapacity, config); + Console.WriteLine($"[{tag}] baseline : {baseline.TokPerSec,7:F2} tok/s prefill {baseline.PrefillTokPerSec,7:F1} tok/s ({baseline.DecodeMs:F0} ms / {baseline.TokensGenerated} tok)"); + + TrialResult? mtp = null; + if (typedModel.SupportsMtp) + { + mtp = RunMtp(typedModel, tokenizer, prefill, prefillLen, decodeTokens, k, cacheCapacity, config); + Console.WriteLine($"[{tag}] mtp : {mtp.Value.TokPerSec,7:F2} tok/s prefill {mtp.Value.PrefillTokPerSec,7:F1} tok/s ({mtp.Value.DecodeMs:F0} ms / {mtp.Value.TokensGenerated} tok) accept={mtp.Value.AcceptanceRate:P0} (drafted {mtp.Value.Drafted}, accepted {mtp.Value.Accepted})"); + } + + if (!isWarmup) + { + baselineResults.Add(baseline); + if (mtp.HasValue) mtpResults.Add(mtp.Value); + } + } + + Console.WriteLine(); + Console.WriteLine("──────── SUMMARY (median across non-warmup trials) ────────"); + double baseMedian = Median(baselineResults.Select(r => r.TokPerSec).ToArray()); + double basePrefillMedian = Median(baselineResults.Select(r => r.PrefillTokPerSec).ToArray()); + Console.WriteLine($" baseline (no MTP) decode = {baseMedian,7:F2} tok/s prefill = {basePrefillMedian,7:F1} tok/s"); + + if (mtpResults.Count > 0) + { + double mtpMedian = Median(mtpResults.Select(r => r.TokPerSec).ToArray()); + double mtpPrefillMedian = Median(mtpResults.Select(r => r.PrefillTokPerSec).ToArray()); + double acceptMedian = Median(mtpResults.Select(r => r.AcceptanceRate).ToArray()); + Console.WriteLine($" MTP decode = {mtpMedian,7:F2} tok/s prefill = {mtpPrefillMedian,7:F1} tok/s speedup = {mtpMedian / baseMedian,5:F2}x acceptance = {acceptMedian:P1}"); + } + else + { + Console.WriteLine(" MTP decode = N/A (model does not support MTP)"); + } + Console.WriteLine("─────────────────────────────────────────────────────────"); + + return 0; + } + + private readonly struct TrialResult + { + public required double TokPerSec { get; init; } + public required double PrefillTokPerSec { get; init; } + public required double DecodeMs { get; init; } + public required int TokensGenerated { get; init; } + public double AcceptanceRate { get; init; } + public int Drafted { get; init; } + public int Accepted { get; init; } + } + + private static TrialResult RunBaseline( + DotLLM.Core.Models.IModel model, DotLLM.Tokenizers.ITokenizer tokenizer, + int[] prefill, int prefillLen, int decodeTokens, int cacheCapacity, DotLLM.Core.Models.ModelConfig config) + { + using var kv = new SimpleKvCache(config.NumLayers, config.NumKvHeads, config.HeadDim, cacheCapacity); + + int[] prefillPositions = new int[prefillLen]; + for (int i = 0; i < prefillLen; i++) prefillPositions[i] = i; + + var prefillSw = Stopwatch.StartNew(); + int currentToken; + using (var t = model.Forward(prefill, prefillPositions, deviceId: -1, kv)) + currentToken = ArgmaxFirstRow(t, prefillLen - 1, config.VocabSize); + prefillSw.Stop(); + + int pos = prefillLen; + var sw = Stopwatch.StartNew(); + int generated = 0; + int[] tokBuf = new int[1]; + int[] posBuf = new int[1]; + for (int i = 0; i < decodeTokens && pos < cacheCapacity; i++) + { + tokBuf[0] = currentToken; + posBuf[0] = pos; + using var t = model.Forward(tokBuf, posBuf, deviceId: -1, kv); + currentToken = ArgmaxFirstRow(t, 0, config.VocabSize); + pos++; + generated++; + } + sw.Stop(); + + return new TrialResult + { + TokPerSec = generated / (sw.Elapsed.TotalMilliseconds / 1000.0), + PrefillTokPerSec = prefillLen / prefillSw.Elapsed.TotalSeconds, + DecodeMs = sw.Elapsed.TotalMilliseconds, + TokensGenerated = generated, + }; + } + + private static TrialResult RunMtp( + DotLLM.Core.Models.IModel model, DotLLM.Tokenizers.ITokenizer tokenizer, + int[] prefill, int prefillLen, int decodeTokens, int k, int cacheCapacity, DotLLM.Core.Models.ModelConfig config) + { + using var kv = new SimpleKvCache(config.NumLayers, config.NumKvHeads, config.HeadDim, cacheCapacity); + using var mtpState = model.CreateMtpState()!; + var decoder = new MtpSpeculativeDecoder(greedy: true); + var pipeline = new SamplerPipeline(new InferenceOptions { Temperature = 0f }); + + int[] prefillPositions = new int[prefillLen]; + for (int i = 0; i < prefillLen; i++) prefillPositions[i] = i; + + var prefillSw = Stopwatch.StartNew(); + int lastToken; + using (var t = model.Forward(prefill, prefillPositions, deviceId: -1, kv)) + lastToken = ArgmaxFirstRow(t, prefillLen - 1, config.VocabSize); + prefillSw.Stop(); + + var generatedIds = new List(prefill) { lastToken }; + int position = prefillLen; + + var sw = Stopwatch.StartNew(); + int generated = 0; + int totalDrafted = 0, totalAccepted = 0; + Span outputBuffer = new int[k + 1]; + int guard = 0; + while (generated < decodeTokens && position < cacheCapacity && guard++ < decodeTokens * 4) + { + int remaining = decodeTokens - generated; + int thisK = Math.Min(k, remaining); + if (thisK <= 0) break; + + var result = decoder.DraftAndVerify( + model, kv, mtpState, pipeline, generatedIds, + constraint: null, position, config.VocabSize, thisK, outputBuffer); + + if (result.AcceptedCount == 0) break; + + totalDrafted += result.DraftedCount; + totalAccepted += result.AcceptedCount; + + for (int i = 0; i < result.AcceptedCount && generated < decodeTokens; i++) + { + generatedIds.Add(outputBuffer[i]); + generated++; + } + position += result.AcceptedCount; + } + sw.Stop(); + + return new TrialResult + { + TokPerSec = generated / (sw.Elapsed.TotalMilliseconds / 1000.0), + PrefillTokPerSec = prefillLen / prefillSw.Elapsed.TotalSeconds, + DecodeMs = sw.Elapsed.TotalMilliseconds, + TokensGenerated = generated, + Drafted = totalDrafted, + Accepted = totalAccepted, + // Approximate acceptance rate: accepted tokens (including any bonus token beyond the + // K drafted per round) over drafted tokens. Not llama.cpp's exact definition (which + // excludes the bonus token) but a reasonable single-number proxy for "how often did + // the MTP head's guesses land" for this harness's purposes. + AcceptanceRate = totalDrafted > 0 ? (double)totalAccepted / totalDrafted : 0, + }; + } + + private static unsafe int ArgmaxFirstRow(ITensor logits, int row, int vocabSize) + { + var span = new ReadOnlySpan((void*)(logits.DataPointer + (long)row * vocabSize * sizeof(float)), vocabSize); + return TensorPrimitives.IndexOfMax(span); + } + + private static double Median(double[] values) + { + if (values.Length == 0) return double.NaN; + var sorted = (double[])values.Clone(); + Array.Sort(sorted); + int n = sorted.Length; + return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0; + } + + private static string? GetOption(string[] args, string name) + { + for (int i = 0; i < args.Length - 1; i++) + if (args[i] == name) return args[i + 1]; + return null; + } + + private static int GetIntOption(string[] args, string name, int defaultValue) + { + string? s = GetOption(args, name); + return s is not null && int.TryParse(s, out int v) ? v : defaultValue; + } +} diff --git a/benchmarks/DotLLM.Benchmarks/Program.cs b/benchmarks/DotLLM.Benchmarks/Program.cs index ecac76ac..4562c9a2 100644 --- a/benchmarks/DotLLM.Benchmarks/Program.cs +++ b/benchmarks/DotLLM.Benchmarks/Program.cs @@ -22,6 +22,10 @@ { return VulkanPipelineSpanProfile.Run(args[1..]); } +if (args.Length > 0 && args[0] == "mtp-bench") +{ + return MtpBenchProfile.Run(args[1..]); +} // Mutator job: run benchmarks in-process so BDN skips the separate subprocess // build step entirely. This avoids the 2-minute build timeout that DotLLM.Cuda's