Skip to content

feat: Native Speculative Decoding & MTP (Multi-Token Prediction) - #1431

Open
zsogitbe wants to merge 2 commits into
SciSharp:masterfrom
zsogitbe:SpeculativeDecodingLLamaSharp
Open

feat: Native Speculative Decoding & MTP (Multi-Token Prediction)#1431
zsogitbe wants to merge 2 commits into
SciSharp:masterfrom
zsogitbe:SpeculativeDecodingLLamaSharp

Conversation

@zsogitbe

Copy link
Copy Markdown
Contributor

⚠️ DEPENDENCY NOTICE: This Pull Request relies on new native C APIs introduced in the companion llama.cpp PR feat: Expose Multi-Sequence Speculative Decoding & MTP to Public C API- #27788. It will not work with the standard llama.cpp binaries currently shipped in LLamaSharp. To test this PR, you must compile and link the modified llama.cpp binaries from the upstream PR.

Summary

This PR adds native managed support for Speculative Decoding (Draft-Simple) and Multi-Token Prediction (MTP). It integrates seamlessly into both StatelessExecutor and BatchedExecutor, allowing users to achieve significant Tokens-Per-Second (TPS) speedups with a single boolean parameter while preserving the existing IAsyncEnumerable streaming API (cc @martindevans).

Key Architectural Changes

  • Native Bindings (NativeApiSpeculative.cs): Added complete P/Invoke definitions for the new llama_speculative_* API, wrapping the native context in a robust SafeSpeculativeContextHandle.
  • The SpeculativeDecoder Wrapper: Safely initializes the native speculative context and automatically mounts a native greedy sampler to guarantee mathematical verification constraints.
  • StatelessExecutor Integration: Updated constructors to accept optional draft weights or a useMtp flag. Transparently intercepts token generation to route through the SpeculativeDecoder using the standard StreamingTokenDecoder.
  • BatchedExecutor Multiplexing: Introduced a Queue<LLamaToken> _speculativeTokens to the Conversation multiplexer. Because BatchedExecutor natively relies on C# to sample tokens after logits are calculated, the Conversation.Sample() method was overridden to safely intercept and dequeue natively pre-sampled speculative tokens.
  • KV Deduplication (_speculativeTokensToIgnore): Automatically tracks and ignores user prompt inputs for tokens that the C++ engine has already evaluated and cached during a speculative burst, preventing cache duplication.
  • Model Parameter Mapping: Exposed LoadMtp in ModelParams to explicitly command the backend to load nextn tensors into VRAM (strictly required for DeepSeek-R1 / Qwen MTP execution).

