bench(mtp): add standalone MTP self-speculative decode benchmark harness (#253) - #290
Merged
Conversation
…ess (#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 <path.gguf> [--threads N] [--prefill N] [--decode N] [--k N] [--repeats N]
…efore mtp-bench verification
There was a problem hiding this comment.
Pull request overview
Adds a new standalone benchmark entrypoint (mtp-bench) to measure Multi-Token Prediction (MTP) self-speculative decoding directly against a real GGUF-loaded CPU model, without integrating into the production TextGenerator loops.
Changes:
- Adds an
mtp-benchsubcommand routing in the benchmarksProgram. - Introduces
MtpBenchProfile, an interleaved A/B harness (baseline greedy vs. MTP) that reports median tok/s and an acceptance proxy, with baseline-only behavior when MTP is unsupported.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| benchmarks/DotLLM.Benchmarks/Program.cs | Adds mtp-bench subcommand routing to the new benchmark harness. |
| benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs | Implements the standalone MTP benchmark runner with interleaved trials, summary reporting, and model loading/tokenization. |
Suppressed comments (3)
benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs:78
- If tokenization yields 0 tokens (e.g., empty --prompt) or --prefill is 0, prefillLen becomes 0 and the subsequent Argmax call uses row (prefillLen - 1) = -1, which will compute an invalid pointer offset. Guard against empty prompts/prefill before slicing and running the model.
int[] promptTokens = tokenizer.Encode(prompt).ToArray();
int prefillLen = Math.Min(promptTokens.Length, prefillTokens);
int[] prefill = promptTokens[..prefillLen];
benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs:200
- Same as baseline prefill: this prefill forward pass only needs the last row's logits, but it currently uses the non-hinted overload. Consider calling Forward(..., lastTokenLogitsOnly: true) and selecting the last row via t.Shape[0]-1 so the code works whether or not the model honors the hint.
using (var t = model.Forward(prefill, prefillPositions, deviceId: -1, kv))
lastToken = ArgmaxFirstRow(t, prefillLen - 1, config.VocabSize);
benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs:123
- The summary computes speedup as mtpMedian / baseMedian without guarding against baseMedian being 0/NaN (e.g., --decode 0 yields 0 tok/s, or repeats=0 would leave baselineResults empty). This can print Infinity/NaN or throw in downstream formatting. Consider printing "N/A" when the baseline median is not positive.
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}");
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+156
to
+157
| using (var t = model.Forward(prefill, prefillPositions, deviceId: -1, kv)) | ||
| currentToken = ArgmaxFirstRow(t, prefillLen - 1, config.VocabSize); |
Comment on lines
+45
to
+50
| 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); |
Comment on lines
+1
to
+11
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds `mtp-bench`, a standalone CPU benchmark harness (`benchmarks/DotLLM.Benchmarks/Profile/MtpBenchProfile.cs`) that drives `MtpSpeculativeDecoder` directly against a real loaded model. Needed because MTP has no CLI/server wiring yet (tracked follow-up in `docs/SPECULATIVE.md`) — this exists purely for real-model measurement without touching `TextGenerator`'s production decode loops.
Runs interleaved A/B trials (baseline greedy decode vs. MTP self-speculative decode, alternating per round to share thermal/scheduling conditions), reports median tok/s and MTP acceptance rate. Gracefully falls back to baseline-only reporting when the loaded GGUF has no MTP head — verified via a smoke test against a non-MTP model (SmolLM2-135M): correct detection, correct fallback message, correct baseline numbers, no crash.
Real-model results produced with this harness (first-ever validation of dotLLM's MTP against a genuine
Qwen3.6-27B-MTP-GGUF)Full comparison against llama.cpp (mainline, commit `3653e6d`, 2026-08-07) — fixture `froggeric/Qwen3.6-27B-MTP-GGUF` Q4_K_M:
Headline finding: dotLLM's MTP acceptance rate (92.6%) closely tracks llama.cpp's own (~94%) on the identical model/prompt — strong cross-validation that the MTP head math (#285/#286) is correct. Neither engine shows a CPU win on this specific hardware (an old SSE-only Xeon Westmere, compute- not bandwidth-bound at decode — MTP's core assumption doesn't hold there); llama.cpp shows a modest real CUDA win (1.058x, diluted by partial GPU offload).
Caveats: single-run (not the project's usual interleaved-repeat convention — this CPU is too slow, 3-14s/token, to make that practical), prompt token counts not perfectly matched between engines (llama.cpp's new chat-first CLI wraps in a chat template, dotLLM's harness uses raw completion), dotLLM CUDA baseline number is real but comes from a degraded path (see the filed follow-up issue below) — not a fair GPU comparison yet.
Real bug found while getting even the baseline CUDA number: `HybridTransformerModel.LoadFromGguf` throws `KeyNotFoundException` for `Qwen3HybridDense` on the partial-GPU-layer-offload path — not architecture-aware the way the CPU and full-offload CUDA paths are. Worked around with full 64-layer offload, which only "succeeds" because Windows WDDM silently overcommits the 16.8GB weights past the 3060's 12GB VRAM. Filing separately.
Refs #253.