Over 107tok/s Qwen3.8-Flash-Next on M3 Ultra
Ours: 107.16 tok/s · llama.cpp (MTP3): 55.74 tok/s · +92.2%.

Figure 1. Rust merge benchmark.
tldr
I optimized llama.cpp to keep cached attention keys after speculative decoding rejects tokens. The rejected tokens change only the end of the sequence, so rebuilding keys for the earlier history wastes work. Keeping those keys leaves a small update; padding it to eight rows gives the runtime a consistent shape to execute.
The implementation is public on GitHub. These runs use Huihui’s Qwen3.8-Flash-Next abliterated GGUF. The target weights are unchanged.
For the comparison above, I measured the Unsloth build of llama.cpp with MTP3 against our fastest saved result with MTP5. The task is to write a Rust function that merges two sorted integer slices. Both runs process the same 4,096-token prompt and generate 512 tokens; the reported speed covers generation only. MTP3 and MTP5 allow up to three and five draft tokens per step, respectively.
Speed as the prompt grows
The context sweep compares the same two configurations from 512 to 6,144 prompt tokens, with 128 generated tokens per request. Each point is a median of five runs on the Rust merge task. Ours reaches 87.13 tok/s at 4K here; the headline 107.16 tok/s comes from the separate run generating 512 tokens.