Validation

  • Added SpeculativeDecodingTests.cs to LLama.Unittest utilizing Constants.GenerativeModelPath.
  • Added a runnable SpeculativeBenchmark class to isolate and measure Tokens-Per-Second (TPS) gains across Baseline, Dual-Model Speculation, and MTP strategies.
  • Cross-Ecosystem Testing: (Note: This PR is paired with a companion PR submitted to llama.cpp feat: Expose Multi-Sequence Speculative Decoding & MTP to Public C API- #27788 which implements and exposes the standalone native C API. The full architecture has been rigorously tested across both boundaries, from native C++ state-rollback CI validation to managed C# asynchronous streaming, batch queue multiplexing, and interactive benchmarking.)

ANNEX: Model Selection & Evaluation

1. Model Compatibility & Selection Rules

  • Dual-Model Speculation: The target and draft models must share the exact same tokenizer architecture and vocabulary size to prevent immediate cache desynchronization crashes. Crucially, the larger the target model and the smaller/faster the draft model, the higher the resulting speedup.

  • Multi-Token Prediction (MTP): This requires a single model with pre-trained speculative projection layers (nextn_predict_layers >= 1). The more draft heads the model has, the higher the potential speedup, as it can verify more tokens per native API call without context-switching overhead. The draft budget must match the available heads.


2. Real-World Benchmark Examples

Draft-Simple (Expected Speedup)
Pairing Meta-Llama-3-8B-Instruct-Q8_0.gguf (Target) with Llama-3.2-1B-Instruct-Q4_0.gguf (Draft) yielded a 1.26x speedup (+25.7%). Even on a fast GPU, the 1B draft model was lightweight enough to outpace the compute overhead of the 8B target.

MTP Speculation (Hardware Slowdown)
Running Qwen3.5-4B-MTP-Q4_K_M.gguf (1 MTP head) resulted in a 0.47x slowdown (-53.2%). Because the 4B base model is computed incredibly fast on high-end hardware, the API overhead and CUDA graph launch latency of evaluating the MTP head completely overshadowed the memory bandwidth savings. To achieve a speedup instead, you must use a much larger model (like 14B or 32B) where the GPU's memory bandwidth becomes a true bottleneck as it fetches massive weight matrices from VRAM for every single token. Alternatively, using an MTP model with multiple prediction heads allows you to verify several tokens in a single API roundtrip, making the overhead worthwhile.


3. Hardware & Workload Dynamics

  • 100% VRAM Offloading: Both models (or the full MTP model) must fit entirely within GPU VRAM. If layers spill over to the CPU, parallel batch verification turns into serialized matrix multiplication, causing a severe net slowdown.

  • The Size Threshold: Speculative decoding is an optimization for memory-bandwidth-bound workloads. Speedups reliably appear on mid-to-large models (8B, 14B, 32B, 70B) where reading massive weight matrices per token is the actual bottleneck.

  • Task Predictability: Deterministic tasks (code completion, JSON extraction) achieve high draft acceptance rates (>70%), maximizing throughput. Creative writing and high-temperature sampling trigger frequent draft rejections, wasting compute cycles.


4. Quick Selection & Viability Matrix

Strategy Required Model Setup Minimum Target Size Ideal Workload Expected Result
Draft-Simple Target + Draft (Same Vocab & Family) >= 8B (100% VRAM) Code, JSON, Structured Tasks 1.4x – 2.2x Speedup
Draft-Simple Target + Draft (Different Vocab) Any Any Immediate Crash
MTP Single Model (nextn_predict_layers >= 1) >= 8B–14B (100% VRAM) Low-Temperature / High-Confidence 1.3x – 1.8x Speedup
Any Speculative Model partially offloaded to CPU Any Any Slowdown
Any Speculative Models <= 4B on High-End GPU <= 4B (100% VRAM) High-Entropy / Creative Prompts Slowdown

@zsogitbe

Copy link
Copy Markdown
Contributor Author

The CI failed because the new 3GB Qwen model download from Hugging Face timed out/dropped mid-way (ResponseEnded). Since MSBuild's native download task doesn't have great retry logic for large files, this might become a flaky test.

For now, could we re-run the workflow? Long-term, we might want to either cache this model in the GitHub Actions runner,...

@zsogitbe zsogitbe changed the title feat: Native Speculative Decoding & DeepSeek/Qwen MTP Support feat: Native Speculative Decoding & MTP (Multi-Token Prediction) Aug 27, 2026
Comment thread LLama.Examples/Program.cs
Comment on lines +34 to +37
// enable this for forcing specific version of llama.cpp; disable for standard use
//.WithLibrary(
// @"D:\_MTP\LLamaSharp\llama.cpp\_vs\bin\Release\llama.dll",
// @"D:\_MTP\LLamaSharp\llama.cpp\_vs\bin\Release\mtmd.dll")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// enable this for forcing specific version of llama.cpp; disable for standard use
//.WithLibrary(
// @"D:\_MTP\LLamaSharp\llama.cpp\_vs\bin\Release\llama.dll",
// @"D:\_MTP\LLamaSharp\llama.cpp\_vs\bin\Release\mtmd.dll")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will go ahead and remove these hardcoded local paths so they don't break the build for others. I originally added them as a temporary workaround because it is currently very difficult to test a custom llama.cpp build (which this PR relies on).

I also noticed a potential bug during this: a strange DLL appeared in the cuda12 folder called libmtmd.dll (it should probably be mtmd.dll). Without these custom MSBuild scripts and .WithLibrary() overrides, it felt nearly impossible to force the project to use my custom binaries. Now that I am removing my workaround, what is the recommended/standard way in LLamaSharp to point to local custom backend binaries during development?

@martindevans martindevans Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libmtmd.dll

That's odd, I see the same in my local CUDA12 folder. It does look like a bug. The build action outputs mtmd.dll so I'm not sure where that's coming from.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think WithLibrary as you had is the right way to do it for development, just don't commit it as part of the PR.

public static readonly string MtmdMmpPath = "Models/gemma-mmproj-model-f16.gguf";
public static readonly string MtmdImage = "Models/extreme-ironing-taxi-610x427.jpg";

public static readonly string MtpModelPath = "Models/Qwen3.5-4B-MTP-Q4_K_M.gguf";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get away with https://huggingface.co/unsloth/Qwen3.5-2B-MTP-GGUF here instead? CI runs entirely on (underpowered) CPUs, so we need models to be as lightweight as possible. Even smaller than my suggestion would be better, but I can't find anything smaller that still has MTP head.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes total sense for the CI runners, and I agree we should use something as lightweight as possible to save both cache space and CPU time.

I actually tried using the unsloth MTP quants earlier, but I ran into a bad crash. It turns out some of those specific GGUFs were exported missing the MTP head metadata (like mtp_num_hidden_layers), which causes llama.cpp (and LLamaSharp) to fail when initializing the speculative context.

I will hunt for a working ~1B or 2B MTP quant later.

Comment on lines +162 to +171
<!-- Automatically overwrite Nuget DLLs with custom local builds -->
<Target Name="CopyCustomNativeLibs" AfterTargets="Build;PostBuildEvent">
<ItemGroup>
<CustomNativeLibs Include="D:\_MTP\LLamaSharp\llama.cpp\_vs\bin\Release\*.dll" />
</ItemGroup>
<Message Text="[Custom Script] Overwriting old LLamaSharp native libraries with custom builds..." Importance="high" />
<!-- Overwrite CPU and CUDA folders to guarantee LLamaSharp finds them -->
<Copy SourceFiles="@(CustomNativeLibs)" DestinationFolder="$(OutDir)runtimes\win-x64\native\avx2" SkipUnchangedFiles="false" />
<Copy SourceFiles="@(CustomNativeLibs)" DestinationFolder="$(OutDir)runtimes\win-x64\native\cuda12" SkipUnchangedFiles="false" />
</Target>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<!-- Automatically overwrite Nuget DLLs with custom local builds -->
<Target Name="CopyCustomNativeLibs" AfterTargets="Build;PostBuildEvent">
<ItemGroup>
<CustomNativeLibs Include="D:\_MTP\LLamaSharp\llama.cpp\_vs\bin\Release\*.dll" />
</ItemGroup>
<Message Text="[Custom Script] Overwriting old LLamaSharp native libraries with custom builds..." Importance="high" />
<!-- Overwrite CPU and CUDA folders to guarantee LLamaSharp finds them -->
<Copy SourceFiles="@(CustomNativeLibs)" DestinationFolder="$(OutDir)runtimes\win-x64\native\avx2" SkipUnchangedFiles="false" />
<Copy SourceFiles="@(CustomNativeLibs)" DestinationFolder="$(OutDir)runtimes\win-x64\native\cuda12" SkipUnchangedFiles="false" />
</Target>

@martindevans

Copy link
Copy Markdown
Member

Thanks for putting this together. It's a big chunk of work, so we'll definitely need to break this up into multiple smaller PRs, but we can worry about the details of that once the upstream PR is resolved :)

For now, could we re-run the workflow?

I've triggered it, but aren't we expecting it to fail since te binaries don't include your new upstream code?

Long-term, we might want to either cache this model in the GitHub Actions runner

Anything that goes into the test models folder is cached (see here). Actually that's another reason we need a small model - we're running out of cache space!

@zsogitbe

Copy link
Copy Markdown
Contributor Author

Thanks for putting this together. It's a big chunk of work, so we'll definitely need to break this up into multiple smaller PRs, but we can worry about the details of that once the upstream PR is resolved :)

I completely understand that a PR of this size is daunting to review, and breaking it down is usually the best approach for large features.

However, I am quite hesitant to split this specific PR. The Native Speculative Decoding and MTP implementations are deeply intertwined - they share the same underlying C# native bindings, state management, and CI testing logic. Because they act as a single cohesive unit, trying to untangle them into separate PRs would be a massive amount of work on my end, and I fear it would actually make the review more confusing, as the intermediate PRs would be missing crucial context from each other.

Furthermore, I have already thoroughly tested this LLamaSharp code alongside my custom llama.cpp PR, and it works incredibly well together as-is. I’ve actually included the real-world benchmark results in the 'Annex' section of the PR description, which demonstrates the stability and performance gains of this exact setup.

I've triggered it, but aren't we expecting it to fail since te binaries don't include your new upstream code?

Thanks Martin! You are completely right - the CI will absolutely fail right now because the current LLamaSharp binaries do not contain my upstream llama.cpp PR yet. I opened this PR early so that you can also test the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants