Reported against Luce-Org/lucebox at commit c3b71e4b2ed2d08db96d55e7985f20035aa08d6f, using the ROCMFP2 Strix quant from Hugging Face Lucebox/DeepSeek-V4-Flash-ROCMFPX.
Found while qualifying local models on a two-node Strix Halo cluster. Everything below comes from the single-node monolithic DS4 configuration on one of those boxes. Four defects, two of them with patches that are validated on this hardware and inlined at the end, one with a working sidecar card, and one diagnosed but not fixed. Happy to raise the patched items as PRs instead if that is easier to review.
Summary
| # |
Defect |
Severity |
Status |
| 1 |
Non-fused DSpark verify reads past the logits tensor and aborts |
Fatal, deterministic |
Patch below, validated |
| 2 |
Prefix cache never stores or restores for DeepSeek models |
Severe performance |
Patch below, validated |
| 3 |
No deepseek4 model-card family, so reasoning silently disables |
Silent quality loss |
Root cause named, working card below, code fix suggested |
| 4 |
DSpark output is not greedy-identical to target-only decoding |
Correctness question |
Diagnosed, not fixed |
Environment
- AMD Ryzen AI Max+ 395 (Strix Halo),
gfx1151, wave size 32, 128 GB unified memory, 122880 MiB reported to the HIP device
- ROCm 7.2.4, HIP backend,
dflash_server built as build-hip-strix from the commit above
- Target:
DeepSeek-V4-Flash-ROCMFP2-STRIX.gguf, 102,320,631,200 bytes, sha256 8fa6c30d9badd8e72f83c62952fe24b2e8cd647e103aff086bece328cb9c9208
- Drafter:
DeepSeek-V4-Flash-DSpark-draft-Q4RMFP4-denseF16.gguf, 11,304,737,056 bytes, sha256 48883d35b8a67ecfd2858a90e12a47d04cb5ac581acef868ca0f58544816f746
- Common serving flags:
--target-device hip:0 --ds4-fused-decode --ds4-expert-top-k 6 --cache-type-k f16 --cache-type-v f16 --max-ctx 16384 --chunk 2048
- Backend banner:
[deepseek4] monolithic execution requested, fused_decode=on, 43 layers, 256 experts (6 routed), 97161.2 MB GPU buffer
Defect 1: non-fused DSpark verify aborts on any multi-token batch
Symptom
With speculative decoding on and DFLASH_DS4_FUSED_VERIFY unset, the server aborts on the first speculative step:
[ds4-spec] hidden nnan=0/20480 rms=0.1703 ctx_len=0
[ds4-spec] dbg ds_ok=1 q=2 lt=35 draft=[35 223 -1 -1]
/src/server/deps/llama.cpp/ggml/src/ggml-backend.cpp:384: GGML_ASSERT(offset + size <= ggml_nbytes(tensor) && "tensor read out of bounds") failed
...
libggml-base.so.0(ggml_abort+0x11f)
libggml-base.so.0(ggml_backend_tensor_get+0x1d1)
dflash_server() [0x4c3fa8]
dflash_server() [0x542747]
dflash_server() [0x547953]
Reproduced twice with identical stacks.
Root cause
Resolved by disassembling the symbolled binary. The failing call is server/src/deepseek4/deepseek4_graph.cpp:6684, reached through run_deepseek4_dspark_spec_decode to DeepSeek4DFlashTarget::verify_batch to deepseek4_dspark_verify_forward to deepseek4_step_layer_range.
In the dynamic output block:
6643 const bool last_only = n_tokens > 1;
6644 const int output_tokens = last_only ? 1 : n_tokens;
6646 inp = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, n_embd, output_tokens);
...
6682 if (verify_hooks && verify_hooks->all_logits_out) {
6683 verify_hooks->all_logits_out->resize((size_t) w.n_vocab * n_tokens);
6684 ggml_backend_tensor_get(logits, verify_hooks->all_logits_out->data(), 0,
6685 sizeof(float) * (size_t) w.n_vocab * n_tokens);
For a verify batch of n_tokens the logits tensor holds one row, while the hook reads n_tokens rows. deepseek4_dspark_verify_forward always sets hooks.all_logits_out (deepseek4_dspark_spec.cpp:379-386), so every non-fused multi-token verify overruns by exactly n_tokens - 1 rows. The failure cannot depend on data. Single-token sequential verify is safe.
The fused verify graph handles widths 2 to 4 and is gated on the environment flag at deepseek4_graph.cpp:6019-6021, which is why the published recipe never hits this path.
Reproduction
DFLASH_DS4_SPEC=1 \
DFLASH_DS4_DRAFT=/models/DeepSeek-V4-Flash-DSpark-draft-Q4RMFP4-denseF16.gguf \
DFLASH_DS4_SPEC_Q=4 \
LUCE_MMVQ_MAX_NCOLS=4 \
DFLASH_DS4_DSPARK_DEBUG=1 \
./server/build-hip-strix/dflash_server /models/DeepSeek-V4-Flash-ROCMFP2-STRIX.gguf \
--target-device hip:0 --ds4-fused-decode --ds4-prefill exact \
--ds4-expert-top-k 6 --cache-type-k f16 --cache-type-v f16 \
--max-ctx 16384 --chunk 2048
DFLASH_DS4_FUSED_VERIFY deliberately unset. Send any chat completion. The server aborts as soon as the drafter proposes more than one token.
Fix
- const bool last_only = n_tokens > 1;
+ const bool need_all_logits = verify_hooks && verify_hooks->all_logits_out;
+ const bool last_only = n_tokens > 1 && !need_all_logits;
const int output_tokens = last_only ? 1 : n_tokens;
Clamping the read instead would be unsound. The single physical row holds the prediction after the last input token, so a clamped copy to offset zero mislabels it as the first position and the remaining rows stay zero-filled. deepseek4_dspark_verify_forward takes a per-row argmax to decide acceptance, so acceptance decisions would be silently wrong rather than noisy.
Validation
Patched binary sha256 e630f1b0b78cf8648855dc90f268336a52422643781d9559f18da187b23e5a57. All four probes (warmup, sustained 2048-token decode, maths, code) complete with no abort. Two independent source reviews agreed the wider projection is the sound minimal repair and confirmed the allocator supports it: the dynamic output graph has invariant topology, and ggml_gallocr re-reserves when a later graph needs larger tensors (ggml-alloc.c:996-1058).
One reviewer suggested deriving output_tokens directly and adding a shape assertion before the read, which looks worth doing to stop a later edit reintroducing the mismatch.
Note on exit status
The process exits 139 rather than the 134 expected from abort(). Tracing shows GGML_ASSERT reaching plain abort() with no registered callback, so the segfault appears to come from teardown or a second thread. It does not affect the diagnosis, but it may indicate a separate shutdown fault.
Defect 2: the prefix cache never stores or restores for DeepSeek models
Symptom
With --prefix-cache-slots 32 (startup confirms prefix_cache = 32 slots), a three-turn conversation whose prompts strictly extend one another (7,016 then 7,051 then 7,067 tokens) logs on every request:
[pc] enabled: cap=32 family=laguna
[server] chat CACHE chatcmpl_0000000000000000 restore=false slot=-1 prefix_len=0 effective_prompt=7016 ... snap_slot=-1 snap_pos=0 full_snap_slot=-1 full_snap_pos=0
[server] chat CACHE chatcmpl_0000000000000001 restore=false slot=-1 prefix_len=0 effective_prompt=7051 ... snap_slot=-1 snap_pos=0 full_snap_slot=-1 full_snap_pos=0
[server] chat CACHE chatcmpl_0000000000000002 restore=false slot=-1 prefix_len=0 effective_prompt=7067 ... snap_slot=-1 snap_pos=0 full_snap_slot=-1 full_snap_pos=0
Every turn re-prefills the whole history, about 365 seconds each at roughly 19 tokens per second. That makes multi-turn agent use impractical.
Root cause, two independent defects
Chat markers mis-resolve. resolve_chat_markers (server/src/server/prefix_cache.cpp:15) tries qwen, then gemma, then falls through to a laguna branch (line 40) that only requires tok.encode("<system>") and friends to be non-empty, which holds for any BPE tokenizer. The cache then searches DeepSeek prompts for <system> sequences that never occur. find_all_boundaries returns empty and prepare_inline_snap returns {-1, 0} (prefix_cache.cpp:273-284), so no snapshot target is ever reserved.
The monolithic backend cannot snapshot. DeepSeek4Backend::snapshot_save (server/src/deepseek4/deepseek4_backend.cpp:1072) is a stub:
bool DeepSeek4Backend::snapshot_save(int slot) {
if (slot < 0 || slot >= PREFIX_SLOTS) return false;
// TODO: Implement snapshot save (copy KV cache + HC state to CPU)
return false;
}
restore_and_generate_impl (line 1093) is likewise a TODO that falls through to a full prefill, and generate_impl never reads GenerateRequest::snap_slot or snap_pos. Since the HTTP layer only commits a cache entry when backend_.snapshot_used(...) returns true (http_server.cpp:2983,3002), nothing is ever cached. Either --ds4-fused-decode or a non-exact --ds4-prefill forces this backend through requires_monolithic_model() (deepseek4_backend.cpp:349).
No configuration works around it. --prefill-cache-slots N enables only an exact whole-prompt hash, useless for extending turns, and it still depends on backend snapshot support. The layer-split adapter does implement DS4 snapshots (deepseek4_layer_split_adapter.cpp:626,685) but requires multi-device placement and forbids dense prefill and fused decode.
Reproduction
Start the server with the common flags above plus --prefix-cache-slots 32 --prefill-cache-slots 0 --disk-prefix-cache off, then send three chat completions where each prompt is the previous conversation plus one short turn. Every request logs restore=false slot=-1 prefix_len=0 and pays a full prefill.
Fix
The state capture already exists and is exercised by the spec-decode rollback path. deepseek4_snapshot_save / _restore (deepseek4_graph.cpp:6862) captures raw and compressed KV, indexer compressed KV, attention and indexer compressor states, the HC residual and cur_pos. The patch therefore:
- Adds a
deepseek marker family before the laguna fallback, using the template's own tokens (<|begin_of_sentence|>, <|end_of_sentence|>, <|User|>, <|Assistant|> in their full-width DeepSeek forms), each required to encode to a single token so other vocabularies are unaffected.
- Implements
snapshot_save through the existing helper.
- Extends
do_prefill with snap_slot and snap_pos, splitting the chunk walk at the snapshot position and saving there, because the compressor and HC state are sequential and cannot be truncated after the fact.
- Implements
restore_and_generate_impl as restore plus suffix-only prefill at base position cur_pos, with a fresh-prefill fallback for an invalid slot, a failed restore, or an empty suffix.
About 110 lines, tagged cache-fix-20260729 in the patch below. The marker hunk is the part most worth a maintainer's eye:
+ // cache-fix-20260729: DeepSeek family. The renderer emits
+ // <|begin_of_sentence|>{system}<|User|>...<|Assistant|>...<|end_of_sentence|>
+ // (see chat_template.cpp DEEPSEEK4). Every marker must encode to a single
+ // special token; otherwise fall through so behaviour for other
+ // vocabularies is unchanged.
+ auto ds_bos = tok.encode("<|begin_of_sentence|>");
+ auto ds_eos = tok.encode("<|end_of_sentence|>");
+ auto ds_user = tok.encode("<|User|>");
+ auto ds_asst = tok.encode("<|Assistant|>");
+ if (ds_bos.size() == 1 && ds_eos.size() == 1 &&
+ ds_user.size() == 1 && ds_asst.size() == 1) {
+ out.family = "deepseek";
+ out.sys_role_prefix = {ds_bos[0]};
+ out.end_msg_seqs = {{ds_eos[0]}};
+ out.next_role_starts = {{ds_user[0]}, {ds_asst[0]}};
+ return true;
+ }
(The literals in the patch file are the full-width DeepSeek marker characters, not the ASCII forms shown here.)
Validation
Same three-turn probe on the patched binary (36c87b30eec95be5da79bb75fc4993f7d6d78c2a09e9eadbdab26a5eb84a26e7):
[pc] enabled: cap=32 family=deepseek
[snap] inline slot=0 cur_pos=7023
[pc] inline snapshot requested=7023 saved=7023 slot=0
[pc] inline-snap committed slot=0 prefix_len=7023
[pc] lookup hit slot=0 prefix_len=7023 (of 7067 total)
[deepseek4] restored slot=0 cur_pos=7023, prefilling 44 suffix tokens
| Turn |
Prompt tokens |
Prefill before |
Prefill after |
Answer |
| 0 |
7,016 |
377.0 s |
378.2 s |
identical |
| 1 |
7,051 |
365.0 s |
365.8 s |
identical |
| 2 |
7,067 |
365.1 s |
2.5 s |
identical |
All three answers are byte-identical to the unpatched baseline under greedy decoding, so the restored state is the same state and not merely a faster one. The one-turn lag is inherent: a boundary can only be snapshotted on the turn after its content is generated. Snapshotting at the end of generation as well would remove the turn-one cost.
Defect 3: no deepseek4 model-card family, so reasoning silently disables
Symptom and root cause
resolve_model_card normalises general.name to a filename stem, so DeepSeek V4 Flash Src becomes deepseek-v4-flash-src. No such card ships in share/model_cards/, and family_fallback (server/src/server/model_card.cpp:257-289) has branches for qwen35, qwen36, qwen3, gemma4 and laguna, but none for deepseek4, even though deepseek4 is a first-class architecture elsewhere in the tree. Every DeepSeek V4 model without a sidecar therefore drops to the hard fallback (model_card.cpp:344) with max_tokens=16000 and hard_limit_reply_budget=4096.
The consequence is quiet and severe. With --default-max-tokens 2048, the derived thinking budget becomes max(0, 2048 - 4096) = 0, so the server answers with reasoning entirely disabled while reporting success. Our first quality run was invalid for this reason and we did not know until we read the resolution log. A single stderr warning when the hard fallback engages would have saved the run.
Two further notes for anyone reproducing this. The card only resolves when the server's working directory or binary location puts share/model_cards on the search path, and --model-name does not redirect card lookup: it only sets the /v1/models label. Separately, this server engages reasoning only when the request carries reasoning_effort. The chat_template_kwargs route does not.
Reproduction
Start the server on any DeepSeek V4 Flash GGUF with no matching sidecar and --default-max-tokens 2048, then send a request with "reasoning_effort": "high". The reply arrives with no thinking phase, and startup logs the hard fallback rather than a card path.
Suggested fix
Add a deepseek4 branch to family_fallback with a smaller reply budget matching the terse DeepSeek style, warn on stderr when the hard fallback engages, and tighten the laguna branch in resolve_chat_markers so it stops claiming unknown tokenizers (same root cause as defect 2).
A working sidecar we used for the runs in this report, sha256 dc4624a72fac9d87f022f16704cbce97157ce98295cfe9dc2901d35ffba64497, validates against share/model_cards/_schema.json. With it, startup resolves the card and clamps the high tier to max_ctx - hard_limit_reply_budget as expected:
share/model_cards/deepseek-v4-flash-src.json
{
"name": "DeepSeek V4 Flash Src",
"source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash",
"verified_at": "2026-07-29",
"max_tokens": 32768,
"complex_problem_max_tokens": 32768,
"hard_limit_reply_budget": 1024,
"sampling": {
"temperature": 1.0,
"top_p": 1.0,
"top_k": 0,
"min_p": 0.0,
"presence_penalty": 0.0,
"repetition_penalty": 1.0
},
"reasoning_effort_tiers": {
"low": 4096,
"medium": 8192,
"high": 16384,
"x-high": 24576,
"max": 32256
},
"notes": "Authored locally on 2026-07-29 for the Lucebox ROCMFP2 quality rerun; not vendor-published. Sampling follows DeepSeek's official local recommendation (temperature 1.0, top_p 1.0). hard_limit_reply_budget 1024 reflects the terse DeepSeek V4 Flash reply style noted in the Lucebox source comments (originally 512 in ds4_eval). DeepSeek V4 Flash defaults to thinking mode; the max tier is nominal because true Think Max needs at least 384K context. Effort tiers are locally derived, not vendor figures."
}
Defect 4: DSpark output is not greedy-identical to target-only decoding
Speculative decoding with correct rejection sampling should preserve the target distribution exactly. On this hardware it does not.
Four probes were run at temperature 0 in three configurations: target-only, DSpark through the published fused-verify path, and DSpark through the patched non-fused path. All eight identity comparisons against target-only fail, with divergence appearing early in the output rather than at the tail.
| Probe |
Target sha256 (prefix) |
Fused verify |
Patched non-fused |
| warmup |
3d6b876b |
differs |
differs |
| speed (2048 tokens) |
19240055 |
differs |
differs |
| maths |
f3a54684 |
differs |
differs |
| code |
684f2671 |
differs |
differs |
The most plausible explanation, suggested during review, is that batched verification changes floating-point reduction order, so a per-row argmax flips whenever two candidates are close. That would make the divergence benign in distribution while still breaking any claim of identical output. It cannot be confirmed from source alone.
The practical implication is that DSpark should not be presented as a quality-free speedup until an acceptance test with an explicit numerical tolerance exists. We suggest comparing each verify row against a trusted single-token forward pass for widths 1 to 4 and requiring identical argmax wherever the winning margin exceeds the tolerance.
We should also report that DSpark was slower than target-only decoding in every configuration we measured:
| Probe |
Target-only |
Fused verify |
Patched non-fused |
| warmup |
23.9 t/s |
12.3 |
11.2 |
| speed (2048 tokens) |
23.0 t/s |
14.6 |
10.7 |
| maths |
23.0 t/s |
23.1 |
15.3 |
| code |
22.8 t/s |
20.5 |
13.1 |
The observation below probably explains part of that.
Related observation: the drafter can run with an empty feature window
The debug log shows ctx_len=0 entering the drafter after a completed prefill. --ds4-prefill exact forces single-token prefill steps, and with --ds4-fused-decode each step takes the fused fast path (deepseek4_graph.cpp:6042), whose signature has no hooks parameter, so the per-layer feature capture at lines 6577-6588 never runs and spec_feat_window_ stays empty. A comment in verify_batch (deepseek4_dspark_spec.cpp:104-105) shows the author knew reused graphs skip hooks and forced single-token verifies onto the dynamic path for that reason, but prefill did not get the same treatment.
The drafter tolerates the empty window and still proposes tokens, so the target stays correct and only acceptance suffers. We have not patched this. Two routes look reasonable: pass allow_decode_graph_reuse=false from prefill when capture hooks are set, or have the fused decode step decline when hooks are present.
This may matter for the published configuration. The layer-major multi-token prefill path also sits above the dynamic hook handling and has no hooks parameter, so sparse prefill may starve the drafter in the same way. If so, published acceptance rates and the 32 tokens per second headline may have headroom.
Quality numbers for this quant
We could not find a published broad quality evaluation for the ROCMFP2 quant, so these may be useful. EvalPlus HumanEval+ (164 tasks) run through the repository's own server/scripts/quality_humaneval_plus.py for canonical prompts, completion extraction and sandboxed grading, driving an already-running server rather than spawning one. Temperature pinned to 0.
| Configuration |
pass@1 |
| Non-thinking, greedy |
87.2% (143/164) |
Thinking, reasoning_effort high, 20,480 token budget |
95.1% (156/164) |
Method for the thinking figure: non-thinking passes plus a thinking rerun of only the non-thinking failures.
Three notes for anyone repeating this:
- A run at a 4,096 token budget truncated seven tasks, because the whole generation shares the budget with the roughly 16,000 token high-effort thinking tier. Budget at least 20,480 for high effort.
- Four non-thinking failures were smart-quote characters breaking Python syntax. They vanish under thinking, so they look like a sampling artefact rather than a decode-level fault.
- Sustained evaluation load at
high GPU clocks tripped a 95 degree abort about 18 minutes in. At auto clocks the same load peaks near 88 degrees and holds.
For context, this 2.88 bit-per-parameter quant reaching 95.1% with reasoning matches the 153 GiB Q4 two-node configuration we measured on the same fixture, which also reached 95.1%.
Other measurements
Non-thinking, greedy, needle retrieval at depth.
| Occupied context |
Prefill |
Decode |
| 8K |
about 5 minutes |
24.7 t/s |
| 16K |
about 13 minutes |
19.1 t/s |
Prefill runs at roughly 18 to 22 tokens per second in both exact and dense modes. dense chunking works, but attention compute dominates, and with --ds4-prefill dense the chunk size resolves to 1 anyway (deepseek4_backend.cpp:767-770), so both modes take single-token steps. A 28K request returned HTTP 400. Separately, exact-literal recall degrades with occupied context: a planted KESTREL-77 came back as KESTREL-7-7 on all four attempts, and we have not established whether that is specific to this quant.
Patch
Both patched defects in one diff against c3b71e4b2ed2d08db96d55e7985f20035aa08d6f. Defect 1 is the deepseek4_graph.cpp hunk; defect 2 is the rest.
cache-fix-20260729.patch
diff --git a/server/src/deepseek4/deepseek4_backend.cpp b/server/src/deepseek4/deepseek4_backend.cpp
index 90e8284..f195bd1 100644
--- a/server/src/deepseek4/deepseek4_backend.cpp
+++ b/server/src/deepseek4/deepseek4_backend.cpp
@@ -750,7 +750,7 @@ bool DeepSeek4Backend::unpark(ParkTarget target) {
int DeepSeek4Backend::do_prefill(const std::vector<int32_t> & tokens,
const DaemonIO & io,
- int kv_offset) {
+ int kv_offset, int snap_slot, int snap_pos) {
// The all-hot layer-range path supports causal chunked prefill. The
// optimized graph snapshots the previous raw SWA window, attends over
// that snapshot plus the current ubatch, and commits only the final SWA
@@ -808,10 +808,17 @@ int DeepSeek4Backend::do_prefill(const std::vector<int32_t> & tokens,
DeepSeek4StepTelemetry tel_acc;
int steps = 0;
- for (int i = 0; i < n_total; i += chunk) {
+ for (int i = 0; i < n_total; ) {
if (io.cancelled) return pos;
- const int n_tok = std::min(chunk, n_total - i);
+ int n_tok = std::min(chunk, n_total - i);
+ // cache-fix-20260729: split the chunk at the requested snapshot
+ // position so the cache is consistent exactly there. Any chunk
+ // boundary is a safe split: deepseek4_step_layer_range already
+ // splits internally at learned-compressor boundaries.
+ if (snap_slot >= 0 && snap_pos > pos && snap_pos < pos + n_tok) {
+ n_tok = snap_pos - pos;
+ }
// Embed tokens
std::vector<float> embed(w_.n_embd * n_tok);
@@ -863,6 +870,16 @@ int DeepSeek4Backend::do_prefill(const std::vector<int32_t> & tokens,
}
last_logits_ = std::move(logits);
pos += n_tok;
+ i += n_tok;
+ // cache-fix-20260729: inline snapshot at the requested boundary.
+ // cache_.cur_pos == pos here (the step just committed to pos).
+ if (snap_slot >= 0 && pos == snap_pos) {
+ if (snapshot_save(snap_slot)) {
+ std::printf("[snap] inline slot=%d cur_pos=%d\n",
+ snap_slot, snap_pos);
+ std::fflush(stdout);
+ }
+ }
}
if (timing) {
log_step_tel("prefill", n_total, steps, elapsed_s(phase_t0), tel_acc);
@@ -969,6 +986,16 @@ bool DeepSeek4Backend::do_decode(int committed, int n_gen,
GenerateResult DeepSeek4Backend::generate_impl(const GenerateRequest & req,
const DaemonIO & io) {
+ return generate_from_state(req, io, /*kv_offset=*/0);
+}
+
+// cache-fix-20260729: shared prefill+decode tail behind generate_impl and
+// restore_and_generate_impl. kv_offset > 0 resumes on a just-restored cache:
+// only prompt[kv_offset..] is prefilled, at that base position. Decode and
+// the sampler's penalty history still see the full prompt, and `committed`
+// stays an absolute position either way.
+GenerateResult DeepSeek4Backend::generate_from_state(
+ const GenerateRequest & req, const DaemonIO & io, int kv_offset) {
GenerateResult result;
DaemonIO out_io = io.with_token_callback(req.on_token);
auto t0 = Clock::now();
@@ -978,7 +1005,16 @@ GenerateResult DeepSeek4Backend::generate_impl(const GenerateRequest & req,
}
// Prefill
- int committed = do_prefill(req.prompt, out_io);
+ int committed;
+ if (kv_offset > 0) {
+ const std::vector<int32_t> delta(req.prompt.begin() + kv_offset,
+ req.prompt.end());
+ committed = do_prefill(delta, out_io, kv_offset,
+ req.snap_slot, req.snap_pos);
+ } else {
+ committed = do_prefill(req.prompt, out_io, /*kv_offset=*/0,
+ req.snap_slot, req.snap_pos);
+ }
if (committed < 0) {
result.fail(GenerateErrorCode::PrefillFailed);
return result;
@@ -1071,8 +1107,12 @@ GenerateResult DeepSeek4Backend::generate_impl(const GenerateRequest & req,
bool DeepSeek4Backend::snapshot_save(int slot) {
if (slot < 0 || slot >= PREFIX_SLOTS) return false;
- // TODO: Implement snapshot save (copy KV cache + HC state to CPU)
- return false;
+ // cache-fix-20260729: CPU-resident copy of the full recurrent state —
+ // raw + compressed KV, attention/indexer compressor states, HC residual
+ // and cur_pos. deepseek4_snapshot_save frees any previous snapshot in
+ // the slot before writing.
+ if (!snap_backend_) return false;
+ return deepseek4_snapshot_save(cache_, snap_backend_, snapshots_[slot]);
}
void DeepSeek4Backend::snapshot_free(int slot) {
@@ -1092,8 +1132,37 @@ int DeepSeek4Backend::snapshot_cur_pos(int slot) const {
GenerateResult DeepSeek4Backend::restore_and_generate_impl(
int slot, const GenerateRequest & req, const DaemonIO & io) {
- // TODO: Implement snapshot restore + generate
- (void)slot;
+ // cache-fix-20260729: restore the CPU snapshot into the live cache and
+ // prefill only the prompt suffix past the snapshot position. Anything
+ // that cannot be restored exactly falls back to a fresh full prefill,
+ // which matches the pre-fix behaviour.
+ if (slot >= 0 && slot < PREFIX_SLOTS && snapshots_[slot].ctx) {
+ const int snap_pos = snapshots_[slot].cur_pos;
+ // An empty suffix cannot re-seed decode logits: the snapshot stores
+ // no logits, and re-stepping the last token would advance the
+ // compressor state a second time. Exact-length hits therefore also
+ // take the fresh-prefill path.
+ if (snap_pos > 0 && snap_pos < (int)req.prompt.size()) {
+ if (deepseek4_snapshot_restore(snapshots_[slot], cache_)) {
+ // The host feature window belongs to the previous request,
+ // not to the restored state.
+ spec_feat_window_.clear();
+ std::fprintf(stderr,
+ "[deepseek4] restored slot=%d cur_pos=%d, prefilling "
+ "%zu suffix tokens\n",
+ slot, snap_pos, req.prompt.size() - (size_t)snap_pos);
+ return generate_from_state(req, io, snap_pos);
+ }
+ std::fprintf(stderr,
+ "[deepseek4] snapshot restore failed slot=%d — full prefill\n",
+ slot);
+ } else {
+ std::fprintf(stderr,
+ "[deepseek4] snapshot slot=%d pos=%d unusable for prompt=%zu "
+ "— full prefill\n",
+ slot, snap_pos, req.prompt.size());
+ }
+ }
return generate_impl(req, io);
}
diff --git a/server/src/deepseek4/deepseek4_backend.h b/server/src/deepseek4/deepseek4_backend.h
index 1cb3b53..90f247c 100644
--- a/server/src/deepseek4/deepseek4_backend.h
+++ b/server/src/deepseek4/deepseek4_backend.h
@@ -88,8 +88,17 @@ private:
void release_spec_drafter(bool mark_parked);
// Prefill prompt tokens in chunks, return absolute committed position.
+ // cache-fix-20260729: a non-negative snap_slot requests an inline
+ // snapshot: the chunk walk splits at absolute position snap_pos and
+ // saves the cache state there.
int do_prefill(const std::vector<int32_t> & tokens, const DaemonIO & io,
- int kv_offset = 0);
+ int kv_offset = 0, int snap_slot = -1, int snap_pos = -1);
+
+ // cache-fix-20260729: shared prefill+decode tail. kv_offset > 0 resumes
+ // on a just-restored cache: only prompt[kv_offset..] is prefilled, at
+ // that base position.
+ GenerateResult generate_from_state(const GenerateRequest & req,
+ const DaemonIO & io, int kv_offset);
// Autoregressive decode loop.
bool do_decode(int committed, int n_gen,
diff --git a/server/src/deepseek4/deepseek4_graph.cpp b/server/src/deepseek4/deepseek4_graph.cpp
index b14af44..cd9d7df 100644
--- a/server/src/deepseek4/deepseek4_graph.cpp
+++ b/server/src/deepseek4/deepseek4_graph.cpp
@@ -6640,7 +6640,12 @@ bool deepseek4_step_layer_range(
ggml_context * ctx = ggml_init(params);
if (!ctx) return false;
- const bool last_only = n_tokens > 1;
+ // dspark-fix-20260729: the non-fused verify path must project
+ // logits for every batch row when the verify hook requests them;
+ // a single-row projection under-allocates the tensor the hook
+ // reads n_tokens rows from (out-of-bounds read, exit on assert).
+ const bool need_all_logits = verify_hooks && verify_hooks->all_logits_out;
+ const bool last_only = n_tokens > 1 && !need_all_logits;
const int output_tokens = last_only ? 1 : n_tokens;
ggml_tensor * inp = ggml_new_tensor_2d(
ctx, GGML_TYPE_F32, n_embd, output_tokens);
diff --git a/server/src/server/prefix_cache.cpp b/server/src/server/prefix_cache.cpp
index 4ab20c5..28dbeb2 100644
--- a/server/src/server/prefix_cache.cpp
+++ b/server/src/server/prefix_cache.cpp
@@ -37,6 +37,24 @@ bool resolve_chat_markers(const Tokenizer & tok, ChatMarkers & out) {
return true;
}
+ // cache-fix-20260729: DeepSeek family. The renderer emits
+ // <|begin▁of▁sentence|>{system}<|User|>...<|Assistant|>...
+ // <|end▁of▁sentence|> (see chat_template.cpp DEEPSEEK4). Every marker
+ // must encode to a single special token; otherwise fall through so
+ // behaviour for other vocabularies is unchanged.
+ auto ds_bos = tok.encode("<|begin▁of▁sentence|>");
+ auto ds_eos = tok.encode("<|end▁of▁sentence|>");
+ auto ds_user = tok.encode("<|User|>");
+ auto ds_asst = tok.encode("<|Assistant|>");
+ if (ds_bos.size() == 1 && ds_eos.size() == 1 &&
+ ds_user.size() == 1 && ds_asst.size() == 1) {
+ out.family = "deepseek";
+ out.sys_role_prefix = {ds_bos[0]};
+ out.end_msg_seqs = {{ds_eos[0]}};
+ out.next_role_starts = {{ds_user[0]}, {ds_asst[0]}};
+ return true;
+ }
+
// Try Laguna family: XML-style markers.
auto start_sys = tok.encode("<system>");
auto end_sys = tok.encode("</system>");
Reported against
Luce-Org/luceboxat commitc3b71e4b2ed2d08db96d55e7985f20035aa08d6f, using the ROCMFP2 Strix quant from Hugging FaceLucebox/DeepSeek-V4-Flash-ROCMFPX.Found while qualifying local models on a two-node Strix Halo cluster. Everything below comes from the single-node monolithic DS4 configuration on one of those boxes. Four defects, two of them with patches that are validated on this hardware and inlined at the end, one with a working sidecar card, and one diagnosed but not fixed. Happy to raise the patched items as PRs instead if that is easier to review.
Summary
deepseek4model-card family, so reasoning silently disablesEnvironment
gfx1151, wave size 32, 128 GB unified memory, 122880 MiB reported to the HIP devicedflash_serverbuilt asbuild-hip-strixfrom the commit aboveDeepSeek-V4-Flash-ROCMFP2-STRIX.gguf, 102,320,631,200 bytes, sha2568fa6c30d9badd8e72f83c62952fe24b2e8cd647e103aff086bece328cb9c9208DeepSeek-V4-Flash-DSpark-draft-Q4RMFP4-denseF16.gguf, 11,304,737,056 bytes, sha25648883d35b8a67ecfd2858a90e12a47d04cb5ac581acef868ca0f58544816f746--target-device hip:0 --ds4-fused-decode --ds4-expert-top-k 6 --cache-type-k f16 --cache-type-v f16 --max-ctx 16384 --chunk 2048[deepseek4] monolithic execution requested,fused_decode=on, 43 layers, 256 experts (6 routed), 97161.2 MB GPU bufferDefect 1: non-fused DSpark verify aborts on any multi-token batch
Symptom
With speculative decoding on and
DFLASH_DS4_FUSED_VERIFYunset, the server aborts on the first speculative step:Reproduced twice with identical stacks.
Root cause
Resolved by disassembling the symbolled binary. The failing call is
server/src/deepseek4/deepseek4_graph.cpp:6684, reached throughrun_deepseek4_dspark_spec_decodetoDeepSeek4DFlashTarget::verify_batchtodeepseek4_dspark_verify_forwardtodeepseek4_step_layer_range.In the dynamic output block:
For a verify batch of
n_tokensthe logits tensor holds one row, while the hook readsn_tokensrows.deepseek4_dspark_verify_forwardalways setshooks.all_logits_out(deepseek4_dspark_spec.cpp:379-386), so every non-fused multi-token verify overruns by exactlyn_tokens - 1rows. The failure cannot depend on data. Single-token sequential verify is safe.The fused verify graph handles widths 2 to 4 and is gated on the environment flag at
deepseek4_graph.cpp:6019-6021, which is why the published recipe never hits this path.Reproduction
DFLASH_DS4_FUSED_VERIFYdeliberately unset. Send any chat completion. The server aborts as soon as the drafter proposes more than one token.Fix
Clamping the read instead would be unsound. The single physical row holds the prediction after the last input token, so a clamped copy to offset zero mislabels it as the first position and the remaining rows stay zero-filled.
deepseek4_dspark_verify_forwardtakes a per-row argmax to decide acceptance, so acceptance decisions would be silently wrong rather than noisy.Validation
Patched binary sha256
e630f1b0b78cf8648855dc90f268336a52422643781d9559f18da187b23e5a57. All four probes (warmup, sustained 2048-token decode, maths, code) complete with no abort. Two independent source reviews agreed the wider projection is the sound minimal repair and confirmed the allocator supports it: the dynamic output graph has invariant topology, andggml_gallocrre-reserves when a later graph needs larger tensors (ggml-alloc.c:996-1058).One reviewer suggested deriving
output_tokensdirectly and adding a shape assertion before the read, which looks worth doing to stop a later edit reintroducing the mismatch.Note on exit status
The process exits 139 rather than the 134 expected from
abort(). Tracing showsGGML_ASSERTreaching plainabort()with no registered callback, so the segfault appears to come from teardown or a second thread. It does not affect the diagnosis, but it may indicate a separate shutdown fault.Defect 2: the prefix cache never stores or restores for DeepSeek models
Symptom
With
--prefix-cache-slots 32(startup confirmsprefix_cache = 32 slots), a three-turn conversation whose prompts strictly extend one another (7,016 then 7,051 then 7,067 tokens) logs on every request:Every turn re-prefills the whole history, about 365 seconds each at roughly 19 tokens per second. That makes multi-turn agent use impractical.
Root cause, two independent defects
Chat markers mis-resolve.
resolve_chat_markers(server/src/server/prefix_cache.cpp:15) tries qwen, then gemma, then falls through to a laguna branch (line 40) that only requirestok.encode("<system>")and friends to be non-empty, which holds for any BPE tokenizer. The cache then searches DeepSeek prompts for<system>sequences that never occur.find_all_boundariesreturns empty andprepare_inline_snapreturns{-1, 0}(prefix_cache.cpp:273-284), so no snapshot target is ever reserved.The monolithic backend cannot snapshot.
DeepSeek4Backend::snapshot_save(server/src/deepseek4/deepseek4_backend.cpp:1072) is a stub:restore_and_generate_impl(line 1093) is likewise a TODO that falls through to a full prefill, andgenerate_implnever readsGenerateRequest::snap_slotorsnap_pos. Since the HTTP layer only commits a cache entry whenbackend_.snapshot_used(...)returns true (http_server.cpp:2983,3002), nothing is ever cached. Either--ds4-fused-decodeor a non-exact--ds4-prefillforces this backend throughrequires_monolithic_model()(deepseek4_backend.cpp:349).No configuration works around it.
--prefill-cache-slots Nenables only an exact whole-prompt hash, useless for extending turns, and it still depends on backend snapshot support. The layer-split adapter does implement DS4 snapshots (deepseek4_layer_split_adapter.cpp:626,685) but requires multi-device placement and forbids dense prefill and fused decode.Reproduction
Start the server with the common flags above plus
--prefix-cache-slots 32 --prefill-cache-slots 0 --disk-prefix-cache off, then send three chat completions where each prompt is the previous conversation plus one short turn. Every request logsrestore=false slot=-1 prefix_len=0and pays a full prefill.Fix
The state capture already exists and is exercised by the spec-decode rollback path.
deepseek4_snapshot_save/_restore(deepseek4_graph.cpp:6862) captures raw and compressed KV, indexer compressed KV, attention and indexer compressor states, the HC residual andcur_pos. The patch therefore:deepseekmarker family before the laguna fallback, using the template's own tokens (<|begin_of_sentence|>,<|end_of_sentence|>,<|User|>,<|Assistant|>in their full-width DeepSeek forms), each required to encode to a single token so other vocabularies are unaffected.snapshot_savethrough the existing helper.do_prefillwithsnap_slotandsnap_pos, splitting the chunk walk at the snapshot position and saving there, because the compressor and HC state are sequential and cannot be truncated after the fact.restore_and_generate_implas restore plus suffix-only prefill at base positioncur_pos, with a fresh-prefill fallback for an invalid slot, a failed restore, or an empty suffix.About 110 lines, tagged
cache-fix-20260729in the patch below. The marker hunk is the part most worth a maintainer's eye:(The literals in the patch file are the full-width DeepSeek marker characters, not the ASCII forms shown here.)
Validation
Same three-turn probe on the patched binary (
36c87b30eec95be5da79bb75fc4993f7d6d78c2a09e9eadbdab26a5eb84a26e7):All three answers are byte-identical to the unpatched baseline under greedy decoding, so the restored state is the same state and not merely a faster one. The one-turn lag is inherent: a boundary can only be snapshotted on the turn after its content is generated. Snapshotting at the end of generation as well would remove the turn-one cost.
Defect 3: no
deepseek4model-card family, so reasoning silently disablesSymptom and root cause
resolve_model_cardnormalisesgeneral.nameto a filename stem, soDeepSeek V4 Flash Srcbecomesdeepseek-v4-flash-src. No such card ships inshare/model_cards/, andfamily_fallback(server/src/server/model_card.cpp:257-289) has branches forqwen35,qwen36,qwen3,gemma4andlaguna, but none fordeepseek4, even thoughdeepseek4is a first-class architecture elsewhere in the tree. Every DeepSeek V4 model without a sidecar therefore drops to the hard fallback (model_card.cpp:344) withmax_tokens=16000andhard_limit_reply_budget=4096.The consequence is quiet and severe. With
--default-max-tokens 2048, the derived thinking budget becomesmax(0, 2048 - 4096) = 0, so the server answers with reasoning entirely disabled while reporting success. Our first quality run was invalid for this reason and we did not know until we read the resolution log. A single stderr warning when the hard fallback engages would have saved the run.Two further notes for anyone reproducing this. The card only resolves when the server's working directory or binary location puts
share/model_cardson the search path, and--model-namedoes not redirect card lookup: it only sets the/v1/modelslabel. Separately, this server engages reasoning only when the request carriesreasoning_effort. Thechat_template_kwargsroute does not.Reproduction
Start the server on any DeepSeek V4 Flash GGUF with no matching sidecar and
--default-max-tokens 2048, then send a request with"reasoning_effort": "high". The reply arrives with no thinking phase, and startup logs the hard fallback rather than a card path.Suggested fix
Add a
deepseek4branch tofamily_fallbackwith a smaller reply budget matching the terse DeepSeek style, warn on stderr when the hard fallback engages, and tighten the laguna branch inresolve_chat_markersso it stops claiming unknown tokenizers (same root cause as defect 2).A working sidecar we used for the runs in this report, sha256
dc4624a72fac9d87f022f16704cbce97157ce98295cfe9dc2901d35ffba64497, validates againstshare/model_cards/_schema.json. With it, startup resolves the card and clamps the high tier tomax_ctx - hard_limit_reply_budgetas expected:share/model_cards/deepseek-v4-flash-src.json{ "name": "DeepSeek V4 Flash Src", "source": "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash", "verified_at": "2026-07-29", "max_tokens": 32768, "complex_problem_max_tokens": 32768, "hard_limit_reply_budget": 1024, "sampling": { "temperature": 1.0, "top_p": 1.0, "top_k": 0, "min_p": 0.0, "presence_penalty": 0.0, "repetition_penalty": 1.0 }, "reasoning_effort_tiers": { "low": 4096, "medium": 8192, "high": 16384, "x-high": 24576, "max": 32256 }, "notes": "Authored locally on 2026-07-29 for the Lucebox ROCMFP2 quality rerun; not vendor-published. Sampling follows DeepSeek's official local recommendation (temperature 1.0, top_p 1.0). hard_limit_reply_budget 1024 reflects the terse DeepSeek V4 Flash reply style noted in the Lucebox source comments (originally 512 in ds4_eval). DeepSeek V4 Flash defaults to thinking mode; the max tier is nominal because true Think Max needs at least 384K context. Effort tiers are locally derived, not vendor figures." }Defect 4: DSpark output is not greedy-identical to target-only decoding
Speculative decoding with correct rejection sampling should preserve the target distribution exactly. On this hardware it does not.
Four probes were run at temperature 0 in three configurations: target-only, DSpark through the published fused-verify path, and DSpark through the patched non-fused path. All eight identity comparisons against target-only fail, with divergence appearing early in the output rather than at the tail.
3d6b876b19240055f3a54684684f2671The most plausible explanation, suggested during review, is that batched verification changes floating-point reduction order, so a per-row argmax flips whenever two candidates are close. That would make the divergence benign in distribution while still breaking any claim of identical output. It cannot be confirmed from source alone.
The practical implication is that DSpark should not be presented as a quality-free speedup until an acceptance test with an explicit numerical tolerance exists. We suggest comparing each verify row against a trusted single-token forward pass for widths 1 to 4 and requiring identical argmax wherever the winning margin exceeds the tolerance.
We should also report that DSpark was slower than target-only decoding in every configuration we measured:
The observation below probably explains part of that.
Related observation: the drafter can run with an empty feature window
The debug log shows
ctx_len=0entering the drafter after a completed prefill.--ds4-prefill exactforces single-token prefill steps, and with--ds4-fused-decodeeach step takes the fused fast path (deepseek4_graph.cpp:6042), whose signature has no hooks parameter, so the per-layer feature capture at lines 6577-6588 never runs andspec_feat_window_stays empty. A comment inverify_batch(deepseek4_dspark_spec.cpp:104-105) shows the author knew reused graphs skip hooks and forced single-token verifies onto the dynamic path for that reason, but prefill did not get the same treatment.The drafter tolerates the empty window and still proposes tokens, so the target stays correct and only acceptance suffers. We have not patched this. Two routes look reasonable: pass
allow_decode_graph_reuse=falsefrom prefill when capture hooks are set, or have the fused decode step decline when hooks are present.This may matter for the published configuration. The layer-major multi-token prefill path also sits above the dynamic hook handling and has no hooks parameter, so sparse prefill may starve the drafter in the same way. If so, published acceptance rates and the 32 tokens per second headline may have headroom.
Quality numbers for this quant
We could not find a published broad quality evaluation for the ROCMFP2 quant, so these may be useful. EvalPlus HumanEval+ (164 tasks) run through the repository's own
server/scripts/quality_humaneval_plus.pyfor canonical prompts, completion extraction and sandboxed grading, driving an already-running server rather than spawning one. Temperature pinned to 0.reasoning_efforthigh, 20,480 token budgetMethod for the thinking figure: non-thinking passes plus a thinking rerun of only the non-thinking failures.
Three notes for anyone repeating this:
highGPU clocks tripped a 95 degree abort about 18 minutes in. Atautoclocks the same load peaks near 88 degrees and holds.For context, this 2.88 bit-per-parameter quant reaching 95.1% with reasoning matches the 153 GiB Q4 two-node configuration we measured on the same fixture, which also reached 95.1%.
Other measurements
Non-thinking, greedy, needle retrieval at depth.
Prefill runs at roughly 18 to 22 tokens per second in both
exactanddensemodes.densechunking works, but attention compute dominates, and with--ds4-prefill densethe chunk size resolves to 1 anyway (deepseek4_backend.cpp:767-770), so both modes take single-token steps. A 28K request returned HTTP 400. Separately, exact-literal recall degrades with occupied context: a plantedKESTREL-77came back asKESTREL-7-7on all four attempts, and we have not established whether that is specific to this quant.Patch
Both patched defects in one diff against
c3b71e4b2ed2d08db96d55e7985f20035aa08d6f. Defect 1 is thedeepseek4_graph.cpphunk; defect 2 is the rest.cache-fix-20260729.patch