Figure 2. llama.cpp and Ours across prompt lengths.
Why rollback was rebuilding history
Qwen Sparse Attention (QSA) groups earlier tokens into blocks and computes a summary vector, or pooled key, for each block. Each new query scores these keys to decide which parts of the history to read. Earlier work explores reuse across layers and speculative queries [1], [2], [3]. Here, we reuse keys across rollback.
Speculative decoding proposes several tokens, checks them together, and removes the rejected suffix. Earlier tokens survive. Their pooled keys also remain in memory, but rollback cleared the records that told the runtime those keys were valid. The next verification could then compute them again.
We changed rollback to retain the records for blocks whose inputs are still valid.
Figure 3. Speculative decoding and rollback.
Keep the pooled keys; score them again
To reuse a pooled key, we need to know that its source keys and positions have not changed. A position alone is insufficient because cache storage can be recycled or shared. We check each complete block from oldest to newest, keeping it only while its cells still belong to the expected sequence and hold the expected positions. We stop at the first invalid block.
At the next verification, we compare each block’s source cells, positions, destination, and stream with its retained record. Only new or changed blocks need keys. The append path can supply this update list directly when its validity checks pass.
The current query still scores both retained and updated keys to choose which blocks to read. Cache scanning and query scoring continue to grow with history length.
Figure 4. Reusing cached keys.
Recurrent-state removal runs first because it can refuse the rollback. Once indexer and attention-cache removal succeed, we discard the pending update layout and trim the records of computed blocks.
This path handles one index-cache stream, sequence 0, and removal from a nonnegative position to the end. Other removals clear the records.
Here, requires exactly source cells, cache stream 0, a nonnegative start, and . The key destination must be one of those cells. All four stored positional components equal the block start. Each source cell must be in bounds, live, exclusively owned by sequence 0, and at logical position .
Algorithm 1. Retain a rollback-valid QSA prefix
Input: Cached block vectors , surviving cell metadata , sequence , removal bounds (negative denotes an unbounded suffix).
Output: A retained descriptor prefix for each pooling ratio; surviving key values are unchanged.
- Require recurrent removal accepted and attention removal succeeded
- ▷ discard the pending layout
- if preservation is disabled, indexer absent, streams ≠ 1, , , or then
- return ▷ invalidate all cached descriptors
- for each pooling ratio do
- if the entry does not contain exactly one stream vector then
- ; continue
- for each block in , in prefix order do
- if then break
- return
Invariant: Every retained block is complete, exclusively owned, and strictly before the removed suffix.
Algorithm 2. Incrementally materialize pooled indexer keys
Input: Pooling ratio , current complete blocks , retained descriptors , source keys , pooled-key cache .
Output: Updated pooled-key cache and descriptor cache for the current layout.
- for each stream and current block index do
- if is absent or differs from then
- Append to
- ▷ Algorithm 3
- for each descriptor do ▷ evaluated as a batched tensor graph
- ▷ only when the types differ
- Write to via SET_ROWS
- Resize to the current stream/block dimensions
- for each do
- return
Invariant: Blocks outside the real delta retain valid values; optional filler writes are idempotent.
After preservation, one step may need a single new key and the next may need three. That variation became the next thing to address.
Why doing eight rows can beat doing two
Changing the number of update rows changes the graph’s tensor shapes. We pad small updates to eight rows so the runtime can reuse the same update shape.
When two blocks change, their keys occupy the first two rows. Six unchanged blocks fill the rest. Each filler keeps its original inputs and writes back to its own location, reproducing the value already there. We choose distinct destinations so the extra rows cannot write to the same address in parallel.
Figure 5. Two real updates padded to eight rows.
We spend a little extra computation to avoid changing the update shape. This helps when recomputing the filler keys costs less than the overhead it removes.
The implementation supports targets of eight or sixteen rows; these results use eight. Padding requires one stream, at most 16 tokens in the microbatch, an update count that fits the target, and enough complete blocks to supply distinct destinations. Larger updates use their ordinary size. A linear scan is sufficient for such a small list.
Even an empty update has a minimum capacity of one row. It uses an existing complete block when possible, or the general path’s fallback descriptor otherwise. With at least eight complete blocks, an empty delta can use Pad8 and recompute eight existing keys.
Algorithm 3. Construct a fixed-capacity update list
Input: Real update list , current complete blocks , target , microbatch size , stream count .
Output: Ordered update list with capacity . This procedure describes execution, not graph reservation.
- ;
- if padding is enabled, , , , and then
- if then
- if a complete block exists then append the first such block to
- else append the existing minimum-row fallback descriptor to
- if padding is enabled and then
- for each , in order do
- if then break
- if then append to
- assert
- else extend to with the legacy first-row fill, if required
- return
Invariant: Every real update is retained; every added filler targets an existing destination not already in .
Drafting five tokens at a time
MTP3 is a practical choice in existing setups: one published depth sweep chose it for regular use, although MTP4 was slightly faster in that benchmark [4]. Drafting further gives each verification more chances to accept tokens, but also adds work that may be discarded.
Our changes reduce the cost of maintaining state between those rounds. That makes deeper drafting more affordable, and our fastest saved configuration uses MTP5. The useful depth depends on the runtime’s costs as well as the model’s acceptance rate.
The Rust merge benchmark supplies reference code and asks for a function that merges two sorted i32 slices into a Vec<i32>, preserving duplicates. Its input is exactly 4,096 tokens, and each run generates 512 tokens. The table reports median decode speed over three runs on this task.
| Inference configuration | Decode speed |
|---|---|
| llama.cpp (MTP3, Unsloth build) | 55.74 tok/s |
| Ours | 107.16 tok/s |
Ours is 92.2% faster on this workload. That includes the runtime changes, MTP5, and a Q4 draft output head; llama.cpp uses MTP3 with the supplied Q8 draft. This comparison measures their combined effect.
Measurement details
The measurements were taken in separate sessions: new llama.cpp runs and saved runs of our fastest configuration. Both use the same target GGUF and exact prompt tokens, greedy decoding, one sequence, Q8 K/V caches, an 8K context capacity, a 32-token warmup, and disabled prompt caching. For the headline comparison, the raw server logs record 4,096 prompt tokens and 512 generated tokens for every scored run. No production overlap was sampled. Different draft settings can change acceptance rates as well as execution cost.
A later extension: load snapshot history once
Rollback needs saved convolution histories for different numbers of accepted tokens. These snapshots are overlapping windows into the same short history row. Making each copy separately reads some of those values again and launches another GPU operation.
A later optimization combines those copies. Each thread loads one row, then writes every required snapshot from that local copy. For an eight-element row and three snapshots of three elements each, this reduces logical source reads from 17 to eight, and GPU dispatches from four to one. The throughput results above do not include this later change.
Figure 6. Separate and fused snapshot execution.
Before combining the copies, the CPU checks that they write to separate regions and waits for earlier GPU work that uses those regions. If a group fails the checks, it uses the original copy operations.
The specialization accepts F32 rows with and . It examines up to eight following copy nodes and requires at least two eligible snapshots. Shapes and strides must match the supported layout, and destinations must be in bounds and disjoint from the inputs, concatenation, and each other. The first unsupported candidate ends the scan. Every output is tracked by the scheduler; a dependency barrier is inserted when needed.
Algorithm 4. Recognize and dispatch a safe snapshot group
Input: A concatenation node , following copy nodes, and the encoder’s pending memory accesses.
Output: One fused dispatch consuming nodes, or the ordinary execution path.
- if fusion is disabled or then return ordinary path
- ;
- for , while a following node exists do
- if is not a supported F32 copy of a view of then break
- if ownership, shape, stride, or window-offset checks fail then break
- if ’s destination is out of bounds or overlaps then break
- Append to
- Add ’s destination range to
- ; if then return ordinary path
- if a destination conflicts with prior encoded work then insert a dependency barrier
- Dispatch Algorithm 5 over all channel/sequence rows
- Record memory accesses for the concatenation and every accepted copy
- return consumed nodes
Invariant: Accepted destinations are in bounds, pairwise disjoint, and do not alias , , or .
These are logical read counts; actual memory traffic depends on caches and the compiler. The kernel writes the same outputs.
Each thread handles one channel/sequence row, using a fixed uint values[16] array. Integer copies preserve F32 bit patterns. The dispatch uses groups of 128 threads; source and destination strides preserve the supported sequence layouts.
Algorithm 5. Materialize concatenation and snapshot windows
Input: State of width , new activations of width , channels, sequences, and safe window descriptors .
Output: Concatenated rows and all snapshot windows .
- in parallel for each GPU thread index do
- if then return
- ;
- Allocate thread-local of unsigned 32-bit words
- for do
- for do
- for do
- for each snapshot descriptor do
- for do
Invariant: Each valid GPU thread owns one row and reads each source element once; output bit patterns are preserved.
References
[1] Yushi Bai et al. IndexCache: Accelerating Sparse Attention via Cross-Layer Index Reuse. arXiv:2603.12201, 2026.
[2] Harsh Shah. SpecAttn: Speculating Sparse Attention. arXiv:2510.27641, 2025.
[3] Zhibin Wang et al. SSV: Sparse Speculative Verification for Efficient LLM Inference. arXiv:2605.19893, 2026.
[4] On The Stack. Qwen3.8-Flash-Next on the EVO-X2: 125B Parameters in 96GB of Unified Memory. 2026.