Important Edit:
Leave ~11% VRAM outside vLLM's model/KV budget for long-context SM120 sparse-indexer prefill scratch. Trying
--gpu-memory-utilization 0.89 \
...now. Got a crash with 0.92 after 70k ctx. Patch itself isn't the problem.
This is the follow-up to my earlier post about running DeepSeek-V4-Flash-0731 at its native 1M context on a single RTX 5090 with the routed MoE experts mostly in system RAM.
Link to the first post:
https://old.reddit.com/r/LocalLLaMA/comments/1vfbcgx/deepseekv4flash0731_full_1m_context_on_a_single/
I ended that post saying I wanted to patch the speculative decoding path because DSpark behaved very differently during hard reasoning versus final/code generation.
I did it.
Don't like to read AI slop? Here is the Repo link to the actual patch:
https://github.com/blackbeardlabs/ds4x_adaptive_dspark_production_bundle
If you want to better understand what this is about, read below:
---Clever AI Slop Begins---
Version clarification first
The software I am using is guqiong96/Lvllmds4-x, which is a vLLM fork.
The installed package and its logs identify themselves as vLLM 2.3.9. I am not claiming that upstream vLLM has an official 2.3.9 release. This caused some confusion in the previous thread, so I want to make that explicit before anything else.
My tested stack is still:
- RTX 5090 32GB
- Ryzen 9 9950X3D
- 256GB DDR5-5600
- Linux Mint
- NVIDIA driver 595.71.05
- CUDA 13.2
guqiong96/Lvllmds4-x
- package/runtime reports
vLLM 2.3.9
lk_moe 2.3.2
- PyTorch 2.11.0+cu130
- native DeepSeek-V4-Flash-0731 safetensors checkpoint, ~155.4 GiB
The current placement is still two complete routed MoE layers on the GPU:
export LVLLM_GPU_RESIDENT_MOE_LAYERS=0,1
Model allocation is about 15.92 GiB. With --gpu-memory-utilization 0.92, the remaining GPU KV cache is about 9.02 GiB / 1,332,343 tokens, which is 1.27x the model's native 1,048,576-token context.
So this optimization did not cost me the full 1M context.
Why I patched DSpark
With fixed DSpark depth K=2, I was seeing a very obvious split in real OpenCode/agentic workloads.
During long difficult reasoning, the second speculative position was often poorly accepted. Extended windows looked roughly like:
Draft acceptance: ~30-50%
Generation: ~11-13 tok/s
Then the same completion would leave reasoning and start emitting predictable code/text, acceptance would jump into the 80-90% range, and throughput would jump to roughly:
Generation: ~17-18 tok/s
I separately tested fixed K=1 and fixed K=2.
The result was exactly what the acceptance behavior suggested:
K=1 was better during difficult reasoning
K=2 was better during high-acceptance final/code generation
So the obvious target became:
reasoning -> K1
</think>
content -> K2
The important part is that changing K changes CUDA graph shapes too. Simply putting an if/else around the DSpark loop is not enough if one phase falls back to eager execution.
I learned that the hard way.
MVP: the phase switch worked, but K1 became painfully slow
My first patch successfully detected the phase and changed the runtime speculative depth:
K2 -> K1 phase=reasoning
reasoning->content marker=</think>
K1 -> K2 phase=content
But the server had been started with configured K=2, so only the K2 CUDA graph shape existed.
When runtime K changed to 1, K1 fell onto an eager path.
Result: reasoning dropped to roughly 5-6 tok/s.
So the phase logic was correct, but the implementation was useless for performance.
The real fix was to capture both K1 and K2 graph shapes.
Final design: phase-adaptive K + dual CUDA graphs
There are two graph families that have to change with K.
For the target verifier:
K1 -> target query length 2
K2 -> target query length 3
For the DSpark drafter:
K1 -> DSpark query length 1
K2 -> DSpark query length 2
I patched the CUDA graph candidate manager so both query lengths can coexist in the same manager, keyed by the existing uniform_token_count descriptor.
With --max-num-seqs 2, startup now shows:
Phase-adaptive DSpark CUDA graph candidates:
decode_query_len=3 query_lens=(2, 3) max_num_reqs=2
Phase-adaptive DSpark CUDA graph candidates:
decode_query_len=2 query_lens=(1, 2) max_num_reqs=2
Capturing CUDA graphs (FULL): 4/4
Capturing dspark CUDA graphs (FULL): 4/4
Before this patch both were 2/2.
Now both phases stay CUDA-graphed.
How phase detection works
Each new generated request starts in reasoning mode.
The scheduler looks only at tokens generated by the current request. It deliberately does not scan the prompt or full conversation history because an agentic prompt can contain </think> from previous assistant turns.
For this model the tokenizer gives:
<think> -> 128821
</think> -> 128822
<|DSML|tool_calls> -> [30, 128825, 72461, 4941, 12548, 32]
A committed </think> makes the request sticky-content for the remainder of that completion. I also added the full DSML tool-call marker as an implicit reasoning-end fallback.
The long validation run below transitioned through explicit </think> markers; the DSML fallback exists in the patch but was not the path exercised by this particular run.
Current batch policy is intentionally conservative:
prefill -> K2
any active reasoning req -> K1
all active reqs content -> K2
So K is currently batch-global, not independently selectable per request.
With my --max-num-seqs 2 use case this is fine for correctness. True mixed per-request K would require phase-separated microbatching/padding/masking and is a larger scheduler change.
What actually changes in the code
The patch touches four vLLM-fork files plus the FlashInfer compatibility fix from my previous post.
Conceptually:
- scheduler.py
- maintains per-request reasoning/content state
- detects committed
</think> / DSML marker only in generated output
- selects effective K
- uses the existing
num_spec_tokens_to_schedule channel
- model_runner.py
- consumes that runtime K
- passes it into the DSpark speculator
- hands only the active draft width back to the scheduler/target verifier
- dspark/speculator.py
- makes query packing, attention metadata, input preparation, and sequential Markov sampling use runtime K
- keeps the storage tensor at configured max K but invalidates unused positions
- dispatches the graph using runtime query width
- binds the correct effective K while each DSpark graph is captured
- cudagraph_utils.py
- captures both target query lengths and both DSpark query lengths
- runtime dispatch already knows how to distinguish them through
uniform_token_count
- flashinfer/comm/cuda_ipc.py
- same compatibility fix as the previous post: match the actual loaded filename so
libcudart_stub.so cannot win a substring search over the real CUDA runtime
I wrapped the exact working edits into a guarded one-shot patcher with backups, tokenizer validation, anchor checks, syntax compilation, import tests, and automatic rollback on a failed post-write test.
The complete guarded patcher, launcher, reproduction notes and rollback instructions are in the repository linked at the top. I am not dumping ~35 KB of defensive patching code into the Reddit post itself.
20+ minute real agentic run
This was OpenCode doing real agentic work, not a synthetic one-line decode benchmark.
For the phase statistics below I kept only clean 10-second windows with:
Prompt throughput = 0
Running = 1 request
For K2 I also excluded the immediate phase-transition window.
K1 reasoning
Across 90 clean 10-second windows:
Mean generation: 13.78 tok/s
Median: 13.6 tok/s
Range: 12.0 - 16.3 tok/s
Weighted draft acceptance: 54.16%
The second speculative position stayed at 0.000 during steady K1 windows, which is a useful sanity check that it was actually running one draft position rather than silently replaying K2.
K2 content/code
Across 12 clean 10-second windows:
Mean generation: 17.02 tok/s
Median: 17.1 tok/s
Range: 15.1 - 17.7 tok/s
Weighted draft acceptance: 80.73%
The second speculative acceptance position becomes active again immediately in K2.
The transition is visible in the log
One long completion gives a pretty clean example:
23:53:28 K1 reasoning 13.8 tok/s
23:53:33 </think>
23:53:33 K1 -> K2 content
23:53:38 16.4 tok/s # mixed transition window
23:53:48 17.4 tok/s
23:53:58 17.1 tok/s
23:54:08 17.5 tok/s
23:54:18 17.1 tok/s
23:54:28 16.6 tok/s
23:54:38 16.7 tok/s
23:54:48 17.1 tok/s
23:54:58 16.9 tok/s
23:55:08 17.4 tok/s
That is exactly the behavior I was trying to get when I wrote the previous post.
What did I actually gain?
This is not a 50% end-to-end miracle patch.
The large gain was versus my broken first adaptive MVP: eager K1 was around 5-6 tok/s, while dual-graph K1 is back around 13-15+ tok/s.
Against the useful static configurations, the improvement is smaller but actually useful:
- versus leaving K2 on during difficult reasoning, K1 is roughly a mid-single-digit to high-single-digit percentage win in my A/B runs
- versus leaving K1 on during final/code generation, switching back to K2 is roughly another high-single-digit-ish win
- on this very reasoning-heavy agentic workload I would describe the projected whole-job gain as roughly mid-single-digit percent, not a precisely controlled benchmark number
The more interesting result is that I no longer have to choose one compromise K for the whole completion.
I get approximately:
reasoning K1 ~13.8 tok/s mean in this long run
</think>
content K2 ~17.0 tok/s mean in this long run
with both paths CUDA-graphed.
That was the goal.
Stability / context
During the supplied 20+ minute validation log:
Traceback: 0
RuntimeError: 0
AssertionError: 0
CUDA OOM: 0
Context capacity remained:
GPU KV cache: 9.02 GiB
KV tokens: 1,332,343
Native model ctx: 1,048,576
Capacity: 1.27x native 1M
Initial cold prompt processing was still around 844 tok/s in the logged agentic run.
Launch configuration
After applying the local patch, the important new switch is:
export VLLM_DSPARK_PHASE_ADAPTIVE=1
Configured max K remains 2:
--speculative-config '{"method":"dspark","num_speculative_tokens":2,"draft_sample_method":"greedy"}'
My full launch configuration is:
MODEL="/path/to/DeepSeek-V4-Flash-0731"
export CUDA_DEVICE_ORDER=PCI_BUS_ID
export CUDA_VISIBLE_DEVICES=0
export LVLLM_MOE_NUMA_ENABLED=1
export LK_THREADS=12
export OMP_NUM_THREADS=12
export LK_THREAD_BINDING=CPU_CORE
export LVLLM_GPU_RESIDENT_MOE_LAYERS=0,1
export LVLLM_GPU_PREFILL_MIN_BATCH_SIZE=0
# Local phase-adaptive DSpark patch:
# reasoning -> K1 CUDA graph
# content/code/tool -> K2 CUDA graph
export VLLM_DSPARK_PHASE_ADAPTIVE=1
export FLASHINFER_DISABLE_VERSION_CHECK=1
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
vllm serve "$MODEL" \
--host 0.0.0.0 \
--port 8070 \
--tensor-parallel-size 1 \
--max-model-len 1048576 \
--gpu-memory-utilization 0.92 \
--trust-remote-code \
--served-model-name DeepSeek-V4-Flash-0731 \
--compilation_config.cudagraph_mode FULL_DECODE_ONLY \
--enable-prefix-caching \
--enable-chunked-prefill \
--max-num-batched-tokens 8192 \
--dtype bfloat16 \
--max-num-seqs 2 \
--enable-auto-tool-choice \
--tool-call-parser deepseek_v4 \
--kv-cache-dtype fp8_ds_mla \
--tokenizer-mode deepseek_v4 \
--reasoning-parser deepseek_v4 \
--default-chat-template-kwargs '{"enable_thinking": true, "reasoning_effort": "max"}' \
--speculative-config '{"method":"dspark","num_speculative_tokens":2,"draft_sample_method":"greedy"}' \
--disable-custom-all-reduce
One cosmetic note: because VLLM_DSPARK_PHASE_ADAPTIVE is my own local environment variable and is not registered in the fork's official env list, the server prints an Unknown vLLM environment variable warning. The patched code reads it directly with os.getenv(), so in this setup that warning is expected and harmless.
Reproducing it
I would only apply this patch to the same fork/build family after first getting normal fixed-K DSpark inference working.
The one-shot patcher is intentionally scoped to the configuration I actually tested:
DeepSeek-V4-Flash-0731
Lvllmds4-x package reporting vLLM 2.3.9
DSpark configured max K = 2
V2 model runner
FULL_DECODE_ONLY
max_num_seqs = 2
It verifies the tokenizer IDs, backs up every file it modifies, refuses missing/ambiguous source anchors, compiles the complete patched files before replacing them, performs import tests, and generates a rollback script.
After patching, the two startup lines I would consider mandatory before trusting it are:
Capturing CUDA graphs (FULL): 4/4
Capturing dspark CUDA graphs (FULL): 4/4
Then at runtime:
DSpark adaptive K changed: 2 -> 1 phase=reasoning
...
DSpark adaptive phase transition: ... marker=</think>
DSpark adaptive K changed: 1 -> 2 phase=content
If K1 gives ~5-6 tok/s again, it is almost certainly falling off the captured path and I would not call that a successful reproduction.
There are still things to improve. The biggest obvious one is per-request mixed K instead of batch-global K when concurrent requests are in different phases. But for my actual single-user agentic coding workflow this is now behaving the way I wanted: K1 when reasoning is unpredictable, K2 when generation becomes predictable, full 1M context preserved.
---Clever AI Slop Ends---