Repository navigation
[HybridEngineRollout] Add multi-turn tool-calling rollout, GRPO primitives and turn-level continuous batching - #9037
anyadontfly wants to merge 43 commits into
Conversation
Stage 1 of the agentic RL RFC (deepspeedai#8942). HybridEngineRollout generated one response per prompt as a padded tensor, which cannot carry interleaved model and tool spans or per-trajectory metadata. - Requests and outputs are lists of RolloutRecord: tokens across all turns, loss mask, rollout log-probs, reward, status and group index. ResponseTokenBatch.from_rollout honors the loss mask, so tool output is never a training target. - Multi-turn generation: generate, parse tool calls, run tools, append their output, within turn, token and context budgets. Model tokens are kept exactly as sampled, so recorded log-probs match the trained sequence. - Tool, ToolSet and ToolCall, with hermes and qwen3_coder parsers. Tool errors go back to the model as text. - Temperature, top-k, top-p and repetition penalty on the graph and continuous batching paths, in generate()'s order. The graph decode forward gets a pre-built 4-D mask, so capture works on Transformers 4.57.x with SDPA. Includes the changes in deepspeedai#8939 and deepspeedai#9004. - SamplingConfig gains return_log_probs, max_new_tokens_per_turn and an optional repetition_penalty; None uses the checkpoint's value. - Turn-level continuous batching: a row is freed when a turn ends and serves another trajectory while tools run. It rejects ZeRO-3. - group_advantages, grpo_policy_loss and old_log_probs_from_rollout. Tested on 1x A100, torch 2.14.1+cu130: rollout, static cache and v1 suites pass on Transformers 5.19.0 and 4.57.6. On a Qwen3-4B checkpoint with real tool calls in float32, recorded log-probs match a full forward within 1e-4 on continuous and static batching, with and without graph capture. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…gine settings continuous_batch_size sizes the KV cache and the captured decode graph, so it cannot change between generate() calls. It moves with return_log_probs from SamplingConfig to RolloutConfig and HybridEngineRolloutConfig, and a value below 1 is rejected when the config is built. Idle-row reset is removed. It only shortens KV reads for a kernel that reads up to the write position; SDPA reads the full cache width either way. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Multi-turn continuous batching read the rows' state back to the host every continuous_poll_steps steps (16 by default), so a turn that ended in between kept its row until the next check, and its tool call and the next queued turn waited with it. The static graph path skipped its EOS check on the same schedule. Both now check after every step, and continuous_poll_steps is removed. The profile's per-poll trace becomes a per-step trace, continuous_step_trace, read in the same host sync as the row state. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…rojects The tool and parser docstrings now say what each piece does in a line or two. References to SGLang's parser names and to miles' behavior are replaced by the behavior itself. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…peedai#8602 The test belongs to deepspeedai#8602, which adds the same file. Keeping it here would make both PRs add one path. Graph decode against native generation stays covered by test_static_graph_matches_native_llama_generation. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…on_2 test The test checks a rejection on the static graph path, but on an accelerator without graph capture generate() falls back to HF generate and never reaches it, so the test failed on CPU. The graph_capture_accelerator fixture turns the opt-in on, as the other graph-path tests do. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Single-turn generation is now a rollout of one turn without tool calls, so generate() has one driver and three schedulers instead of four drivers: static batching, continuous batching on a captured graph, and continuous batching without graph capture. - Continuous batching without graph capture keeps the shared write column with align_decode_fronts and enable_cache_trimming, and now also runs multi-turn rollouts. A returning turn longer than the column moves the column forward. The cache width is continuous_cache_capacity, or the longest admissible context plus one turn's budget. - Continuous batching on a captured graph gives each row its own write position, prefills each admission in one padded forward and stores only real tokens. Its decode mask is additive, since eager attention adds the mask to the attention weights. - Both continuous runs live in continuous_batching.py, and ContinuousBatchScheduler gains per-request budgets and front-of-queue submission for returning turns. - AutoTP is supported with static batching: the graph cache is sized per TP rank, ranks share their group's random state, and tools run on the group's first rank. AutoTP with continuous batching, hybrid engine inference tensor parallelism, and ZeRO-3 with multi-turn rollout or graph capture are rejected. - AutoTP's input consistency check treats DeepSpeedStaticCache as a cache, and the static graph path no longer fails without an EOS id. - Shared prefill applies to each trajectory's first turn and is rejected at construction where it can't run. - SamplingConfig checks its own options when created. - Graph reuse across calls, master's cache-size estimate and the single-turn continuous drivers are removed. Greedy float32 records from Qwen3-4B are identical to the previous tree and to static batching in all modes. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…g step On two ranks with ZeRO-2, each rank rolls out its own prompts on static and continuous batching, with and without graph capture. Its greedy records must match HF generate on its own module, before and after an optimizer step, and the step must change them. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Master gained deepspeedai#8939 (sampling on the graph decode path) and deepspeedai#9004 (a pre-built 4-D mask for the static graph decode forward). This branch already carries reworked versions of both, so the rollout module, its tests and the docs keep the branch's versions. The docs keep master's graph decode section, merged with the branch's sampling notes. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…llel rollout test ZeRO-3 runs single-turn static batching on HF generate, the only path it supports, with the same number of prompts on every rank. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb5660f598
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if sampling.max_new_tokens_per_turn is not None: | ||
| room = min(room, sampling.max_new_tokens_per_turn) | ||
| if self.max_context_len is not None: | ||
| room = min(room, self.max_context_len - sum(sample.attention_mask)) |
There was a problem hiding this comment.
Keep ZeRO-3 decode counts equal under context caps
When ZeRO-3 data-parallel ranks receive prompts with different effective lengths and max_context_len is set, this rank-local subtraction can produce different turn budgets, or no active row at all on one rank. _generate() then either skips generation or calls module.generate() with a rank-local max(budgets), even though _generate_tokens() relies on every ZeRO-3 rank executing the same number of parameter-gathering forwards, so one rank can exit while another enters a collective and hang; reject this combination or preserve a synchronized decode count.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
| attention_mask=attention_mask, | ||
| response_start_idx=torch.full((total, ), response_start, dtype=torch.long, device=device), | ||
| ) | ||
| prompt_ids, prompt_attn = left_pad_samples(turn_samples, pad_token_id) |
There was a problem hiding this comment.
Restore the EOS fallback for padless tokenizers
When a tokenizer defines eos_token_id but not pad_token_id (an explicitly documented and previously supported setup), pad_token_id remains None here and left_pad_samples() passes it to torch.full, causing every static rollout to fail before generation, even for a single unpadded prompt. Restore the EOS fallback before prompt rendering/padding and retain the clear error when neither token is defined.
Useful? React with 👍 / 👎.
| for row, i in enumerate(active): | ||
| tool_response = [] | ||
| if row in turn_calls: | ||
| tool_response = self._tool_response(turn_calls[row], tool_outputs[row]) |
There was a problem hiding this comment.
Enforce max_context_len on appended tool output
When a tool call is generated near the context limit, _turn_room() reserves space only for model-generated tokens, but the entire unbounded tool response is appended here without checking the remaining max_context_len. The next turn merely marks the record truncated, leaving a returned training sequence longer than the configured/model context despite the contract in docs/code-docs/source/inference-engine.rst:241-243; a long tool result can therefore make the subsequent training forward exceed positional limits or memory, so settle or truncate the trajectory before appending over-cap output.
Useful? React with 👍 / 👎.
| return_log_probs: bool = False | ||
|
|
||
| def __post_init__(self) -> None: | ||
| if self.multi_turn and not self.max_turns: |
There was a problem hiding this comment.
Reject negative max_turns values
With multi_turn=True, max_turns=-1, this truthiness check accepts the value; static rollout feeds it to range(-1) and returns untouched PENDING records, while continuous rollout never reaches its terminal-turn comparison and instead runs until another cap. Validate that max_turns is positive here and in HybridEngineRolloutConfig.__post_init__, which duplicates the same check.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
| looks_integral = "." not in value and "e" not in value.lower() | ||
| return int(number) if looks_integral and number.is_integer() else number | ||
| if param_type in ("boolean", "bool"): | ||
| return value.lower() == "true" |
There was a problem hiding this comment.
Preserve malformed boolean tool arguments
When a Qwen3-Coder response supplies an invalid boolean value such as <parameter=flag>yes</parameter>, this expression silently converts it to False instead of preserving the unconvertible text as _convert_parameter() promises or reporting a parse error. The tool can then execute with a plausible opposite argument and corrupt the trajectory/reward; only map valid true/false text and retain or reject other values.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb5660f598
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| forward_profiler = _ForwardProfiler(accelerator) | ||
| handles.extend(forward_profiler.register(module)) | ||
|
|
||
| turn_max_new_tokens = max(budgets) |
There was a problem hiding this comment.
Stop each static row at its context budget
When static batching on ZeRO 0–2 receives rows with unequal effective prompt lengths and max_context_len is set, this chooses the largest room for one HF generate() call, so a row whose own room is smaller still executes the extra decode steps until it happens to emit EOS; slicing the result later cannot prevent those forwards. If the cap is the model's positional limit, a non-EOS row is driven past that limit and can error or OOM despite the configured cap. The continuous runners stop per request, so the static path needs to group by budget or otherwise stop rows before the model forward.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
| tokens = prefill_output.sequences[:, -1:] | ||
| # output_logits holds the raw logits; return_log_probs rules out every other setting that changes them. | ||
| scale = sampling.temperature if sampling.temperature > 0.0 else 1.0 | ||
| log_probs = _chosen_log_probs(prefill_output.logits[-1] / scale, tokens) |
There was a problem hiding this comment.
Score fallback prefill tokens after repetition penalty
When continuous eager batching takes this compatibility branch for a model whose _supports_cache_class is None and the repetition penalty is not 1, generate() samples the first token from penalized scores but output_logits is explicitly raw, so this line records a probability from the unpenalized distribution. The first token of every admitted turn then has an incorrect old log-probability, silently distorting GRPO ratios; apply the same penalty used by generation or consume processed scores, as the cache-class sibling path does.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
|
|
||
|
|
||
| def build_rollout(rollout_cfg, student_engine=None, tokenizer=None, **kwargs): | ||
| def build_rollout(rollout_cfg, generation_engine=None, tokenizer=None, tools=None, **kwargs): |
There was a problem hiding this comment.
Preserve the public student_engine keyword
For callers using the prior public form build_rollout(cfg, student_engine=engine, tokenizer=tok), the renamed argument is now swallowed by **kwargs, leaving generation_engine as None and raising the misleading "needs both" error; positional callers alone continue to work. Retain student_engine as a deprecated alias (or explicitly translate it from kwargs) so this factory signature remains backward compatible.
AGENTS.md reference: AGENTS.md:L40-L40
Useful? React with 👍 / 👎.
|
|
||
| def _end_turn(self, sample, response, turn, generated, max_new_tokens): | ||
| """Settle a trajectory after a turn, and return the tool calls it should run next, if any.""" | ||
| if not response or response[-1] != self.tokenizer.eos_token_id: |
There was a problem hiding this comment.
Treat EOS ID lists as membership sets
When a tokenizer exposes multiple EOS IDs, which _pad_after_eos explicitly supports, response[-1] is an integer and can never equal the list, so every response is marked TRUNCATED and multi-turn tool calls are never parsed or run. The continuous graph sibling also assigns the list to a scalar stop_token and compares it row-wise; normalize both paths to test membership against all EOS IDs.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
| if value.lower() == "null": | ||
| return None |
There was a problem hiding this comment.
Preserve null text for string tool parameters
When a Qwen3-Coder call supplies the literal text null for a schema-declared string or an undeclared parameter, this unconditional branch runs before the type check and passes Python None to the tool instead of the promised original text. Only convert null when the schema actually admits a null value; string and unknown parameters must retain the literal.
Useful? React with 👍 / 👎.
…ol arguments - ZeRO-3 with max_context_len: each rank's turn budget depended on its own prompts, so ranks ran different numbers of forwards and the parameter gathers hung. Ranks now agree on the longest budget, and a rank with no room runs the same forwards on a placeholder prompt. - A tokenizer without a pad token pads with its EOS id again, as on master. - A tool output that would pass max_context_len ends the trajectory as truncated instead of overrunning the cap, on every scheduler. - multi_turn requires a positive integer max_turns. - The Qwen3-Coder parser converts only "true" and "false" to booleans and keeps other text unchanged. The ZeRO-3 test setup sets stage3_param_persistence_threshold to 0, so the test model's parameters are partitioned and the lockstep is exercised. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
|
Hi @anyadontfly thanks for your PR. I left a quick inline comments and may have other comments as I review. Thanks! |
Thanks for viewing! I'll check the commented lines. |
|
Known limitation (AutoTP): ranks of a tensor-parallel group share their random state once per |
…g in render_prompts - get_pad_token_id(tokenizer) replaces HybridEngineRollout._pad_token_id(), so the continuous-batching runs read it without going through the rollout. - get_repetition_penalty moves to utils next to it. - render_prompts only renders and tokenizes. Every generation path pads its own batch, so padding the whole request to one width was redundant. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
- index: nothing read it; group_index already ties samples to their prompt. - reward: rollout never sets it, and the trainer keeps rewards itself. - Status.ABORTED: no path produces it. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…an up names - The rollout reads ZeRO-3 partitioning, the AutoTP size and the inference TP size once in __init__, through zero_optimization_partition_weights(), autotp_size() and _config.hybrid_engine, as the rest of the repo does, instead of probing the engine with getattr fallbacks. The ZeRO-3 step all-reduce uses seq_data_parallel_group directly. - A turn's token allowance is its budget: _turn_room becomes _turn_budget. - Clearer names on the static path (static_batching, generated_length, prompt_attn_mask, the static graph's *_attn_mask buffers), and the profile reports max_prompt_length. _turn_limit is inlined, and some comments that restated the code are dropped. - Test engine stand-ins provide the accessors through _engine_for. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…ap length with hybrid_engine.max_out_tokens - RolloutConfig only selects the backend (engine). The hybrid engine's settings live on HybridEngineRolloutConfig, which subclasses it and is exported from deepspeed.runtime.rollout. HybridEngineRollout reads them directly and raises TypeError for another backend's config, which would otherwise leave every setting at its default. - max_context_len is removed. A trajectory's prompt, responses and tool output are capped by the DeepSpeed config's hybrid_engine.max_out_tokens, the hybrid engine's own total-length setting, which HybridEngineRollout requires to be set explicitly. Continuous batching sizes its rows from it, so multi-turn continuous batching no longer needs a separate capacity. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
The tokenizer has a single eos_token_id, but generation configs often list more, such as Llama 3.1's <|eom_id|> after a tool call. Passing the tokenizer's id to generate() replaced that list, and _end_turn and the graph paths compared tokens against a scalar, so a list EOS marked every turn TRUNCATED. get_eos_token_ids merges the tokenizer's and the generation config's ids. Every path stops on, trims at and ends turns on that set, and tokenize_tool_responses joins tool output after whichever of them the chat template ends the assistant turn with. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…ecated alias Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…any type Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…ool queue Replace the _ContinuousRun classes with master's _generate_continuous and _generate_continuous_graph, plus what multi-turn needs: per-request budgets, requeueing at the front of the pending queue, and a ToolQueue that runs tool calls on a background event loop while the other rows decode. Read EOS once per step, write the graph inputs with indexed writes, keep the step trace on both paths, record the fallback prefill's log-probs after the repetition penalty, and size the multi-turn shared column for the longest context plus one turn. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…te tests Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…rofile fields Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Combine master's ZeRO-3 synchronized early stopping (deepspeedai#8390) and KV caches sized from local prefill shapes under AutoTP (deepspeedai#9008) with the branch's rollout: the static path uses the group-aware stopping criterion, with a rank decoding only a placeholder prompt counted as idle; every decode path sizes its cache lazily from the prefill; single-turn continuous batching supports AutoTP again, and only multi-turn continuous batching rejects it. Master's new tests are ported to the records API. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…ens, use the accelerator API in tests Remove _continuous_prefill's target_slots, which no caller passes since the graph path prefills with _prefill_rows. Document hybrid_engine.max_out_tokens, which HybridEngineRollout requires as the cap on each trajectory, and route the on-device rollout tests' device checks through get_accelerator(). Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…edai#9058) ## what `TestZero3SynchronizedEarlyStop` (added in deepspeedai#8390) builds its tensors on `torch.device(f"cuda:{rank}")`. on the cpu multi-rank nightly (`cpu-torch-multi-latest`, `LOCAL_SIZE=4`) the test is admitted by the world-size gate, and the first `torch.tensor(..., device=device)` raises `AssertionError: Torch not compiled with CUDA enabled`. that is the 2026-11-11 recurrence in deepspeedai#9033 (run 42957495892). this takes the device from `get_accelerator().current_device_name()`, which is `cpu` on the cpu accelerator and `cuda:<local rank>` on cuda, so the gpu path is unchanged. ## why this is not a duplicate no open pr touches this test; deepspeedai#9009 and deepspeedai#9037 edit the same file but not this class. ## tests on cpu (8 vcpu x86_64, torch 2.15.0+cpu, python 3.12), with `LOCAL_SIZE=4`: - before: `pytest unit/runtime/rollout/test_hybrid_engine_rollout.py::TestZero3SynchronizedEarlyStop` fails with `Torch not compiled with CUDA enabled`, same traceback as the nightly - after: `1 passed` - `pytest unit/runtime/rollout/`: 94 passed, 2 skipped; `TestZero3SubgroupGeneration` failed locally only because `accelerate` is not installed in my env - flake8 and yapf clean on the changed file not run on gpu; the cuda device string is the same as before apart from using the local rank instead of the global rank. fixes deepspeedai#9033 (the hybrid engine rollout part; the earlier `test_tp_compile.py` failure looked flaky per the thread) Signed-off-by: Dundy Pasupuleti <dundysm@gmail.com> Co-authored-by: Ma, Guokai <guokai.ma@gmail.com>
Tools finish at wall-clock times, so tensor-parallel ranks admitted returning turns at different steps and ran different forwards. The first rank of each tensor-parallel group now runs the tools and, at each step where a row frees, sends the finished tools' output to its group over a host-side Gloo group, so every rank appends the same output and admits the same turns. deepspeed.comm.new_group takes an optional backend for that group. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
… tool group explicitly ZeRO-3 ranks now decode up to the same configured cap, and master's _GlobalEosStoppingCriteria counts a row as finished once it reaches EOS or its own budget. This replaces the per-turn all-reduce of the largest budget over seq_data_parallel_group, so the criterion's optimizer.dp_process_group is the only group, and a row past its budget no longer keeps every rank decoding. A rank with nothing left joins with a placeholder row whose budget is 0, in place of the idle flag. ToolQueue takes the AutoTP Gloo group and its first rank as arguments instead of reading the rollout's fields. Redundant local imports of deepspeed.comm are removed, and _chosen_log_probs is inlined into _TokenSelector. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Static batching now retires each row of a turn into ToolQueue and drains it before the next turn, so both batching modes share one implementation of the turn bookkeeping, the tool-output size check and the AutoTP tool sharing. The new ToolQueue.drain waits for every running tool and makes one pop_ready call, one collective per turn as before. _run_tools and its NCCL broadcast_object_list are removed, and the Gloo group is created for any multi-turn rollout under AutoTP. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
… group When the AutoTP size equals the world size, the tensor-parallel group is the default group, for which get_global_rank is the identity and never raises. get_all_ranks_from_group probes ranks until an error, so building the Gloo tool group looped forever in HybridEngineRollout.__init__ and exhausted host memory. Count the group's ranks from its size instead. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…toTP rollout Under Transformers 5.x, AutoTP turns a tied-embedding model's output head into VocabParallelLinear(gather_output=False), so each rank's logits cover only its vocabulary shard. Every rollout path selected tokens from that shard: ranks picked different, wrong tokens and normalized log-probs over one shard. generate() now sets gather_output=True on every vocabulary-parallel head for the duration of the call and restores it afterwards, so all decode paths see the full vocabulary and training keeps rank-local logits for the vocabulary-parallel loss. The fallback prefill sizes the repetition-penalty mask from the head's vocab_size instead of the local weight shard. Every path requests last-position logits only, so the gather is [B, V] per step. A model whose forward does not accept logits_to_keep would gather [B, T, V] at prefill. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
ToolQueue.requeue pops the turns whose tools finished, computes each next turn's budget (capped by the cache row on the graph path, from the sample's unpadded context), truncates trajectories with no budget left, and submits the rest to the front of the scheduler's pending queue. The continuous loops now set a request's context length and open its response buffers when it is admitted, and pop the buffers when it retires, so _requeue_tool_turns and its loop-state arguments are gone. Also drops an unused import. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…t helpers The class decides where each trajectory goes after a turn (end, run its tools, or back to the pending queue), not only tool execution. Move it next to the rollout that drives it, so continuous_batching.py again holds only scheduling primitives. Also move the sampled log-prob recorder and the host tensor-parallel group to rollout utils, run a turn's tool calls through ToolSet.run_all, and rename _continuous_turn to _pop_response. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…ous prefill Fold the cache-class check for continuous batching into _check_model (was _check_attention), which generate() runs before dispatch. Graph continuous batching now prefills through the same helper as the eager path, which uses HF generate() for models without _supports_cache_class, as master's graph path did; it then stores each row's tokens from column 0 of its cache row. Rename the two prefills after their cache layouts: _continuous_prefill_shared_column (eager) and _continuous_prefill_per_row (graph). Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…l execution Ending a turn is synchronous: every retired turn is recorded, and the trajectory either ends or calls tools, before anything else happens to it. Tool execution is asynchronous: calls are submitted and collected later at a boundary, after the whole batch's turn for static batching and at steps where a row frees for continuous batching. PostTurnRouter mixed the two. ToolQueue (now in tool.py) only runs tool calls: submit, pop_ready, wait, drain, and the AutoTP broadcast of the first rank's output. The rollout ends each turn (_finish_turn), which records the response and decides whether the trajectory ends or which tools it calls; appends tool output when it returns (_append_tool_output); and in continuous batching queues each turn with its budget (_submit_turn), for first and returning turns alike. All four decode paths share these. RolloutRecord gains num_turns, and the tokens a trajectory has generated are read from its loss mask, so the rollout keeps no per-trajectory counters of its own. RolloutRequest rejects records that already hold a rollout. tokenize_tool_responses takes the calls and their outputs and builds the tool messages itself. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
A tool output that would take a trajectory past max_out_tokens is dropped and the trajectory ends truncated, so one long output, such as a stack trace, ends a rollout that could have gone on. SamplingConfig.max_tool_output_chars cuts each tool output longer than that many characters before it is templated, keeping its "start", "end" or "both" ends (tool_output_keep) with a "...(truncated)" marker at the cut, as verl's max_tool_response_length does. The cap is per output and off by default; the check on the whole conversation stays as the backstop. Each record counts its cut outputs in num_tool_outputs_truncated. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
…ey serve Move only the methods this PR adds: the checks, the turn lifecycle and the static loop after generate(), each continuous-batching helper beside its loop or the profile code, and the EOS helpers where master's _is_eos was. Master's methods return to master's order. No code changes. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Resolve conflicts with deepspeedai#9009 (reuse continuous rollout graphs). Take its graph signature, which keys on each parameter's and buffer's address and layout instead of its version, so an in-place optimizer step no longer recaptures, and its shutdown(). Keep this branch's graph loop, which already dropped the prompt width from the signature and keeps its own single-turn capacity check. Port the new GPU test to rollout records, and describe reuse and shutdown() in the graph-capture part of the docs. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
Remove the static graph tests that test_decode_paths_match_hf_generate and test_graph_capture_decodes_left_padded_rows_as_if_alone subsume, the AutoTP tool-output test that the continuous AutoTP test covers on both schedules, and two repetition-penalty tests that other tests cover. Trim the parametrizations that add no path: the multi-turn as-if-alone cases at temperature 1.0, the second model in the reused-row repetition-penalty test, the static cases of the GPU over-budget tool-output test (covered on CPU), and the data-parallel matrix, which keeps every decode path on ZeRO-2 and one or two for offload, fp16 and ZeRO-3. Merge the config and sampling validation tests into one test each. Signed-off-by: Puyuan Yao <williamyao034@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad555e4710
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if not samples: | ||
| return RolloutBatch(samples=[]) |
There was a problem hiding this comment.
Keep empty ZeRO-3 ranks in the rollout collectives
When ZeRO-3 data-parallel ranks have uneven local work and one receives RolloutRequest(samples=[]), this rank-local return skips every parameter-gathering forward and GlobalWorkRemaining, while nonempty ranks enter module.generate() and block on ZeRO collectives. The zero-row rank needs to participate with placeholder synchronization just like over-cap rows, or empty local requests must be rejected collectively.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
| token_kl = torch.exp(log_ratio) - log_ratio - 1.0 | ||
| token_loss = token_loss + kl_coef * token_kl |
There was a problem hiding this comment.
Mask excluded KL positions before exponentiation
When ref_log_probs is supplied and a masked prompt/tool/pad position has a large positive ref_log_probs - log_probs (which is easy for a very unlikely fixed token), this exponentiation becomes inf; the later mask then evaluates inf * 0 as NaN, poisoning both the loss and KL metric, and even kl_coef=0 computes 0 * inf. Zero the masked log-ratios before exp or compute KL only on valid positions.
Useful? React with 👍 / 👎.
| ], | ||
| dim=0) | ||
| return select_next_tokens(logits) | ||
| tokens, log_probs = selector.first(prefill_output.logits[:, -1, :], prompt_ids, prompt_attention, rows) |
There was a problem hiding this comment.
Align padded repetition history across decode paths
When a batch is left-padded and repetition_penalty != 1, the cache-class continuous prefill removes masked pad IDs from the selector history here, while static graph prefill calls selector.first without a mask and Hugging Face's repetition processor on the static generate() path scans all input IDs. If the pad ID is EOS (a common fallback) or otherwise has a competitive logit, continuous rollout can sample or stop differently from static rollout for the same record; all paths need one padding policy.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
| return cdb.new_group(ranks) | ||
| if backend is None: | ||
| return cdb.new_group(ranks) | ||
| return cdb.new_group(ranks, backend=backend) |
There was a problem hiding this comment.
Forward backend through the CCL new_group override
When cdb is CCLBackend and multi-turn AutoTP initializes host_tensor_parallel_group(), this dispatch passes backend="gloo", but deepspeed/comm/ccl.py still overrides new_group(self, ranks) without accepting that keyword. The rollout therefore raises TypeError before generation on the CCL path; update that override to accept and forward the backend while preserving its subgroup bookkeeping.
AGENTS.md reference: AGENTS.md:L40-L40
Useful? React with 👍 / 👎.
| profile["tool_ms"] += tool_ms | ||
| profile["post_processing_ms"] += (turn_end - generation_end) * 1000.0 - tool_ms | ||
| profile["num_generated_tokens"] += int(output_ids.shape[0] * (output_ids.shape[1] - prompt_len)) | ||
| profile["num_turns"] += 1 |
There was a problem hiding this comment.
Count trajectory turns in static profiles
With static batching of multiple records, each batched generation round increments num_turns only once, while both the records themselves and the continuous profiler count one turn per trajectory. Thus a one-turn batch of 32 reports num_turns=1 in static mode and 32 in continuous mode, making cross-mode tool and throughput profiles misleading; increment by the number of turn_samples processed in the round.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
Implements stage 1 of the agentic RL proposal (#8942):
HybridEngineRolloutcan roll out multi-turn, tool-calling trajectories for RL training, with the primitives a GRPO trainer needs.RolloutRequestandRolloutBatchcarryRolloutRecords: one prompt and its rollout across all turns, withloss_mask(0 on tool output), recorded log-probs, reward and status.multi_turn=True, each turn's response is parsed for tool calls, the tools run, and their output is appended as tool messages. Tools subclassTool; parse errors, unknown tools and exceptions go back to the model as text. Parsers for the Hermes/Qwen3 and Qwen3-Coder formats.group_advantages,grpo_policy_loss(PPO-clip with clip-higher, optional k3 KL penalty, token-mean or sample-mean aggregation) andold_log_probs_from_rollout.ToolQueuewhile its tools run, and its next turn returns to the front of the pending queue.temperature,top_k,top_pandrepetition_penaltyapply ingenerate()'s order on every path, andreturn_log_probsrecords each token's log-prob under the sampling distribution.Changes
API
RolloutRequest/RolloutBatchholdRolloutRecords instead of tensorsRolloutRecordholds a series of states to manage trajectories across turnscontinuous_batch_sizeandreturn_log_probsmove fromSamplingConfigtoHybridEngineRolloutConfighybrid_engine.max_out_tokens, which must be set explicitly in the DeepSpeed configHybridEngineRolloutConfiggainsmulti_turn,max_turns,chat_template_kwargs,tool_call_parser;SamplingConfiggainsmax_new_tokens_per_turnSamplingConfig.max_tool_output_charscuts each tool output longer than that many characters before it is added, keeping its"start","end"or"both"ends (tool_output_keep) with a...(truncated)marker, as verl'smax_tool_response_lengthdoes;RolloutRecordcounts the cuts innum_tool_outputs_truncatedand the turns innum_turnsmax_out_tokensis dropped and the trajectory endsTRUNCATEDbuild_rollout(..., generation_engine=...)replacesstudent_engine, which still works as a deprecated aliasgeneration_configgenerate(), for example Llama 3.1's `<SamplingConfig.repetition_penaltyis optional;Noneuses the model'sgeneration_configgenerate()SamplingConfigvalidates its own options when createdgenerate()deepspeed.runtime.rollout.toolanddeepspeed.runtime.rollout.grpoGeneration paths
generate, or on a captured decode graph._generate_continuousand_generate_continuous_graphloops andContinuousBatchScheduler, with three additions for multi-turn: each request carries its own token budget, a request returning from tools goes to the front of the pending queue, and aToolQueueruns tool calls on a background event loop while the other rows decode; the rollout ends each turn itself and resubmits a turn once its tools return. Both loops read EOS once per step for all rows, and record each step for the profile.align_decode_frontsandenable_cache_trimming. A returning turn whose context is longer than the space behind the column moves the column forward, and live rows mask the skipped columns. A single-turn call keeps master's sizing; a multi-turn rollout gets the longest context a turn can be admitted with (max_out_tokens, plus any left padding of pre-tokenized samples) plus one turn's budget, which trimming at full always leaves room for.continuous_cache_capacitystays a memory cap for single-turn calls, and is raised to that bound, with a warning, for multi-turn rollouts.shutdown()releases it.Parallelism
generate. Ranks decode up to the same configured cap and stop together, through master's group-aware criterion from #8390, once every row on every rank has reached EOS or its own budget, whichmax_out_tokenscan make differ between ranks. A rank with no room joins with a placeholder row whose budget is 0, so it does not hold the others back. Multi-turn rollout, graph capture and continuous batching raise, since ranks would run different forwardsgenerate()call, and tool calls run on the group's first rank, which broadcasts the output. A tool that draws from the accelerator RNG therefore advances only that rank's state, and the group samples different tokens from the next turn on; such tools should use their owntorch.Generator. In multi-turn continuous batching the first rank also decides when a turn returns from its tools: at each step where a row frees, it sends the finished tools' output to its group over a host-side Gloo group, so every rank admits the same turns at the same step. With a tied output head that AutoTP shards by vocabulary (VocabParallelLinear, which master enables automatically for tied-embedding models under Transformers 5.x), each rank's logits cover only its shard, so ranks picked different, wrong tokens (R-1 in RFC #8790); the head now gathers the full logits for the duration of each rollout call and returns rank-local logits to training afterwards. Risk: the gather covers whatever positions the forward returns. Every rollout path, and HFgenerate(), requests last-position logits only (logits_to_keep=1), so it gathers[B, V]per step, about 10 MB at 32 rows in bf16. A model whose forward does not acceptlogits_to_keepwould return logits for every prompt position, and the gather would then build the full[B, T, V]tensor at prefill (about 20 GB at 32 rows x 2048 tokens with a 152k vocabulary); current Hugging Face causal LMs accept itinference_tp_size > 1Validation
Hardware and software: NVIDIA A100-SXM4-40GB (1 or 2 per run, driver 595.71.05) and NVIDIA A40, torch 2.14.1+cu130, CUDA 13.0, Python 3.12.15, Transformers 5.19.0 and 4.57.6.
pytest tests/unit/runtime/rollout tests/unit/utils/test_static_cache.py tests/unit/v1/test_hybrid_engine_rollout_v1.pypytest tests/unit/v1/autotp/test_tp_plan_real_models.py tests/unit/v1/autotp/test_tp_plan_e2e.pytensor_parallel.vocab_parallel_lm_head: falseDS_ACCELERATOR=cpu pytest tests/unit/runtime/rollout tests/unit/utils/test_static_cache.pypre-commitpre-commit run --files <changed files>sphinx-build -b html docs/code-docs/sourceBehavior checks with a real model (Qwen3-4B SFT checkpoint, stored in bf16; the equivalence checks load it in float32, so that rounding cannot flip near-tied tokens, and bf16 results are noted where run):
generateon 205 of 205 tokens for a padded batch of 8 (bf16: 190 of 207; rows split on near-tied logits).TRUNCATEDafter its first turn without the cap, since the output would passmax_out_tokens. Withmax_tool_output_chars=512, every trajectory completes its second turn on static, continuous and continuous graph batching,num_tool_outputs_truncatedcounts each cut output, and the chat template stays intact, with one assistant header per turn.responseequals a decode of all its response tokens at once, although it is built one turn and tool output at a time.Distributed tests added (2 GPUs):
generateon its own module, before and after an optimizer step, and the step changes them. Covered: ZeRO-2 on every path, ZeRO-2 with CPU optimizer offload and ZeRO-2 fp16 on graph capture, and ZeRO-3 on single-turn static batching.Generation speed against master, single-turn, path by path. Setup: Qwen3-4B in bf16 on one A100-SXM4-40GB; 32 chat prompts of varied length per call; 256 new tokens; 32 rows for continuous batching, as many as static batching decodes per call. Each cell is 2 timed calls after a warm-up, one round per tree, in tokens per second, master → branch. "Fixed length" disables EOS, including the model's
generation_configEOS ids, so both trees generate exactly the same tokens.generate)The weight-update row is the RL case. Neither tree captures the graph again after an in-place weight update (0 captures in the timed calls), so it runs at the speed of plain graph capture. In the real-EOS column, continuous graph capture on the branch generates 5,174 tokens per call against 5,242 on master and on every other continuous path, likely because its prefill rounds differently in bf16 and a few greedy rows split at near-tied logits; in float32 all modes give identical records. Peak extra memory matches master on every path except static graph decode, which needs about half as much (1.45 vs 2.72 GiB), because the prefill writes straight into the decode cache. Differences within about 2% are inside what one round with 2 timed calls can resolve.
Compatibility matrix
Companion PR
A DeepSpeedExamples PR adds a Qwen3 GRPO ReTool training example on this API. deepspeedai/DeepSpeedExamples#1023.
AI assistance
This PR was written with AI assistance (Claude Code). The submitter reviewed every changed line and ran the tests above.