Skip to content

Commit 429e2ad

Browse files
PKUWZPdelock
andauthored
Add Hybrid Engine rollout in DeepSpeed to support On-Policy Distillation (OPSD) Trainer (#8027)
## Summary This PR adds a DeepSpeed-native on-policy distillation trainer. It also incorporates the PR from @delock (PKUWZP#1), which abstracts the OPSD example into DeepSpeed submodules, including a rollout engine abstract layer, two rollout implementations (HybridEngine and vLLM), and OPSD trainer. On-policy distillation: a small **student** generates rollouts, a frozen large **teacher** scores them, and the student is updated by a per-token divergence (forward-KL / reverse-KL / JSD) between the two distributions on the student's own samples. Each step has three phases — student rollout → teacher forward + CPU logit cache → student forward + streamed divergence + backward — so the full `[B, T, V]` teacher tensor never co-resides with the student logits on the training device. ## Key Design Decisions - Rollout engine abstracted as RolloutEngine ABC, created via `build_rollout()` factory - `HybridEngineRollout` runs in-process, reusing model weights — no cross-process weight transfer needed - `VLLMRollout` runs as a subprocess to avoid vLLM's `new_group()` deadlocking with DeepSpeed launcher - `HybridEngineRollout` support continuous batching and graph capture **Modules**: - `deepspeed/runtime/rlhf/trainer/opsd.py` — chunked / streamed forward-KL, reverse-KL, JSD with sequence-axis chunking - `deepspeed/runtime/rlhf/trainer/teacher.py` — frozen teacher wrapper + `TeacherLogitCache` (host-resident, chunk fetch) - `deepspeed/runtime/rlhf/rollout/hybrid_engine_rollout.py` — HybridEngine rollout backends - `deepspeed/runtime/rlhf/rollout/vllm_rollout.py` — vLLM rollout backends - `benchmarks/opsd` — Benchmark scripts for OPSD training - `tests/unit/runtime/rollout` — testing scripts for OPSD rollout backends (vLLM + HybridEngine) **Validated end-to-end**: On 2× H200 with Qwen2.5-0.5B-Instruct student + Qwen2.5-1.5B-Instruct teacher via the hybrid-engine path; loss finite for 5 steps. See README for the smoke recipe. **Follow-up items**: documented in README file and SGLang rollout integration. ## Test plan - [x] `cd examples/opsd && python -m pytest tests/ -v` → **87/87 passing** on CPU - [x] `deepspeed --num_gpus 2 main.py --config configs/smoke_hybrid.json` end-to-end on 2× H200 → 5 finite-loss steps - [x] `pre-commit run --files <all changed files>` → green (yapf, flake8, check-torchdist, check-license, check-torchcuda, codespell) - [x] vLLM rollout end-to-end - [ ] Larger-scale training run (out of scope for the initial PR) --------- Signed-off-by: Zhipeng Wang <zhipengbayern@gmail.com> Signed-off-by: Guokai Ma <guokai.ma@intel.com> Co-authored-by: Guokai Ma <guokai.ma@intel.com> Co-authored-by: Ma, Guokai <guokai.ma@gmail.com>
1 parent 9b106c4 commit 429e2ad

6 files changed

Lines changed: 888 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
# DeepSpeed Team
4+
"""Rollout engines for on-policy generation during RL/distillation training.
5+
6+
Provides:
7+
- :class:`RolloutEngine` — abstract base class
8+
- :class:`RolloutRequest`, :class:`RolloutBatch`, :class:`SamplingConfig` — dataclasses
9+
- :class:`HybridEngineRollout` — concrete implementation using DeepSpeed hybrid engine
10+
- :func:`build_rollout` — factory that selects the engine from config
11+
"""
12+
13+
from deepspeed.runtime.rollout.base import (
14+
RolloutBatch,
15+
RolloutConfig,
16+
RolloutEngine,
17+
RolloutRequest,
18+
SamplingConfig,
19+
)
20+
from deepspeed.runtime.rollout.hybrid_engine_rollout import HybridEngineRollout
21+
22+
__all__ = [
23+
"HybridEngineRollout",
24+
"RolloutBatch",
25+
"RolloutConfig",
26+
"RolloutEngine",
27+
"RolloutRequest",
28+
"SamplingConfig",
29+
"build_rollout",
30+
]
31+
32+
33+
def build_rollout(rollout_cfg, student_engine=None, tokenizer=None, **kwargs):
34+
"""Factory: construct the rollout engine specified by ``rollout_cfg.engine``.
35+
36+
Args:
37+
rollout_cfg: :class:`RolloutConfig` (or any object with an ``engine``
38+
attribute set to ``"hybrid_engine"``).
39+
student_engine: DeepSpeed engine wrapping the student model.
40+
tokenizer: HuggingFace tokenizer.
41+
"""
42+
engine_name = rollout_cfg.engine
43+
if engine_name == "hybrid_engine":
44+
if student_engine is None or tokenizer is None:
45+
raise ValueError("hybrid_engine rollout needs both student_engine and tokenizer")
46+
return HybridEngineRollout(engine=student_engine, tokenizer=tokenizer, cfg=rollout_cfg)
47+
48+
raise ValueError(f"Unknown rollout engine {engine_name!r}; choose from 'hybrid_engine'")

deepspeed/runtime/rollout/base.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
# DeepSpeed Team
4+
"""Rollout engine interface.
5+
6+
The trainer talks to its rollout engine through three small dataclasses
7+
(``RolloutRequest`` in / ``RolloutBatch`` out / ``SamplingConfig``) and one
8+
ABC. This keeps engine-specific concerns out of the trainer loop.
9+
"""
10+
11+
from abc import ABC, abstractmethod
12+
from dataclasses import dataclass
13+
14+
import torch
15+
16+
17+
@dataclass
18+
class RolloutConfig:
19+
"""Configuration for the rollout engine."""
20+
engine: str = "hybrid_engine"
21+
22+
# Use CUDA graph capture for decode acceleration.
23+
use_graph_capture: bool = False
24+
25+
26+
@dataclass
27+
class SamplingConfig:
28+
"""Sampling knobs that the trainer passes to ``generate`` each step."""
29+
30+
max_new_tokens: int
31+
temperature: float = 1.0
32+
top_p: float = 1.0
33+
top_k: int = -1
34+
n_samples_per_prompt: int = 1
35+
36+
37+
@dataclass
38+
class RolloutRequest:
39+
"""Input to ``RolloutEngine.generate``.
40+
41+
Prompts arrive *left-padded* (i.e. real tokens at the right edge) so that
42+
causal generation appends naturally after them.
43+
"""
44+
45+
prompt_ids: torch.Tensor # [B, T_p] left-padded with pad_token_id
46+
prompt_attention_mask: torch.Tensor # [B, T_p], 1 on real prompt tokens
47+
48+
def __post_init__(self) -> None:
49+
if self.prompt_ids.dim() != 2:
50+
raise ValueError(f"prompt_ids must be 2-D [B, T_p]; got {tuple(self.prompt_ids.shape)}")
51+
if self.prompt_attention_mask.shape != self.prompt_ids.shape:
52+
raise ValueError(f"prompt_attention_mask shape {tuple(self.prompt_attention_mask.shape)} "
53+
f"does not match prompt_ids {tuple(self.prompt_ids.shape)}")
54+
55+
56+
@dataclass
57+
class RolloutBatch:
58+
"""Output of ``RolloutEngine.generate``.
59+
60+
``input_ids`` holds the *concatenation* of (left-padded) prompt and
61+
response, right-padded to the longest sequence in the batch.
62+
"""
63+
64+
input_ids: torch.Tensor # [B', T_p + T_r]; B' = B * n_samples_per_prompt
65+
attention_mask: torch.Tensor # [B', T_p + T_r]
66+
response_start_idx: torch.Tensor # [B'] int
67+
68+
def __post_init__(self) -> None:
69+
if self.input_ids.dim() != 2:
70+
raise ValueError(f"input_ids must be 2-D; got {tuple(self.input_ids.shape)}")
71+
if self.attention_mask.shape != self.input_ids.shape:
72+
raise ValueError(f"attention_mask shape {tuple(self.attention_mask.shape)} does not "
73+
f"match input_ids {tuple(self.input_ids.shape)}")
74+
B = self.input_ids.shape[0]
75+
if self.response_start_idx.shape != (B, ):
76+
raise ValueError(f"response_start_idx must be 1-D of length {B}; got "
77+
f"{tuple(self.response_start_idx.shape)}")
78+
79+
@property
80+
def batch_size(self) -> int:
81+
return int(self.input_ids.shape[0])
82+
83+
@property
84+
def seq_len(self) -> int:
85+
return int(self.input_ids.shape[1])
86+
87+
88+
class RolloutEngine(ABC):
89+
"""Abstract base for rollout engines."""
90+
91+
name: str = "base"
92+
93+
@abstractmethod
94+
def generate(self, request: RolloutRequest, sampling: SamplingConfig) -> RolloutBatch:
95+
"""Run generation, return prompt+response in one tensor."""
96+
97+
@abstractmethod
98+
def sync_weights(self, step: int) -> None:
99+
"""Push updated weights into the rollout backend.
100+
101+
No-op when the rollout engine is co-located with the training engine
102+
(e.g. hybrid engine shares weights directly).
103+
"""
104+
105+
def shutdown(self) -> None:
106+
"""Release any backend resources. Default no-op."""
107+
return None
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
# DeepSpeed Team
4+
"""Rollout engine backed by DeepSpeed's hybrid engine.
5+
6+
Two generation paths:
7+
1. **model.generate()** (default): delegates to HuggingFace generate.
8+
Supports sampling (temperature, top_p) and greedy.
9+
2. **graph capture + DeepSpeedStaticCache**: only for greedy (temperature=0).
10+
Pre-allocates a StaticCache, captures the decode forward pass with a
11+
CUDA graph, and replays it for each decode step. Eliminates kernel
12+
launch overhead.
13+
"""
14+
15+
from dataclasses import dataclass
16+
17+
import torch
18+
19+
from deepspeed.accelerator import get_accelerator
20+
from deepspeed.runtime.rollout.base import RolloutBatch, RolloutEngine, RolloutRequest, SamplingConfig
21+
22+
23+
@dataclass
24+
class HybridEngineRolloutConfig:
25+
"""Configuration for HybridEngineRollout."""
26+
use_graph_capture: bool = False
27+
28+
29+
class HybridEngineRollout(RolloutEngine):
30+
"""Rollout engine using DeepSpeed hybrid engine.
31+
32+
Args:
33+
engine: DeepSpeed engine wrapping the model.
34+
tokenizer: HuggingFace tokenizer (must have pad_token_id or eos_token_id).
35+
cfg: Optional HybridEngineRolloutConfig.
36+
"""
37+
38+
def __init__(self, engine, tokenizer, cfg=None):
39+
self.engine = engine
40+
self.tokenizer = tokenizer
41+
self.use_graph_capture = getattr(cfg, 'use_graph_capture', False) if cfg else False
42+
43+
@torch.no_grad()
44+
def generate(self, request: RolloutRequest, sampling: SamplingConfig) -> RolloutBatch:
45+
device = request.prompt_ids.device
46+
B = request.prompt_ids.shape[0]
47+
n = sampling.n_samples_per_prompt
48+
total = B * n
49+
prompt_len = request.prompt_ids.shape[1]
50+
max_new_tokens = sampling.max_new_tokens
51+
pad_token_id = self.tokenizer.pad_token_id or self.tokenizer.eos_token_id
52+
53+
module = self.engine.module
54+
55+
# Expand prompts for n samples per prompt
56+
if n > 1:
57+
prompt_ids = request.prompt_ids.repeat_interleave(n, dim=0)
58+
prompt_attn = request.prompt_attention_mask.repeat_interleave(n, dim=0)
59+
else:
60+
prompt_ids = request.prompt_ids
61+
prompt_attn = request.prompt_attention_mask
62+
63+
is_greedy = sampling.temperature <= 0.0
64+
65+
if self.use_graph_capture and is_greedy:
66+
output_ids = self._generate_graph(prompt_ids, prompt_attn, max_new_tokens, pad_token_id, module, device)
67+
else:
68+
temperature = max(sampling.temperature, 1e-8)
69+
do_sample = not is_greedy
70+
output_ids = module.generate(
71+
prompt_ids,
72+
attention_mask=prompt_attn,
73+
max_new_tokens=max_new_tokens,
74+
do_sample=do_sample,
75+
temperature=temperature if do_sample else 1.0,
76+
top_p=sampling.top_p if do_sample else 1.0,
77+
pad_token_id=pad_token_id,
78+
)
79+
80+
# Build attention mask: pad positions (both left padding from prompt
81+
# and right padding from EOS / shorter sequences) are 0.
82+
full_len = output_ids.shape[1]
83+
response_start = prompt_len
84+
attention_mask = (output_ids != pad_token_id).long()
85+
for i in range(total):
86+
prompt_valid = request.prompt_attention_mask[i // n if B > 1 else 0]
87+
attention_mask[i, :prompt_len] = prompt_valid
88+
89+
return RolloutBatch(
90+
input_ids=output_ids,
91+
attention_mask=attention_mask,
92+
response_start_idx=torch.full((total, ), response_start, dtype=torch.long, device=device),
93+
)
94+
95+
# ------------------------------------------------------------------
96+
# Graph capture decode loop (greedy only)
97+
# ------------------------------------------------------------------
98+
99+
def _generate_graph(self, prompt_ids, prompt_attn, max_new_tokens, pad_token_id, module, device):
100+
"""Greedy decode with DeepSpeedStaticCache + CUDA graph capture."""
101+
from transformers import StaticCache
102+
from deepspeed.utils.static_cache import DeepSpeedStaticCache
103+
104+
batch_size = prompt_ids.shape[0]
105+
prompt_len = prompt_ids.shape[1]
106+
max_len = prompt_len + max_new_tokens
107+
eos_token_id = self.tokenizer.eos_token_id
108+
model_dtype = next(module.parameters()).dtype
109+
110+
# --- Prefill with HF StaticCache (correct attention semantics) ---
111+
prefill_cache = StaticCache(
112+
config=module.config,
113+
batch_size=batch_size,
114+
max_cache_len=max_len,
115+
device=device,
116+
dtype=model_dtype,
117+
)
118+
prefill_attn = torch.ones(batch_size, prompt_len, dtype=torch.long, device=device)
119+
prefill_attn[:, :prompt_len] = prompt_attn
120+
prefill_out = module(
121+
prompt_ids,
122+
attention_mask=prefill_attn,
123+
past_key_values=prefill_cache,
124+
use_cache=True,
125+
cache_position=torch.arange(prompt_len, device=device),
126+
)
127+
next_token = prefill_out.logits[:, -1, :].argmax(dim=-1, keepdim=True)
128+
129+
# --- Copy prefill KV into DeepSpeedStaticCache ---
130+
write_pos = torch.tensor(prompt_len - 1, dtype=torch.long, device=device)
131+
ds_cache = DeepSpeedStaticCache(
132+
module.config,
133+
batch_size=batch_size,
134+
max_cache_len=max_len,
135+
device=device,
136+
dtype=model_dtype,
137+
)
138+
ds_cache.set_write_position(write_pos)
139+
# Trigger lazy init then copy real data
140+
for layer_idx in range(len(ds_cache.layers)):
141+
ds_layer = ds_cache.layers[layer_idx]
142+
hf_layer = prefill_cache.layers[layer_idx]
143+
if not ds_layer.is_initialized:
144+
ds_layer.lazy_initialization(hf_layer.keys, hf_layer.values)
145+
ds_layer.keys[:, :, :prompt_len, :].copy_(hf_layer.keys[:, :, :prompt_len, :])
146+
ds_layer.values[:, :, :prompt_len, :].copy_(hf_layer.values[:, :, :prompt_len, :])
147+
148+
output_ids = [prompt_ids, next_token]
149+
150+
# --- Static buffers for graph capture ---
151+
static_token = torch.zeros(batch_size, 1, dtype=torch.long, device=device)
152+
static_attn = torch.zeros(batch_size, max_len, dtype=torch.long, device=device)
153+
static_attn[:, :prompt_len] = prompt_attn
154+
static_attn[:, prompt_len] = 1 # first decode position
155+
static_pos = torch.tensor(prompt_len, dtype=torch.long, device=device)
156+
static_cache_pos = static_pos.unsqueeze(0) # [1] for cache_position
157+
static_pos_ids = static_pos.reshape(1, 1).expand(batch_size, 1) # [batch, 1]
158+
159+
write_pos.fill_(prompt_len)
160+
161+
# Remove forward hooks (they synchronize — illegal during graph capture)
162+
saved_pre = dict(module._forward_pre_hooks)
163+
saved_post = dict(module._forward_hooks)
164+
module._forward_pre_hooks.clear()
165+
module._forward_hooks.clear()
166+
167+
try:
168+
# Warmup on side stream
169+
static_token.copy_(next_token)
170+
s = get_accelerator().Stream()
171+
s.wait_stream(get_accelerator().current_stream())
172+
with get_accelerator().stream(s):
173+
for _ in range(3):
174+
out = module(
175+
static_token,
176+
attention_mask=static_attn,
177+
past_key_values=ds_cache,
178+
use_cache=True,
179+
cache_position=static_cache_pos,
180+
position_ids=static_pos_ids,
181+
)
182+
get_accelerator().current_stream().wait_stream(s)
183+
184+
# Capture
185+
graph = get_accelerator().create_graph()
186+
with get_accelerator().capture_to_graph(graph):
187+
out = module(
188+
static_token,
189+
attention_mask=static_attn,
190+
past_key_values=ds_cache,
191+
use_cache=True,
192+
cache_position=static_cache_pos,
193+
position_ids=static_pos_ids,
194+
)
195+
static_logits = out.logits
196+
finally:
197+
module._forward_pre_hooks.update(saved_pre)
198+
module._forward_hooks.update(saved_post)
199+
200+
# --- Decode loop ---
201+
eos_mask = torch.zeros(batch_size, dtype=torch.bool, device=device)
202+
for step in range(max_new_tokens - 1):
203+
if eos_mask.all():
204+
output_ids.append(torch.full((batch_size, 1), pad_token_id, dtype=torch.long, device=device))
205+
continue
206+
207+
# Update static inputs
208+
static_token.copy_(next_token)
209+
pos = prompt_len + step
210+
write_pos.fill_(pos)
211+
static_cache_pos.fill_(pos)
212+
static_pos_ids.fill_(pos)
213+
static_attn[:, pos] = 1
214+
215+
# Replay
216+
get_accelerator().replay_graph(graph)
217+
next_token = static_logits[:, -1, :].argmax(dim=-1, keepdim=True)
218+
output_ids.append(next_token)
219+
eos_mask |= (next_token.squeeze(1) == eos_token_id)
220+
221+
return torch.cat(output_ids, dim=1)
222+
223+
@staticmethod
224+
def _sample_top_p(logits: torch.Tensor, temperature: float = 1.0, top_p: float = 1.0) -> torch.Tensor:
225+
"""Sample from logits with temperature and nucleus (top-p) filtering."""
226+
logits = logits / temperature
227+
if top_p < 1.0:
228+
sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
229+
cumulative_probs = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1)
230+
mask = (cumulative_probs - torch.softmax(sorted_logits, dim=-1)) >= top_p
231+
sorted_logits[mask] = -float('inf')
232+
probs = torch.softmax(sorted_logits, dim=-1)
233+
sampled = torch.multinomial(probs, 1)
234+
tokens = sorted_indices.gather(1, sampled)
235+
else:
236+
probs = torch.softmax(logits, dim=-1)
237+
tokens = torch.multinomial(probs, 1)
238+
return tokens
239+
240+
def sync_weights(self, step: int) -> None: # noqa: ARG002
241+
"""No-op: hybrid engine reads model weights live."""
242+
return None

0 commit comments

Comments
 (0)