Skip to content

vllm:spec_decode_* counters stay 0 on the stepped bridge (DFlash/DSpark) #896

Description

@xiaguan

Symptom

vllm:spec_decode_num_drafts_total / num_draft_tokens_total / num_accepted_tokens_total stay at 0 in production even when DFlash/DSpark speculative decoding is actively running (visible per step in the debug log as Qwen3 DFlash request=... accepted_draft=N).

Root cause

#787 wires SpecDecodeCounters from Qwen3Executor::execute_speculative_verify_impl into LoadSnapshot.spec_decode, and the legacy handle bridge forwards per-interval deltas via publish_scheduler_stats (pegainfer-frontend/src/vllm/bridge.rs). But every served model line launches as LaunchedEngine::Stepped (e.g. qwen3 at pegainfer-qwen3/src/model_line.rs), and SteppedEngineBridge:

  • passes load_watch = None, so publish_scheduler_stats never spawns, and
  • stamps stats with a bare scheduler_stats_from(load) per output batch (bridge/stepped.rs, two sites), where ..SchedulerStats::default() leaves spec_decoding_stats = None forever.

The sim frontend e2e covers only the handle path, so the gap has no test net.

Why it matters

Upstream vllm-bench (now maintained in vllm-project/vllm at rust/src/bench) scrapes the server's /metrics before and after a run and auto-reports a Speculative Decoding section (acceptance rate/length, per-position rates) plus spec_decode_* JSON fields — no flags needed. With the counters stuck at 0 that whole surface is unusable for spec-decode A/B work against pegainfer.

Suggested fix

Give SteppedEngineBridge its own last_spec: SpecDecodeCounters state and diff the cumulative snapshot with the same spec_decode_delta the legacy publisher uses, attaching spec_decoding_stats only on intervals that drafted (delta.num_drafts > 0). Minimal sketch:

// in SteppedEngineBridge, one fn used by both stats-stamping sites
let snapshot = self.scheduler.load();
let mut stats = scheduler_stats_from(&snapshot);
if let Some(cur) = &snapshot.spec_decode {
    let delta = spec_decode_delta(&self.last_spec, cur);
    self.last_spec = *cur;
    stats.spec_decoding_stats = (delta.num_drafts > 0).then_some(delta);
}

Local verification of the patch

Qwen3-4B + dspark_qwen3_4b_block7, vllm-bench on openai-chat, c4, 20 prompts each:

dataset acceptance rate acceptance length
random 14.2% 1.97
sonnet 28.9% 2.99
sharegpt 20.5% 2.40

All in the documented DFlash/DSpark ranges (see docs/models/qwen3/dspark-integration.md); result JSON carries all spec_decode_* fields.

Follow-up edge case (from review)

With the state held in the bridge, a step whose request updates are all dropped (e.g. an abort arriving while a speculative step is in flight — dispatch_step returns at outputs.is_empty()) never stamps stats, so the last cumulative increment could stay unpublished while the engine idles. The final version should also emit/refresh stats for steps with no surviving request output, or snapshot on every received step.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions