Repository navigation
refactor: add engine and scheduler seams for multi-row decode steps - #833
Merged
Merged
Conversation
2 of 7 tasks
# Conflicts: # python/freetoken/scheduler/cache.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #828
First PR of the speculative decoding plan in #828: the engine and scheduler seams for decode steps that run several query rows per request. Nothing in this PR runs more than one row: every new knob defaults to one row per request, so serving behaves as on main. The full stack that uses these seams is in the draft PoC #832.
Summary
A speculative verify step runs k+1 rows per request through the target and then commits up to k+1 tokens. Today the engine and the scheduler assume one row and one token per request per decode step in several places. This PR turns those assumptions into explicit parameters, all fixed at 1 for now:
EngineConfig.decode_rows_per_req(always 1 here) drives every per-request decode allowance: the scheduler's admission and in-flight token headroom, the longest request (rows - 1short of the page-table width, so a widened step still fits), the CPU MoE executor's token capacity, and with GPU decode the MoE slot pools (the decode-batch clamp, the startup split and the rebuild check).Batch.rows_per_req(1 for decode) and averifyphase; a verify batch must setrows_per_req(asserted). The scheduler takes one GDN slot and one page-table row per request, everyrows_per_reqrows.Req.widen_pending(k)widens a request by k provisional rows;Req.commit_pending(n)andCacheManager.rollback_tail(...)keep the committed rows and free the rest._commit_tokens(req, tokens), which appends tokens in order and stops at the first one that finishes the request (EOS, stop string or length, same rules as before).DetokenizeMsg.next_token: intbecomesnext_tokens: List[int]; the detokenizer,LLMand the tokenizer server read the list.Nothing in this PR calls the multi-row pieces yet. The verify PR is the first caller of
widen_pending/commit_pending/rollback_tail, theverifyphase,Batch.featuresandFeatureSink, GraphRunner with more than one row per request,BackendInfo.uniform_extend_graph,report_batch(generated=)and the count_commit_tokensreturns; the MTP PR is the first caller ofContext.overridewith arguments and of GraphRunner with several outputs. The tests below cover multi-row attention graphs against the eager path and the pages a rollback keeps; the other pieces are exercised by their callers in those PRs (the verify PR's contract test runs multi-row CUDA graphs on fa and triton).Design
GraphRunnercaptures anyforwardcallable forrows_per_reqrows per request instead of the model's forward for one row. Its output buffers are sized from the first (largest) capture, so it is no longer tied to[bs, vocab]logits. Engine graphs passforward=self.model.forward, out_dtype=float32, i.e. what they captured before. The other new keyword arguments (phase,ctx_override,batch_hook,pool) are unused by the engine here; they let a draft capture its own graphs on its own backend, in the target's graph memory pool.init_capture_graph(max_seq_len, bs_list, rows_per_req=1)on every attention backend. fa, triton and flashinfer capturerows_per_req > 1(flashinfer through its prefill wrapper); dsa (and the GLM-5 Next backend built on it), dsv4, dsv41, m3, qsa and trtllm raiseNotImplementedErrorforrows_per_req != 1.BackendInfo.uniform_extend_graphrecords which is which.Context.override(kv_cache=, attn_backend=, hide_target_state=)lets a draft forward run on its own KV pool and backend with the target's GDN pool hidden;forward_batch(batch, **override)scopes it with the batch.Engine.forward_logits(batch)is split out offorward_batch, andParallelLMHead.logits(x)out of the LM head forward. The LM head hands its input rows tobatch.features(aFeatureSink,Noneunless a draft asks for them).Tests
New tests:
tests/attention/test_capture_rows_per_req.py: attention graphs captured with 1 and 4 rows per request replay what the eager path computes on fa, triton and flashinfer.tests/scheduler/test_rollback_tail.py: after a step widened by 3 rows commits 1..4 tokens, the request keeps exactly the pages of its committed tokens (page sizes 1 and 4) and the next step allocates no page twice.Existing tests change only for the renamed fields and the new signatures (
next_token->next_tokens,GraphRunner(forward=...),init_capture_graph(..., rows_per_req=1)on a fake backend,_commit_tokenson a scheduler stub,decode_rows_per_reqon a stub engine config);tests/engine/test_cache_budget.pygains a case with two rows per request for the GPU-decode MoE pool clamp.Test environment
decode_rows_per_req(no change at 1), 1cb80f7 removes the unreadContext.moe_offload_cachefield (no behavior change), and the last commit trims the new tests. Unit tests rerun on the last commit.Unit tests
1672 passed, 202 skipped, 1 failed. The failure is
tests/models/test_quant_config.py::test_scheme_for_agrees_with_the_stored_tensors[apodex/Apodex-1.1-mini-NVFP4[index-only]](a local checkpoint withquant_method: modelopt_mixed); it fails the same way on main.End-to-end A/B against main
AIME25 problem 1 through
freetoken.llm.LLM(model_path, dtype=torch.bfloat16, max_running_req=4)with the chat template, on main 9221fd7 and on this branch.Greedy, one request at a time (
temperature=0,max_tokens=16384, natural end), compared token by token:Qwen/Qwen3.6-35B-A3B(GDN hybrid, MoE)Qwen/Qwen3-30B-A3B(full attention, MoE)Decode speed: the checkpoint's recommended sampling from
generation_config.json(Qwen3.6-35B-A3B: T=1.0, top_p=0.95, top_k=20; Qwen3-30B-A3B: T=0.6, top_p=0.95, top_k=20), 1024 decode tokens withignore_eos, a 32-token warmup, median of 3 runs:Qwen/Qwen3.6-35B-A3BQwen/Qwen3-30B-A3BBatch: problems 1-8 of AIME25 submitted together (
max_running_req=4, so requests 5-8 queue until slots free up),Qwen/Qwen3.6-35B-A3B, recommended sampling,max_tokens=4096. All 8 requests succeed on both sides and run to 4096 tokens; on both sides the output reads coherently, and the final 300 characters of every request are the same on main and on this branch. Wall time 62.5 s on both.Smoke test on this branch, passed: