From bf2826ec3613646f5237cc7d52ef8669e9f47f4a Mon Sep 17 00:00:00 2001 From: mrciffa Date: Wed, 23 Sep 2026 12:37:17 +0200 Subject: [PATCH 01/12] 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) --- docs/image-input.md | 21 +++++++++++++-------- server/src/qwen35/qwen35_backend.cpp | 7 ++++--- server/src/qwen35/qwen35_dflash_target.cpp | 4 ++-- server/src/qwen35/qwen35_dflash_target.h | 7 +++++++ server/src/server/http_server.cpp | 3 ++- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index d4a264b9d..eb73ba092 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -36,8 +36,8 @@ hf download Lucebox/Qwen3.8-27B-DFlash2-GGUF \ --port 8216 ``` -About 21 GiB of VRAM at the peak of an image request. Text requests keep the -DFlash2 drafter; image requests decode without it. +About 21 GiB of VRAM at the peak of an image request. Text and image requests +both decode with the DFlash2 drafter. ### DeepSeek V4 Flash Vision on a Strix Halo @@ -106,10 +106,11 @@ Decoder pixel and aspect limits also apply. A model's image marker cannot be sup as ordinary text. The server expands image markers after final rendering and tokenization, and -the expanded image tokens count toward context and usage. Image requests use -plain autoregressive decoding and bypass the token-keyed prefix, disk and -agent-turn caches and prompt compression: tokens alone do not identify an -image. Text requests on the same server keep speculative decoding and caching. +the expanded image tokens count toward context and usage. Image requests +bypass the token-keyed prefix, disk and agent-turn caches and prompt +compression: tokens alone do not identify an image. Qwen3.5 / Qwen3.8 image +requests decode with the drafter like text; DS4V image requests decode +without it. Layer or tensor splitting across GPUs, remote target shards, concurrent sequence scheduling (`--max-concurrency`) and upstream forwarding do not @@ -144,8 +145,12 @@ Lucebox `Qwen3.8-27B-IQ4_XS-pure` file and a Q8_0 projector: lmms-eval prompts: AI2D 90/100, ChartQA relaxed accuracy 56/60 (augmented) and 42/60 (human). Image prompts prefill in 0.56 s on average; one to four images per request all answer correctly (four images, 2,495 tokens: 3.2 s). -- Text decodes at 56 to 117 tok/s on 256-token answers (84 on average); image - requests decode without the drafter at about 36 tok/s. +- Text decodes at 56 to 117 tok/s on 256-token answers (84 on average). +- Image requests decode with the drafter. On 12 images with 256-token + answers: 4.0 s per answer (76 tok/s after the first token), against 5.5 s + for llama.cpp with the same drafter (`--spec-type draft-dflash`) and 8.4 s + without one; faster on every image, 1.21x to 1.58x. The 220-question score + is unchanged (188, 218 answers identical to plain decode). With unsloth's UD-IQ4_XS file and the published BF16 projector: diff --git a/server/src/qwen35/qwen35_backend.cpp b/server/src/qwen35/qwen35_backend.cpp index 2dbbaa4dd..6d8ddf3ea 100644 --- a/server/src/qwen35/qwen35_backend.cpp +++ b/server/src/qwen35/qwen35_backend.cpp @@ -1324,6 +1324,7 @@ DFlashTarget * Qwen35Backend::dflash_target() { qt->set_kvflash_pager(&kvflash_pager_); } qt->set_fast_rollback(cfg_.fast_rollback); + qt->set_rope_offset(&rope_delta_); } return dflash_target_.get(); } @@ -1508,9 +1509,9 @@ GenerateResult Qwen35Backend::generate_impl(const GenerateRequest & req, req.n_gen, ar_n_gen, committed, cfg_.device.max_ctx); } } - // Speculative decoding takes rotary positions from KV positions, which - // an image prompt pulls apart, so image requests decode one by one. - if (cfg_.paged_attention || req.force_ar_decode || has_images) { + // Image requests speculate too: the verify target shifts its rotary + // positions by rope_delta_, and the drafter only proposes tokens. + if (cfg_.paged_attention || req.force_ar_decode) { decode_ok = do_ar_decode(committed, ar_n_gen, result.tokens, out_io, req.budget_hook, &result.budget_forced_close, diff --git a/server/src/qwen35/qwen35_dflash_target.cpp b/server/src/qwen35/qwen35_dflash_target.cpp index 8153bc0e0..478ee0d02 100644 --- a/server/src/qwen35/qwen35_dflash_target.cpp +++ b/server/src/qwen35/qwen35_dflash_target.cpp @@ -307,7 +307,7 @@ bool Qwen35DFlashTarget::verify_batch( // GGML M-RoPE positions are axis-major. std::vector pos(4 * n_tokens); - fill_qwen35_mrope_positions(pos.data(), base_pos, n_tokens); + fill_qwen35_mrope_positions(pos.data(), base_pos + rope_offset(), n_tokens); ggml_backend_tensor_set(sg_.positions, pos.data(), 0, sizeof(int32_t) * pos.size()); @@ -452,7 +452,7 @@ bool Qwen35DFlashTarget::verify_tree( // M-RoPE axis-major positions: each node sits at committed + its depth. std::vector pos4(4 * N, 0); for (int i = 0; i < N_actual; i++) { - const int p = committed + (i == 0 ? 0 : tree.depths[i - 1]); + const int p = committed + rope_offset() + (i == 0 ? 0 : tree.depths[i - 1]); pos4[0 * N + i] = p; pos4[1 * N + i] = p; pos4[2 * N + i] = p; diff --git a/server/src/qwen35/qwen35_dflash_target.h b/server/src/qwen35/qwen35_dflash_target.h index e782d262a..425545b89 100644 --- a/server/src/qwen35/qwen35_dflash_target.h +++ b/server/src/qwen35/qwen35_dflash_target.h @@ -93,6 +93,11 @@ class Qwen35DFlashTarget : public DFlashTarget { // so rollback_to() can restore recurrent state without replay. void set_fast_rollback(bool enabled) { fast_rollback_ = enabled; } + // Rotary positions run ahead of KV positions by this many after an image + // (image tokens take 2D positions). Points at the owner's per-request + // offset, which every prefill sets; null means zero. + void set_rope_offset(const int * offset) { rope_offset_ = offset; } + private: TargetWeights & w_; TargetCache & cache_; @@ -102,6 +107,8 @@ class Qwen35DFlashTarget : public DFlashTarget { int fa_window_; KvFlashPager * pager_ = nullptr; bool fast_rollback_ = false; + const int * rope_offset_ = nullptr; + int rope_offset() const { return rope_offset_ ? *rope_offset_ : 0; } // SpecLA (docs/SPECLA.md): true when the cache was // migrated with factor buffers. Capture-verify then runs the diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 18d8c7973..655d182c2 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -4102,7 +4102,8 @@ void HttpServer::prepare_generation_inputs( inputs.request.prompt = prepared.tokens; inputs.request.images = prepared.images; - inputs.request.force_ar_decode = bool(prepared.images); + // Image requests may speculate: each backend decides (DeepSeek4 decodes + // them one by one, Qwen3.5 verifies at image-shifted rotary positions). inputs.request.n_gen = inputs.generation_cap; inputs.request.sampler = req.sampler; inputs.request.do_sample = req.sampler.needs_logit_processing(); From 04ce6a1bd3bc810e8b32cb40a64a360703aba2e6 Mon Sep 17 00:00:00 2001 From: mrciffa Date: Wed, 23 Sep 2026 13:18:30 +0200 Subject: [PATCH 02/12] 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) --- docs/image-input.md | 14 +++++++---- server/src/deepseek4/deepseek4_backend.cpp | 28 ++++++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index eb73ba092..614d04a4a 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -108,9 +108,8 @@ as ordinary text. The server expands image markers after final rendering and tokenization, and the expanded image tokens count toward context and usage. Image requests bypass the token-keyed prefix, disk and agent-turn caches and prompt -compression: tokens alone do not identify an image. Qwen3.5 / Qwen3.8 image -requests decode with the drafter like text; DS4V image requests decode -without it. +compression: tokens alone do not identify an image. Image requests decode +with the model's drafter like text requests. Layer or tensor splitting across GPUs, remote target shards, concurrent sequence scheduling (`--max-concurrency`) and upstream forwarding do not @@ -265,7 +264,14 @@ importance matrix, the shipped recipe above), on a Strix Halo alone at top-k 6: and one-to-four-image sets are all correct. - With the published DSpark drafter and fused decode and verify, text decodes at 25 to 37 tok/s on 256-token answers (30 mean), as fast as the shipped - text model; image requests decode without the drafter at about 22 tok/s. + text model. +- Image requests decode with the DSpark drafter too: on 12 images with + 256-token answers, 13.3 s per answer (30 tok/s after the first token) + against 15.7 s (22 tok/s) without it. Capturing the drafter's features + during prefill adds about 0.7 s before the first token, so one-word answers + come back slightly later. The 220 questions score AI2D 86, ChartQA 55 and + 40 with the drafter (209 answers identical to plain decode); one to four + images all correct. Not yet established: diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index d45d9b762..a4d3e3274 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -2641,8 +2641,9 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, int snap_slot, int snap_pos, const DeepSeek4ImagePrompt * images) { - const bool capture_spec = !images && spec_enabled_ && spec_drafter_; - if (images) spec_feat_window_.clear(); + // Image prompts capture DSpark features from their text chunks only: the + // image graph takes no capture hooks (see the chunking below). + const bool capture_spec = spec_enabled_ && spec_drafter_; const InferencePhase phase = deepseek4_roctx_prefill_phase( prefill_attention_mode_name(cfg_.prefill_mode)); const DeepSeek4RoctxPhaseScope roctx_phase(phase); @@ -2823,10 +2824,29 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, spec_snap_from, spec_snap_to); } + bool chunk_has_image = false; if (images) { n_tok = vision::atomic_image_chunk(images->spans(), uint64_t(pos), n_tok, uint64_t(n_total - i), image_capacity); if (!n_tok) return -1; + // An image batch cannot capture DSpark features, so end it at its + // last image: the text after the image then prefills (and + // captures) as ordinary chunks. + const vision::ImageSpanView spans = images->spans(); + uint64_t last_image_end = 0; + for (size_t k = 0; k < spans.size; ++k) { + const auto & span = spans.data[k]; + if (span.block_begin < uint64_t(pos + n_tok) && span.block_end > uint64_t(pos)) { + chunk_has_image = true; + last_image_end = std::max(last_image_end, span.block_end); + } + } + if (chunk_has_image && capture_spec && last_image_end < uint64_t(pos + n_tok)) { + n_tok = int(last_image_end - uint64_t(pos)); + } + // 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(); } // Bulk prompt graphs and the final DSpark feature-capture graph have @@ -2873,7 +2893,7 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, const bool capture_snapshot = !snapshot_saved && i < spec_snap_to && i + n_tok > spec_snap_from; - if (capture_spec && + if (capture_spec && !chunk_has_image && (capture_final || capture_snapshot)) { spec_hooks.capture_layer_ids = &spec_drafter_->capture_layer_ids; spec_hooks.capture_out = &spec_cap; @@ -3279,7 +3299,7 @@ GenerateResult DeepSeek4Backend::generate_from_state( } } if (spec_enabled_ && spec_drafter_ && req.n_gen > 0 && - !req.images && !req.force_ar_decode && !budget_requires_ar && !sampling_requires_ar) { + !req.force_ar_decode && !budget_requires_ar && !sampling_requires_ar) { if (last_logits_.empty()) { result.fail(GenerateErrorCode::DecodeFailed, "spec: no prefill logits"); return result; From 457cf8a974f1042119adf8355035ea3b7c207b4e Mon Sep 17 00:00:00 2001 From: mrciffa Date: Wed, 23 Sep 2026 16:23:23 +0200 Subject: [PATCH 03/12] 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) --- docs/image-input.md | 21 +- server/src/common/backend_args.h | 3 + server/src/common/backend_factory.cpp | 1 + server/src/common/backend_factory.h | 1 + server/src/common/backend_plan.cpp | 1 + server/src/common/feature_gate.cpp | 8 + server/src/common/image_prompt.h | 5 + server/src/deepseek4/deepseek4_backend.cpp | 183 ++++++++++++++---- server/src/deepseek4/deepseek4_backend.h | 9 + .../deepseek4/deepseek4_image_assembly.cpp | 4 +- .../src/deepseek4/deepseek4_image_prompt.cpp | 4 +- server/src/deepseek4/deepseek4_image_spans.h | 3 +- server/src/deepseek4/deepseek4_internal.h | 1 + server/src/qwen35/qwen35_backend_images.cpp | 3 +- server/src/server/image_input.h | 2 +- server/src/server/server_main.cpp | 8 + server/test/test_ds4v_image_integration.cpp | 4 +- server/test/test_ds4v_image_prompt.cpp | 3 +- 18 files changed, 219 insertions(+), 45 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index d4a264b9d..6bc77336b 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -66,6 +66,25 @@ launch plus `--mmproj`: the Vision file replaces `DeepSeek-V4-Flash-0731-ROCMFPX-MIX-STRIX.gguf` for text as well and decodes at least as fast (numbers below). For R9700 + Strix Halo see [DS4V](#ds4v) below. +With an R9700 in the same box, run the image encoder there while the model +stays on the Strix Halo: expose both GPUs, point `--target-device` at the Strix +Halo and add `--mmproj-device` with the R9700 (on lucebox6, without +`HIP_VISIBLE_DEVICES`, that is `--target-device hip:1 --mmproj-device hip:0`). +The encoder then runs about twice as fast and streams each image into prefill +as soon as it is encoded, so the Strix Halo never waits for the next one: + +| Images | Prompt tokens | Encoder on the Strix Halo | Encoder on the R9700 | +| --- | --- | --- | --- | +| 1 | 126 | 2.97 s | 2.94 s | +| 4 | 942 | 11.2 s | 9.1 s | +| 8 | 2,262 | 27.7 s | 19.2 s | +| 16 | 4,358 | 51.8 s | 34.6 s | + +Time to the first token, with the published launch above and ChartQA charts. +Answers are identical in both layouts. `--mmproj-device` applies to this one-GPU +layout; with the experts split across both GPUs the encoder already runs on the +R9700. + ### Send an image ```bash @@ -101,7 +120,7 @@ Use `POST /v1/chat/completions` with user-message content parts in display order Only base64 JPEG/PNG data URLs are supported. Remote URLs, images outside user content arrays, and image parts through other API formats are rejected. A -request carries at most four images, 16 MiB encoded each and 32 MiB combined. +request carries at most 16 images, 16 MiB encoded each and 32 MiB combined. Decoder pixel and aspect limits also apply. A model's image marker cannot be supplied as ordinary text. diff --git a/server/src/common/backend_args.h b/server/src/common/backend_args.h index 8c2a295ff..0c0907530 100644 --- a/server/src/common/backend_args.h +++ b/server/src/common/backend_args.h @@ -56,6 +56,9 @@ struct BackendArgs { // Optional: vision projector .gguf (deepseek4 only) std::optional mmproj_path; + // Optional: GPU for the vision encoder when it should not share the + // target's (deepseek4, one-GPU layout only). + std::optional mmproj_device; // Device placement DevicePlacement device; diff --git a/server/src/common/backend_factory.cpp b/server/src/common/backend_factory.cpp index 6e3dea2b5..1202dca6c 100644 --- a/server/src/common/backend_factory.cpp +++ b/server/src/common/backend_factory.cpp @@ -370,6 +370,7 @@ std::unique_ptr construct_backend( DeepSeek4BackendConfig cfg; cfg.model_path = model.path; cfg.mmproj_path = model.mmproj_path.value_or(""); + cfg.mmproj_gpu = model.mmproj_device ? model.mmproj_device->gpu : -1; cfg.device = placement.target; cfg.stream_fd = execution.stream_fd; cfg.max_ctx = placement.target.max_ctx; diff --git a/server/src/common/backend_factory.h b/server/src/common/backend_factory.h index 11a53b809..ab884ac80 100644 --- a/server/src/common/backend_factory.h +++ b/server/src/common/backend_factory.h @@ -37,6 +37,7 @@ class BackendPlan final { struct Model { std::string path; std::optional mmproj_path; + std::optional mmproj_device; GgufModelInfo metadata; }; diff --git a/server/src/common/backend_plan.cpp b/server/src/common/backend_plan.cpp index adf3c7c9f..afa390691 100644 --- a/server/src/common/backend_plan.cpp +++ b/server/src/common/backend_plan.cpp @@ -131,6 +131,7 @@ BackendPreparation BackendPlanBuilder::resolve( BackendPlan plan; plan.model_.path = std::move(args.model_path); plan.model_.mmproj_path = std::move(args.mmproj_path); + plan.model_.mmproj_device = std::move(args.mmproj_device); plan.model_.metadata = std::move(model); plan.placement_.target = std::move(args.device); diff --git a/server/src/common/feature_gate.cpp b/server/src/common/feature_gate.cpp index fadbb6d40..7e371856e 100644 --- a/server/src/common/feature_gate.cpp +++ b/server/src/common/feature_gate.cpp @@ -59,6 +59,14 @@ std::string check_feature_compatibility( return "--mmproj with DeepSeek4 requires a HIP backend"; } } + if (args.mmproj_device.has_value()) { + if (!args.mmproj_path.has_value() || arch != "deepseek4" || + args.mmproj_device->backend != PlacementBackend::Hip || + args.mmproj_device->gpu == args.device.gpu) { + return "--mmproj-device needs --mmproj, a DeepSeek4 target, and a HIP GPU " + "other than the target's"; + } + } // ── PFlash enablement × drafter model if (admission.pflash_enabled && diff --git a/server/src/common/image_prompt.h b/server/src/common/image_prompt.h index 2692b6f70..44e46af11 100644 --- a/server/src/common/image_prompt.h +++ b/server/src/common/image_prompt.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -7,6 +8,10 @@ namespace luce::common { +// Most images one request may carry, for every vision backend and the HTTP +// transport. Each image still has its own token and byte bounds. +inline constexpr size_t MAX_REQUEST_IMAGES = 16; + struct EncodedImage { std::string mime_type; std::vector bytes; diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index d45d9b762..587ab2e01 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -34,6 +34,8 @@ #include #include #include +#include +#include #include #include #include @@ -61,6 +63,7 @@ class DeepSeek4ImagePrompt final : public ImagePromptPayload { bool embed_chunk(const CpuEmbedder & embedder, size_t position, int count, float * output) const { if (!output || count <= 0 || embedder.n_embd <= 0) return false; + if (!wait_for_images(position, size_t(count))) return false; std::vector result; std::string error; const bool ok = vision::embed_image_prompt_chunk( @@ -74,12 +77,43 @@ class DeepSeek4ImagePrompt final : public ImagePromptPayload { return ok; } + // Streaming encode: images arrive in prompt order. A chunk waits only for + // the images it contains; failure or cancellation releases every waiter. + bool wait_for_images(size_t position, size_t count) const { + size_t needed = 0; + for (size_t i = 0; i < spans_.size(); ++i) { + if (spans_[i].block_begin < position + count && spans_[i].block_end > position) needed = i + 1; + } + std::unique_lock lock(stream_mutex_); + stream_ready_.wait(lock, [&] { return stream_failed_ || ready_ >= needed; }); + return ready_ >= needed; + } + void publish_image(size_t index, std::vector rows) const { + std::lock_guard lock(stream_mutex_); + materialized_[index] = std::move(rows); + ready_ = index + 1; + stream_ready_.notify_all(); + } + void fail_stream() const { + std::lock_guard lock(stream_mutex_); + stream_failed_ = true; + stream_ready_.notify_all(); + } + bool complete() const { + std::lock_guard lock(stream_mutex_); + return !materialized_.empty() && ready_ == materialized_.size(); + } + const DeepSeek4Backend * const owner_; const vision::PreparedImagePrompt prepared_; const std::vector encoded_; const std::shared_ptr lease_; std::vector spans_; mutable std::vector> materialized_; + mutable std::mutex stream_mutex_; + mutable std::condition_variable stream_ready_; + mutable size_t ready_ = 0; + mutable bool stream_failed_ = false; }; namespace { @@ -1065,7 +1099,7 @@ bool DeepSeek4Backend::prepare_images( return false; } try { - if (images.size() > 4) { + if (images.size() > MAX_REQUEST_IMAGES) { error = "too many images in request"; return false; } @@ -1115,6 +1149,10 @@ bool DeepSeek4Backend::prepare_images( } } +void DeepSeek4Backend::join_image_stream() { + if (image_stream_.joinable()) image_stream_.join(); +} + bool DeepSeek4Backend::materialize_images(const DeepSeek4ImagePrompt & images, const DaemonIO & io, std::string & error) { if (io.is_cancelled()) return false; @@ -1141,6 +1179,7 @@ bool DeepSeek4Backend::materialize_images(const DeepSeek4ImagePrompt & images, name, released); }; trim_pool(backend_, "primary"); + if (vision_backend_) trim_pool(vision_backend_, "vision"); if (expert_backend_ && expert_backend_ != backend_) { trim_pool(expert_backend_, "expert"); } @@ -1148,7 +1187,7 @@ bool DeepSeek4Backend::materialize_images(const DeepSeek4ImagePrompt & images, trim_pool(spec_backend_, "spec"); } auto reserves = image_reserves_; - const uint64_t resident_workspace = + const uint64_t resident_workspace = !vision_backend_ && (vision::detail::hip_bias_launches(backend_) || vision::detail::hip_av_launches(backend_)) ? vision::detail::hip_bias_workspace(backend_) : 0; if (resident_workspace > vision::SCRATCH_RESERVATION) { @@ -1156,8 +1195,9 @@ bool DeepSeek4Backend::materialize_images(const DeepSeek4ImagePrompt & images, return false; } // Current free memory already reflects weights, KV, optional drafter, - // snapshots and retained backend pools. Charge only upcoming work here. - reserves.primary_future_bytes = vision::SCRATCH_RESERVATION - resident_workspace + + // snapshots and retained backend pools. Charge only upcoming work here; + // an encoder on its own GPU charges nothing to the target. + reserves.primary_future_bytes = (vision_backend_ ? 0 : vision::SCRATCH_RESERVATION - resident_workspace) + 128ULL * 1024 * 1024; if (!moe_hybrid_) { uint64_t free_bytes = 0; @@ -1182,37 +1222,74 @@ bool DeepSeek4Backend::materialize_images(const DeepSeek4ImagePrompt & images, gib(report.host_required_bytes), gib(report.host_available_bytes), error.c_str()); return false; } - if (!images.materialized_.empty()) return true; + if (images.complete()) return true; + const auto encode_one = [this](const vision::PromptImage & image, vision::ImageRaster & raster, + std::string & encode_error) { + std::vector patches(image.input.patches_bf16.size()); + for (size_t i = 0; i < patches.size(); ++i) { + const uint32_t bits = uint32_t(image.input.patches_bf16[i]) << 16; + std::memcpy(&patches[i], &bits, sizeof(bits)); + } + vision::VisionOutput output; + if (!vision_->encode(patches, + {int(image.input.plan.vit_rows), int(image.input.plan.vit_cols)}, + output, encode_error)) return false; + if (output.rows <= 0 || output.columns != w_.n_embd) { + encode_error = "vision output shape differs from decoder dimensions"; + return false; + } + raster = {size_t(output.rows), size_t(output.columns), std::move(output.embeddings)}; + return true; + }; + vision::ImageSentinels sentinels; + if (!vision_->sentinel(vision::Sentinel::Start, sentinels.start, error) || + !vision_->sentinel(vision::Sentinel::Pad, sentinels.pad, error) || + !vision_->sentinel(vision::Sentinel::Newline, sentinels.newline, error) || + !vision_->sentinel(vision::Sentinel::End, sentinels.end, error)) return false; + if (vision_backend_) { + // The encoder has its own GPU: encode image k+1 there while the target + // prefills image k. Prefill waits per chunk in embed_chunk. + join_image_stream(); + { + std::lock_guard lock(images.stream_mutex_); + images.materialized_.assign(images.prepared_.images.size(), {}); + images.ready_ = 0; + images.stream_failed_ = false; + } + image_stream_ = std::thread([this, &images, &io, encode_one, sentinels] { + const auto t0 = Clock::now(); + bool ok = true; + std::string stream_error; + for (size_t i = 0; ok && i < images.prepared_.images.size(); ++i) { + vision::ImageRows one; + ok = vision::materialize_image_rows({images.prepared_.images[i]}, sentinels, + size_t(w_.n_embd), encode_one, [&] { return io.is_cancelled(); }, + one, stream_error) && one.size() == 1; + if (ok) images.publish_image(i, std::move(one.front())); + } + vision_->release_scratch(); + if (!ok) { + std::fprintf(stderr, "[deepseek4] streaming image encode stopped: %s\n", + stream_error.empty() ? "cancelled" : stream_error.c_str()); + images.fail_stream(); + } + std::fprintf(stderr, "[deepseek4] images encoded in %.0f ms on the --mmproj-device GPU (streamed)\n", + elapsed_s(t0) * 1000.0); + }); + return true; + } struct ReleaseScratch { vision::VisionRuntime & runtime; ~ReleaseScratch() { runtime.release_scratch(); } } release{*vision_}; try { - vision::ImageSentinels sentinels; - if (!vision_->sentinel(vision::Sentinel::Start, sentinels.start, error) || - !vision_->sentinel(vision::Sentinel::Pad, sentinels.pad, error) || - !vision_->sentinel(vision::Sentinel::Newline, sentinels.newline, error) || - !vision_->sentinel(vision::Sentinel::End, sentinels.end, error)) return false; - return vision::materialize_image_rows( - images.prepared_.images, sentinels, size_t(w_.n_embd), - [&](const vision::PromptImage & image, vision::ImageRaster & raster, - std::string & encode_error) { - std::vector patches(image.input.patches_bf16.size()); - for (size_t i = 0; i < patches.size(); ++i) { - const uint32_t bits = uint32_t(image.input.patches_bf16[i]) << 16; - std::memcpy(&patches[i], &bits, sizeof(bits)); - } - vision::VisionOutput output; - if (!vision_->encode(patches, - {int(image.input.plan.vit_rows), int(image.input.plan.vit_cols)}, - output, encode_error)) return false; - if (output.rows <= 0 || output.columns != w_.n_embd) { - encode_error = "vision output shape differs from decoder dimensions"; - return false; - } - raster = {size_t(output.rows), size_t(output.columns), std::move(output.embeddings)}; - return true; - }, [&] { return io.is_cancelled(); }, images.materialized_, error); + vision::ImageRows rows; + if (!vision::materialize_image_rows(images.prepared_.images, sentinels, size_t(w_.n_embd), + encode_one, [&] { return io.is_cancelled(); }, rows, error)) return false; + std::lock_guard lock(images.stream_mutex_); + images.materialized_ = std::move(rows); + images.ready_ = images.materialized_.size(); + return true; } catch (const std::bad_alloc &) { error = "image materialization allocation failed"; return false; @@ -1234,7 +1311,7 @@ bool DeepSeek4Backend::init_single_gpu_vision() { reserves.primary_domain = properties.integrated || std::getenv("GGML_CUDA_ENABLE_UNIFIED_MEMORY") ? vision::ImageMemoryDomain::HostShared : vision::ImageMemoryDomain::Dedicated; reserves.primary_future_bytes = estimate_ds4_cache_bytes(w_, cfg_.max_ctx > 0 ? cfg_.max_ctx : 8192) + - vision::SCRATCH_RESERVATION + 256ULL * 1024 * 1024; + (vision_backend_ ? 0 : vision::SCRATCH_RESERVATION) + 256ULL * 1024 * 1024; uint64_t free_bytes = 0; std::string error; const bool admitted = vision::check_deepseek4_image_single_gpu_admission( @@ -1267,15 +1344,32 @@ bool DeepSeek4Backend::load_vision() { return false; } } + if (cfg_.mmproj_gpu >= 0 && cfg_.mmproj_gpu != cfg_.device.gpu && !vision_backend_) { + // The encoder only hands host rows to the decoder, so it can run on a + // faster idle GPU (an R9700 next to a Strix Halo holding the model). + vision_backend_ = ggml_backend_cuda_init(cfg_.mmproj_gpu); + size_t free_bytes = 0, total_bytes = 0; + if (vision_backend_) { + ggml_backend_dev_memory(ggml_backend_get_device(vision_backend_), &free_bytes, &total_bytes); + } + if (!vision_backend_ || !vision::detail::hip_bias_workspace(vision_backend_) || + free_bytes < vision::SCRATCH_RESERVATION + 2ULL * 1024 * 1024 * 1024) { + std::fprintf(stderr, "[deepseek4] --mmproj-device hip:%d is unavailable, lacks the DS4V " + "vision ops, or has under 4 GiB free\n", cfg_.mmproj_gpu); + return false; + } + } + ggml_backend_t vision_owner = vision_backend_ ? vision_backend_ : backend_; auto runtime = std::make_unique(); std::string error; - if (!runtime->load(cfg_.mmproj_path, backend_, w_.n_embd, w_.n_vocab, error)) { + if (!runtime->load(cfg_.mmproj_path, vision_owner, w_.n_embd, w_.n_vocab, error)) { std::fprintf(stderr, "[deepseek4] projector load failed: %s\n", error.c_str()); return false; } std::fprintf(stderr, - "[deepseek4] vision weights=%.3f GiB scratch reservation=%.3f GiB before expert placement\n", - gib(runtime->weight_bytes()), gib(vision::SCRATCH_RESERVATION)); + "[deepseek4] vision weights=%.3f GiB scratch reservation=%.3f GiB on %s\n", + gib(runtime->weight_bytes()), gib(vision::SCRATCH_RESERVATION), + vision_backend_ ? "the --mmproj-device GPU" : "the target GPU, before expert placement"); vision_ = std::move(runtime); return true; } @@ -1338,6 +1432,11 @@ bool DeepSeek4Backend::load_model() { "on one GPU or with in-process expert owners on two distinct GPUs\n"); return false; } + if (cfg_.mmproj_gpu >= 0 && tp.requested) { + std::fprintf(stderr, "[deepseek4] --mmproj-device is for the one-GPU layout; with two GPUs " + "the encoder already runs on the primary\n"); + return false; + } if (!vision::detail::hip_bias_workspace(backend_)) { std::fprintf(stderr, "[deepseek4] --mmproj needs the DS4V vision ops, which this build lacks " "(hipBLASLt was not found when ggml-hip was configured)\n"); @@ -2473,6 +2572,7 @@ bool DeepSeek4Backend::park(ParkTarget target) { } moe_placement_ = {}; moe_decode_placement_ = {}; + join_image_stream(); vision_.reset(); free_deepseek4_weights(w_); parked_ = true; @@ -3198,6 +3298,11 @@ GenerateResult DeepSeek4Backend::generate_from_state( } const auto * images = dynamic_cast(req.images.get()); + // A streaming encode reads the payload and the IO: finish it before either ends. + struct ImageStreamJoin { + DeepSeek4Backend * backend; + ~ImageStreamJoin() { backend->join_image_stream(); } + } image_stream_join{this}; if (req.images) { if (!images || images->owner_ != this || !images->matches(req.prompt) || kv_offset != 0 || req.snap_slot >= 0 || req.snap_pos >= 0 || @@ -3208,7 +3313,13 @@ GenerateResult DeepSeek4Backend::generate_from_state( return result; } std::string error; - if (!materialize_images(*images, out_io, error)) { + const auto encode_t0 = Clock::now(); + const bool encoded = materialize_images(*images, out_io, error); + if (!vision_backend_) { + std::fprintf(stderr, "[deepseek4] images encoded in %.0f ms on the target GPU\n", + elapsed_s(encode_t0) * 1000.0); + } + if (!encoded) { if (out_io.is_cancelled()) { result.succeed(); return result; } result.fail(GenerateErrorCode::PrefillFailed, error.empty() ? "image materialization failed" : error); return result; @@ -3719,6 +3830,7 @@ void DeepSeek4Backend::maybe_save_routing_stats() { } void DeepSeek4Backend::shutdown() { + join_image_stream(); maybe_save_routing_stats(); free_drafter(); for (int i = 0; i < PREFIX_SLOTS; i++) { @@ -3739,6 +3851,7 @@ void DeepSeek4Backend::shutdown() { moe_placement_ = {}; moe_decode_placement_ = {}; vision_.reset(); + if (vision_backend_) { ggml_backend_free(vision_backend_); vision_backend_ = nullptr; } free_deepseek4_weights(w_); if (snap_backend_) { ggml_backend_free(snap_backend_); snap_backend_ = nullptr; } if (backend_) { ggml_backend_free(backend_); backend_ = nullptr; } diff --git a/server/src/deepseek4/deepseek4_backend.h b/server/src/deepseek4/deepseek4_backend.h index 4e45ca153..68dad4204 100644 --- a/server/src/deepseek4/deepseek4_backend.h +++ b/server/src/deepseek4/deepseek4_backend.h @@ -16,6 +16,8 @@ #include "deepseek4_internal.h" #include "deepseek4_dspark.h" #include "deepseek4_vision.h" + +#include #include "deepseek4_image_prompt.h" #include "deepseek4_image_assembly.h" #include "deepseek4_image_admission.h" @@ -131,6 +133,11 @@ class DeepSeek4Backend : public ModelBackend { bool image_capable_ = false; bool cache_has_images_ = false; std::unique_ptr vision_; + // Owned backend for the vision encoder when --mmproj-device names a GPU + // other than the target's; null when the encoder shares backend_. + ggml_backend_t vision_backend_ = nullptr; + // Encodes images on vision_backend_ while prefill consumes them. + std::thread image_stream_; vision::ImageRequestGate image_request_gate_; vision::ImageAdmissionReserves image_reserves_; @@ -195,6 +202,8 @@ class DeepSeek4Backend : public ModelBackend { const DeepSeek4ImagePrompt * images = nullptr); bool load_vision(); bool init_single_gpu_vision(); + // Waits for a streaming image encode started by materialize_images. + void join_image_stream(); bool materialize_images(const DeepSeek4ImagePrompt & images, const DaemonIO & io, std::string & error); diff --git a/server/src/deepseek4/deepseek4_image_assembly.cpp b/server/src/deepseek4/deepseek4_image_assembly.cpp index 7a25ad456..204107476 100644 --- a/server/src/deepseek4/deepseek4_image_assembly.cpp +++ b/server/src/deepseek4/deepseek4_image_assembly.cpp @@ -104,7 +104,7 @@ bool materialize_image_rows(const std::vector & images, ImageRows & output, std::string & error) { error.clear(); try { - require(!images.empty() && images.size() <= 4 && bool(encode), "invalid image encode request"); + require(!images.empty() && images.size() <= DS4V_MAX_IMAGES && bool(encode), "invalid image encode request"); cancelled(is_cancelled); sentinels_valid(sentinels, dimension); uint64_t previous_end = 0; @@ -145,7 +145,7 @@ bool embed_image_prompt_chunk(const PreparedImagePrompt & prompt, const ImageRow try { require(bool(prompt) && vocabulary > 0 && count && position <= prompt.tokens.size() && count <= prompt.tokens.size() - position && rows.size() == prompt.images.size() && - prompt.images.size() <= 4, "invalid mixed embedding request"); + prompt.images.size() <= DS4V_MAX_IMAGES, "invalid mixed embedding request"); const size_t end = position + count; uint64_t previous_end = 0; for (size_t i = 0; i < prompt.images.size(); ++i) { diff --git a/server/src/deepseek4/deepseek4_image_prompt.cpp b/server/src/deepseek4/deepseek4_image_prompt.cpp index 70e02d661..d3d7dac0f 100644 --- a/server/src/deepseek4/deepseek4_image_prompt.cpp +++ b/server/src/deepseek4/deepseek4_image_prompt.cpp @@ -1,4 +1,5 @@ #include "deepseek4_image_prompt.h" +#include "../common/image_prompt.h" #include #include #include @@ -21,7 +22,8 @@ PreparedImagePrompt prepare_image_prompt(const std::vector & token limits.output_reserve>limits.context_capacity || limits.max_expanded_tokens==0 || limits.max_expanded_tokens>MAX_PREPARED_PROMPT_TOKENS) return fail(ImagePromptError::InvalidLimits,"invalid context, output reserve, or prompt bound"); - if (images.size()>4) return fail(ImagePromptError::ImageCount,"at most four images are supported"); + if (images.size()>common::MAX_REQUEST_IMAGES) + return fail(ImagePromptError::ImageCount,"too many images in request"); if (tokens.size()>limits.max_expanded_tokens) return fail(ImagePromptError::TokenLimit,"rendered tokens exceed prompt bound"); size_t markers=0; diff --git a/server/src/deepseek4/deepseek4_image_spans.h b/server/src/deepseek4/deepseek4_image_spans.h index 3b4d1ed6e..5dde1b6b6 100644 --- a/server/src/deepseek4/deepseek4_image_spans.h +++ b/server/src/deepseek4/deepseek4_image_spans.h @@ -1,11 +1,12 @@ // DS4V limits for the shared image span helpers. #pragma once +#include "../common/image_prompt.h" #include "../common/vision/image_spans.h" namespace luce::vision { -inline constexpr size_t DS4V_MAX_IMAGES = 4; +inline constexpr size_t DS4V_MAX_IMAGES = common::MAX_REQUEST_IMAGES; inline constexpr uint64_t DS4V_MAX_IMAGE_BLOCK_TOKENS = 384; inline bool valid_image_spans(ImageSpanView spans, uint64_t prompt_size) { diff --git a/server/src/deepseek4/deepseek4_internal.h b/server/src/deepseek4/deepseek4_internal.h index 5872ff703..48c20aba5 100644 --- a/server/src/deepseek4/deepseek4_internal.h +++ b/server/src/deepseek4/deepseek4_internal.h @@ -369,6 +369,7 @@ struct DeepSeek4Head4Tail2Routes { struct DeepSeek4BackendConfig { std::string model_path; std::string mmproj_path; + int mmproj_gpu = -1; // vision encoder GPU; -1 = the target's DevicePlacement device; int stream_fd = -1; int chunk = 512; // prefill chunk size diff --git a/server/src/qwen35/qwen35_backend_images.cpp b/server/src/qwen35/qwen35_backend_images.cpp index 9020ebaee..abe0616b2 100644 --- a/server/src/qwen35/qwen35_backend_images.cpp +++ b/server/src/qwen35/qwen35_backend_images.cpp @@ -3,6 +3,7 @@ // in qwen35_backend.cpp. #include "qwen35_backend.h" +#include "common/image_prompt.h" #include "common/vision/image_decode.h" #include "qwen35_image_request.h" @@ -14,7 +15,7 @@ namespace luce::common { namespace { -constexpr size_t MAX_IMAGES_PER_REQUEST = 4; // the server's transport limit +constexpr size_t MAX_IMAGES_PER_REQUEST = common::MAX_REQUEST_IMAGES; } bool Qwen35Backend::load_vision() { diff --git a/server/src/server/image_input.h b/server/src/server/image_input.h index 16c770de6..be1531f55 100644 --- a/server/src/server/image_input.h +++ b/server/src/server/image_input.h @@ -17,7 +17,7 @@ inline constexpr size_t MAX_IMAGE_BYTES = 16 * 1024 * 1024; struct ImageInputLimits { size_t image_bytes = MAX_IMAGE_BYTES; size_t request_bytes = 32 * 1024 * 1024; - size_t image_count = 4; + size_t image_count = MAX_REQUEST_IMAGES; }; struct ImageRequestPolicy { diff --git a/server/src/server/server_main.cpp b/server/src/server/server_main.cpp index a70e05081..10818a54c 100644 --- a/server/src/server/server_main.cpp +++ b/server/src/server/server_main.cpp @@ -86,6 +86,7 @@ static void print_usage(const char * prog) { " do not change generation routing.\n" " --draft Draft model for speculative decode\n" " --mmproj Vision projector GGUF: enables image input (Qwen3.5/3.8, DS4V)\n" + " --mmproj-device hip:N Run the DS4V image encoder on another GPU (one-GPU layout)\n" " --port Listen port (default: 8080)\n" " --host Bind address (default: 0.0.0.0)\n" " --max-ctx Max context length (default: 131072)\n" @@ -365,6 +366,13 @@ static int parse_model_options(int argc, char ** argv, ModelOptions & model, return 2; } bargs.mmproj_path = argv[++i]; + } else if (std::strcmp(argv[i], "--mmproj-device") == 0 && i + 1 < argc) { + DevicePlacement vision_device; + if (!parse_placement_device(argv[++i], vision_device)) { + std::fprintf(stderr, "[server] bad --mmproj-device value (expected hip:gpu)\n"); + return 2; + } + bargs.mmproj_device = vision_device; } else if (std::strcmp(argv[i], "--port") == 0 && i + 1 < argc) { sconfig.port = std::atoi(argv[++i]); } else if (std::strcmp(argv[i], "--host") == 0 && i + 1 < argc) { diff --git a/server/test/test_ds4v_image_integration.cpp b/server/test/test_ds4v_image_integration.cpp index 45cb7967f..6d798b8fb 100644 --- a/server/test/test_ds4v_image_integration.cpp +++ b/server/test/test_ds4v_image_integration.cpp @@ -69,8 +69,8 @@ void validation_and_lookup() { require(valid_image_spans({}, 0), "empty prompt/view is valid"); require(!image_block_at({}, 0), "empty lookup is null"); require(!valid_image_spans({nullptr, 1}, 100), "nonempty null view rejected"); - std::array too_many{}; - require(!valid_image_spans({too_many.data(), too_many.size()}, 100), "more than four images rejected"); + std::array too_many{}; + require(!valid_image_spans({too_many.data(), too_many.size()}, 100), "too many images rejected"); const std::vector spans{{10, 13, 18, 20}, {20, 20, 25, 25}, {30, 31, 33, 35}}; require(valid_image_spans(view(spans), 35), "adjacent and separated blocks valid"); require(!image_block_at(view(spans), 9), "text before block excluded"); diff --git a/server/test/test_ds4v_image_prompt.cpp b/server/test/test_ds4v_image_prompt.cpp index f24901a53..e588e6590 100644 --- a/server/test/test_ds4v_image_prompt.cpp +++ b/server/test/test_ds4v_image_prompt.cpp @@ -1,4 +1,5 @@ #include "deepseek4_image_prompt.h" +#include "common/image_prompt.h" #include #include #include @@ -63,7 +64,7 @@ int main() { for (int kind=0;kind<5;++kind) rejected(prepare_image_prompt({vocab+kind},{}),ImagePromptError::InvalidToken); rejected(prepare_image_prompt({marker},{image},{},{129279,marker}),ImagePromptError::InvalidContract); rejected(prepare_image_prompt({marker},{image},{},{vocab,marker-1}),ImagePromptError::InvalidContract); - rejected(prepare_image_prompt(std::vector(5,marker),std::vector(5,image)),ImagePromptError::ImageCount); + rejected(prepare_image_prompt(std::vector(luce::common::MAX_REQUEST_IMAGES+1,marker),std::vector(luce::common::MAX_REQUEST_IMAGES+1,image)),ImagePromptError::ImageCount); auto bad=image; bad.plan.resized_width++; rejected(prepare_image_prompt({marker,marker},{image,bad}),ImagePromptError::InvalidPlan); bad=image; bad.plan.aligner_rows=2; From bfe6f4d9ff2ba2cf1374abe2e7015a229ed4b21f Mon Sep 17 00:00:00 2001 From: mrciffa Date: Wed, 23 Sep 2026 16:32:07 +0200 Subject: [PATCH 04/12] 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) --- docs/image-input.md | 11 ++- server/src/common/concurrency/seq_engine.h | 17 +++++ server/src/common/feature_gate.cpp | 6 +- .../qwen35/concurrency/qwen35_seq_engine.cpp | 69 +++++++++++++++++-- .../qwen35/concurrency/qwen35_seq_engine.h | 28 ++++++++ server/src/server/http_server.cpp | 3 +- server/src/server/scheduler.cpp | 9 ++- 7 files changed, 128 insertions(+), 15 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index d4a264b9d..d676568f8 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -111,9 +111,14 @@ plain autoregressive decoding and bypass the token-keyed prefix, disk and agent-turn caches and prompt compression: tokens alone do not identify an image. Text requests on the same server keep speculative decoding and caching. -Layer or tensor splitting across GPUs, remote target shards, concurrent -sequence scheduling (`--max-concurrency`) and upstream forwarding do not -support images. `/props` reports the effective capability in +Layer or tensor splitting across GPUs, remote target shards and upstream +forwarding do not support images. Qwen3.5 / Qwen3.8 serve images with +concurrent sequence scheduling (`--paged-attention --max-concurrency N`): each +image request is encoded when it is admitted and then prefills and decodes in +the shared batch like text, with the drafter. On one R9700, four concurrent +256-token image answers finish in 6.9 s (149 tok/s in total) against 13.3 s +(77 tok/s) one at a time. DeepSeek V4 image requests still need one request +at a time. `/props` reports the effective capability in `capabilities.image_input_supported` after backend initialization. ## Qwen3.5 / Qwen3.8 diff --git a/server/src/common/concurrency/seq_engine.h b/server/src/common/concurrency/seq_engine.h index 98444f955..2292f259a 100644 --- a/server/src/common/concurrency/seq_engine.h +++ b/server/src/common/concurrency/seq_engine.h @@ -54,6 +54,7 @@ #include #include +#include "common/image_prompt.h" #include "common/sampler.h" #include "prefix_store.h" @@ -181,6 +182,22 @@ class SeqEngine { const std::vector & prompt, const SamplerCfg & sampler) = 0; + // Image requests. The engine keeps the payload with the slot for as long + // as the slot lives, so a re-prefill after eviction sees the images again. + // Engines without image support refuse; the server only routes images to + // an engine that reports supports_images(). + virtual bool supports_images() const { return false; } + virtual AdmitResult admit_images(uint64_t request_id, + const std::vector & prompt, + const SamplerCfg & sampler, + const ImagePromptHandle & images) { + (void) request_id; (void) prompt; (void) sampler; (void) images; + AdmitResult result; + result.status = AdmitResult::Status::failed; + result.error = "this engine does not serve image requests"; + return result; + } + // Optional prefix-checkpoint admission. Unsupported engines remain on // cold admission and never receive a plan from the scheduler. virtual bool supports_prefix_store() const { return false; } diff --git a/server/src/common/feature_gate.cpp b/server/src/common/feature_gate.cpp index fadbb6d40..39dbb617e 100644 --- a/server/src/common/feature_gate.cpp +++ b/server/src/common/feature_gate.cpp @@ -51,9 +51,9 @@ std::string check_feature_compatibility( if (args.mmproj_path.has_value()) { if ((arch != "deepseek4" && arch != "qwen35") || args.device.is_layer_split() || args.device.is_tensor_parallel() || args.remote_target_shard.enabled() || - args.max_concurrency != 1) { - return "--mmproj requires a local single-request DeepSeek4 or Qwen3.5 backend " - "that is not split across GPUs by layer or tensor"; + (arch == "deepseek4" && args.max_concurrency != 1)) { + return "--mmproj requires a local DeepSeek4 (one request at a time) or Qwen3.5 " + "backend that is not split across GPUs by layer or tensor"; } if (arch == "deepseek4" && target_backend != PlacementBackend::Hip) { return "--mmproj with DeepSeek4 requires a HIP backend"; diff --git a/server/src/qwen35/concurrency/qwen35_seq_engine.cpp b/server/src/qwen35/concurrency/qwen35_seq_engine.cpp index e38dc751f..e9852634c 100644 --- a/server/src/qwen35/concurrency/qwen35_seq_engine.cpp +++ b/server/src/qwen35/concurrency/qwen35_seq_engine.cpp @@ -379,6 +379,7 @@ SeqEngine::AdmitResult Qwen35SeqEngine::admit( const SamplerCfg & sampler) { AdmitResult result = slots_.admit(request_id, prompt, sampler); if (result.status == AdmitResult::Status::admitted) { + clear_slot_images(result.slot); reset_recurrent_slot(b_.cache_, result.slot); if (result.slot >= 0 && result.slot < static_cast(slot_draft_kv_.size()) && @@ -389,6 +390,43 @@ SeqEngine::AdmitResult Qwen35SeqEngine::admit( return result; } +bool Qwen35SeqEngine::supports_images() const { + return b_.supports_images(); +} + +SeqEngine::AdmitResult Qwen35SeqEngine::admit_images( + uint64_t request_id, + const std::vector & prompt, + const SamplerCfg & sampler, + const ImagePromptHandle & images) { + AdmitResult refused; + refused.status = AdmitResult::Status::failed; + const auto * payload = dynamic_cast(images.get()); + if (!payload || payload->owner != &b_ || !payload->matches(prompt)) { + refused.error = "image binding does not match this prompt"; + return refused; + } + // Encode before claiming a slot: a failed encode then leaves no slot to + // unwind. The tower runs on this (the scheduler) thread, between steps. + Qwen35ImageRows rows; + std::string error = "image encoding failed"; + if (!b_.encode_images(*payload, rows, error)) { + refused.error = error; + return refused; + } + AdmitResult result = admit(request_id, prompt, sampler); + if (result.status != AdmitResult::Status::admitted) return result; + if (slot_images_.size() < static_cast(slots_.slot_count())) { + slot_images_.resize(static_cast(slots_.slot_count())); + } + SlotImages & state = slot_images_[static_cast(result.slot)]; + state.payload = images; + state.rows = std::move(rows); + state.rows.prompt = payload; + state.rope_delta = payload->positions.next - static_cast(prompt.size()); + return result; +} + size_t Qwen35SeqEngine::estimate_prefix_store_bytes(int tokens) const { return estimate_paged_target_cache_snapshot_bytes(b_.cache_, tokens); } @@ -425,6 +463,7 @@ SeqEngine::AdmitResult Qwen35SeqEngine::admit_with_prefix( const PrefixStorePlan & plan) { AdmitResult result = slots_.admit(request_id, prompt, sampler); if (result.status != AdmitResult::Status::admitted) return result; + clear_slot_images(result.slot); const int slot = result.slot; slots_.slot(slot).pending_capture = {}; @@ -652,6 +691,13 @@ Qwen35SeqEngine::PrefillStage Qwen35SeqEngine::stage_prefill_chunk( "prefill embedding failed"); return PrefillStage{}; } + if (slot < static_cast(slot_images_.size()) && + slot_images_[static_cast(slot)].payload) { + const SlotImages & images = slot_images_[static_cast(slot)]; + images.rows.overwrite(stage.embeddings.data(), stage.kv_pos, stage.chunk, b_.w_.n_embd); + stage.positions.assign(static_cast(4) * stage.chunk, 0); + images.rows.prompt->positions.fill(stage.positions.data(), stage.kv_pos, stage.chunk); + } stage.ready = true; return stage; } @@ -856,7 +902,7 @@ SeqEngine::StepResult Qwen35SeqEngine::step_chain_spec( seq_lens_[static_cast(lane.slot)] = lane.position + 1; for (int axis = 0; axis < 3; ++axis) { positions[static_cast(axis) * total_rows + lane_index] = - lane.position; + lane.position + rope_delta(lane.slot); } for (int head = 0; head < n_head_kv; ++head) { write_rows[static_cast(head) * total_rows + lane_index] = @@ -884,7 +930,8 @@ SeqEngine::StepResult Qwen35SeqEngine::step_chain_spec( ? -1 : node - 1; query_slots[static_cast(row)] = proposal.slot; - const int position = slots_.slot(proposal.slot).cur_pos + node; + const int position = slots_.slot(proposal.slot).cur_pos + node + + rope_delta(proposal.slot); for (int axis = 0; axis < 3; ++axis) { positions[static_cast(axis) * total_rows + row] = position; @@ -1427,14 +1474,23 @@ SeqEngine::StepResult Qwen35SeqEngine::step(const StepPlan & plan) { pos_buf_.assign((size_t)4 * n_total, 0); token_offset = 0; for (const PrefillStage & prefill : prefills) { - fill_qwen35_mrope_positions( - pos_buf_.data(), n_total, token_offset, - prefill.kv_pos, prefill.chunk); + if (prefill.positions.empty()) { + fill_qwen35_mrope_positions( + pos_buf_.data(), n_total, token_offset, + prefill.kv_pos, prefill.chunk); + } else { + for (int axis = 0; axis < 4; ++axis) { + std::copy_n(prefill.positions.data() + (size_t)axis * prefill.chunk, prefill.chunk, + pos_buf_.data() + (size_t)axis * n_total + token_offset); + } + } token_offset += prefill.chunk; } if (with_decode) { for (int row = 0; row < live_count; ++row) { - const int pos = live_positions_[(size_t)row]; + // Rotary positions run ahead of KV positions after an image. + const int pos = live_positions_[(size_t)row] + + rope_delta(live_slot_ids_[(size_t)row]); const int packed_row = n_prefill + row; pos_buf_[(size_t)0 * n_total + packed_row] = pos; pos_buf_[(size_t)1 * n_total + packed_row] = pos; @@ -1650,6 +1706,7 @@ bool Qwen35SeqEngine::evict_kv(int slot, int32_t pending_token, void Qwen35SeqEngine::retire(int slot) { offload_.discard(slot); + clear_slot_images(slot); if (!slots_.is_active(slot)) return; if (slot >= 0 && slot < static_cast(slot_draft_kv_.size()) && slot_draft_kv_[static_cast(slot)]) { diff --git a/server/src/qwen35/concurrency/qwen35_seq_engine.h b/server/src/qwen35/concurrency/qwen35_seq_engine.h index 938bb9356..e33e36892 100644 --- a/server/src/qwen35/concurrency/qwen35_seq_engine.h +++ b/server/src/qwen35/concurrency/qwen35_seq_engine.h @@ -26,6 +26,7 @@ #include "common/dflash_draft_kv.h" #include "common/dflash_feature_ring.h" #include "qwen35_slot_manager.h" +#include "../qwen35_image_request.h" #include #include @@ -114,6 +115,11 @@ class Qwen35SeqEngine final : public SeqEngine { return slots_.kv_restore_feasible(slot); } void retire(int slot) override; + bool supports_images() const override; + AdmitResult admit_images(uint64_t request_id, + const std::vector & prompt, + const SamplerCfg & sampler, + const ImagePromptHandle & images) override; bool token_is_eos(int32_t token) const override; @@ -126,8 +132,30 @@ class Qwen35SeqEngine final : public SeqEngine { bool commit = false; std::vector rows; std::vector embeddings; + // Axis-major [4 x chunk] rotary positions when the slot holds images; + // empty means the plain kv_pos + i positions. + std::vector positions; }; + // Per-slot image state: the payload (kept alive for re-prefill after + // eviction), its encoded rows, and how far rotary positions run ahead of + // KV positions after the images. + struct SlotImages { + ImagePromptHandle payload; + Qwen35ImageRows rows; + int rope_delta = 0; + }; + std::vector slot_images_; + int rope_delta(int slot) const { + return slot >= 0 && slot < static_cast(slot_images_.size()) + ? slot_images_[static_cast(slot)].rope_delta : 0; + } + void clear_slot_images(int slot) { + if (slot >= 0 && slot < static_cast(slot_images_.size())) { + slot_images_[static_cast(slot)] = SlotImages{}; + } + } + struct PreparedChainDraft { std::vector tokens; }; diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 18d8c7973..be2c28f58 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -1248,7 +1248,8 @@ HttpServer::HttpServer(luce::engine::LuceEngine & engine, config.disk_cache_cold_max_tokens}, backend_) { config_.image_input_enabled = backend_.supports_images() && - config_.pflash_upstream_base.empty() && !backend_.seq_engine(); + config_.pflash_upstream_base.empty() && + (!backend_.seq_engine() || backend_.seq_engine()->supports_images()); if (backend_.supports_images() && !config_.image_input_enabled) { std::fprintf(stderr, "[server] WARNING: a vision projector is loaded but image input is off: it is " diff --git a/server/src/server/scheduler.cpp b/server/src/server/scheduler.cpp index f1b184ce1..91705cdfe 100644 --- a/server/src/server/scheduler.cpp +++ b/server/src/server/scheduler.cpp @@ -451,7 +451,9 @@ void HttpServer::scheduler_loop(SeqEngine & engine) { PrefixCaptureTxn prepared_capture; PrefixCache::InlineReservation capture_reservation; int restore_policy_slot = -1; - const bool prefix_supported = + // Tokens alone do not identify an image: image requests never touch + // the prefix cache. + const bool prefix_supported = !req.images && engine.supports_prefix_store() && !prefix_cache_.disabled(); if (prefix_supported) { const auto hit = prefix_cache_.lookup_candidate( @@ -502,7 +504,10 @@ void HttpServer::scheduler_loop(SeqEngine & engine) { // Admission only claims the slot and queues the prompt. Prefill // advances one chunk per engine step alongside live decode. const PrefixStorePlan requested_plan = prefix_plan; - auto ar = prefix_supported + auto ar = req.images + ? engine.admit_images( + next_request_id, req.prompt_tokens, req.sampler, req.images) + : prefix_supported ? engine.admit_with_prefix( next_request_id, req.prompt_tokens, req.sampler, requested_plan) From 9cf92ab2d129bebad622e39454e49135295809bd Mon Sep 17 00:00:00 2001 From: mrciffa Date: Wed, 23 Sep 2026 16:55:25 +0200 Subject: [PATCH 05/12] 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) --- docs/image-input.md | 23 +++++- server/src/common/feature_gate.cpp | 6 +- server/src/deepseek4/deepseek4_backend.cpp | 55 ++++++++++++-- server/src/deepseek4/deepseek4_backend.h | 9 ++- .../deepseek4/deepseek4_image_assembly.cpp | 8 +- .../src/deepseek4/deepseek4_image_assembly.h | 5 +- server/src/deepseek4/deepseek4_internal.h | 9 +++ .../src/deepseek4/deepseek4_paged_cache.cpp | 75 +++++++++++++++++++ server/src/deepseek4/deepseek4_seq_engine.cpp | 44 +++++++++++ server/src/deepseek4/deepseek4_seq_engine.h | 5 ++ 10 files changed, 221 insertions(+), 18 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index 615ae681b..1ee9ceb07 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -136,8 +136,27 @@ concurrent sequence scheduling (`--paged-attention --max-concurrency N`): each image request is encoded when it is admitted and then prefills and decodes in the shared batch like text, with the drafter. On one R9700, four concurrent 256-token image answers finish in 6.9 s (149 tok/s in total) against 13.3 s -(77 tok/s) one at a time. DeepSeek V4 image requests still need one request -at a time. `/props` reports the effective capability in +(77 tok/s) one at a time. + +DeepSeek V4 Flash Vision batches too, with the batched launch from the DeepSeek +guide plus `--mmproj` (and `--mmproj-device` for an R9700 encoder): + +``` +luce_server models/DeepSeek-V4-Flash-Vision-Exp-ROCMFPX-MIX-STRIX.gguf \ + --target-device hip:1 --mmproj-device hip:0 \ + --paged-attention --max-concurrency 4 --kv-pool-tokens 24576 --max-ctx 8192 \ + --ds4-prefill exact --prefix-cache-slots 0 --ds4-expert-top-k 6 \ + --mmproj models/DeepSeek-V4-Flash-Vision-Exp-mmproj-BF16.gguf +``` + +Its image blocks need whole-block bidirectional prefill, which the batched +engine's 16-row step cannot run. An image request is therefore prefilled up to +its last token on the single-request sparse path, into a staging cache, while +the batch waits; that state is copied into the request's paged slot and the +last token prefills in the batch, so the answer decodes alongside everyone +else. On the Strix Halo with the encoder on the R9700, four concurrent image +answers of 256 tokens finish in 39 s (26 tok/s in total), two images plus two +text requests in 33 s (31 tok/s); four text requests reach 38 tok/s. `/props` reports the effective capability in `capabilities.image_input_supported` after backend initialization. ## Qwen3.5 / Qwen3.8 diff --git a/server/src/common/feature_gate.cpp b/server/src/common/feature_gate.cpp index bc69afd7e..d3a703880 100644 --- a/server/src/common/feature_gate.cpp +++ b/server/src/common/feature_gate.cpp @@ -51,9 +51,9 @@ std::string check_feature_compatibility( if (args.mmproj_path.has_value()) { if ((arch != "deepseek4" && arch != "qwen35") || args.device.is_layer_split() || args.device.is_tensor_parallel() || args.remote_target_shard.enabled() || - (arch == "deepseek4" && args.max_concurrency != 1)) { - return "--mmproj requires a local DeepSeek4 (one request at a time) or Qwen3.5 " - "backend that is not split across GPUs by layer or tensor"; + (arch == "deepseek4" && args.max_concurrency != 1 && !args.paged_attention)) { + return "--mmproj requires a local DeepSeek4 or Qwen3.5 backend that is not split " + "across GPUs by layer or tensor (DeepSeek4 batching needs --paged-attention)"; } if (arch == "deepseek4" && target_backend != PlacementBackend::Hip) { return "--mmproj with DeepSeek4 requires a HIP backend"; diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index b0c257aba..5df7d3162 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -1149,6 +1149,29 @@ bool DeepSeek4Backend::prepare_images( } } +bool DeepSeek4Backend::prefill_image_prefix(const std::vector & prompt, + const ImagePromptHandle & handle, + int prefix_tokens, std::string & error) { + const auto * images = dynamic_cast(handle.get()); + if (!images || images->owner_ != this || !images->matches(prompt) || !cache_.buf || + prefix_tokens <= 0 || prefix_tokens >= int(prompt.size()) || + prompt.size() > size_t(cache_.max_ctx)) { + error = "image binding, staging cache, or prompt length is invalid"; + return false; + } + DaemonIO io; + struct ImageStreamJoin { + DeepSeek4Backend * backend; + ~ImageStreamJoin() { backend->join_image_stream(); } + } join{this}; + if (!materialize_images(*images, io, error)) return false; + if (do_prefill(prompt, io, 0, -1, -1, images, prefix_tokens) != prefix_tokens) { + if (error.empty()) error = "image prefix prefill failed"; + return false; + } + return true; +} + void DeepSeek4Backend::join_image_stream() { if (image_stream_.joinable()) image_stream_.join(); } @@ -1424,8 +1447,10 @@ bool DeepSeek4Backend::load_model() { const bool two_gpu_ok = tp.in_process && tp.backend_valid && tp.secondary_backend == PlacementBackend::Hip && tp.secondary_gpu != cfg_.device.gpu && !tp.all_on_secondary && !force_full; + // Batched serving keeps exact prefill for text; its image admissions + // prefill into a sparse staging cache instead. if (target_backend != PlacementBackend::Hip || cfg_.device.is_layer_split() || - cfg_.prefill_mode != PrefillAttentionMode::Sparse || + (cfg_.prefill_mode != PrefillAttentionMode::Sparse && !cfg_.paged_attention) || (tp.requested && !two_gpu_ok) || env_flag_enabled("LUCE_DS4_DENSE_TP_MASK")) { std::fprintf(stderr, "[deepseek4] --mmproj requires a HIP target with --ds4-prefill sparse, " @@ -1808,6 +1833,18 @@ bool DeepSeek4Backend::init() { (unsigned long long)requested); return false; } + if (vision_) { + // Image admissions prefill on the single-request sparse path into + // this staging cache, then copy it into their paged slot. + if (!create_deepseek4_cache(backend_, w_, max_ctx, cache_)) { + std::fprintf(stderr, "[deepseek4] image staging cache allocation failed (ctx=%d)\n", max_ctx); + return false; + } + cache_.prefill_mode = PrefillAttentionMode::Sparse; + image_request_gate_.set_capacity(cfg_.max_concurrency); + std::fprintf(stderr, "[deepseek4] batched image serving: %d slots, staging cache ctx=%d\n", + cfg_.max_concurrency, max_ctx); + } } else { if (!create_deepseek4_cache(backend_, w_, max_ctx, cache_)) { std::fprintf(stderr, "[deepseek4] failed to allocate KV cache (ctx=%d)\n", max_ctx); @@ -2740,12 +2777,13 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, int kv_offset, int snap_slot, int snap_pos, - const DeepSeek4ImagePrompt * images) { + const DeepSeek4ImagePrompt * images, + int prefix_tokens) { // Image prompts capture DSpark features from their text chunks only: the // image graph takes no capture hooks (see the chunking below). const bool capture_spec = spec_enabled_ && spec_drafter_; const InferencePhase phase = deepseek4_roctx_prefill_phase( - prefill_attention_mode_name(cfg_.prefill_mode)); + prefill_attention_mode_name(cache_.prefill_mode)); const DeepSeek4RoctxPhaseScope roctx_phase(phase); const DeepSeek4RoctxRange roctx_range( "ds4.prefill", @@ -2766,7 +2804,8 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, // Mixed hot/cold hybrid execution still has single-token HC semantics, so // retain the reference path there. --chunk 1 is the explicit fallback. const int requested_chunk = cfg_.chunk > 0 ? cfg_.chunk : w_.n_swa; - const int n_total = (int)tokens.size(); + const int n_total = prefix_tokens > 0 + ? std::min(prefix_tokens, (int)tokens.size()) : (int)tokens.size(); // Bound the layer-major graph to the topology validated by the prefill // kernels. Smaller tail chunks use the same scheduler or its reference // fallback. @@ -2777,17 +2816,17 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, // hybrid execution remains tokenwise; batching it would skip per-token HC // post-mixing and corrupt the hidden state. const bool hybrid_batch_supported = - !moe_hybrid_ || cfg_.prefill_mode == PrefillAttentionMode::Sparse; + !moe_hybrid_ || cache_.prefill_mode == PrefillAttentionMode::Sparse; const int base_chunk = !hybrid_batch_supported || - (cfg_.prefill_mode == PrefillAttentionMode::Exact && + (cache_.prefill_mode == PrefillAttentionMode::Exact && spec_drafter_ != nullptr) ? 1 : std::max(1, std::min(requested_chunk, layer_major_cap)); const bool bound_hybrid_scratch = moe_hybrid_ && - cfg_.prefill_mode == PrefillAttentionMode::Sparse; + cache_.prefill_mode == PrefillAttentionMode::Sparse; const int chunk = bound_hybrid_scratch ? deepseek4_hybrid_prefill_chunk_tokens( base_chunk, kv_offset + n_total, @@ -3048,7 +3087,7 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, need_logits ? &logits : nullptr, tokens.data() + i, timing ? &step_tel : nullptr, - cfg_.prefill_mode != PrefillAttentionMode::Sparse, hp, + cache_.prefill_mode != PrefillAttentionMode::Sparse, hp, /*moe_hybrid=*/nullptr, /*expert_runtime=*/nullptr, /*routing_stats=*/nullptr, images ? images->spans() : vision::ImageSpanView{}); diff --git a/server/src/deepseek4/deepseek4_backend.h b/server/src/deepseek4/deepseek4_backend.h index 68dad4204..73b1513ba 100644 --- a/server/src/deepseek4/deepseek4_backend.h +++ b/server/src/deepseek4/deepseek4_backend.h @@ -197,13 +197,20 @@ class DeepSeek4Backend : public ModelBackend { int snapshot_capture_to); // Prefill prompt tokens in chunks, return absolute committed position. + // prefix_tokens > 0 prefills only that many leading tokens (the batched + // image admission leaves the last prompt token to the paged engine). int do_prefill(const std::vector & tokens, const DaemonIO & io, int kv_offset = 0, int snap_slot = -1, int snap_pos = -1, - const DeepSeek4ImagePrompt * images = nullptr); + const DeepSeek4ImagePrompt * images = nullptr, + int prefix_tokens = 0); bool load_vision(); bool init_single_gpu_vision(); // Waits for a streaming image encode started by materialize_images. void join_image_stream(); + // Batched serving: encode an image request and prefill its first + // prefix_tokens into the single-request staging cache (cache_). + bool prefill_image_prefix(const std::vector & prompt, const ImagePromptHandle & images, + int prefix_tokens, std::string & error); bool materialize_images(const DeepSeek4ImagePrompt & images, const DaemonIO & io, std::string & error); diff --git a/server/src/deepseek4/deepseek4_image_assembly.cpp b/server/src/deepseek4/deepseek4_image_assembly.cpp index 204107476..ac67569bf 100644 --- a/server/src/deepseek4/deepseek4_image_assembly.cpp +++ b/server/src/deepseek4/deepseek4_image_assembly.cpp @@ -58,11 +58,13 @@ void cancelled(const ImageCancelled & callback) { } // namespace std::shared_ptr ImageRequestGate::try_acquire() const { - bool expected = false; - if (!active_->compare_exchange_strong(expected, true, std::memory_order_acq_rel)) return {}; + int current = active_->load(std::memory_order_acquire); + do { + if (current >= capacity_) return {}; + } while (!active_->compare_exchange_weak(current, current + 1, std::memory_order_acq_rel)); // shared_ptr invokes the deleter if control-block allocation throws too. return std::shared_ptr(active_.get(), [active = active_](void *) { - active->store(false, std::memory_order_release); + active->fetch_sub(1, std::memory_order_release); }); } diff --git a/server/src/deepseek4/deepseek4_image_assembly.h b/server/src/deepseek4/deepseek4_image_assembly.h index f343a6a41..23821e58f 100644 --- a/server/src/deepseek4/deepseek4_image_assembly.h +++ b/server/src/deepseek4/deepseek4_image_assembly.h @@ -12,8 +12,11 @@ namespace luce::vision { class ImageRequestGate { public: std::shared_ptr try_acquire() const; + // Concurrent serving admits up to one image request per batch slot. + void set_capacity(int capacity) { capacity_ = capacity > 0 ? capacity : 1; } private: - std::shared_ptr> active_ = std::make_shared>(false); + std::shared_ptr> active_ = std::make_shared>(0); + int capacity_ = 1; }; struct ImageRaster { diff --git a/server/src/deepseek4/deepseek4_internal.h b/server/src/deepseek4/deepseek4_internal.h index 48c20aba5..cc85597f1 100644 --- a/server/src/deepseek4/deepseek4_internal.h +++ b/server/src/deepseek4/deepseek4_internal.h @@ -451,6 +451,15 @@ bool create_deepseek4_paged_cache(ggml_backend_t backend, uint32_t slots, uint32_t max_ctx, uint32_t physical_blocks, DeepSeek4PagedCache & out); +// Copies the first n_tokens of a single-request cache (prefilled from position +// 0) into one paged slot: the raw SWA ring, the completed compressed and +// indexer rows through the slot's block table, and the compressor states. +// Both caches must come from the same weights. The slot's first n_tokens +// blocks must already be in block_table. +bool import_deepseek4_paged_slot(const DeepSeek4Cache & src, int n_tokens, + DeepSeek4PagedCache & dst, uint32_t slot, + const int32_t * block_table, uint32_t block_table_len, + std::string & error); void reset_deepseek4_paged_slot(DeepSeek4PagedCache & c, uint32_t slot); void free_deepseek4_paged_cache(DeepSeek4PagedCache & c); // Exact gathered-reference decode for up to six independent lanes. Inputs are diff --git a/server/src/deepseek4/deepseek4_paged_cache.cpp b/server/src/deepseek4/deepseek4_paged_cache.cpp index da8f2b8e0..1ee2af1ad 100644 --- a/server/src/deepseek4/deepseek4_paged_cache.cpp +++ b/server/src/deepseek4/deepseek4_paged_cache.cpp @@ -6,7 +6,10 @@ #include "deepseek4_internal.h" #endif +#include #include +#include +#include #include #include @@ -222,6 +225,78 @@ void reset_deepseek4_paged_slot(DeepSeek4PagedCache & c, uint32_t slot) { } } +bool import_deepseek4_paged_slot(const DeepSeek4Cache & src, int n_tokens, + DeepSeek4PagedCache & dst, uint32_t slot, + const int32_t * block_table, uint32_t block_table_len, + std::string & error) { + const auto fail = [&](const char * why) { error = why; return false; }; + if (!dst.buf || slot >= dst.plan.slots || n_tokens <= 0 || !block_table || + src.layers.size() != dst.layers.size()) return fail("invalid paged import request"); + const uint64_t blocks_needed = (uint64_t(n_tokens) + DS4_PAGE_TOKENS - 1) / DS4_PAGE_TOKENS; + if (blocks_needed > block_table_len) return fail("paged import exceeds the block table"); + for (uint64_t b = 0; b < blocks_needed; ++b) { + if (block_table[b] < 0 || uint32_t(block_table[b]) >= dst.plan.physical_blocks) + return fail("paged import block is not allocated"); + } + const auto same_rows = [](const ggml_tensor * a, const ggml_tensor * b) { + return a && b && a->type == b->type && a->ne[0] == b->ne[0] && a->nb[1] == b->nb[1]; + }; + // One whole slot plane of a [width, rows, slots] tensor from a [width, rows] one. + const auto copy_plane = [&](const ggml_tensor * from, ggml_tensor * to) { + if (!from && !to) return true; + if (!same_rows(from, to) || from->ne[1] != to->ne[1] || ggml_nbytes(from) != to->nb[2]) return false; + std::vector host(ggml_nbytes(from)); + ggml_backend_tensor_get(from, host.data(), 0, host.size()); + ggml_backend_tensor_set(to, host.data(), size_t(slot) * to->nb[2], host.size()); + return true; + }; + // Completed compression groups, chronological in src, paged in dst. + // Groups inside one logical block land on consecutive rows of its page. + const auto copy_groups = [&](const ggml_tensor * from, ggml_tensor * to, uint32_t ratio, int groups) { + if (!groups) return true; + if (!same_rows(from, to) || groups > from->ne[1]) return false; + std::vector host(size_t(groups) * from->nb[1]); + ggml_backend_tensor_get(from, host.data(), 0, host.size()); + const int per_block = int(DS4_PAGE_TOKENS / ratio); + for (int g = 0; g < groups;) { + const uint64_t end_token = uint64_t(g) * ratio + ratio - 1; + const uint64_t logical = end_token / DS4_PAGE_TOKENS; + uint64_t row = 0; bool emitted = false; + if (!ds4_compressed_page_row(end_token, uint32_t(block_table[logical]), ratio, row, emitted) || + !emitted || row >= uint64_t(to->ne[1])) return false; + const int run = std::min(groups - g, per_block - int((end_token % DS4_PAGE_TOKENS) / ratio)); + ggml_backend_tensor_set(to, host.data() + size_t(g) * from->nb[1], + size_t(row) * to->nb[1], size_t(run) * from->nb[1]); + g += run; + } + return true; + }; + for (size_t il = 0; il < dst.layers.size(); ++il) { + const DeepSeek4LayerCache & s = src.layers[il]; + DeepSeek4PagedLayerCache & d = dst.layers[il]; + // The single-request ring has the paged ring's 128 rows and the same + // position % 128 indexing, so the whole ring moves as one plane. + if (!same_rows(s.raw_kv, d.raw_kv) || s.raw_kv->ne[1] != int64_t(DS4_PAGE_TOKENS) || + ggml_nbytes(s.raw_kv) != d.raw_kv->nb[2]) return fail("raw ring layouts differ"); + if (!copy_plane(s.raw_kv, d.raw_kv)) return fail("raw ring copy failed"); + if (!d.ratio) continue; + const int groups = n_tokens / int(d.ratio); + if (s.n_comp != groups) return fail("compressed row count does not match the prefix"); + if (!copy_groups(s.comp_kv, d.comp_kv, d.ratio, groups)) return fail("compressed row copy failed"); + if (!copy_plane(s.attn_compressor.state_kv, d.attn_compressor.state_kv) || + !copy_plane(s.attn_compressor.state_score, d.attn_compressor.state_score)) + return fail("compressor state copy failed"); + if (d.index_comp_kv) { + if (s.n_index_comp != groups) return fail("indexer row count does not match the prefix"); + if (!copy_groups(s.index_comp_kv, d.index_comp_kv, d.ratio, groups) || + !copy_plane(s.indexer_compressor.state_kv, d.indexer_compressor.state_kv) || + !copy_plane(s.indexer_compressor.state_score, d.indexer_compressor.state_score)) + return fail("indexer copy failed"); + } + } + return true; +} + void free_deepseek4_paged_cache(DeepSeek4PagedCache & c) { deepseek4_release_paged_gathered_runtime(c); if (c.buf) { ggml_backend_buffer_free(c.buf); c.buf = nullptr; } diff --git a/server/src/deepseek4/deepseek4_seq_engine.cpp b/server/src/deepseek4/deepseek4_seq_engine.cpp index a8856f6fe..10f7b8923 100644 --- a/server/src/deepseek4/deepseek4_seq_engine.cpp +++ b/server/src/deepseek4/deepseek4_seq_engine.cpp @@ -80,6 +80,50 @@ SeqEngine::AdmitResult DeepSeek4SeqEngine::admit( return result; } +bool DeepSeek4SeqEngine::supports_images() const { + return b_.image_capable_ && b_.vision_ && b_.cache_.buf; +} + +SeqEngine::AdmitResult DeepSeek4SeqEngine::admit_images( + uint64_t request_id, const std::vector & prompt, + const SamplerCfg & sampler, const ImagePromptHandle & images) { + AdmitResult refused; + refused.status = AdmitResult::Status::failed; + if (!supports_images() || prompt.size() < 2) { + refused.error = "image support or prompt length is invalid"; + return refused; + } + // DS4V image blocks need whole-block bidirectional prefill, which the + // 16-row gathered graph cannot run. Prefill every token but the last on + // the single-request sparse path into the staging cache, copy that state + // into the slot, and let the paged engine prefill the final (text) token, + // which yields the first sampled token through the normal step. + const int prefix = int(prompt.size()) - 1; + std::string error; + if (!b_.prefill_image_prefix(prompt, images, prefix, error)) { + refused.error = error.empty() ? "image prefill failed" : error; + return refused; + } + AdmitResult result = admit(request_id, prompt, sampler); + if (result.status != AdmitResult::Status::admitted) return result; + SeqSlotManager::PrefillChunk seeded = slots_.seed_restored_prefix(result.slot, prefix); + bool ok = seeded.ok && seeded.rows.size() == size_t(prefix); + for (size_t i = 0; ok && i < seeded.new_blocks.size(); ++i) { + ok = set_block(result.slot, seeded.first_new_block + int(i), seeded.new_blocks[i]); + } + if (ok) { + ok = import_deepseek4_paged_slot( + b_.cache_, prefix, b_.paged_cache_, uint32_t(result.slot), + host_tables_.data() + size_t(result.slot) * stride_, stride_, error); + } + if (!ok) { + retire(result.slot); + refused.error = error.empty() ? "image prefix could not be seeded into the paged slot" : error; + return refused; + } + return result; +} + bool DeepSeek4SeqEngine::set_block(int slot, int logical, int32_t physical) { if (slot < 0 || slot >= slots_.slot_count() || logical < 0 || (uint32_t) logical >= stride_) return false; diff --git a/server/src/deepseek4/deepseek4_seq_engine.h b/server/src/deepseek4/deepseek4_seq_engine.h index cec1d0d2d..03a413960 100644 --- a/server/src/deepseek4/deepseek4_seq_engine.h +++ b/server/src/deepseek4/deepseek4_seq_engine.h @@ -38,6 +38,11 @@ class DeepSeek4SeqEngine final : public SeqEngine { } void retire(int slot) override; bool token_is_eos(int32_t token) const override; + bool supports_images() const override; + AdmitResult admit_images(uint64_t request_id, + const std::vector & prompt, + const SamplerCfg & sampler, + const ImagePromptHandle & images) override; private: bool set_block(int slot, int logical, int32_t physical); From d2adbebe4c61053ba9af0526f8699697e8e514a3 Mon Sep 17 00:00:00 2001 From: mrciffa Date: Wed, 23 Sep 2026 19:11:05 +0200 Subject: [PATCH 06/12] 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) --- server/src/deepseek4/deepseek4_backend.cpp | 94 +++++-- server/src/deepseek4/deepseek4_backend.h | 19 +- server/src/deepseek4/deepseek4_graph.cpp | 235 ++++++++++++++++++ server/src/deepseek4/deepseek4_internal.h | 22 ++ server/src/deepseek4/deepseek4_seq_engine.cpp | 81 ++++-- server/src/deepseek4/deepseek4_seq_engine.h | 17 ++ 6 files changed, 434 insertions(+), 34 deletions(-) diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index 5df7d3162..e08c94b25 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -1149,27 +1149,91 @@ bool DeepSeek4Backend::prepare_images( } } -bool DeepSeek4Backend::prefill_image_prefix(const std::vector & prompt, - const ImagePromptHandle & handle, - int prefix_tokens, std::string & error) { +bool DeepSeek4Backend::encode_image_request(const std::vector & prompt, + const ImagePromptHandle & handle, std::string & error) { const auto * images = dynamic_cast(handle.get()); - if (!images || images->owner_ != this || !images->matches(prompt) || !cache_.buf || - prefix_tokens <= 0 || prefix_tokens >= int(prompt.size()) || - prompt.size() > size_t(cache_.max_ctx)) { - error = "image binding, staging cache, or prompt length is invalid"; + if (!images || images->owner_ != this || !images->matches(prompt)) { + error = "image binding does not match this prompt"; return false; } DaemonIO io; - struct ImageStreamJoin { - DeepSeek4Backend * backend; - ~ImageStreamJoin() { backend->join_image_stream(); } - } join{this}; - if (!materialize_images(*images, io, error)) return false; - if (do_prefill(prompt, io, 0, -1, -1, images, prefix_tokens) != prefix_tokens) { - if (error.empty()) error = "image prefix prefill failed"; + const auto t0 = Clock::now(); + const bool ok = materialize_images(*images, io, error); + join_image_stream(); + if (ok && !images->complete()) { + error = "image encoding did not complete"; return false; } - return true; + std::fprintf(stderr, "[deepseek4] batched image request encoded in %.0f ms\n", elapsed_s(t0) * 1000.0); + return ok; +} + +void DeepSeek4Backend::prefill_staged(std::vector & batch) { + // Embeddings for every request's prefix (image rows + text rows). + struct Seq { StagedPrefill * item; const DeepSeek4ImagePrompt * images; std::vector embed; int done = 0; }; + std::vector seqs; + for (auto & item : batch) { + const auto * images = dynamic_cast(item.images.get()); + if (!images || !item.prompt || !item.staging || item.prefix < 5 || + item.prefix > int(item.prompt->size()) || item.prefix > item.staging->max_ctx) { + item.error = "invalid staged prefill request"; + continue; + } + std::vector embed(size_t(item.prefix) * size_t(w_.n_embd)); + if (!images->embed_chunk(w_.embedder, 0, item.prefix, embed.data())) { + item.error = "staged prefill embedding failed"; + continue; + } + reset_deepseek4_cache(*item.staging); + item.staging->prefill_mode = PrefillAttentionMode::Sparse; + seqs.push_back({&item, images, std::move(embed), 0}); + } + ggml_backend_synchronize(backend_); + deepseek4_release_image_scratch(cache_, moe_hybrid_.get()); + const int budget_total = std::min(1024, DS4_MAX_LAYER_MAJOR_PREFILL_TOKENS); + const auto t0 = Clock::now(); + int passes = 0, rows = 0; + for (;;) { + // One pass takes the next chunk of every unfinished request that fits: + // whole image blocks only, and never leave a tail shorter than 5 rows. + std::vector pass; + std::vector members; + int budget = budget_total; + for (auto & s : seqs) { + const int remaining = s.item->prefix - s.done; + if (remaining <= 0 || !s.item->error.empty() || budget < 5) continue; + int n = std::min(remaining, budget); + if (remaining - n > 0 && remaining - n < 5) n = std::max(0, remaining - 5); + n = n >= 5 ? vision::atomic_image_chunk(s.images->spans(), uint64_t(s.done), n, + uint64_t(remaining), budget) : 0; + if (n < 5) continue; + DeepSeek4PrefillSeq seq; + seq.cache = s.item->staging; + seq.embed = s.embed.data() + size_t(s.done) * size_t(w_.n_embd); + seq.token_ids = s.item->prompt->data() + s.done; + seq.n_tokens = n; + seq.kv_start = s.done; + seq.image_spans = s.images->spans(); + pass.push_back(seq); + members.push_back(&s); + budget -= n; + } + if (pass.empty()) break; + std::string error; + if (!deepseek4_prefill_multi(backend_, cfg_.device.gpu, w_, pass, error)) { + for (Seq * m : members) m->item->error = error.empty() ? "staged prefill failed" : error; + continue; + } + for (size_t k = 0; k < members.size(); ++k) members[k]->done += pass[k].n_tokens; + ++passes; + for (const auto & p : pass) rows += p.n_tokens; + } + for (auto & s : seqs) { + if (s.item->error.empty() && s.done != s.item->prefix) s.item->error = "staged prefill could not be chunked"; + s.item->ok = s.item->error.empty(); + } + std::fprintf(stderr, "[deepseek4] staged prefill: %zu requests, %d rows in %d shared passes, %.0f ms\n", + seqs.size(), rows, passes, elapsed_s(t0) * 1000.0); } void DeepSeek4Backend::join_image_stream() { diff --git a/server/src/deepseek4/deepseek4_backend.h b/server/src/deepseek4/deepseek4_backend.h index 73b1513ba..adba4c478 100644 --- a/server/src/deepseek4/deepseek4_backend.h +++ b/server/src/deepseek4/deepseek4_backend.h @@ -207,10 +207,21 @@ class DeepSeek4Backend : public ModelBackend { bool init_single_gpu_vision(); // Waits for a streaming image encode started by materialize_images. void join_image_stream(); - // Batched serving: encode an image request and prefill its first - // prefix_tokens into the single-request staging cache (cache_). - bool prefill_image_prefix(const std::vector & prompt, const ImagePromptHandle & images, - int prefix_tokens, std::string & error); + // Batched serving. encode_image_request materializes an image request's + // rows; prefill_staged fills each request's first `prefix` tokens into + // its own staging cache in shared layer-major passes (expert weights read + // once per pass for every request in it). + struct StagedPrefill { + ImagePromptHandle images; + const std::vector * prompt = nullptr; + int prefix = 0; + DeepSeek4Cache * staging = nullptr; + bool ok = false; + std::string error; + }; + bool encode_image_request(const std::vector & prompt, const ImagePromptHandle & images, + std::string & error); + void prefill_staged(std::vector & batch); bool materialize_images(const DeepSeek4ImagePrompt & images, const DaemonIO & io, std::string & error); diff --git a/server/src/deepseek4/deepseek4_graph.cpp b/server/src/deepseek4/deepseek4_graph.cpp index cb91d7387..d5d05f852 100644 --- a/server/src/deepseek4/deepseek4_graph.cpp +++ b/server/src/deepseek4/deepseek4_graph.cpp @@ -7739,6 +7739,241 @@ static int ds4_try_layer_major_prefill( return (out_logits && out_logits->empty()) ? -1 : 1; } + +static bool initialize_layer_range_cache( + DeepSeek4LayerRangeCache & runtime, ggml_backend_t backend, int device, + const DeepSeek4Weights & w, int layer_begin, int layer_end, bool owns_output); + +bool deepseek4_prefill_multi(ggml_backend_t backend, int device, + const DeepSeek4Weights & w, + const std::vector & seqs, + std::string & error) { + const auto fail_early = [&](const char * why) { error = why; return false; }; + if (!backend || seqs.empty() || w.moe_hybrid || !ds4_backend_is_gpu(backend)) + return fail_early("shared prefill needs a full model on one GPU"); + int total = 0; + for (const auto & s : seqs) { + if (!s.cache || !s.embed || !s.token_ids || s.n_tokens < 5 || s.kv_start < 0 || + s.cache->prefill_mode == PrefillAttentionMode::Exact || + s.kv_start + s.n_tokens > s.cache->max_ctx) + return fail_early("invalid shared prefill sequence"); + total += s.n_tokens; + } + if (total > DS4_MAX_LAYER_MAJOR_PREFILL_TOKENS) return fail_early("shared prefill exceeds the pass size"); + + // Runtime (HC weights, hash tables, HC mirrors) from the first cache. + DeepSeek4Cache & owner = *seqs.front().cache; + if (!owner.layer_range_cache) owner.layer_range_cache = new DeepSeek4LayerRangeCache(); + DeepSeek4LayerRangeCache & runtime = *owner.layer_range_cache; + if (!runtime.matches(w, backend, device, 0, w.n_layer, true) && + !initialize_layer_range_cache(runtime, backend, device, w, 0, w.n_layer, true)) + return fail_early("layer runtime initialization failed"); + auto & fc = runtime.fused_decode_graph_cache; + const auto & hc_weights = runtime.hc_layer_weights; + const auto & hc_out_weights = runtime.hc_output_weights; + const auto & hash_tables = runtime.hash_routing_tables; + if (fc.owner_ctx != w.ctx || fc.backend != backend) { + fc.destroy(); fc.owner_ctx = w.ctx; fc.backend = backend; + } + if (!ds4_fused_ensure_fn_mirrors(fc, backend, w, hc_weights, hc_out_weights)) + return fail_early("HC weight mirrors failed"); + + const int n_embd = w.n_embd, n_hc = w.n_hc; + const int64_t hc_dim = (int64_t) n_embd * n_hc; + const int64_t mix_dim = 2 * (int64_t) n_hc + (int64_t) n_hc * n_hc; + + // Row offsets, concatenated ids and "is image row" flags for the pass. + std::vector offset(seqs.size()); + std::vector ids((size_t) total); + std::vector image_row((size_t) total, 0); + bool any_image = false; + for (size_t k = 0, off = 0; k < seqs.size(); off += (size_t) seqs[k].n_tokens, ++k) { + offset[k] = (int) off; + std::copy_n(seqs[k].token_ids, seqs[k].n_tokens, ids.begin() + (ptrdiff_t) off); + for (int t = 0; t < seqs[k].n_tokens; ++t) { + if (vision::image_block_at(seqs[k].image_spans, uint64_t(seqs[k].kv_start + t))) { + image_row[off + (size_t) t] = 1; + any_image = true; + } + } + } + + ggml_init_params state_params{}; + state_params.mem_size = 4 * ggml_tensor_overhead() + 4096; + state_params.no_alloc = true; + ggml_context * state_ctx = ggml_init(state_params); + if (!state_ctx) return fail_early("state context failed"); + ggml_tensor * state_a = ggml_new_tensor_2d(state_ctx, GGML_TYPE_F32, hc_dim, total); + ggml_tensor * state_b = ggml_new_tensor_2d(state_ctx, GGML_TYPE_F32, hc_dim, total); + ggml_backend_buffer_t state_buf = ggml_backend_alloc_ctx_tensors(state_ctx, backend); + if (!state_buf) { ggml_free(state_ctx); return fail_early("state allocation failed"); } + { + std::vector initial((size_t) hc_dim * total); + for (size_t k = 0; k < seqs.size(); ++k) { + for (int t = 0; t < seqs[k].n_tokens; ++t) { + float * dst = initial.data() + (size_t) (offset[k] + t) * hc_dim; + for (int h = 0; h < n_hc; ++h) { + std::memcpy(dst + (size_t) h * n_embd, seqs[k].embed + (size_t) t * n_embd, + sizeof(float) * (size_t) n_embd); + } + } + } + ggml_backend_tensor_set(state_a, initial.data(), 0, sizeof(float) * initial.size()); + } + ggml_gallocr_t alloc = ds4_layer_major_get_shared_alloc(w, backend); + const size_t meta_bytes = 160u * 1024 * 1024; + if (ds4_layer_major_meta_owner != w.ctx) { + ds4_layer_major_meta_arena.clear(); + ds4_layer_major_meta_arena.shrink_to_fit(); + ds4_layer_major_meta_owner = w.ctx; + } + if (ds4_layer_major_meta_arena.size() < meta_bytes) ds4_layer_major_meta_arena.resize(meta_bytes); + auto fail = [&](const char * what, int il) { + std::fprintf(stderr, "[deepseek4-prefill-multi] %s at layer %d\n", what, il); + ggml_backend_buffer_free(state_buf); + ggml_free(state_ctx); + error = what; + return false; + }; + if (!alloc) return fail("shared allocator unavailable", -1); + + std::vector hash_scratch; + ggml_tensor * state_in = state_a; + ggml_tensor * state_out = state_b; + for (int il = 0; il < w.n_layer; ++il) { + ggml_init_params params{}; + params.mem_size = ds4_layer_major_meta_arena.size(); + params.mem_buffer = ds4_layer_major_meta_arena.data(); + params.no_alloc = true; + ggml_context * ctx = ggml_init(params); + if (!ctx) return fail("metadata allocation failed", il); + ggml_cgraph * gf = ggml_new_graph_custom(ctx, 65536, false); + const DeepSeek4Layer & L = w.layers[(size_t) il]; + const HcLayerWeightsCpu & hlw = hc_weights[(size_t) il]; + + // HC pre over every row of every sequence. + ggml_tensor * norm_hc = ggml_rms_norm(ctx, state_in, w.hc_eps); + ggml_tensor * mix_attn = ggml_reshape_2d(ctx, ggml_mul_mat(ctx, fc.fn_attn_f16[(size_t) il], norm_hc), + mix_dim, total); + ggml_tensor * pre_attn = ggml_ds4_hc_pre( + ctx, mix_attn, ds4_fused_hc_base_f32(ctx, L.hc_attn_base), state_in, n_hc, + w.n_hc_sinkhorn_iter, hlw.attn.scale_data[0], hlw.attn.scale_data[1], hlw.attn.scale_data[2]); + ggml_tensor * attn_in = ggml_view_2d(ctx, pre_attn, n_embd, total, pre_attn->nb[1], 0); + ggml_tensor * split_attn = ggml_view_2d(ctx, pre_attn, mix_dim, total, pre_attn->nb[1], + (size_t) n_embd * sizeof(float)); + ggml_tensor * attn_normed = ggml_cont(ctx, build_rms_norm(ctx, attn_in, L.attn_norm, w.rms_eps)); + + // Attention per sequence, each against its own cache. + std::vector i32_inputs; + std::vector i32_array_inputs; + std::vector i64_array_inputs; + std::vector f32_array_inputs; + ggml_tensor * attn_out = nullptr; + for (size_t k = 0; k < seqs.size(); ++k) { + const DeepSeek4PrefillSeq & s = seqs[k]; + ggml_tensor * rows = ggml_view_2d(ctx, attn_normed, n_embd, s.n_tokens, attn_normed->nb[1], + (size_t) offset[k] * attn_normed->nb[1]); + ggml_tensor * out = build_mla_attention( + ctx, gf, rows, w, L, s.cache->layers[(size_t) il], il, s.kv_start, s.n_tokens, + nullptr, i32_inputs, i32_array_inputs, i64_array_inputs, &f32_array_inputs, + DeepSeek4AttentionImpl::SparseFlash, /*boundary_checkpoint=*/nullptr, s.image_spans); + if (!out) { ggml_free(ctx); return fail("attention graph build failed", il); } + attn_out = attn_out ? ggml_concat(ctx, attn_out, out, 1) : out; + } + ggml_tensor * hc_after_attn = ggml_ds4_hc_post(ctx, state_in, attn_out, split_attn, n_hc); + + // HC pre -> one MoE FFN over all rows. + norm_hc = ggml_rms_norm(ctx, hc_after_attn, w.hc_eps); + ggml_tensor * mix_ffn = ggml_reshape_2d(ctx, ggml_mul_mat(ctx, fc.fn_ffn_f16[(size_t) il], norm_hc), + mix_dim, total); + ggml_tensor * pre_ffn = ggml_ds4_hc_pre( + ctx, mix_ffn, ds4_fused_hc_base_f32(ctx, L.hc_ffn_base), hc_after_attn, n_hc, + w.n_hc_sinkhorn_iter, hlw.ffn.scale_data[0], hlw.ffn.scale_data[1], hlw.ffn.scale_data[2]); + ggml_tensor * ffn_in = ggml_view_2d(ctx, pre_ffn, n_embd, total, pre_ffn->nb[1], 0); + ggml_tensor * split_ffn = ggml_view_2d(ctx, pre_ffn, mix_dim, total, pre_ffn->nb[1], + (size_t) n_embd * sizeof(float)); + ggml_tensor * ffn_normed = build_rms_norm(ctx, ffn_in, L.ffn_norm, w.rms_eps); + const bool hash_routed = il < w.n_hash_layer && L.ffn_gate_tid2eid && hash_tables[(size_t) il].loaded; + ggml_tensor * selection_bias = nullptr; + ggml_tensor * hash_ids = nullptr; + ggml_tensor * ffn_out = nullptr; + if (any_image) { + if (!L.ffn_gate_bias_vl) { ggml_free(ctx); return fail("image rows without an image router bias", il); } + selection_bias = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, w.n_expert, total); + ggml_set_input(selection_bias); + ffn_out = build_moe_ffn(ctx, ffn_normed, w, L, il, total, selection_bias); + } else if (hash_routed) { + hash_ids = ggml_new_tensor_2d(ctx, GGML_TYPE_I32, w.n_expert_used, total); + ggml_set_input(hash_ids); + ffn_out = ds4_build_hash_routed_ffn(ctx, w, L, ffn_normed, hash_ids, total); + } else { + ffn_out = build_moe_ffn(ctx, ffn_normed, w, L, il, total); + } + if (!ffn_out) { ggml_free(ctx); return fail("FFN graph build failed", il); } + ggml_tensor * hc_next = ggml_ds4_hc_post(ctx, hc_after_attn, ffn_out, split_ffn, n_hc); + ggml_tensor * state_copy = ggml_cpy(ctx, hc_next, state_out); + ggml_set_output(state_copy); + ggml_build_forward_expand(gf, state_copy); + + if (!ggml_gallocr_alloc_graph(alloc, gf)) { ggml_free(ctx); return fail("scratch allocation failed", il); } + for (const auto & b : i32_inputs) ggml_backend_tensor_set(b.tensor, &b.value, 0, sizeof(b.value)); + for (const auto & b : i32_array_inputs) + ggml_backend_tensor_set(b.tensor, b.values.data(), 0, sizeof(int32_t) * b.values.size()); + for (const auto & b : i64_array_inputs) + ggml_backend_tensor_set(b.tensor, b.values.data(), 0, sizeof(int64_t) * b.values.size()); + for (const auto & b : f32_array_inputs) + ggml_backend_tensor_set(b.tensor, b.values.data(), 0, sizeof(float) * b.values.size()); + if (hash_ids) { + const int n_used = w.n_expert_used; + hash_scratch.resize((size_t) n_used * total); + const auto & table = hash_tables[(size_t) il].ids; + for (int t = 0; t < total; ++t) { + std::memcpy(hash_scratch.data() + (size_t) t * n_used, table.data() + (size_t) ids[(size_t) t] * n_used, + sizeof(int32_t) * (size_t) n_used); + } + ggml_backend_tensor_set(hash_ids, hash_scratch.data(), 0, sizeof(int32_t) * hash_scratch.size()); + } + if (selection_bias) { + // Same rule as the single-sequence image path: image rows take the + // image router bias; text rows the layer bias, and on hash-routed + // layers a large bias on exactly the experts the table names. + constexpr float HASH_PICK = 1.0e4f; + const size_t n_expert = (size_t) w.n_expert; + std::vector image_bias(n_expert), text_bias(n_expert, 0.0f); + ggml_backend_tensor_get(L.ffn_gate_bias_vl, image_bias.data(), 0, sizeof(float) * n_expert); + if (!hash_routed && L.ffn_exp_probs_b) + ggml_backend_tensor_get(L.ffn_exp_probs_b, text_bias.data(), 0, sizeof(float) * n_expert); + std::vector bias(n_expert * (size_t) total); + for (int t = 0; t < total; ++t) { + float * row = bias.data() + (size_t) t * n_expert; + if (image_row[(size_t) t]) { std::copy(image_bias.begin(), image_bias.end(), row); continue; } + std::copy(text_bias.begin(), text_bias.end(), row); + if (hash_routed) { + const int32_t * picks = hash_tables[(size_t) il].ids.data() + + (size_t) ids[(size_t) t] * (size_t) w.n_expert_used; + for (int j = 0; j < w.n_expert_used; ++j) row[picks[j]] = HASH_PICK; + } + } + ggml_backend_tensor_set(selection_bias, bias.data(), 0, sizeof(float) * bias.size()); + } + if (ggml_backend_graph_compute(backend, gf) != GGML_STATUS_SUCCESS) { ggml_free(ctx); return fail("compute failed", il); } + ggml_free(ctx); + const int ratio = (int) w.compress_ratios[(size_t) il]; + for (const auto & s : seqs) { + if (ratio <= 0) continue; + DeepSeek4LayerCache & lc = s.cache->layers[(size_t) il]; + const int next_pos = s.kv_start + s.n_tokens; + lc.n_comp = std::max(lc.n_comp, next_pos / ratio); + if (ratio == 4) lc.n_index_comp = std::max(lc.n_index_comp, next_pos / ratio); + } + std::swap(state_in, state_out); + } + for (const auto & s : seqs) s.cache->cur_pos = s.kv_start + s.n_tokens; + ggml_backend_buffer_free(state_buf); + ggml_free(state_ctx); + return true; +} + static bool ds4_hc_layer_weights_ready(const HcWeightsCpu & weights, int n_embd, int n_hc) { diff --git a/server/src/deepseek4/deepseek4_internal.h b/server/src/deepseek4/deepseek4_internal.h index cc85597f1..e97a6cd4d 100644 --- a/server/src/deepseek4/deepseek4_internal.h +++ b/server/src/deepseek4/deepseek4_internal.h @@ -574,6 +574,28 @@ bool deepseek4_step_layer_range( MoeHybridRoutingStats * routing_stats = nullptr, vision::ImageSpanView image_spans = {}); +// One sequence of a shared prefill pass: `n_tokens` rows of `embed` starting +// at `kv_start` of `cache`, with the sequence's image spans in its own prompt +// positions. +struct DeepSeek4PrefillSeq { + DeepSeek4Cache * cache = nullptr; + const float * embed = nullptr; // [n_tokens, n_embd] + const int32_t * token_ids = nullptr; // n_tokens ids (image rows use their marker ids) + int n_tokens = 0; + int kv_start = 0; + vision::ImageSpanView image_spans; +}; + +// Prefills several independent sequences in one layer-major pass on a full +// (non-hybrid) GPU model with sparse attention. Attention runs per sequence +// against its own cache; the HC mixing and the MoE FFN run once over all the +// sequences' rows, so every layer's expert weights are read once for all of +// them. Produces no logits and no feature capture. +bool deepseek4_prefill_multi(ggml_backend_t backend, int device, + const DeepSeek4Weights & w, + const std::vector & seqs, + std::string & error); + bool deepseek4_validate_image_batch( const DeepSeek4Weights & w, const DeepSeek4Cache & cache, const MoeHybridStorage * hybrid, const int32_t * tokens, diff --git a/server/src/deepseek4/deepseek4_seq_engine.cpp b/server/src/deepseek4/deepseek4_seq_engine.cpp index 10f7b8923..088447b54 100644 --- a/server/src/deepseek4/deepseek4_seq_engine.cpp +++ b/server/src/deepseek4/deepseek4_seq_engine.cpp @@ -80,6 +80,12 @@ SeqEngine::AdmitResult DeepSeek4SeqEngine::admit( return result; } +DeepSeek4SeqEngine::~DeepSeek4SeqEngine() { + for (auto & cache : staging_caches_) { + if (cache) free_deepseek4_cache(*cache); + } +} + bool DeepSeek4SeqEngine::supports_images() const { return b_.image_capable_ && b_.vision_ && b_.cache_.buf; } @@ -89,21 +95,22 @@ SeqEngine::AdmitResult DeepSeek4SeqEngine::admit_images( const SamplerCfg & sampler, const ImagePromptHandle & images) { AdmitResult refused; refused.status = AdmitResult::Status::failed; - if (!supports_images() || prompt.size() < 2) { + // DS4V image blocks need whole-block bidirectional prefill, which the + // 16-row gathered graph cannot run. Admission encodes the images and seeds + // the slot with every prompt token but the last; the next step() prefills + // all pending image requests together on the layer-major sparse path and + // copies their state into the slots, and the last (text) token then + // prefills in the batch, yielding the first sampled token as usual. + if (!supports_images() || prompt.size() < 6 || prompt.size() > size_t(b_.cache_.max_ctx)) { refused.error = "image support or prompt length is invalid"; return refused; } - // DS4V image blocks need whole-block bidirectional prefill, which the - // 16-row gathered graph cannot run. Prefill every token but the last on - // the single-request sparse path into the staging cache, copy that state - // into the slot, and let the paged engine prefill the final (text) token, - // which yields the first sampled token through the normal step. - const int prefix = int(prompt.size()) - 1; std::string error; - if (!b_.prefill_image_prefix(prompt, images, prefix, error)) { - refused.error = error.empty() ? "image prefill failed" : error; + if (!b_.encode_image_request(prompt, images, error)) { + refused.error = error.empty() ? "image encoding failed" : error; return refused; } + const int prefix = int(prompt.size()) - 1; AdmitResult result = admit(request_id, prompt, sampler); if (result.status != AdmitResult::Status::admitted) return result; SeqSlotManager::PrefillChunk seeded = slots_.seed_restored_prefix(result.slot, prefix); @@ -111,19 +118,56 @@ SeqEngine::AdmitResult DeepSeek4SeqEngine::admit_images( for (size_t i = 0; ok && i < seeded.new_blocks.size(); ++i) { ok = set_block(result.slot, seeded.first_new_block + int(i), seeded.new_blocks[i]); } - if (ok) { - ok = import_deepseek4_paged_slot( - b_.cache_, prefix, b_.paged_cache_, uint32_t(result.slot), - host_tables_.data() + size_t(result.slot) * stride_, stride_, error); - } if (!ok) { retire(result.slot); - refused.error = error.empty() ? "image prefix could not be seeded into the paged slot" : error; + refused.error = "image prefix could not be seeded into the paged slot"; return refused; } + pending_images_.push_back({result.slot, images, prompt, prefix}); return result; } +void DeepSeek4SeqEngine::run_pending_images(std::vector & failures, + std::vector & failed_slots) { + if (pending_images_.empty()) return; + std::vector batch(pending_images_.size()); + for (size_t k = 0; k < pending_images_.size(); ++k) { + // Staging cache k: the backend's own for the first request, then + // one more per concurrent request (allocated once, reused). + DeepSeek4Cache * staging = &b_.cache_; + if (k > 0) { + while (staging_caches_.size() < k) staging_caches_.emplace_back(); + auto & owned = staging_caches_[k - 1]; + if (!owned) { + owned = std::make_unique(); + if (!create_deepseek4_cache(b_.backend_, b_.w_, b_.cache_.max_ctx, *owned)) owned.reset(); + } + staging = owned.get(); + } + batch[k].images = pending_images_[k].images; + batch[k].prompt = &pending_images_[k].prompt; + batch[k].prefix = pending_images_[k].prefix; + batch[k].staging = staging; + if (!staging) batch[k].error = "staging cache allocation failed"; + } + b_.prefill_staged(batch); + for (size_t k = 0; k < batch.size(); ++k) { + const PendingImage & p = pending_images_[k]; + std::string error = batch[k].error; + bool ok = batch[k].ok; + if (ok) { + ok = import_deepseek4_paged_slot( + *batch[k].staging, p.prefix, b_.paged_cache_, uint32_t(p.slot), + host_tables_.data() + size_t(p.slot) * stride_, stride_, error); + } + if (!ok) { + fail_prefill(p.slot, failures, error.empty() ? "staged image prefill failed" : error); + if (p.slot >= 0 && p.slot < int(failed_slots.size())) failed_slots[size_t(p.slot)] = 1; + } + } + pending_images_.clear(); +} + bool DeepSeek4SeqEngine::set_block(int slot, int logical, int32_t physical) { if (slot < 0 || slot >= slots_.slot_count() || logical < 0 || (uint32_t) logical >= stride_) return false; @@ -194,6 +238,9 @@ SeqEngine::StepResult DeepSeek4SeqEngine::step(const StepPlan & plan) { } if (inputs.empty() && plan.prefills.empty()) return result; + std::vector image_failed((size_t)n_slots, 0); + run_pending_images(result.prefills, image_failed); + std::vector lane_tokens; std::vector lane_positions; std::vector lane_slots; @@ -244,6 +291,7 @@ SeqEngine::StepResult DeepSeek4SeqEngine::step(const StepPlan & plan) { std::vector prefill_lanes; prefill_lanes.reserve(plan.prefills.size()); for (const PrefillSlice & slice : plan.prefills) { + if (image_failed[(size_t)slice.slot]) continue; const SeqSlot & before = slots_.slot(slice.slot); const int remaining = before.prompt_len - before.cur_pos; const int n_rows = std::min(slice.max_tokens, remaining); @@ -413,6 +461,9 @@ bool DeepSeek4SeqEngine::evict_kv(int slot, int32_t pending_token, } void DeepSeek4SeqEngine::retire(int slot) { + pending_images_.erase(std::remove_if(pending_images_.begin(), pending_images_.end(), + [slot](const PendingImage & p) { return p.slot == slot; }), + pending_images_.end()); offload_.discard(slot); if (!slots_.is_active(slot)) return; slots_.retire(slot); diff --git a/server/src/deepseek4/deepseek4_seq_engine.h b/server/src/deepseek4/deepseek4_seq_engine.h index 03a413960..c10126622 100644 --- a/server/src/deepseek4/deepseek4_seq_engine.h +++ b/server/src/deepseek4/deepseek4_seq_engine.h @@ -5,11 +5,14 @@ #include "common/concurrency/seq_slot_manager.h" #include +#include +#include #include namespace luce::common { class DeepSeek4Backend; +struct DeepSeek4Cache; // Exact concurrent serving path for DeepSeek4. Model state remains in // DeepSeek4PagedCache; this class owns only scheduler-facing slot state and @@ -18,6 +21,7 @@ class DeepSeek4SeqEngine final : public SeqEngine { public: DeepSeek4SeqEngine(DeepSeek4Backend & backend, PagedKvPool & pool, int max_ctx, uint32_t table_stride); + ~DeepSeek4SeqEngine() override; int slot_count() const override { return slots_.slot_count(); } int max_context() const override { return slots_.max_context(); } @@ -49,6 +53,19 @@ class DeepSeek4SeqEngine final : public SeqEngine { void fail_prefill(int slot, std::vector & outputs, const std::string & error); + // Image requests admitted since the last step: their slots hold the + // prompt minus its last token as seeded blocks, and step() fills those + // blocks from shared staged prefill passes before running the batch. + struct PendingImage { + int slot = -1; + ImagePromptHandle images; + std::vector prompt; + int prefix = 0; + }; + std::vector pending_images_; + std::vector> staging_caches_; + void run_pending_images(std::vector & failures, std::vector & failed_slots); + DeepSeek4Backend & b_; SeqSlotManager slots_; PagedKvOffload offload_; From c2b02a3974a1852290cab91c33197f503bd652eb Mon Sep 17 00:00:00 2001 From: mrciffa Date: Thu, 24 Sep 2026 13:31:02 +0200 Subject: [PATCH 07/12] 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) --- docs/image-input.md | 12 ++++++++---- server/src/common/vision/image_spans.h | 11 +++++++++++ server/src/deepseek4/deepseek4_backend.cpp | 17 +++++++---------- server/src/server/http_server.cpp | 5 +++-- server/test/test_ds4v_image_integration.cpp | 5 +++++ 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index 614d04a4a..930df908b 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -148,7 +148,8 @@ Lucebox `Qwen3.8-27B-IQ4_XS-pure` file and a Q8_0 projector: - Image requests decode with the drafter. On 12 images with 256-token answers: 4.0 s per answer (76 tok/s after the first token), against 5.5 s for llama.cpp with the same drafter (`--spec-type draft-dflash`) and 8.4 s - without one; faster on every image, 1.21x to 1.58x. The 220-question score + without one; faster than llama.cpp with the drafter on every image, 1.21x + to 1.58x. The 220-question score is unchanged (188, 218 answers identical to plain decode). With unsloth's UD-IQ4_XS file and the published BF16 projector: @@ -156,7 +157,8 @@ With unsloth's UD-IQ4_XS file and the published BF16 projector: - 220 seeded questions from `lmms-lab/ai2d` and `lmms-lab/ChartQA` with lmms-eval prompts: AI2D 85/100, ChartQA relaxed accuracy 55/60 (augmented) and 43/60 (human), no errors. Image prompts average 448 tokens and prefill in - 0.71 s (largest 1,068 tokens, 1.8 s); decode runs at 31 to 35 tok/s. + 0.71 s (largest 1,068 tokens, 1.8 s); decode runs at 31 to 35 tok/s (plain + decode, measured before image requests used the drafter). - A projector with its weight matrices in Q8_0 (rows that are not a multiple of 32 stay F16) encodes a 975-token image in 443 ms instead of 677 ms with the BF16 file, with the same scores on the 220 questions and 216 identical @@ -171,7 +173,8 @@ With unsloth's UD-IQ4_XS file and the published BF16 projector: tokens). The projector adds 0.9 GiB of VRAM; the peak during image requests was 21.6 GiB against 20.8 GiB for text. - The same requests answer correctly on a Strix Halo alone, where a - 1,012-token image prompt prefills in 4.6 s and decodes at 14 tok/s. + 1,012-token image prompt prefills in 4.6 s and decodes at 14 tok/s (plain + decode). Not yet established: a comparison against the reference implementation on the same questions, and CUDA. The tower uses only standard ggml @@ -250,7 +253,8 @@ the exported projector, on a Strix Halo alone and on R9700 + Strix Halo, 220 seeded questions from `lmms-lab/ai2d` and `lmms-lab/ChartQA` with lmms-eval prompts: AI2D 85/100, ChartQA relaxed accuracy 55/60 (augmented) and 43/60 (human). Both layouts score the same and give word-identical answers on 213 of -220 questions. An image request prefills in about 4 s and decodes at about +220 questions. An image request prefills in about 4 s and, without the +drafter, decodes at about 23 tok/s. With our own ROCMFP MIX conversion of the same checkpoint (per-expert diff --git a/server/src/common/vision/image_spans.h b/server/src/common/vision/image_spans.h index bf1c14a82..2ae2f9648 100644 --- a/server/src/common/vision/image_spans.h +++ b/server/src/common/vision/image_spans.h @@ -33,6 +33,17 @@ inline const TokenSpan * image_block_at(ImageSpanView spans, uint64_t position) return nullptr; } +// End of the last image block that overlaps [begin, end), or 0 when none does. +inline uint64_t last_image_end_in(ImageSpanView spans, uint64_t begin, uint64_t end) { + uint64_t last = 0; + for (size_t i = 0; i < spans.size; ++i) { + const auto & span = spans.data[i]; + if (span.block_begin >= end) break; + if (span.block_end > begin) last = span.block_end; + } + return last; +} + inline bool valid_image_spans(ImageSpanView spans, uint64_t prompt_size, size_t max_images, uint64_t max_block_tokens) { if (spans.size > max_images || (spans.size && !spans.data)) return false; diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index a4d3e3274..b07ce6961 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -2832,15 +2832,9 @@ int DeepSeek4Backend::do_prefill(const std::vector & tokens, // An image batch cannot capture DSpark features, so end it at its // last image: the text after the image then prefills (and // captures) as ordinary chunks. - const vision::ImageSpanView spans = images->spans(); - uint64_t last_image_end = 0; - for (size_t k = 0; k < spans.size; ++k) { - const auto & span = spans.data[k]; - if (span.block_begin < uint64_t(pos + n_tok) && span.block_end > uint64_t(pos)) { - chunk_has_image = true; - last_image_end = std::max(last_image_end, span.block_end); - } - } + const uint64_t last_image_end = + vision::last_image_end_in(images->spans(), uint64_t(pos), uint64_t(pos + n_tok)); + chunk_has_image = last_image_end != 0; if (chunk_has_image && capture_spec && last_image_end < uint64_t(pos + n_tok)) { n_tok = int(last_image_end - uint64_t(pos)); } @@ -3298,7 +3292,10 @@ GenerateResult DeepSeek4Backend::generate_from_state( sampler_.rep_pen, sampler_.freq_pen, sampler_.pres_pen); } } - if (spec_enabled_ && spec_drafter_ && req.n_gen > 0 && + // An image prompt whose last image leaves no captured text rows gives the + // drafter no context to start from; decode that request plainly. + const bool image_without_draft_context = req.images && spec_feat_window_.empty(); + if (spec_enabled_ && spec_drafter_ && req.n_gen > 0 && !image_without_draft_context && !req.force_ar_decode && !budget_requires_ar && !sampling_requires_ar) { if (last_logits_.empty()) { result.fail(GenerateErrorCode::DecodeFailed, "spec: no prefill logits"); diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 655d182c2..7dbd4be54 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -4102,8 +4102,9 @@ void HttpServer::prepare_generation_inputs( inputs.request.prompt = prepared.tokens; inputs.request.images = prepared.images; - // Image requests may speculate: each backend decides (DeepSeek4 decodes - // them one by one, Qwen3.5 verifies at image-shifted rotary positions). + // Image requests may speculate; each backend decides (Qwen3.5 verifies at + // image-shifted rotary positions, DeepSeek4 drafts from the text after + // the last image). inputs.request.n_gen = inputs.generation_cap; inputs.request.sampler = req.sampler; inputs.request.do_sample = req.sampler.needs_logit_processing(); diff --git a/server/test/test_ds4v_image_integration.cpp b/server/test/test_ds4v_image_integration.cpp index 45cb7967f..84837cc35 100644 --- a/server/test/test_ds4v_image_integration.cpp +++ b/server/test/test_ds4v_image_integration.cpp @@ -72,6 +72,11 @@ void validation_and_lookup() { std::array too_many{}; require(!valid_image_spans({too_many.data(), too_many.size()}, 100), "more than four images rejected"); const std::vector spans{{10, 13, 18, 20}, {20, 20, 25, 25}, {30, 31, 33, 35}}; + require(last_image_end_in(view(spans), 0, 10) == 0, "no image before the first block"); + require(last_image_end_in(view(spans), 0, 11) == 20, "chunk reaching into the first image"); + require(last_image_end_in(view(spans), 12, 26) == 25, "last overlapping image wins"); + require(last_image_end_in(view(spans), 25, 30) == 0, "text between images"); + require(last_image_end_in(view(spans), 34, 40) == 35, "chunk starting inside an image"); require(valid_image_spans(view(spans), 35), "adjacent and separated blocks valid"); require(!image_block_at(view(spans), 9), "text before block excluded"); require(image_block_at(view(spans), 10) == &spans[0], "leading padding belongs to image block"); From 87bce9e99ae069de45f0eea5ddae6a503b86c8d5 Mon Sep 17 00:00:00 2001 From: mrciffa Date: Thu, 24 Sep 2026 13:34:08 +0200 Subject: [PATCH 08/12] 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) --- server/src/qwen35/concurrency/qwen35_seq_engine.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/src/qwen35/concurrency/qwen35_seq_engine.cpp b/server/src/qwen35/concurrency/qwen35_seq_engine.cpp index e9852634c..d6da7541e 100644 --- a/server/src/qwen35/concurrency/qwen35_seq_engine.cpp +++ b/server/src/qwen35/concurrency/qwen35_seq_engine.cpp @@ -406,16 +406,19 @@ SeqEngine::AdmitResult Qwen35SeqEngine::admit_images( refused.error = "image binding does not match this prompt"; return refused; } - // Encode before claiming a slot: a failed encode then leaves no slot to - // unwind. The tower runs on this (the scheduler) thread, between steps. + // Claim the slot first: a busy pool defers the request and retries it, + // and encoding before that would rerun the tower on every retry. The + // tower runs on this (the scheduler) thread, so no step sees the slot + // before its images are in place. + AdmitResult result = admit(request_id, prompt, sampler); + if (result.status != AdmitResult::Status::admitted) return result; Qwen35ImageRows rows; std::string error = "image encoding failed"; if (!b_.encode_images(*payload, rows, error)) { + retire(result.slot); refused.error = error; return refused; } - AdmitResult result = admit(request_id, prompt, sampler); - if (result.status != AdmitResult::Status::admitted) return result; if (slot_images_.size() < static_cast(slots_.slot_count())) { slot_images_.resize(static_cast(slots_.slot_count())); } From 88e54cff48968b48387f8893e58111666d98c3f1 Mon Sep 17 00:00:00 2001 From: mrciffa Date: Thu, 24 Sep 2026 13:34:08 +0200 Subject: [PATCH 09/12] 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 joins the system includes. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/src/deepseek4/deepseek4_backend.cpp | 22 +++++++++++++++------- server/src/deepseek4/deepseek4_backend.h | 3 +-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index 587ab2e01..d5f238858 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -1260,18 +1261,25 @@ bool DeepSeek4Backend::materialize_images(const DeepSeek4ImagePrompt & images, const auto t0 = Clock::now(); bool ok = true; std::string stream_error; - for (size_t i = 0; ok && i < images.prepared_.images.size(); ++i) { - vision::ImageRows one; - ok = vision::materialize_image_rows({images.prepared_.images[i]}, sentinels, - size_t(w_.n_embd), encode_one, [&] { return io.is_cancelled(); }, - one, stream_error) && one.size() == 1; - if (ok) images.publish_image(i, std::move(one.front())); + try { + for (size_t i = 0; ok && i < images.prepared_.images.size(); ++i) { + vision::ImageRows one; + ok = vision::materialize_image_rows({images.prepared_.images[i]}, sentinels, + size_t(w_.n_embd), encode_one, [&] { return io.is_cancelled(); }, + one, stream_error) && one.size() == 1; + if (ok) images.publish_image(i, std::move(one.front())); + } + } catch (const std::exception & e) { + // Nothing may escape the thread: fail the stream so prefill stops waiting. + ok = false; + stream_error = e.what(); } vision_->release_scratch(); if (!ok) { std::fprintf(stderr, "[deepseek4] streaming image encode stopped: %s\n", stream_error.empty() ? "cancelled" : stream_error.c_str()); images.fail_stream(); + return; } std::fprintf(stderr, "[deepseek4] images encoded in %.0f ms on the --mmproj-device GPU (streamed)\n", elapsed_s(t0) * 1000.0); @@ -3315,7 +3323,7 @@ GenerateResult DeepSeek4Backend::generate_from_state( std::string error; const auto encode_t0 = Clock::now(); const bool encoded = materialize_images(*images, out_io, error); - if (!vision_backend_) { + if (encoded && !vision_backend_) { std::fprintf(stderr, "[deepseek4] images encoded in %.0f ms on the target GPU\n", elapsed_s(encode_t0) * 1000.0); } diff --git a/server/src/deepseek4/deepseek4_backend.h b/server/src/deepseek4/deepseek4_backend.h index 68dad4204..51ea90169 100644 --- a/server/src/deepseek4/deepseek4_backend.h +++ b/server/src/deepseek4/deepseek4_backend.h @@ -16,8 +16,6 @@ #include "deepseek4_internal.h" #include "deepseek4_dspark.h" #include "deepseek4_vision.h" - -#include #include "deepseek4_image_prompt.h" #include "deepseek4_image_assembly.h" #include "deepseek4_image_admission.h" @@ -30,6 +28,7 @@ #include #include #include +#include #include namespace luce::common { From 14e238543d74c349d172c6da3475522236b906ae Mon Sep 17 00:00:00 2001 From: mrciffa Date: Thu, 24 Sep 2026 13:34:08 +0200 Subject: [PATCH 10/12] 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) --- server/src/deepseek4/deepseek4_backend.cpp | 14 ++++++----- server/src/deepseek4/deepseek4_graph.cpp | 2 +- server/src/deepseek4/deepseek4_internal.h | 2 ++ server/src/deepseek4/deepseek4_seq_engine.cpp | 23 +++++++++++-------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index e08c94b25..b515a2169 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -1174,7 +1174,7 @@ void DeepSeek4Backend::prefill_staged(std::vector & batch) { std::vector seqs; for (auto & item : batch) { const auto * images = dynamic_cast(item.images.get()); - if (!images || !item.prompt || !item.staging || item.prefix < 5 || + if (!images || !item.prompt || !item.staging || item.prefix < DS4_MIN_LAYER_MAJOR_PREFILL_TOKENS || item.prefix > int(item.prompt->size()) || item.prefix > item.staging->max_ctx) { item.error = "invalid staged prefill request"; continue; @@ -1191,22 +1191,24 @@ void DeepSeek4Backend::prefill_staged(std::vector & batch) { ggml_backend_synchronize(backend_); deepseek4_release_image_scratch(cache_, moe_hybrid_.get()); const int budget_total = std::min(1024, DS4_MAX_LAYER_MAJOR_PREFILL_TOKENS); + constexpr int min_rows = DS4_MIN_LAYER_MAJOR_PREFILL_TOKENS; const auto t0 = Clock::now(); int passes = 0, rows = 0; for (;;) { // One pass takes the next chunk of every unfinished request that fits: - // whole image blocks only, and never leave a tail shorter than 5 rows. + // whole image blocks only, and never a chunk or tail below the + // layer-major minimum. std::vector pass; std::vector members; int budget = budget_total; for (auto & s : seqs) { const int remaining = s.item->prefix - s.done; - if (remaining <= 0 || !s.item->error.empty() || budget < 5) continue; + if (remaining <= 0 || !s.item->error.empty() || budget < min_rows) continue; int n = std::min(remaining, budget); - if (remaining - n > 0 && remaining - n < 5) n = std::max(0, remaining - 5); - n = n >= 5 ? vision::atomic_image_chunk(s.images->spans(), uint64_t(s.done), n, + if (remaining - n > 0 && remaining - n < min_rows) n = std::max(0, remaining - min_rows); + n = n >= min_rows ? vision::atomic_image_chunk(s.images->spans(), uint64_t(s.done), n, uint64_t(remaining), budget) : 0; - if (n < 5) continue; + if (n < min_rows) continue; DeepSeek4PrefillSeq seq; seq.cache = s.item->staging; seq.embed = s.embed.data() + size_t(s.done) * size_t(w_.n_embd); diff --git a/server/src/deepseek4/deepseek4_graph.cpp b/server/src/deepseek4/deepseek4_graph.cpp index d5d05f852..c7a77c918 100644 --- a/server/src/deepseek4/deepseek4_graph.cpp +++ b/server/src/deepseek4/deepseek4_graph.cpp @@ -7753,7 +7753,7 @@ bool deepseek4_prefill_multi(ggml_backend_t backend, int device, return fail_early("shared prefill needs a full model on one GPU"); int total = 0; for (const auto & s : seqs) { - if (!s.cache || !s.embed || !s.token_ids || s.n_tokens < 5 || s.kv_start < 0 || + if (!s.cache || !s.embed || !s.token_ids || s.n_tokens < DS4_MIN_LAYER_MAJOR_PREFILL_TOKENS || s.kv_start < 0 || s.cache->prefill_mode == PrefillAttentionMode::Exact || s.kv_start + s.n_tokens > s.cache->max_ctx) return fail_early("invalid shared prefill sequence"); diff --git a/server/src/deepseek4/deepseek4_internal.h b/server/src/deepseek4/deepseek4_internal.h index e97a6cd4d..7e7432da7 100644 --- a/server/src/deepseek4/deepseek4_internal.h +++ b/server/src/deepseek4/deepseek4_internal.h @@ -37,6 +37,8 @@ namespace luce::common { // the raw-cache rounding boundary between them. inline constexpr int DS4_NUMERICAL_PREFILL_BAND = 2048; inline constexpr int DS4_MAX_LAYER_MAJOR_PREFILL_TOKENS = 10240; +// Chunks of four rows or fewer take the decode-shaped path, not layer-major. +inline constexpr int DS4_MIN_LAYER_MAJOR_PREFILL_TOKENS = 5; // Normal verification stays within one ratio-4 compressor window. Q5 is an // explicit opt-in whose fused graph models a second boundary. inline constexpr int DS4_CONSERVATIVE_VERIFY_MAX_TOKENS = 4; diff --git a/server/src/deepseek4/deepseek4_seq_engine.cpp b/server/src/deepseek4/deepseek4_seq_engine.cpp index 088447b54..f1f620248 100644 --- a/server/src/deepseek4/deepseek4_seq_engine.cpp +++ b/server/src/deepseek4/deepseek4_seq_engine.cpp @@ -96,23 +96,28 @@ SeqEngine::AdmitResult DeepSeek4SeqEngine::admit_images( AdmitResult refused; refused.status = AdmitResult::Status::failed; // DS4V image blocks need whole-block bidirectional prefill, which the - // 16-row gathered graph cannot run. Admission encodes the images and seeds - // the slot with every prompt token but the last; the next step() prefills - // all pending image requests together on the layer-major sparse path and - // copies their state into the slots, and the last (text) token then - // prefills in the batch, yielding the first sampled token as usual. - if (!supports_images() || prompt.size() < 6 || prompt.size() > size_t(b_.cache_.max_ctx)) { + // 16-row gathered graph cannot run. Admission claims a slot, encodes the + // images and seeds the slot with every prompt token but the last; the + // next step() prefills all pending image requests together on the + // layer-major sparse path and copies their state into the slots, and the + // last (text) token then prefills in the batch, yielding the first + // sampled token as usual. + const int prefix = int(prompt.size()) - 1; + if (!supports_images() || prefix < DS4_MIN_LAYER_MAJOR_PREFILL_TOKENS || + prompt.size() > size_t(b_.cache_.max_ctx)) { refused.error = "image support or prompt length is invalid"; return refused; } + // Claim the slot before encoding: a busy pool defers the request and + // retries it, and encoding first would rerun the encoder on every retry. + AdmitResult result = admit(request_id, prompt, sampler); + if (result.status != AdmitResult::Status::admitted) return result; std::string error; if (!b_.encode_image_request(prompt, images, error)) { + retire(result.slot); refused.error = error.empty() ? "image encoding failed" : error; return refused; } - const int prefix = int(prompt.size()) - 1; - AdmitResult result = admit(request_id, prompt, sampler); - if (result.status != AdmitResult::Status::admitted) return result; SeqSlotManager::PrefillChunk seeded = slots_.seed_restored_prefix(result.slot, prefix); bool ok = seeded.ok && seeded.rows.size() == size_t(prefix); for (size_t i = 0; ok && i < seeded.new_blocks.size(); ++i) { From 9297fe9b747c6c34b3743bebf3ec525842cdcf17 Mon Sep 17 00:00:00 2001 From: mrciffa Date: Thu, 24 Sep 2026 13:42:47 +0200 Subject: [PATCH 11/12] 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) --- docs/image-input.md | 20 ++++++++------ server/src/common/image_prompt.h | 5 ++++ server/src/common/model_backend.h | 16 +++++------ server/src/deepseek4/deepseek4_backend.cpp | 30 ++++++++++----------- server/src/deepseek4/deepseek4_backend.h | 12 ++++----- server/src/qwen35/qwen35_backend.h | 12 ++++----- server/src/qwen35/qwen35_backend_images.cpp | 20 +++++++------- server/src/server/http_server.cpp | 9 ++++--- server/test/test_ds4v_image_assembly.cpp | 8 ++++++ server/test/test_server_unit.cpp | 7 ++--- 10 files changed, 80 insertions(+), 59 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index 02ad670d9..58b2e967a 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -150,14 +150,18 @@ luce_server models/DeepSeek-V4-Flash-Vision-Exp-ROCMFPX-MIX-STRIX.gguf \ ``` Its image blocks need whole-block bidirectional prefill, which the batched -engine's 16-row step cannot run. An image request is therefore prefilled up to -its last token on the single-request sparse path, into a staging cache, while -the batch waits; that state is copied into the request's paged slot and the -last token prefills in the batch, so the answer decodes alongside everyone -else. On the Strix Halo with the encoder on the R9700, four concurrent image -answers of 256 tokens finish in 39 s (26 tok/s in total), two images plus two -text requests in 33 s (31 tok/s); four text requests reach 38 tok/s. `/props` reports the effective capability in -`capabilities.image_input_supported` after backend initialization. +engine's 16-row step cannot run. Image requests admitted since the last step +are therefore prefilled up to their last token together, in shared +layer-major sparse passes into per-request staging caches (each layer's +experts are read once for all of them); that state is copied into each +request's paged slot and the last token prefills in the batch, so the answers +decode alongside everyone else. On the Strix Halo with the encoder on the +R9700, four concurrent image answers of 256 tokens finish in 35 s (29 tok/s in +total), two images plus two text requests at 31 tok/s; four text requests +reach 38 tok/s. The server holds at most one image request per slot; further +image requests get HTTP 503 and should be retried. `/props` reports the +effective capability in `capabilities.image_input_supported` after backend +initialization. ## Qwen3.5 / Qwen3.8 diff --git a/server/src/common/image_prompt.h b/server/src/common/image_prompt.h index 44e46af11..50cbd2722 100644 --- a/server/src/common/image_prompt.h +++ b/server/src/common/image_prompt.h @@ -12,6 +12,11 @@ namespace luce::common { // transport. Each image still has its own token and byte bounds. inline constexpr size_t MAX_REQUEST_IMAGES = 16; +// Outcome of binding a request's images to its prompt. `busy` means the +// request is valid but the backend already holds as many image requests as it +// serves at once; the server answers 503 so the client retries. +enum class ImagePrepareStatus { ok, invalid, busy }; + struct EncodedImage { std::string mime_type; std::vector bytes; diff --git a/server/src/common/model_backend.h b/server/src/common/model_backend.h index 1f6e6afc4..f25d8ec86 100644 --- a/server/src/common/model_backend.h +++ b/server/src/common/model_backend.h @@ -138,21 +138,21 @@ struct ModelBackend { // turns into the image marker, and binds decoded images to a rendered prompt. virtual bool supports_images() const { return false; } virtual std::string image_placeholder() const { return {}; } - virtual bool prepare_images(std::vector & tokens, - std::vector images, - uint64_t context_capacity, - uint64_t output_reserve, - ImagePromptHandle & payload, - std::string & error) const { + virtual ImagePrepareStatus prepare_images(std::vector & tokens, + std::vector images, + uint64_t context_capacity, + uint64_t output_reserve, + ImagePromptHandle & payload, + std::string & error) const { (void) tokens; (void) context_capacity; (void) output_reserve; if (!images.empty()) { error = "this backend does not support image input"; - return false; + return ImagePrepareStatus::invalid; } payload.reset(); - return true; + return ImagePrepareStatus::ok; } // Print the "[-daemon] ready ..." banner on stdout. diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index 5ecc61358..2e3138044 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -1076,7 +1076,7 @@ DeepSeek4Backend::~DeepSeek4Backend() { shutdown(); } -bool DeepSeek4Backend::prepare_images( +ImagePrepareStatus DeepSeek4Backend::prepare_images( std::vector & tokens, std::vector images, uint64_t context_capacity, uint64_t output_reserve, ImagePromptHandle & payload, std::string & error) const { @@ -1088,29 +1088,29 @@ bool DeepSeek4Backend::prepare_images( for (int32_t token : tokens) { if (token == marker || token < 0 || token >= w_.n_vocab) { error = "unbound image marker or invalid token in rendered prompt"; - return false; + return ImagePrepareStatus::invalid; } } } payload.reset(); - return true; + return ImagePrepareStatus::ok; } if (!image_capable_) { error = "image input requires a validated --mmproj projector and heterogeneous HIP sparse prefill"; - return false; + return ImagePrepareStatus::invalid; } try { if (images.size() > MAX_REQUEST_IMAGES) { error = "too many images in request"; - return false; + return ImagePrepareStatus::invalid; } auto lease = image_request_gate_.try_acquire(); if (!lease) { - error = "an image request is already in progress; retry after it completes"; - return false; + error = "the server is serving as many image requests as it holds; retry shortly"; + return ImagePrepareStatus::busy; } if (!vision::check_deepseek4_image_host_preparation(4ULL * 1024 * 1024 * 1024, error)) { - return false; + return ImagePrepareStatus::invalid; } std::vector patches; patches.reserve(images.size()); @@ -1119,13 +1119,13 @@ bool DeepSeek4Backend::prepare_images( if (image.bytes.size() > 16ULL * 1024 * 1024 || encoded_bytes > 32ULL * 1024 * 1024 - image.bytes.size()) { error = "images exceed request byte limit"; - return false; + return ImagePrepareStatus::invalid; } encoded_bytes += image.bytes.size(); auto decoded = vision::decode_image({image.bytes.data(), image.bytes.size()}); - if (!decoded) { error = decoded.status.message; return false; } + if (!decoded) { error = decoded.status.message; return ImagePrepareStatus::invalid; } auto processed = vision::preprocess_rgb(decoded.image.view(), 0); - if (!processed) { error = processed.status.message; return false; } + if (!processed) { error = processed.status.message; return ImagePrepareStatus::invalid; } patches.push_back({processed.image.plan, std::move(processed.image.patches_bf16)}); } vision::ImagePromptLimits limits; @@ -1133,20 +1133,20 @@ bool DeepSeek4Backend::prepare_images( limits.output_reserve = output_reserve; limits.max_expanded_tokens = std::min(context_capacity, vision::MAX_PREPARED_PROMPT_TOKENS); auto prepared = vision::prepare_image_prompt(tokens, patches, limits); - if (!prepared) { error = prepared.message; return false; } + if (!prepared) { error = prepared.message; return ImagePrepareStatus::invalid; } auto binding = std::shared_ptr( new DeepSeek4ImagePrompt(this, std::move(prepared), std::move(images), std::move(lease))); if (!vision::valid_image_spans(binding->spans(), binding->prepared_.tokens.size())) { error = "invalid prepared image spans"; - return false; + return ImagePrepareStatus::invalid; } std::vector expanded = binding->prepared_.tokens; tokens.swap(expanded); payload = std::move(binding); - return true; + return ImagePrepareStatus::ok; } catch (const std::bad_alloc &) { error = "image preparation allocation failed"; - return false; + return ImagePrepareStatus::invalid; } } diff --git a/server/src/deepseek4/deepseek4_backend.h b/server/src/deepseek4/deepseek4_backend.h index 251638cd9..f569b4af2 100644 --- a/server/src/deepseek4/deepseek4_backend.h +++ b/server/src/deepseek4/deepseek4_backend.h @@ -75,12 +75,12 @@ class DeepSeek4Backend : public ModelBackend { void print_ready_banner() const override; bool supports_images() const override { return image_capable_ && vision_ != nullptr; } std::string image_placeholder() const override { return vision::DS4V_IMAGE_PLACEHOLDER; } - bool prepare_images(std::vector & tokens, - std::vector images, - uint64_t context_capacity, - uint64_t output_reserve, - ImagePromptHandle & payload, - std::string & error) const override; + ImagePrepareStatus prepare_images(std::vector & tokens, + std::vector images, + uint64_t context_capacity, + uint64_t output_reserve, + ImagePromptHandle & payload, + std::string & error) const override; bool park(ParkTarget target) override; bool unpark(ParkTarget target) override; diff --git a/server/src/qwen35/qwen35_backend.h b/server/src/qwen35/qwen35_backend.h index 423bb95d8..275dab3a8 100644 --- a/server/src/qwen35/qwen35_backend.h +++ b/server/src/qwen35/qwen35_backend.h @@ -163,12 +163,12 @@ class Qwen35Backend : public ModelBackend { // decode one token at a time. bool supports_images() const override { return image_input_; } std::string image_placeholder() const override; - bool prepare_images(std::vector & tokens, - std::vector images, - uint64_t context_capacity, - uint64_t output_reserve, - ImagePromptHandle & payload, - std::string & error) const override; + ImagePrepareStatus prepare_images(std::vector & tokens, + std::vector images, + uint64_t context_capacity, + uint64_t output_reserve, + ImagePromptHandle & payload, + std::string & error) const override; bool supports_remote_draft() const override { return true; } // ── Concurrent slot serving (paged AR decode over N sequences) ──── diff --git a/server/src/qwen35/qwen35_backend_images.cpp b/server/src/qwen35/qwen35_backend_images.cpp index abe0616b2..55d2f6bf3 100644 --- a/server/src/qwen35/qwen35_backend_images.cpp +++ b/server/src/qwen35/qwen35_backend_images.cpp @@ -50,7 +50,7 @@ std::string Qwen35Backend::image_placeholder() const { return image_input_ ? QWEN35_IMAGE_PLACEHOLDER : ""; } -bool Qwen35Backend::prepare_images(std::vector & tokens, std::vector images, +ImagePrepareStatus Qwen35Backend::prepare_images(std::vector & tokens, std::vector images, uint64_t context_capacity, uint64_t output_reserve, ImagePromptHandle & payload, std::string & error) const { payload.reset(); @@ -59,20 +59,20 @@ bool Qwen35Backend::prepare_images(std::vector & tokens, std::vector MAX_IMAGES_PER_REQUEST) { error = "too many images in request"; return false; } + if (!image_input_) { error = "this model was started without --mmproj"; return ImagePrepareStatus::invalid; } + if (images.size() > MAX_IMAGES_PER_REQUEST) { error = "too many images in request"; return ImagePrepareStatus::invalid; } try { auto prompt = std::make_shared(); prompt->owner = this; for (const EncodedImage & image : images) { auto decoded = vision::decode_image({image.bytes.data(), image.bytes.size()}); - if (!decoded) { error = decoded.status.message; return false; } + if (!decoded) { error = decoded.status.message; return ImagePrepareStatus::invalid; } vision::Qwen35Pixels pixels; - if (!vision::qwen35_vision_preprocess(vision_config_, decoded.image, pixels, error)) return false; + if (!vision::qwen35_vision_preprocess(vision_config_, decoded.image, pixels, error)) return ImagePrepareStatus::invalid; Qwen35ImageSlot slot; slot.columns = pixels.grid_columns; slot.rows = pixels.grid_rows; @@ -80,14 +80,14 @@ bool Qwen35Backend::prepare_images(std::vector & tokens, std::vectorpixels.push_back(std::move(pixels)); } const uint64_t limit = context_capacity > output_reserve ? context_capacity - output_reserve : 0; - if (!qwen35_expand_image_tokens(tokens, w_.image_pad_id, prompt->slots, limit, error)) return false; + if (!qwen35_expand_image_tokens(tokens, w_.image_pad_id, prompt->slots, limit, error)) return ImagePrepareStatus::invalid; prompt->expanded_tokens = tokens; prompt->positions = qwen35_image_rope_positions((int) tokens.size(), prompt->slots); payload = std::move(prompt); - return true; + return ImagePrepareStatus::ok; } catch (const std::bad_alloc &) { error = "image preparation allocation failed"; - return false; + return ImagePrepareStatus::invalid; } } diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 5925b4299..4cb7ce0c1 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -2494,10 +2494,13 @@ bool HttpServer::handle_model_request(SocketHandle fd, ParsedRequest & req, if (!render_and_tokenize_request(fd, render_messages, req)) return true; std::string image_error; - if (!backend_.prepare_images(req.prompt_tokens, std::move(encoded_images), + const ImagePrepareStatus image_status = backend_.prepare_images( + req.prompt_tokens, std::move(encoded_images), uint64_t(std::max(0, config_.max_ctx)), uint64_t(std::max(0, req.max_output)), - req.images, image_error)) { - send_error(fd, 400, image_error); + req.images, image_error); + if (image_status != ImagePrepareStatus::ok) { + // A full image gate is capacity, not a bad request: clients retry 503. + send_error(fd, image_status == ImagePrepareStatus::busy ? 503 : 400, image_error); return true; } diff --git a/server/test/test_ds4v_image_assembly.cpp b/server/test/test_ds4v_image_assembly.cpp index 91fbf4830..69736f990 100644 --- a/server/test/test_ds4v_image_assembly.cpp +++ b/server/test/test_ds4v_image_assembly.cpp @@ -36,6 +36,14 @@ int main() { retained_lease = transient.try_acquire(); } retained_lease.reset(); + { + ImageRequestGate batched; + batched.set_capacity(2); + auto first = batched.try_acquire(), second = batched.try_acquire(); + check(first && second && !batched.try_acquire(), "gate capacity was not enforced"); + first.reset(); + check(bool(batched.try_acquire()), "released lease did not free gate capacity"); + } const ImageSentinels sentinels{{10,11}, {20,21}, {30,31}, {40,41}}; const ImageRaster raster{4, 2, {100,101,200,201,300,301,400,401}}; const std::vector expected{20,21,10,11,300,301,100,101,30,31, diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 7f7348eb6..370ba8868 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -9445,11 +9445,12 @@ TEST_CASE(ServerUnitFixture, test_default_backend_rejects_encoded_images_without ImagePromptHandle payload; std::string error; TEST_ASSERT(!backend.supports_images()); - TEST_ASSERT(!backend.prepare_images(tokens, {{"image/png", {137, 80, 78, 71}}}, - 8192, 32, payload, error)); + TEST_ASSERT(backend.prepare_images(tokens, {{"image/png", {137, 80, 78, 71}}}, + 8192, 32, payload, error) == ImagePrepareStatus::invalid); TEST_ASSERT(tokens == std::vector({1, 2, 3})); TEST_ASSERT(!payload && !error.empty()); - TEST_ASSERT(backend.prepare_images(tokens, {}, 8192, 32, payload, error)); + TEST_ASSERT(backend.prepare_images(tokens, {}, 8192, 32, payload, error) == + ImagePrepareStatus::ok); TEST_ASSERT(tokens == std::vector({1, 2, 3})); } From 5720b523b1ac5bc140973091a1781334a5718ebf Mon Sep 17 00:00:00 2001 From: mrciffa Date: Thu, 24 Sep 2026 14:53:02 +0200 Subject: [PATCH 12/12] 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) --- docs/image-input.md | 12 +++++----- server/src/deepseek4/deepseek4_backend.cpp | 19 ++++++---------- server/src/deepseek4/deepseek4_backend.h | 1 - .../deepseek4/deepseek4_image_assembly.cpp | 11 ---------- .../src/deepseek4/deepseek4_image_assembly.h | 13 ----------- server/test/test_ds4v_image_assembly.cpp | 22 ------------------- 6 files changed, 13 insertions(+), 65 deletions(-) diff --git a/docs/image-input.md b/docs/image-input.md index 58b2e967a..f6e88c753 100644 --- a/docs/image-input.md +++ b/docs/image-input.md @@ -158,8 +158,7 @@ request's paged slot and the last token prefills in the batch, so the answers decode alongside everyone else. On the Strix Halo with the encoder on the R9700, four concurrent image answers of 256 tokens finish in 35 s (29 tok/s in total), two images plus two text requests at 31 tok/s; four text requests -reach 38 tok/s. The server holds at most one image request per slot; further -image requests get HTTP 503 and should be retried. `/props` reports the +reach 38 tok/s. Image requests beyond the free slots wait in the queue. `/props` reports the effective capability in `capabilities.image_input_supported` after backend initialization. @@ -261,10 +260,11 @@ per-expert layout is used expert by expert; the community publishes one for this model) or `--absmax-only`. The converter uses every core: about 40 minutes for this checkpoint on 32 cores. -One image request may be outstanding per backend. Its admission lease remains -with the immutable payload through queueing and generation; another image -request is rejected until that payload is released. This bounds simultaneous -preprocessing and prepared-image memory. Text requests retain the normal queue. +Image requests wait in the same queue as text requests. A waiting request +holds only its preprocessed patches, a few MB per image; its encoded rows +exist only while it runs, so the number of slots bounds them. When host +memory is too short to prepare another image request, the server answers +HTTP 503 and the client should retry. The server expands image markers after final rendering and tokenization. Expanded image tokens count toward context and usage. Image blocks remain diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp index 2e3138044..1b40aa756 100644 --- a/server/src/deepseek4/deepseek4_backend.cpp +++ b/server/src/deepseek4/deepseek4_backend.cpp @@ -55,9 +55,8 @@ class DeepSeek4ImagePrompt final : public ImagePromptPayload { friend class DeepSeek4Backend; DeepSeek4ImagePrompt(const DeepSeek4Backend * owner, vision::PreparedImagePrompt prepared, - std::vector encoded, std::shared_ptr lease) - : owner_(owner), prepared_(std::move(prepared)), encoded_(std::move(encoded)), - lease_(std::move(lease)) { + std::vector encoded) + : owner_(owner), prepared_(std::move(prepared)), encoded_(std::move(encoded)) { for (const auto & image : prepared_.images) spans_.push_back(image.layout.span); } @@ -108,7 +107,6 @@ class DeepSeek4ImagePrompt final : public ImagePromptPayload { const DeepSeek4Backend * const owner_; const vision::PreparedImagePrompt prepared_; const std::vector encoded_; - const std::shared_ptr lease_; std::vector spans_; mutable std::vector> materialized_; mutable std::mutex stream_mutex_; @@ -1104,13 +1102,11 @@ ImagePrepareStatus DeepSeek4Backend::prepare_images( error = "too many images in request"; return ImagePrepareStatus::invalid; } - auto lease = image_request_gate_.try_acquire(); - if (!lease) { - error = "the server is serving as many image requests as it holds; retry shortly"; - return ImagePrepareStatus::busy; - } + // Image requests queue like text ones: a waiting request holds only + // its patches (a few MB per image); the encoded rows exist only once + // it runs, so the slots bound them. Short host memory is capacity. if (!vision::check_deepseek4_image_host_preparation(4ULL * 1024 * 1024 * 1024, error)) { - return ImagePrepareStatus::invalid; + return ImagePrepareStatus::busy; } std::vector patches; patches.reserve(images.size()); @@ -1135,7 +1131,7 @@ ImagePrepareStatus DeepSeek4Backend::prepare_images( auto prepared = vision::prepare_image_prompt(tokens, patches, limits); if (!prepared) { error = prepared.message; return ImagePrepareStatus::invalid; } auto binding = std::shared_ptr( - new DeepSeek4ImagePrompt(this, std::move(prepared), std::move(images), std::move(lease))); + new DeepSeek4ImagePrompt(this, std::move(prepared), std::move(images))); if (!vision::valid_image_spans(binding->spans(), binding->prepared_.tokens.size())) { error = "invalid prepared image spans"; return ImagePrepareStatus::invalid; @@ -1915,7 +1911,6 @@ bool DeepSeek4Backend::init() { return false; } cache_.prefill_mode = PrefillAttentionMode::Sparse; - image_request_gate_.set_capacity(cfg_.max_concurrency); std::fprintf(stderr, "[deepseek4] batched image serving: %d slots, staging cache ctx=%d\n", cfg_.max_concurrency, max_ctx); } diff --git a/server/src/deepseek4/deepseek4_backend.h b/server/src/deepseek4/deepseek4_backend.h index f569b4af2..729dd13ee 100644 --- a/server/src/deepseek4/deepseek4_backend.h +++ b/server/src/deepseek4/deepseek4_backend.h @@ -137,7 +137,6 @@ class DeepSeek4Backend : public ModelBackend { ggml_backend_t vision_backend_ = nullptr; // Encodes images on vision_backend_ while prefill consumes them. std::thread image_stream_; - vision::ImageRequestGate image_request_gate_; vision::ImageAdmissionReserves image_reserves_; // Sampler diff --git a/server/src/deepseek4/deepseek4_image_assembly.cpp b/server/src/deepseek4/deepseek4_image_assembly.cpp index ac67569bf..de3135307 100644 --- a/server/src/deepseek4/deepseek4_image_assembly.cpp +++ b/server/src/deepseek4/deepseek4_image_assembly.cpp @@ -57,17 +57,6 @@ void cancelled(const ImageCancelled & callback) { } } // namespace -std::shared_ptr ImageRequestGate::try_acquire() const { - int current = active_->load(std::memory_order_acquire); - do { - if (current >= capacity_) return {}; - } while (!active_->compare_exchange_weak(current, current + 1, std::memory_order_acq_rel)); - // shared_ptr invokes the deleter if control-block allocation throws too. - return std::shared_ptr(active_.get(), [active = active_](void *) { - active->fetch_sub(1, std::memory_order_release); - }); -} - bool assemble_image_rows(const ImageLayout & layout, const ImageRaster & raster, const ImageSentinels & sentinels, size_t dimension, std::vector & output, std::string & error) { diff --git a/server/src/deepseek4/deepseek4_image_assembly.h b/server/src/deepseek4/deepseek4_image_assembly.h index 23821e58f..aede51119 100644 --- a/server/src/deepseek4/deepseek4_image_assembly.h +++ b/server/src/deepseek4/deepseek4_image_assembly.h @@ -1,24 +1,11 @@ #pragma once #include "deepseek4_image_prompt.h" -#include #include #include namespace luce::vision { -// Bound decoded/prepared image memory to one outstanding request. The lease -// travels with the immutable payload and may be released by another thread. -class ImageRequestGate { -public: - std::shared_ptr try_acquire() const; - // Concurrent serving admits up to one image request per batch slot. - void set_capacity(int capacity) { capacity_ = capacity > 0 ? capacity : 1; } -private: - std::shared_ptr> active_ = std::make_shared>(0); - int capacity_ = 1; -}; - struct ImageRaster { size_t rows = 0, columns = 0; std::vector values; diff --git a/server/test/test_ds4v_image_assembly.cpp b/server/test/test_ds4v_image_assembly.cpp index 69736f990..f73664bf0 100644 --- a/server/test/test_ds4v_image_assembly.cpp +++ b/server/test/test_ds4v_image_assembly.cpp @@ -22,28 +22,6 @@ static PromptImage fixture(uint64_t position) { int main() { try { - ImageRequestGate gate; - auto lease = gate.try_acquire(); - check(bool(lease) && !gate.try_acquire(), "concurrent image request was admitted"); - auto retained_lease = lease; - lease.reset(); - check(!gate.try_acquire(), "request copy released image admission early"); - retained_lease.reset(); - lease = gate.try_acquire(); - check(bool(lease), "completed request did not release image admission"); - { - ImageRequestGate transient; - retained_lease = transient.try_acquire(); - } - retained_lease.reset(); - { - ImageRequestGate batched; - batched.set_capacity(2); - auto first = batched.try_acquire(), second = batched.try_acquire(); - check(first && second && !batched.try_acquire(), "gate capacity was not enforced"); - first.reset(); - check(bool(batched.try_acquire()), "released lease did not free gate capacity"); - } const ImageSentinels sentinels{{10,11}, {20,21}, {30,31}, {40,41}}; const ImageRaster raster{4, 2, {100,101,200,201,300,301,400,401}}; const std::vector expected{20,21,10,11,300,301,100,101,30,31,