@@ -1416,47 +1416,10 @@ bool Qwen35Backend::do_spec_decode(int committed, int n_gen,
14161416 while (n_generated < n_gen) {
14171417 const int need_commit_budget = n_gen - n_generated;
14181418
1419- // Budget tail-off: when remaining budget is within the spec-decode
1420- // batch size of the force-close threshold, hand off to AR for the
1421- // tail. AR handles the close-token override cleanly; spec-decode's
1422- // verify-and-accept loop can't safely inject a token mid-batch
1423- // without a KV-state rewrite.
1424- //
1425- // IMPORTANT: cache_.last_tok is set during do_prefill (line 701)
1426- // and NEVER updated by the spec-decode commit loop — local
1427- // `last_tok` here is the authoritative most-recently-committed
1428- // token. Sync it into cache_.last_tok before handing off so AR's
1429- // `first_tok = cache_.last_tok` seed is correct. Without this
1430- // sync, AR would re-seed from the prefill's last argmax (stale
1431- // by `n_generated` positions) and produce garbage continuation.
1432- if (budget_hook && !budget_hook->close_token_ids .empty ()) {
1433- int hard = budget_hook->hard_limit_remaining ;
1434- // Tail when remaining <= hard + one spec-decode batch worth of
1435- // headroom. Ensures the force-close fires within the AR tail
1436- // rather than after a final spec-decode batch overshoots.
1437- if (need_commit_budget <= hard + q_len) {
1438- std::fprintf (stderr,
1439- " [budget-hook] spec-decode tail-off at committed=%d/%d "
1440- " (remaining=%d, hard_limit=%d, batch=%d) — switching to AR\n " ,
1441- committed, n_gen, need_commit_budget, hard, q_len);
1442- step_graph_destroy (draft_sg);
1443- cache_.last_tok = last_tok; // sync spec-decode → AR seed
1444- // Build a fresh hook keyed off this call's local n_gen
1445- // (the remaining decode budget) so force-close fires once
1446- // remaining <= hard_limit relative to AR's loop counter,
1447- // not relative to the global decode budget. Without this
1448- // remap force-close would either fire on every iter
1449- // (negative remaining_for_hook) or never (positive but
1450- // misscaled).
1451- BudgetHook tail_hook = *budget_hook;
1452- int ar_n_gen = need_commit_budget;
1453- bool ok = do_ar_decode (committed, ar_n_gen, out_tokens, io,
1454- tail_hook, forced_close_out,
1455- degenerate_close_out);
1456- io.emit (-1 );
1457- return ok;
1458- }
1459- }
1419+ // Budget hook: no tail-off here. The close-token injection fires
1420+ // during the emit phase (step 8) after acceptance+replay, mirroring
1421+ // the existing floor_to_ar pattern. This keeps spec-decode active
1422+ // through the close boundary instead of tearing down to AR early.
14601423
14611424 if (last_tok < 0 && !out_tokens.empty ()) {
14621425 std::fprintf (stderr,
@@ -1633,6 +1596,7 @@ bool Qwen35Backend::do_spec_decode(int committed, int n_gen,
16331596 bool hit_eos = false ;
16341597 bool floor_to_ar = false ;
16351598 bool inject_tool_prefix = false ;
1599+ bool budget_close_fired = false ;
16361600 constexpr size_t kActionSuffixLookback = 16 ;
16371601 constexpr size_t kSkipSequenceLookback = 64 ;
16381602 int emitted = 0 ;
@@ -1668,10 +1632,38 @@ bool Qwen35Backend::do_spec_decode(int committed, int n_gen,
16681632 break ;
16691633 }
16701634 }
1635+ // Budget hook: check if remaining budget has hit the force-close
1636+ // threshold. Override this token with close_token_ids[0] and stop
1637+ // emitting — AR handles the rest via maybe_force_close.
1638+ if (budget_hook && !budget_hook->close_token_ids .empty () &&
1639+ !budget_close_fired)
1640+ {
1641+ const int generated_now = n_generated + emitted;
1642+ int remaining = n_gen - generated_now;
1643+ if (remaining <= budget_hook->hard_limit_remaining ) {
1644+ int32_t first_close = budget_hook->close_token_ids .front ();
1645+ if (replay_tok[i] == first_close) {
1646+ // Model self-closed at the boundary; consume as
1647+ // start of close sequence (no override needed).
1648+ budget_close_fired = true ;
1649+ } else {
1650+ // Force-close: override sampled token with close[0].
1651+ replay_tok[i] = first_close;
1652+ budget_close_fired = true ;
1653+ if (forced_close_out) *forced_close_out = true ;
1654+ }
1655+ std::fprintf (stderr,
1656+ " [budget-hook] spec-decode close at committed=%d/%d "
1657+ " (remaining=%d <= hard_limit=%d)\n " ,
1658+ committed + emitted, n_gen, remaining,
1659+ budget_hook->hard_limit_remaining );
1660+ }
1661+ }
16711662 out_tokens.push_back (replay_tok[i]);
16721663 io.emit (replay_tok[i]);
16731664 emitted++;
16741665 if (io.cancelled ) break ;
1666+ if (budget_close_fired) break ;
16751667 if (IS_EOS_TOK (replay_tok[i], w_)) { hit_eos = true ; break ; }
16761668 }
16771669 int injected = 0 ;
@@ -1747,6 +1739,47 @@ bool Qwen35Backend::do_spec_decode(int committed, int n_gen,
17471739 io.emit (-1 );
17481740 return ok;
17491741 }
1742+ // Budget hook close: close token was emitted during the emit phase.
1743+ // Roll back KV to pre-replay state and replay only the overridden
1744+ // prefix (including the close token) so KV stays consistent with
1745+ // the emitted output before AR takes over.
1746+ if (budget_close_fired) {
1747+ if (!target->restore_kv ()) {
1748+ step_graph_destroy (draft_sg);
1749+ return false ;
1750+ }
1751+ cache_.cur_pos = committed;
1752+ if (emitted > 0 ) {
1753+ std::vector<int32_t > replay_prefix (replay_tok.begin (),
1754+ replay_tok.begin () + emitted);
1755+ int prefix_last_tok = -1 ;
1756+ if (!target->verify_batch (replay_prefix, committed,
1757+ prefix_last_tok, nullptr )) {
1758+ std::fprintf (stderr, " spec-decode: budget-close prefix replay failed\n " );
1759+ step_graph_destroy (draft_sg);
1760+ return false ;
1761+ }
1762+ }
1763+ committed += emitted;
1764+ cache_.cur_pos = committed;
1765+ step_graph_destroy (draft_sg);
1766+ cache_.last_tok = out_tokens.empty () ? last_tok : out_tokens.back ();
1767+ const int total_draft_pos = std::max (1 , n_draft_steps * q_len);
1768+ out_accept_rate =
1769+ (float )((double )n_accept_sum / (double )total_draft_pos);
1770+ const int ar_n_gen = n_gen - n_generated;
1771+ if (ar_n_gen <= 0 ) {
1772+ io.emit (-1 );
1773+ return true ;
1774+ }
1775+ BudgetHook tail_hook = budget_hook ? *budget_hook : BudgetHook{};
1776+ tail_hook.close_token_ids .clear ();
1777+ bool ok = do_ar_decode (committed, ar_n_gen, out_tokens, io,
1778+ tail_hook, forced_close_out,
1779+ degenerate_close_out);
1780+ io.emit (-1 );
1781+ return ok;
1782+ }
17501783 if (hit_eos) break ;
17511784 }
17521785
0 commit comments