feat(vision): speculative decoding for image requests (Qwen3.8 and DeepSeek V4 Vision) - #754
Conversation
Image tokens take 2D rotary positions, so after an image the rotary position runs rope_delta_ ahead of the KV position. AR decode already applied that offset; the DFlash verify target did not, so the HTTP layer and the backend forced every image request to plain AR decode. Qwen35DFlashTarget now reads the backend's per-request rope_delta_ and shifts the M-RoPE positions of chain and tree verify by it (zero for text, so text requests are unchanged). The blanket AR force for images is dropped from the HTTP layer and the Qwen3.5 backend; DeepSeek4 keeps its own image guard and still decodes image requests AR. R9700, Qwen3.8-27B-IQ4_XS-pure + Q8_0 projector + DFlash2, 12 images, 256-token answers: 4.03 s per answer (76 tok/s) vs 7.58 s (36 tok/s) before; llama.cpp with the same drafter 5.54 s, without 8.36 s. Image eval 188/220 unchanged (218 answers identical), 1-4 images 9/9. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The image prefill graph takes no DSpark capture hooks, so image requests skipped feature capture and decoded AR. Image chunks now end at their last image, the text after the image prefills and captures as ordinary chunks, and the feature window is cleared at each image chunk so the drafter always reads one contiguous tail. With that, image requests take the DSpark path like text. Strix Halo alone, Vision-Exp ROCMFPX MIX, published DSpark launch, 12 images, 256-token answers: 13.3 s per answer (30.4 tok/s) vs 15.7 s (22.1 tok/s); first token about 0.7 s later from the capture band. 220 image questions 181 (AI2D 86, ChartQA 55/40), 209 identical to plain decode; one to four images 9/9; text decode unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="server/src/deepseek4/deepseek4_backend.cpp">
<violation number="1" location="server/src/deepseek4/deepseek4_backend.cpp:2849">
P2: For prompts that end with an image (the standard "describe this image:" shape) or have less than `n_swa` text tokens after the last image, `spec_feat_window_` ends up empty or short at the end of prefill, yet the PR now routes these requests into `run_deepseek4_dspark_spec_decode` (the `!req.images` guard was removed). In `run_deepseek4_dspark_spec_decode` this yields `win_len = 0` / `feat_count = 0` — the drafter's feature ring (`feat_win`) is fully zero-initialized and `ctx_len = 0` on the first steps, so the drafter drafts with no context at all until its own emitted features fill the window. Previously image requests always fell back to AR decode, so this state is newly reachable. This is both a quality/accept-rate risk (poor drafts on the first steps for the most common vision prompt shape) and a robustness question: verify that a zero-context draft/verify graph is legal (a zero-row SWA window). Consider keeping an AR fallback when `win_len == 0` (or when the trailing text after the last image is shorter than `n_swa`), e.g. guarding the spec path with a non-empty `spec_feat_window_`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| } | ||
| // The drafter reads the newest rows as one contiguous window, so | ||
| // rows from before an image cannot sit next to rows after it. | ||
| if (chunk_has_image) spec_feat_window_.clear(); |
There was a problem hiding this comment.
P2: For prompts that end with an image (the standard "describe this image:" shape) or have less than n_swa text tokens after the last image, spec_feat_window_ ends up empty or short at the end of prefill, yet the PR now routes these requests into run_deepseek4_dspark_spec_decode (the !req.images guard was removed). In run_deepseek4_dspark_spec_decode this yields win_len = 0 / feat_count = 0 — the drafter's feature ring (feat_win) is fully zero-initialized and ctx_len = 0 on the first steps, so the drafter drafts with no context at all until its own emitted features fill the window. Previously image requests always fell back to AR decode, so this state is newly reachable. This is both a quality/accept-rate risk (poor drafts on the first steps for the most common vision prompt shape) and a robustness question: verify that a zero-context draft/verify graph is legal (a zero-row SWA window). Consider keeping an AR fallback when win_len == 0 (or when the trailing text after the last image is shorter than n_swa), e.g. guarding the spec path with a non-empty spec_feat_window_.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/deepseek4/deepseek4_backend.cpp, line 2849:
<comment>For prompts that end with an image (the standard "describe this image:" shape) or have less than `n_swa` text tokens after the last image, `spec_feat_window_` ends up empty or short at the end of prefill, yet the PR now routes these requests into `run_deepseek4_dspark_spec_decode` (the `!req.images` guard was removed). In `run_deepseek4_dspark_spec_decode` this yields `win_len = 0` / `feat_count = 0` — the drafter's feature ring (`feat_win`) is fully zero-initialized and `ctx_len = 0` on the first steps, so the drafter drafts with no context at all until its own emitted features fill the window. Previously image requests always fell back to AR decode, so this state is newly reachable. This is both a quality/accept-rate risk (poor drafts on the first steps for the most common vision prompt shape) and a robustness question: verify that a zero-context draft/verify graph is legal (a zero-row SWA window). Consider keeping an AR fallback when `win_len == 0` (or when the trailing text after the last image is shorter than `n_swa`), e.g. guarding the spec path with a non-empty `spec_feat_window_`.</comment>
<file context>
@@ -2823,10 +2824,29 @@ int DeepSeek4Backend::do_prefill(const std::vector<int32_t> & tokens,
+ }
+ // The drafter reads the newest rows as one contiguous window, so
+ // rows from before an image cannot sit next to rows after it.
+ if (chunk_has_image) spec_feat_window_.clear();
}
</file context>
There was a problem hiding this comment.
Fixed in c2b02a3: when an image request has no text after its last image to seed the drafter window, DeepSeek4 falls back to plain decode (image_without_draft_context). The chunk boundary now comes from vision::last_image_end_in, with unit checks.
- DeepSeek4 falls back to plain decode when an image request has no text after the last image to seed the drafter window. - vision::last_image_end_in replaces the inline span loop in do_prefill, with unit checks. - Clarify the http_server comment and the image-input doc figures. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(qwen35): speculative decoding for image requests Image tokens take 2D rotary positions, so after an image the rotary position runs rope_delta_ ahead of the KV position. AR decode already applied that offset; the DFlash verify target did not, so the HTTP layer and the backend forced every image request to plain AR decode. Qwen35DFlashTarget now reads the backend's per-request rope_delta_ and shifts the M-RoPE positions of chain and tree verify by it (zero for text, so text requests are unchanged). The blanket AR force for images is dropped from the HTTP layer and the Qwen3.5 backend; DeepSeek4 keeps its own image guard and still decodes image requests AR. R9700, Qwen3.8-27B-IQ4_XS-pure + Q8_0 projector + DFlash2, 12 images, 256-token answers: 4.03 s per answer (76 tok/s) vs 7.58 s (36 tok/s) before; llama.cpp with the same drafter 5.54 s, without 8.36 s. Image eval 188/220 unchanged (218 answers identical), 1-4 images 9/9. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(deepseek4): DSpark speculative decoding for image requests The image prefill graph takes no DSpark capture hooks, so image requests skipped feature capture and decoded AR. Image chunks now end at their last image, the text after the image prefills and captures as ordinary chunks, and the feature window is cleared at each image chunk so the drafter always reads one contiguous tail. With that, image requests take the DSpark path like text. Strix Halo alone, Vision-Exp ROCMFPX MIX, published DSpark launch, 12 images, 256-token answers: 13.3 s per answer (30.4 tok/s) vs 15.7 s (22.1 tok/s); first token about 0.7 s later from the capture band. 220 image questions 181 (AI2D 86, ChartQA 55/40), 209 identical to plain decode; one to four images 9/9; text decode unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(deepseek4): run the vision encoder on a second GPU and stream images into prefill --mmproj-device hip:N loads the DS4V encoder on another GPU in the one-GPU layout (the R9700 next to a Strix Halo holding the model). Its scratch is charged to that GPU. The encoder then runs image by image on a background thread and publishes each image's rows; prefill waits per chunk only for the images that chunk contains, so the Strix Halo prefills image k while the R9700 encodes image k+1. Failure or cancellation releases waiters and the thread is joined before the request, shutdown or park returns. Requests may carry up to 16 images (one shared constant for the HTTP transport, DS4V and Qwen3.5), and each request logs its encode time. lucebox6, Strix Halo decoder, published DSpark launch, ChartQA charts, time to first token, Strix encoder -> R9700 encoder streamed: 1 image 2.97 -> 2.94 s, 4 images 11.2 -> 9.1 s, 8 images 27.7 -> 19.2 s, 16 images (4,358 tokens) 51.8 -> 34.6 s. Answers identical to the sequential encode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(qwen35): serve image requests in the concurrent batch A vision projector used to switch concurrent sequence scheduling off for image input. The batched engine now accepts image requests: - SeqEngine gains supports_images() and admit_images(); the default refuses. The scheduler routes image requests there and never gives them a prefix-cache plan (tokens alone do not identify an image). - Qwen35SeqEngine encodes the images at admission, keeps the payload, rows and rope offset with the slot until it retires, overwrites image rows and writes the image's M-RoPE positions in every prefill chunk that covers an image (so an eviction re-prefill sees them again), and shifts decode and chain-verify rotary positions by the slot's offset. - The server enables image input when the engine supports it; the feature gate allows --mmproj with --max-concurrency for Qwen3.5. DeepSeek4 still requires one request at a time. lucebox6 R9700, Qwen3.8-27B-IQ4_XS-pure + Q8_0 projector + DFlash2, --paged-attention --max-concurrency 4: sanity 6/6, one to four images 9/9 (same as single-request); 1/2/4 concurrent 256-token image answers 81/104/149 tok/s total (single-request server 78/73/77), 4 answers in 6.9 s instead of 13.3 s, each answer about its own chart. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(deepseek4): serve image requests in the concurrent batch DS4V image blocks need whole-block bidirectional prefill; the batched engine's gathered step is 16 causal rows. So an image request admitted to the batch is prefilled up to its last token on the single-request sparse path into a staging cache (the encoder streams from --mmproj-device when set), that state is copied into the request's paged slot, and the last token prefills in the batch, producing the first token through the normal step. Decode then runs alongside every other sequence. - import_deepseek4_paged_slot copies the 128-row raw ring, the completed compressed and indexer rows through the slot's block table, and the compressor states, checking layouts and row counts. - DeepSeek4SeqEngine::admit_images seeds the slot with seed_restored_prefix and retires it on any failure. - do_prefill can stop after a prefix and takes its attention mode from the cache it fills (identical to the config for the single-request cache). - Paged serving with --mmproj creates the staging cache (sparse) and lets one image request per slot through the image gate; the feature gate allows DeepSeek4 --mmproj with --paged-attention batching. lucebox6, Strix Halo decoder, R9700 encoder, 4 slots: sanity 2/2, one to four images 9/9; 4 concurrent 256-token image answers in 39.1 s (26.2 tok/s total; 1/2 at once: 17.4/21.7 tok/s), 2 images + 2 texts in 33.1 s (30.9 tok/s), 4 texts 37.9 tok/s. Stacked on #758, #754 and #759. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(deepseek4): prefill concurrent image requests in one shared pass Image admissions now only encode their images and seed their slot; the next batched step prefills every pending image request together. deepseek4_prefill_multi runs one layer-major pass over several sequences: attention per sequence against its own staging cache (with its image masks), HC mixing and the MoE FFN once over all rows, so each layer's expert weights are read once for every request in the pass. The states are then copied into the paged slots as before. A failed request fails only its own slot. lucebox6, Strix Halo + R9700 encoder, 4 slots: 4 image requests share one 1,020-row pass (7.1 s); 4 concurrent 256-token image answers 39.1 -> 35.2 s (26.2 -> 29.1 tok/s), 2+2 mixed 30.9 -> 31.5 tok/s, sanity 2/2, one to four images 9/9. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vision): address review on image speculative decode - DeepSeek4 falls back to plain decode when an image request has no text after the last image to seed the drafter window. - vision::last_image_end_in replaces the inline span loop in do_prefill, with unit checks. - Clarify the http_server comment and the image-input doc figures. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(qwen35): claim the slot before encoding a batched image request A busy pool defers the request and retries it; encoding first reran the vision tower on every retry. A failed encode now retires the slot. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(deepseek4): keep exceptions inside the image stream thread A throw in the --mmproj-device encoder thread would terminate the server; it now fails the stream so prefill stops waiting. Encode-time logs print only on success, and <thread> joins the system includes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(deepseek4): claim the slot before encoding a batched image request A busy pool defers the request and retries it; encoding first reran the encoder on the scheduler thread on every retry. Names the layer-major prefill minimum (DS4_MIN_LAYER_MAJOR_PREFILL_TOKENS) instead of a bare 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vision): answer 503 when the image request gate is full With batched DS4V serving, image requests beyond one per slot were refused with HTTP 400, which clients do not retry. prepare_images now returns an ImagePrepareStatus (ok, invalid, busy) and the server maps busy to 503. Adds a gate capacity test and brings the DS4V batching docs up to the shared staged prefill (4 image answers 35 s, 29 tok/s). lucebox6: 6 concurrent image requests on 4 slots -> 4 answered in 35.1 s, 2 x 503; test_server_unit 595/595, DS4V image unit tests pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(deepseek4): queue image requests instead of refusing them The image request gate refused every image request beyond one per slot (one per backend without batching). A waiting request holds only its preprocessed patches, a few MB per image, and its encoded rows exist only once it runs, so the slots already bound them: DeepSeek image requests now wait in the scheduler queue like text and Qwen image requests. The gate and its lease are removed; short host memory answers 503 (busy). lucebox6: batched, 6 image requests on 4 slots all answered (4 at 35.1 s, 2 queued at 59.6 s), one encode each; single-request server, 2 at once both answered (14.5 s, 30.4 s); sanity 2/2; test_server_unit 595/595. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: mrciffa <davide@lucebox.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
What
Image requests now decode with the model's drafter, like text requests, for both vision models. Until now every image request decoded one token at a time.
Qwen3.5 / Qwen3.8 (DFlash2)
Image tokens take 2D rotary positions, so after an image the rotary position runs
rope_delta_ahead of the KV position. Plain decode already applied that offset, but the DFlash verify target did not. So the HTTP layer and the backend both forced image requests to plain decode.Qwen35DFlashTargetreads the backend's per-requestrope_delta_and shifts the M-RoPE positions of chain and tree verify by it. The offset is zero for text, so text requests are unchanged.One R9700,
Qwen3.8-27B-IQ4_XS-pure.gguf+ Q8_0 projector, 12 images (6 ChartQA, 6 AI2D), 256-token answers, greedy, streamed:Faster than llama.cpp with the drafter on all 12 images, from 1.21x to 1.58x.
Quality:
DeepSeek V4 Flash Vision (DSpark)
The image prefill graph takes no DSpark capture hooks, so image requests had no drafter features. This PR makes three changes:
!req.imagesguard on the DSpark path is dropped.Strix Halo alone, Vision-Exp ROCMFPX MIX, the published DSpark launch, the same 12 images:
Feature capture during prefill adds about 0.7 s before the first token, the same cost text requests already pay. One-word answers therefore come back slightly later.
Quality:
Exactness
Greedy output with a drafter is not byte-identical to plain decode on this build, for text or images: some answers change a word after a few hundred characters. llama.cpp's DFlash run shows the same against its own plain decode.
🤖 Generated with Claude Code