Repository navigation
feat(qwen3): add a load-aware speculation gate and a static draft trim - #1177
Conversation
949e8fb to
53d9f74
Compare
53d9f74 to
c35fb1b
Compare
|
This pull request has merge conflicts with main. Please rebase it so it can be reviewed and merged. |
c35fb1b to
85edeba
Compare
FeathBow
left a comment
There was a problem hiding this comment.
The one-token verify round is the right shape for the gate: on main a plain decode drops the draft context, and the gated round advances it by the one committed row like any verify round. The span variants keep capture and replay shapes equal, and the trim stays consistent with hedge spans and the DFlash2 selector.
Nothing in the repo runs the lossless gates with either knob set. The runs in the description were manual, and both values are read once from the environment, so a test cannot set them per case. Could the gate cover them, through a gate run with the variables set or with the values passed in through the launch options? PEGAINFER_SPEC_MAX_BATCH=1 also never resumes speculation, so a case where the batch drops below the threshold mid request would show the resumed rounds still accept.
Two smaller points on the study. At c32, K=4 against plain decode is +1.8, +2.2 and +0.6 per round, under the 2% median the doc itself asks for, so the data supports on par with plain decode there. At c16, K=4 against DFlash changes sign across rounds. The #717 lesson and the DSpark Phase 2 rewrite record a different sweep and would sit better in their own doc change.
| // A load-gated round verifies a one-token span and proposes no | ||
| // draft; booking it as one would inflate the published draft | ||
| // count and dilute the acceptance rate. | ||
| if num_draft_tokens > 0 { |
There was a problem hiding this comment.
The auto hedge controller needs the same skip. With the hedge on, a gated round falls back from try_execute_hedged_verify, clear_round_chains sets the chain count to 0, and record_verify_dflash_context then ticks the controller with one-token rounds booked as C=0.
There was a problem hiding this comment.
Fixed in ce7b387: the explicit gated flag is threaded through VerifyPlan into record_verify_dflash_context, which now calls AutoHedge::break_window() (resets last_tick and the window accumulators) instead of tick. The gated interval is neither booked to C=0 nor charged to the first resumed sample. Unit test: break_window_never_charges_the_gated_interval.
18219c1 to
3a84440
Compare
FeathBow
left a comment
There was a problem hiding this comment.
The study wording and the docs split are done, and the one token verify and per span graphs read right. Three things remain.
The auto hedge point from the last round is still open: record_verify_dflash_context ticks the controller for gated rounds with C=0, so under PEGAINFER_SPEC_HEDGE_AUTO the gated throughput lands in the C=0 windows and advances exploration and the commit count. Skipping the tick alone is not enough, because tick times from last_tick and the first resumed sample would carry the gated interval, so the gate boundary should also break the interval.
Both new knobs parse with .parse::<usize>().ok() and fall back to 0, so -1 or a typo turns the gate off or keeps the full span with no message. Could malformed values be rejected with the variable name, keeping unset and explicit 0 as off?
The reversibility section of the study counts accepted_draft=0 lines as gated rounds. A round with every draft rejected logs the same value, and the log is per request, so 8142 and 29 do not show that speculation resumed after gating. Could that section be corrected together with the gate below? The gate needs weights and CI does not run it, so a run record at the new head (passed count and the counts per child) would help.
| let trace = run_child(CONCURRENT, &[("PEGAINFER_SPEC_MAX_BATCH", "3")]); | ||
| let (gated, drafting, _) = rounds(&trace); | ||
| assert!( | ||
| gated > 0 && drafting > 0, |
There was a problem hiding this comment.
This can pass without the gate ever tripping. Ordinary speculation also logs verified_draft=0 when a request has one output token left, since the span is truncated to the remaining budget, and each line is per request. Lossless output also holds whatever the drafts are. Could the trace mark gated rounds explicitly, and the test require one surviving request that is gated and later accepts drafts?
There was a problem hiding this comment.
Fixed in ce7b387: the plan now threads an explicit gated flag through VerifyPlan → StepCommand → the per-round trace (gated=), so a near-budget truncated span cannot stand in for a gated round. The resume case now asserts per request id: a request that had a gated=1 round must later show verified_draft>0 and accepted_draft>0. The MAX_DRAFTS children additionally assert gated=0 throughout.
| } | ||
|
|
||
| #[test] | ||
| fn a_one_token_span_registers_a_single_variant() { |
There was a problem hiding this comment.
This calls span_variant on a hand written [1] and never builds VerifyGraphBuffers, so it cannot catch a duplicate variant. It can go, or test the constructor.
There was a problem hiding this comment.
Removed in ce7b387 — it only exercised the pure selector on a hand-written [1] and could not catch a duplicate variant. The exact-match test above it stays.
| ), | ||
| "at the threshold the batch verifies a one-token span" | ||
| ); | ||
| assert!( |
There was a problem hiding this comment.
The assertion above already pins GatedSpeculativeDecode for the same call, so this one adds nothing.
There was a problem hiding this comment.
Removed in ce7b387; the preceding GatedSpeculativeDecode assertion covers the same call.
1cf0ca5 to
ce7b387
Compare
|
Round 2 addressed in
The run record (per-child |
FeathBow
left a comment
There was a problem hiding this comment.
The hedge window and the explicit gated marker are fixed, and the per request resume check asks the right question. Four things remain, all inline.
The new gate cannot parse a real trace and one new unit test expects the wrong result, so neither has run at this head, and the 1138 passed in the description comes from an earlier one. Malformed knobs still get through the public start_engine_with_offload. The docs still claim the resume from the withdrawn count.
CI runs no Qwen3 tests, so once these are fixed, could you paste the lib run and the gate run with passed counts?
| }; | ||
| out.push(( | ||
| field("round="), | ||
| field("request="), |
There was a problem hiding this comment.
RequestId displays as req-<n>, so a real line reads request=req-0. This field takes the empty digit run after request= and panics on the first trace line, before any assertion. The parser should read the id the way it is printed.
There was a problem hiding this comment.
Fixed in 3b9f0a6: each field now takes the first non-empty digit run after its name, which reads request=req-0. The parser was extracted to levers_trace_rows and has a weight-free unit test (levers_trace_parses_real_trace_lines), passed locally against real-format lines.
|
|
||
| #[test] | ||
| fn malformed_values_name_the_variable() { | ||
| for raw in ["-1", "4x", "1.5", "+2", "1_000"] { |
There was a problem hiding this comment.
"+2".parse::<usize>() returns Ok(2), since core's integer parse accepts a leading + for unsigned types too, so expect_err panics on that entry. It is a valid count, so it can come out of the list.
There was a problem hiding this comment.
Fixed: +2 parses to 2 (core accepts a leading + for unsigned types), so it moved out of the malformed list and into the positive assertions — the test now pins that behaviour instead of expecting an error.
| pub fn launch(model_path: &Path, options: Qwen3LaunchOptions) -> Result<Engine> { | ||
| // Refuse to start on a malformed spec knob instead of silently running with | ||
| // the lever off (unset and an explicit `0` still mean off). | ||
| crate::spec_env::validate(|name| std::env::var(name).ok())?; |
There was a problem hiding this comment.
This runs only in launch. The public start_engine_with_offload also takes a draft path and goes to start_engine_with_offload_inner without it, and spec_env::read then logs the error and returns 0, so the lever is silently off again. Could the check sit in the path every start shares, and read drop its fallback to 0?
There was a problem hiding this comment.
Fixed in 3b9f0a6: validation moved to start_engine_with_offload_inner and start_engine_with_lora_control — every public start (launch, start_engine, start_engine_with_offload, LoRA) funnels through one of them. The duplicate call in launch is gone, and spec_env::read now expects the validated value instead of falling back to 0.
| @@ -0,0 +1,170 @@ | |||
| # Qwen3-4B Speculative Decode on L20 — Gate Study (2026-10) | |||
|
|
|||
| **TL;DR:** A load-aware speculation gate (`PEGAINFER_SPEC_MAX_BATCH`) and a static draft trim (`PEGAINFER_SPEC_MAX_DRAFTS`) were built and measured on one L20 (sm_89) with Qwen3-4B and the two DeepSpec block-7 drafters. The **noise floor of the interleaved A/B protocol is 0.3–2.2 % (paired median)**, so the 5090-era ±1–2 % acceptance rules were never valid here; the measured **spec-off crossover is c32 for DFlash-block7 and c64 for DSpark-block7** (plain decode beats speculation by 4.8 % / 27 % at c64). The gate **recovers +46.6 % output tok/s at c64** (2115 → 3099) while leaving every below-threshold cell untouched (±1.2 %, inside the noise floor), and it is **reversible**: 8142 gated rounds and 29 speculating rounds in one run — the batch shrinks, speculation resumes, the DFlash capture survives. The trim tells the same story from the other end: its sign **flips with concurrency** (it loses at c8 — −3.6 % at K=4, −15.6 % at K=2 — and at c16 for K=2; c16 K=4 changes sign across rounds and is unresolved; it wins +9.1 % at c32 over the untrimmed drafter, which brings it to **parity** with plain decode, not above it; at c64 the span ordering is monotone — K=7 2113 < K=4 2405 < K=2 2572 < gate 3083). **The optimal verify span shrinks as the batch grows**, so a static K is a load-adaptive lever in disguise, and #718's adaptive window is the natural next step. What the gate does *not* do is reach the plain decode: the gated one-token **verify** step costs ~5 % more than a plain **decode** step, because it runs the prefill-style verify path instead of the tuned decode path. | |||
There was a problem hiding this comment.
This still cites 8142 gated and 29 speculating rounds as proof that speculation resumes, as do docs/index.md, dflash-speculative-decoding.md and the Settled list below. The reversibility section withdraws that count, the new gate has not run, and its record slot is a literal RUN_RECORD_PLACEHOLDER. Until the record exists, these places should say the resume is pending verification.
There was a problem hiding this comment.
Fixed: the TL;DR, docs/index.md, dflash-speculative-decoding.md and the Settled list now say the gate is reversible by construction with the resume run record pending; the only remaining 8142 mention is the withdrawal note in the Reversibility section.
There was a problem hiding this comment.
The record now exists. The gate ran on one L20 at head 3b9f0a66: 1 passed / 0 failed (86.5 s, PEGAINFER_TEST_LOG=1). Per-child trace rows (one per request per round): MAX_BATCH=1 315 gated / 0 speculating; MAX_DRAFTS=4 0/116 (max 4) and 0/64 (max 4); MAX_DRAFTS=2 0/142 (max 2) and 0/79 (max 2); MAX_BATCH=3 140 gated / 8 speculating, where the per-request "gated then later accepts" assertion passed. The benchmark doc carries this table; the TL;DR, index.md, dflash-speculative-decoding.md and the Settled list now point at it instead of the withdrawn count.
ce7b387 to
3b9f0a6
Compare
|
Round 3 addressed in
Local runs at this head (CI runs no Qwen3 tests):
The gate itself ( |
The verify pass costs batch * span target rows while a plain decode costs batch, so at high concurrency speculation can lose to plain decode outright. PEGAINFER_SPEC_MAX_BATCH=<n> stops drafting once the active batch reaches n. The obvious fallback -- ExecutionPlan::Decode -- is a one-way latch: a plain decode advances the sequence outside the speculative path, so the executor drops the captured DFlash context and only a fresh prefill can rebuild it. A gated step therefore runs a one-token verify round instead: no draft, one token committed per request, the DFlash context advanced by exactly the one captured row, and the capture plus draft-ready state preserved, so speculation resumes as soon as the batch shrinks below the threshold. The piecewise verify graphs grew a span dimension so the one-token shape gets its own graph set; a step replays a variant only on an exact row-count match and runs eager otherwise. Gated rounds are not counted as draft proposals. PEGAINFER_SPEC_MAX_DRAFTS=<k> caps how many proposed candidates enter the verify span. The drafter still rolls its whole block out (its cache still writes the block_size-slot in-fill tail every round, so the headroom is unchanged) and the span stays uniform across the batch, so a full-span step still satisfies the capture-shape == replay-shape invariant. A verify step now carries an explicit `gated` flag from the plan through the executor to the per-round trace. The flag (not the span length) identifies a gated round: a near-budget request's span is also one token. Under PEGAINFER_SPEC_HEDGE_AUTO a gated round breaks the controller's timing window instead of being booked to C=0, and the first resumed round re-anchors rather than charging the gated interval. The lossless gates run under both knobs: a child-process gate re-execs them all-gated (MAX_BATCH=1), trimmed (K=4 and K=2), and gated-then-resumed (MAX_BATCH=3 on four staggered budgets, where a request that was gated must later accept drafts). Malformed knob values refuse to start with the variable's name on every engine start path (not just `launch`) instead of silently disabling the lever; unset and an explicit 0 still mean off, and the readers no longer fall back to 0 after validation. Measured on one NVIDIA L20 (Qwen3-4B, DFlash/DSpark block-7 drafters, 64 ShareGPT prompts, greedy, interleaved A/B): paired noise floor 0.3-2.2 %; spec-off crossover c32 for DFlash block7 and c64 for DSpark block7; the gate is worth +46.6 % output tok/s at c64 (2115 -> 3099), a no-op below its threshold, and reversible. The trim's sign flips with concurrency: it loses at c8 (-3.6 % at K=4, -15.6 % at K=2) and at c16 for K=2, c16 K=4 is unresolved, and it wins +9.1 % at c32 over the untrimmed drafter, which brings it to parity with plain decode rather than above it; at c64 the span ordering is monotone (K=7 < K=4 < K=2 < the gate's span-1), so the optimal verify span shrinks as the batch grows. Protocol, per-shape caveats and raw CSVs: docs/benchmarks/qwen3-4b-spec-decode-l20.md. Signed-off-by: bohaotu <bohaotu@pony.ai>
3b9f0a6 to
fba93f0
Compare
|
Run record for the new gate (head
Rows are per request per round. The threshold-3 concurrent child is the resume case: it gates first (batch 4→3) and speculates after requests finish, and the gate's per-request assertion (a gated request later speculates and accepts) passed. Environment note: the pod's toolkit is CUDA 12.9 while the tree references CUDA 13's Local lib runs at this head: |
…ndant tests Signed-off-by: Feathbow <feathbow@gmail.com>
Commit attribution noticeThis pull request contains commits attributed to an account other than the PR author @sysuTobo without a verified signature from that account: If you are mentioned above and did not take part in this pull request, please say so here. Attributing a commit to someone else normally requires that person to be involved; maintainers should treat unacknowledged attribution as a review blocker. This check never fails the build. |
Signed-off-by: Feathbow <feathbow@gmail.com>
|
We pushed two commits on top that trim what repeats. Behaviour is unchanged except one fix.
Checked on sm_89: clippy with The description still mentions the CSVs and the earlier unit test count. |
FeathBow
left a comment
There was a problem hiding this comment.
thanks for your contrib :)
Description
Adds two load-aware levers to the Qwen3 speculative-decode path, both off by default, and the L20 measurement study that says when to arm them.
1.
PEGAINFER_SPEC_MAX_BATCH=<n>— the load gate (#718). The verify pass costsbatch * spantarget rows while a plain decode costsbatch, so at high concurrency speculation can lose to plain decode outright. The obvious implementation — fall back toExecutionPlan::Decode— is a one-way latch: a plain decode advances the sequence outside the speculative path, so the executor drops the captured draft context, and only a fresh prefill can rebuild it. The first trip would kill speculation engine-wide, the opposite of "recover when the load drops" (#718 §4).So a gated step is a one-token verify round: no draft is proposed, the target verifies the span
[current_token]alone, one token is committed per request, and the DFlash context advances by exactly the one captured row — the same bookkeeping a full round does. The capture and the draft-ready state survive, so the batch speculates again as soon as it shrinks below the threshold. The scheduler now distinguishes the two fallbacks explicitly:NotReady(a request has no captured context — plain decode, the pre-existing behaviour) versusGated(everything is draft-ready — a one-token verify round that keeps the capture).The verify CUDA graph needed a span dimension for this: a captured dense segment bakes its row count, so the one-token rows cannot share a graph with the full-span rows.
VerifyGraphBuffersnow holds one graph set per span variant, and a step replays a variant only when its row count matches that variant exactly — capture-shape ≡ replay-shape, unchanged.2.
PEGAINFER_SPEC_MAX_DRAFTS=<k>— the static draft trim. The drafter still rolls its whole block out (its cache still writes theblock_size-slot in-fill tail every round — the headroom is unchanged), but only the firstkcandidates enter the verify span, so the target forward carries1 + krows per request. The verify buffers are sized to the trimmed span and the span stays uniform across the batch, so the capture-shape ≡ replay-shape invariant still holds.Interaction and validation. Under
PEGAINFER_SPEC_HEDGE_AUTOa gated round is not a hedging sample: it breaks the controller's timing window instead of being booked toC = 0, and the first resumed round re-anchors rather than charging the gated interval. Malformed knob values (-1, a typo) refuse to start with the variable's name on every engine start path (start_engine_with_offload_innerandstart_engine_with_lora_control, not justlaunch); unset and an explicit0still mean off, and the readers no longer fall back to 0 after validation. The trace parser used by the gate readsrequest=req-<n>(the realRequestIdformat) and has its own weight-free unit test.The measured result (one L20, Qwen3-4B, DFlash/DSpark block7) —
docs/benchmarks/qwen3-4b-spec-decode-l20.md:The two levers are the ends of one axis: the optimal verify span shrinks as the batch grows (the row cost of the target forward scales with
batch × span, the accept-rate cost does not). A static K is therefore a load-adaptive lever in disguise — it needs a concurrency range, not a constant. The threshold must be armed at or above the measured crossover; below it the rows are nearly free and the lever throws the speculation win away (−16…−23 % measured). At c32 the trim's remaining gap to plain decode is +1.8 % by per-round pairing — under the study's own 2 % bar, so read that cell as parity rather than a win, and c16 K=4 changes sign across rounds (unresolved).Cell provenance: the c32 rows come from the trim sweep (0g; the gate cell from 0c), the c64 rows from 0h with the plain cell from 0c — the doc footnotes each cell.
Type of Change
Evidence
cargo test --release --workspace --lib— 1143 passed / 0 failed across 21 test binaries at this head (includes the new gate and the five new unit tests).cargo test --release -p pegainfer-qwen3 --test dflash_speculative_gate levers_trace_parses_real_trace_lines— passed (weight-free: feeds real-format trace lines, includingrequest=req-0).cargo test --release -p pegainfer-qwen3 --test dflash_speculative_gate— needs the target + drafter weights, so CI does not run it.dflash_spec_levers_keep_losslessness(new, child-process gate, the hedged gate's pattern) re-execs the lossless suites with the knobs set:MAX_BATCH=1— every round gated, so the one-token verify path is the only path exercised;MAX_DRAFTS=4/=2— greedy and concurrent gates stay lossless, and no round verifies more than K;MAX_BATCH=3on four staggered budgets — the batch shrinks below the threshold mid-run; the trace must show gated rounds and a request that was gated must later speculate and accept at least one draft (checked per request id), with the child's lossless assertions covering the output.The executor's per-round trace now carries
gated=(explicit, set by the plan) plusverified_draft=andaccepted_draft=and the request id. A zeroverified_draftalone is ambiguous: a near-budget span is truncated to one token too.Gate run record (one L20, head
3b9f0a66, 86.5 s,PEGAINFER_TEST_LOG=1):1 passed / 0 failed, with these per-child trace rows (one per request per round):MAX_BATCH=1MAX_DRAFTS=4MAX_DRAFTS=4MAX_DRAFTS=2MAX_DRAFTS=2MAX_BATCH=3The
MAX_BATCH=3row is the resume case (the per-request "gated then later accepts" assertion passed); theMAX_DRAFTSrows keep the gate off and never exceed K. The pod's toolkit is CUDA 12.9 while this tree references CUDA 13'scudaErrorLossyQuery, so the local build wraps that one error-mapping case in a version guard (outside the speculative path). The earlier 8142/29 c64 count came fromaccepted_draft, which cannot tell a gated round from a fully-rejected speculative one, and is not quoted as evidence.Throughput: 8 interleaved A/B sweeps on one L20 (sm_89), 64 ShareGPT first-turn prompts, greedy, 128 output tokens,
--no-prefix-cachesymmetric across arms. The harness's own paired noise floor is 0.3–2.2 % (measured with two identical arms) — the raw CSVs are retained next to the doc.Scope: the #717 truncation-sweep lesson and the DSpark Phase 2 archival moved to #1185 — they record a different sweep; this PR keeps the gate, the trim and the L20 study.
Checklist
docs/conventions/coding-style.md).CLAUDE.md).