Pull requests / #1426

#1426 dflash: experimental standalone DeepSpec DFlash block drafter for Qwen3.8-Flash-Next (greedy, correctness-first)

open · draft · @j-luwierski · 0 comments · View on GitHub

Server & APIMulti-GPUAMD / HIPNVIDIA / CUDAModels & quantsDocumentationWindows

Description

## Summary

Experimental support for the standalone **DeepSpec DFlash block drafter** for [`PixelML/Qwen3.8-Flash-Next-NVFP4-DFlash`](https://huggingface.co/PixelML/Qwen3.8-Flash-Next-NVFP4-DFlash), integrated alongside Strata's existing MTP speculative decoding path.

```text
Qwen3.8-Flash-Next target (any supported quant)
        +
standalone DFlash GGUF
(BF16, 498M params, no embedding / LM head)
        ↓
one parallel draft pass per cycle
        ↓
existing Strata Verifier
```

DFlash is **opt-in**. Target-only decoding and the existing MTP path are unchanged when `--dflash` is not selected.

The implementation is currently **greedy/correctness-first**, with working speculative acceptance and end-to-end tensor parity against an independent reference.

Full semantics and artifact contract are documented in [`docs/DFLASH.md`](docs/DFLASH.md).

---

## Implemented

### DFlash artifact

Adds a standalone GGUF-based DFlash artifact loaded with:

```text
--dflash FILE.gguf
```

The loader:

- validates all **58 expected tensors**
- currently accepts **BF16 only**
- validates tensor shapes against artifact metadata
- accepts both llama.cpp-style GGUF and raw Hugging Face tensor names
- rejects unsupported DFlash variants/extensions:
  - DFlash2 selector tensors
  - DSpark / Markov extras
  - confidence heads
- rejects artifacts containing their own `embed_tokens` or `lm_head`, since both are bound from the target model

`tools/dflash_gguf.py` converts the source checkpoint to GGUF and performs a bit-exact read-back self-check.

The source safetensors were also checked against the generated GGUF and match **bit-for-bit**.

DFlash is mutually exclusive with `--mtp` and currently requires:

```text
--spec 2..8
```

---

## Target feature taps

The target captures the HC-contracted **2560-wide residual** at boundaries:

```text
[4, 16, 24, 36, 44]
```

corresponding to the checkpoint's trained target layers:

```text
[3, 15, 23, 35, 43] + 1
```

The same feature definition is used by both:

- prompt / prefill
- verifier windows

`tools/dflash_taps.py` provides a cross-path validation gate.

Measured on an IQ3_XXS target with 400-token prompts:

```text
cosine similarity: 0.9966 – 0.9999
```

The semantics were cross-checked against the vLLM DFlash / DSpark reference implementation:

- anchor = bonus token at position `p`
- remaining block rows use the mask token
- non-causal block attention
- feature boundaries `[4,16,24,36,44]`
- attention scale `1/16`

---

## Drafter runtime

The standalone drafter implements the published DeepSpec DFlash architecture:

- 5 dense transformer layers
- hidden size: `2560`
- GQA: `24 Q / 2 KV`
- head dimension: `256`
- MLP intermediate size: `7680`
- full-head **NeoX RoPE**
- RoPE theta: `1e7`
- non-causal block attention
- target embedding and LM head reused directly from the target model
- eager BF16 execution
- single CUDA stream
- greedy proposal path

Feature fusion follows:

```text
rmsnorm(fc(concat(target taps)))
```

The resulting context representation is projected into independent per-layer DFlash K/V state.

---

## Owned DFlash K/V state

Each draft layer owns an independent `QsaState`.

A dedicated `QsaStateInitOptions` path enforces the ownership invariant:

- DFlash K/V pools live inside drafter-owned CUDA arenas
- FP16 K/V
- no elastic K/V membership
- no streaming K/V
- five independent draft-layer states

Because of this ownership requirement:

```text
--dflash + --kv-grow
```

is currently rejected explicitly.

The drafter's VRAM footprint is included in expert-cache reservation/autosizing.

---

## Speculative decode integration

DFlash plugs directly into the existing Strata verifier contract.

Each cycle performs one parallel DFlash proposal pass and builds:

```text
[anchor, K candidates]
```

for the existing `Verifier`.

Rejected candidate cells do not need drafter-side rollback. Position-addressed context/KV cells are overwritten when that position is later committed.

Additional integration guards:

- sampled requests (`temperature > 0`) decode without DFlash
- `--dflash + --serve` is currently rejected
- prompt lookup is disabled while DFlash is active
- `--mtp + --dflash` is rejected
- `--kv-grow + --dflash` is rejected

A sampled request reports explicitly:

```text
dflash: sampled request decoded without the drafter
```

---

## Correctness debugging and fixes

Two independent bugs were found while bringing the implementation to non-zero acceptance.

### 1. Layer-1 anchor-row RoPE corruption

The original tensor-level divergence first appeared at:

```text
L1 q_ row 0, heads 0..13, post-RoPE
```

The producing operation was:

```cpp
dflash_rope_neox_apply(q_, ..., pos_)
```

inside `DFlashDrafter::propose()`.

#### Root cause

`h_pos_` was a single pinned staging buffer reused for two different layouts:

- Q-position staging
- K/V-position staging

The asynchronous device copy for a layer's Q positions could still be pending when the CPU rewrote the first `K * n_head_kv` entries for the K/V layout.

A late DMA could therefore observe the rewritten bytes.

For query row 0 this caused the first 14 of 24 Q heads to be roped at positions:

```text
pos+0 .. pos+6
```

instead of all heads using:

```text
pos+0
```

Rows `1..K-1` remained correct, explaining the original **row-0-only, timing-dependent** symptom.

The same staging hazard could also allow the next prefill chunk to overwrite context position/append metadata while the previous chunk was still consuming it.

#### Fix

The staging memory is now separated into disjoint regions for:

- Q layout
- K/V layout

Rewrites inside each region carry identical values, making any overlap benign.

A synchronization point was also added after fusion's per-chunk position / step copies, where the staged values genuinely change between calls.

Relevant files:

```text
include/strata/core/dflash.hpp
src/core/dflash_runtime.cpp
```

---

### 2. DFlash proposals were not reaching the verifier

After restoring tensor parity, acceptance was still exactly zero.

This turned out to be a separate integration bug in `src/program/generate.cpp`.

The verify-window fill effectively selected:

```cpp
use_mtp ? drafts[i - 1] : (oracle_fixture ? ... : 0)
```

DFlash runs with:

```text
use_mtp == false
```

so the actual verify window was:

```text
[anchor, 0, 0, 0, ...]
```

rather than:

```text
[anchor, dflash_draft_0, dflash_draft_1, ...]
```

Acceptance was therefore zero by construction regardless of drafter quality.

DFlash proposals now use the same draft slot consumed by the existing verifier path.

---

## Parity validation

`tools/dflash_stage_parity.py` now performs full end-to-end reference validation across **all five DFlash layers and every block row**.

The harness:

- validates every row at every stage
- rolls the NumPy reference forward through all five layers
- recomputes attention cell-by-cell from the dumped K/V pools
- fails hard on invalid/out-of-range reference cells
- validates every context K/V cell
- validates every context RoPE position

For the original ~120-token fixture (`pos=120`, `K=6`):

| Stage | Row-0 cosine | Max abs row-0 | Result |
|---|---:|---:|---|
| L0 xn / q raw / q normed / q / k / v | `0.999999–1.000000` | `2.4e-07–1.4e-02` | pass |
| L0 attention | `1.000000` | `3.0e-06` | pass |
| L0 attn+o+res / MLP | `0.999997 / 0.999993` | `8.8e-03 / 4.4e-02` | pass |
| L1 xn / q raw / q normed / q | `0.999993–0.999996` | `1.6e-02–2.6e-02` | pass |
| L1 attention | `1.000000` | `4.8e-06` | pass |
| L2 / L3 / L4, all substages + attention | `0.999988–1.000000` | `≤ 1.2e-01` | pass |
| Final norm | `0.999991` | `1.9e-02` | pass |

```text
FIRST DIVERGENCE (any row): none
```

The previous apparent K/V corruption / NaN result was also traced to the debugging tooling rather than the runtime: the pool dump helper widened FP16 data using a BF16 bit shift instead of a real FP16 conversion.

That dump path now uses a proper `__half2float` conversion, and the reference oracle fails explicitly on invalid cells instead of propagating NaNs.

---

## Speculative acceptance

After both correctness fixes, DFlash produces non-zero acceptance.

| Run | Before | After |
|---|---:|---:|
| Original 119-token fixture, 24 new tokens, `K=6` | `0 / 30` | `10 / 78` (`0.128`) |
| Natural 69-token prompt, 128 new tokens | `0 / 161` | `68 / 413` (`0.165`) |

Observed effective output:

```text
24-token run:   1.71 tokens / round
128-token run:  2.13 tokens / round
```

Acceptance increased with the tested generation length:

```text
~3.0% at 24 generated tokens
→
16.5% at 128 generated tokens
```

No performance optimization has been attempted yet; the current goal is correctness and semantic validation.

---

## Greedy semantic gate

Target-only and target+DFlash decoding at `temperature = 0` are **token-for-token identical** for both the 24-token and 128-token validation runs.

There is one important test-control requirement:

```text
--expert-cache 4283
```

was pinned to the same value for both target-only and DFlash runs.

Without that control, the drafter's ~1 GiB VRAM footprint changes expert-cache autosizing. Some experts can then move between CPU and GPU execution, whose numerical rounding differs slightly and can flip near-tie target argmaxes independently of DFlash.

This caveat is documented in `docs/DFLASH.md`.

---

## Tests

Dedicated DFlash tests pass:

```text
dflash_load_test        OK
dflash_rope_parity      OK
dflash_owned_kv_test    OK
```

Full CTest result:

```text
90 / 92
```

The two remaining failures are pre-existing environment limitations:

- `ple_parity`: Q2_0 PLE table file is absent on this machine
- `expert_multi_test`: host CPU does not provide AVX-512

Neither failure is related to the DFlash changes.

---

## Current limitations

This remains a **correctness-first experimental implementation**.

Currently unsupported:

- sampled DFlash acceptance
- `--serve`
- multi-GPU
- DFlash2
- DSpark extensions
- conversation resume
- elastic `--kv-grow`

Additional constraints:

- greedy decoding only
- drafter attention window defaults to `32768` cells
- trained maximum DFlash query block is 7 candidates
- no performance claims are made yet

The performance numbers published for PixelML's implementation are **not reproduced or claimed by this PR**.

---

## Status

The core DFlash path is now functionally working:

```text
artifact loading
    ✓
target feature capture
    ✓
owned K/V state
    ✓
5-layer DFlash forward
    ✓
full tensor parity
    ✓
verifier integration
    ✓
non-zero acceptance
    ✓
greedy token-identity gate
    ✓
```

The original anchor-row divergence and the zero-acceptance blocker were two separate bugs; both are now identified and fixed.

The next step is performance work and broader validation, without changing the currently verified greedy semantics.

Related on strata.com

Editorial links to help you install, pick models, or read release notes — not part of the upstream thread.