Conversation
Batched DeepSeek V4 Vision serving had four problems that Qwen3.8 does not, all from the staged prefill that DS4V image blocks need: - Eviction corrupted image requests. Recompute replays token ids, so an evicted image slot came back with its image rows prefilled as text. The engine now reports kv_recomputable(); the scheduler parks another decoder instead, and DeepSeek4 refuses to evict (or suspend mid-staging) an image slot. - The whole staged prefill ran inside one step, so every live stream froze for it (33 s for an eight-image request). It now advances one shared pass per step, about 256 rows (a whole image block may be more; 1,024 when nothing decodes), and a slot sits out until its prefix is copied in. - Encoding ran on the scheduler thread. With --mmproj-device a persistent encoder worker now takes admitted requests in order and publishes each image as it lands; a pass only takes rows whose images are ready. The batched path no longer runs the single-request memory transitions. - Staging caches were allocated lazily mid-request. One per slot is now allocated and logged at startup. vision::staged_prefill_chunk picks each chunk (whole image blocks, no chunk or tail under the layer-major minimum) with exhaustive layout tests; it fixes a hang where two text rows before an image left no valid chunk. lucebox6, Strix Halo + R9700 encoder, 4 slots: - two text streams while 3 one-image and 1 eight-image requests arrive: longest pause 4.1 s (was the whole prefill), all answers correct - 1,280-token pool, 1 MB offload: text slots parked and resumed, the image slot never evicted, its answer identical to the solo run (2,227 chars) - four concurrent image answers 38.9 s (was 35.1 s: passes no longer wait for every request); sanity 2/2; test_server_unit 598/598 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
2 issues found across 10 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="server/src/deepseek4/deepseek4_seq_engine.cpp">
<violation number="1" location="server/src/deepseek4/deepseek4_seq_engine.cpp:306">
P2: Failed staging removes the request before `retire()` can cancel its encoder, so a queued `--mmproj-device` encode continues after the request fails and can delay later users. Cancel the staged image payload before erasing it, and apply the same cleanup when admission fails before `pending_images_.push_back()`.</violation>
</file>
<file name="server/src/deepseek4/deepseek4_backend.cpp">
<violation number="1" location="server/src/deepseek4/deepseek4_backend.cpp:1343">
P2: `encode_stop_` is written under `encode_mutex_` in `release_vision()` but read without that mutex by the worker callback, creating a shutdown data race. Read it under the same mutex or make it atomic.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| const bool copied = !failed && staged.done >= staged.prefix && !staged.staging; | ||
| if (failed) fail_prefill(slice.slot, result.prefills, staged.error); | ||
| if (failed || copied) { | ||
| pending_images_.erase(pending_images_.begin() + (pending - pending_images_.data())); |
There was a problem hiding this comment.
P2: Failed staging removes the request before retire() can cancel its encoder, so a queued --mmproj-device encode continues after the request fails and can delay later users. Cancel the staged image payload before erasing it, and apply the same cleanup when admission fails before pending_images_.push_back().
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/deepseek4/deepseek4_seq_engine.cpp, line 306:
<comment>Failed staging removes the request before `retire()` can cancel its encoder, so a queued `--mmproj-device` encode continues after the request fails and can delay later users. Cancel the staged image payload before erasing it, and apply the same cleanup when admission fails before `pending_images_.push_back()`.</comment>
<file context>
@@ -296,7 +295,26 @@ SeqEngine::StepResult DeepSeek4SeqEngine::step(const StepPlan & plan) {
+ const bool copied = !failed && staged.done >= staged.prefix && !staged.staging;
+ if (failed) fail_prefill(slice.slot, result.prefills, staged.error);
+ if (failed || copied) {
+ pending_images_.erase(pending_images_.begin() + (pending - pending_images_.data()));
+ }
+ if (!copied) {
</file context>
There was a problem hiding this comment.
Fixed in 3d6afe5: a staged request that fails now cancels its queued encode before it is dropped.
| for (Seq * m : members) m->item->error = error.empty() ? "staged prefill failed" : error; | ||
| continue; | ||
| try { | ||
| const auto cancelled = [&] { return images->stream_cancelled() || encode_stop_; }; |
There was a problem hiding this comment.
P2: encode_stop_ is written under encode_mutex_ in release_vision() but read without that mutex by the worker callback, creating a shutdown data race. Read it under the same mutex or make it atomic.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/deepseek4/deepseek4_backend.cpp, line 1343:
<comment>`encode_stop_` is written under `encode_mutex_` in `release_vision()` but read without that mutex by the worker callback, creating a shutdown data race. Read it under the same mutex or make it atomic.</comment>
<file context>
@@ -1148,99 +1182,212 @@ ImagePrepareStatus DeepSeek4Backend::prepare_images(
- for (Seq * m : members) m->item->error = error.empty() ? "staged prefill failed" : error;
- continue;
+ try {
+ const auto cancelled = [&] { return images->stream_cancelled() || encode_stop_; };
+ for (size_t i = 0; ok && i < images->prepared_.images.size(); ++i) {
+ vision::ImageRows one;
</file context>
| const auto cancelled = [&] { return images->stream_cancelled() || encode_stop_; }; | |
| const auto cancelled = [&] { | |
| std::lock_guard<std::mutex> lock(encode_mutex_); | |
| return images->stream_cancelled() || encode_stop_; | |
| }; |
There was a problem hiding this comment.
Fixed in 3d6afe5: encode_stop_ is now std::atomic.
- A staged request that fails now cancels its queued encode, so the encoder moves on to other requests. - The idle wait wakes when the next chunk's images are in, not all of them. - encode_stop_ is atomic: the worker's cancel check reads it unlocked. - The single-request path's image wait observes request cancellation: a dropped streaming 12-image request stops, and the next request is served in 6.2 s instead of 34.1 s. - The staged chunk walk now covers every two-image layout too. lucebox6: unit tests 1,295,542 checks + test_server_unit 598/598; mixed traffic unchanged (longest pause 4.1 s, all answers correct). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An image block must go through each layer whole (bidirectional attention), so a staged pass could not be split by rows and live decoders waited for a whole pass, about 4 s per image block on the Strix Halo. The pass now runs 6 of its 43 layers per batched step while others decode: DeepSeek4PrefillPass keeps the hidden state on the GPU between slices, and every layer still sees all rows of the pass. With nothing decoding it still runs whole. The pass cannot be abandoned half-way (its compressor state advances per layer), so a member that retires meanwhile is skipped when the pass completes, and its slot's staging cache stays busy until then (admission reports busy). deepseek4_prefill_multi, now unused, is removed. lucebox6, Strix Halo + R9700 encoder: - A/B with the decode batch held fixed: whole pass vs 8 slices give byte-identical answers on two charts (2,239 and 2,432 chars), and the whole pass repeats identically; slicing costs nothing (3,517 vs 3,617 ms) - two text streams while 3 one-image and 1 eight-image requests arrive: longest pause 0.67 s (was 4.1 s), no gap over 1 s, all answers correct - four concurrent image answers 37.5 s (was 38.9 s); sanity 2/2; test_server_unit 598/598, DS4V image tests pass Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review |
@davide221 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
1 existing issue remains and 2 new issues found across 11 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="server/test/test_ds4v_image_integration.cpp">
<violation number="1" location="server/test/test_ds4v_image_integration.cpp:93">
P3: The walk verifies progress, tail size, and whole-image blocks, but never asserts that a chunk respects the budget. A regression that always returns the entire remaining prefix passes every walk assertion (n >= 5, leftover 0, chunk covers whole blocks), so only the three hard-coded requires pin budget behavior — and all three use budget 256. The budget 5/8/16 loops, which are the small-budget cases the hang fix targets, would not catch such a regression.
Add a size assertion with the legitimate growth cases excepted: `n` may exceed `budget` only when the chunk starts in a block it must finish (block_begin <= done < block_end) or when the no-stub-tail rule forces growth to the end (n == prefix - done). An oracle comparison like check_oracle would be stronger still.</violation>
<violation number="2" location="server/test/test_ds4v_image_integration.cpp:94">
P3: The `walk` assertions throw with a fixed string, so a regression anywhere among the ~1700 walks / ~600K chunk calls cannot be localized. Sibling assertions in this same function embed context (check_oracle reports position/preferred/capacity); these should too.
Include `done`, `n`, `budget`, `prefix`, and `layout.size()` in each message so a failing iteration identifies the exact configuration.</violation>
</file>
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
| require(n >= 5, "staged chunk makes progress"); | ||
| require(prefix - done - n == 0 || prefix - done - n >= 5, "no stub tail"); |
There was a problem hiding this comment.
P3: The walk assertions throw with a fixed string, so a regression anywhere among the ~1700 walks / ~600K chunk calls cannot be localized. Sibling assertions in this same function embed context (check_oracle reports position/preferred/capacity); these should too.
Include done, n, budget, prefix, and layout.size() in each message so a failing iteration identifies the exact configuration.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/test/test_ds4v_image_integration.cpp, line 94:
<comment>The `walk` assertions throw with a fixed string, so a regression anywhere among the ~1700 walks / ~600K chunk calls cannot be localized. Sibling assertions in this same function embed context (check_oracle reports position/preferred/capacity); these should too.
Include `done`, `n`, `budget`, `prefix`, and `layout.size()` in each message so a failing iteration identifies the exact configuration.</comment>
<file context>
@@ -77,6 +77,44 @@ void validation_and_lookup() {
+ int done = 0;
+ while (done < prefix) {
+ const int n = staged_prefill_chunk(view(layout), uint64_t(done), prefix - done, budget, 5);
+ require(n >= 5, "staged chunk makes progress");
+ require(prefix - done - n == 0 || prefix - done - n >= 5, "no stub tail");
+ for (const TokenSpan & span : layout) {
</file context>
| require(n >= 5, "staged chunk makes progress"); | |
| require(prefix - done - n == 0 || prefix - done - n >= 5, "no stub tail"); | |
| require(n >= 5, "staged chunk makes progress at position=" + std::to_string(done) + | |
| " prefix=" + std::to_string(prefix) + " budget=" + std::to_string(budget)); | |
| require(prefix - done - n == 0 || prefix - done - n >= 5, | |
| "no stub tail at position=" + std::to_string(done) + " n=" + std::to_string(n) + | |
| " budget=" + std::to_string(budget)); |
| const auto walk = [&](const std::vector<TokenSpan> & layout, int prefix, int budget) { | ||
| int done = 0; | ||
| while (done < prefix) { | ||
| const int n = staged_prefill_chunk(view(layout), uint64_t(done), prefix - done, budget, 5); |
There was a problem hiding this comment.
P3: The walk verifies progress, tail size, and whole-image blocks, but never asserts that a chunk respects the budget. A regression that always returns the entire remaining prefix passes every walk assertion (n >= 5, leftover 0, chunk covers whole blocks), so only the three hard-coded requires pin budget behavior — and all three use budget 256. The budget 5/8/16 loops, which are the small-budget cases the hang fix targets, would not catch such a regression.
Add a size assertion with the legitimate growth cases excepted: n may exceed budget only when the chunk starts in a block it must finish (block_begin <= done < block_end) or when the no-stub-tail rule forces growth to the end (n == prefix - done). An oracle comparison like check_oracle would be stronger still.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/test/test_ds4v_image_integration.cpp, line 93:
<comment>The walk verifies progress, tail size, and whole-image blocks, but never asserts that a chunk respects the budget. A regression that always returns the entire remaining prefix passes every walk assertion (n >= 5, leftover 0, chunk covers whole blocks), so only the three hard-coded requires pin budget behavior — and all three use budget 256. The budget 5/8/16 loops, which are the small-budget cases the hang fix targets, would not catch such a regression.
Add a size assertion with the legitimate growth cases excepted: `n` may exceed `budget` only when the chunk starts in a block it must finish (block_begin <= done < block_end) or when the no-stub-tail rule forces growth to the end (n == prefix - done). An oracle comparison like check_oracle would be stronger still.</comment>
<file context>
@@ -77,6 +77,44 @@ void validation_and_lookup() {
+ const auto walk = [&](const std::vector<TokenSpan> & layout, int prefix, int budget) {
+ int done = 0;
+ while (done < prefix) {
+ const int n = staged_prefill_chunk(view(layout), uint64_t(done), prefix - done, budget, 5);
+ require(n >= 5, "staged chunk makes progress");
+ require(prefix - done - n == 0 || prefix - done - n >= 5, "no stub tail");
</file context>
Batched DeepSeek V4 Vision serving (#760) had four problems that Qwen3.8 does not. They all come from the staged prefill that DS4V image blocks need (whole-block bidirectional attention, which the 16-row batched step cannot run).
SeqEngine::kv_recomputable(); the scheduler parks another decoder, and DeepSeek4 refuses to evict an image slot or suspend one mid-stagingadvanced) until its prefix is copied in--mmproj-device, a persistent encoder worker takes admitted requests in order and publishes each image as it lands; a pass only takes rows whose images are readyvision::staged_prefill_chunkpicks each chunk (whole image blocks, no chunk or tail below the layer-major minimum) and has exhaustive layout tests. It also fixes a hang found while testing: 2 text rows before an image left no valid chunk under a small budget.Measured on lucebox6 (Strix Halo decoder, R9700 encoder, 4 slots)
test_server_unit598/598; DS4V image tests pass (944,190 checks).Review (cubic, 5 findings) fixed in 3d6afe5. Layer slicing in dad736d.
🤖 Generated with Claude Code