Skip to content

Commit 79cdcf4

Browse files
authored
Add OPSD (On-Policy Distillation) training example (#1002)
* Add OPSD (On-Policy Distillation) training example Entry point, configs, data, and tests for on-policy distillation using DeepSpeed's hybrid engine rollout and vLLM backend. Signed-off-by: Guokai Ma <guokai.ma@intel.com> Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Use ROLLOUT_VISIBLE_DEVICE env var for vLLM GPU placement; rename vllm_dtype to engine_dtype Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Remove vLLM path, absorb trainer/config/losses/utils/benchmarks from DeepSpeed - Delete vLLM configs, scripts (opsd_vllm_disjoint.json, smoke_vllm.json, train_opsd_vllm.sh) - Add trainer.py, config.py, losses.py, utils.py (moved from DeepSpeed) - Add benchmarks/ (5 hybrid engine benchmarks moved from DeepSpeed) - Update main.py imports (trainer, config now local) - Update test imports (losses, utils now local) - Rewrite README (remove all vLLM sections) Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Move teacher.py and data.py from DeepSpeed to DeepSpeedExamples Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Extend RolloutConfig with app-level generation knobs; clean vLLM remnants from JSON - Subclass DeepSpeed's RolloutConfig to add temperature/top_p/etc - Remove weight_sync_interval from JSON configs (vLLM remnant) Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Keep only bench_decode_1p1r; add --graph-capture flag and engine wrapper fix - Remove 14B/multi-GPU benchmarks (bench_14b_rollout, bench_autotp_gc, bench_hybrid_tp, bench_hybrid_tp_opt) - Fix bench_decode_1p1r: wrap model for HybridEngineRollout - Add --graph-capture CLI flag Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Fix distillation temperature from 0 to 1.0 in smoke and production configs temperature=0 causes logits/0 = inf → NaN loss. The correct default for knowledge distillation is temperature=1.0 (standard softmax). Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Fix trailing commas in JSON configs; remove unused smoke_ds_zero3.json Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Remove leftover vLLM comment references from OPSD example Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Fix OPSD README layout to match actual DSE directory structure Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Gather TP-sharded logits to full vocab for teacher and student; no-op at TP=1 / under ZeRO-3 Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Replace Microsoft copyright headers with DeepSpeed Team across OPSD example Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Use SPDX + DeepSpeed Team license header for OPSD files Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Use transformers 5.0+ dtype kwarg; require transformers>=5.0.0 Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Remove TP vocab-gather scaffolding; defer TP support to a later PR Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Shard data across ranks via DistributedSampler for real data parallelism Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Always route teacher through DeepSpeed ZeRO-3; offload opt-in (resolves review) Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Pick teacher ZeRO stage by world size: stage 3 only when sharding/offloading, else stage 0 Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Document that DeepSpeed must be installed from master until 0.19.3 release Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Derive train_batch_size from micro*world*grad_accum; drop it as a config knob Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Let DeepSpeed auto-derive train_batch_size instead of computing it explicitly Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Drop redundant train_batch_size omission comment Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Remove unused per_token_logprobs helper and its test Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Add CPU tests for LeftPaddedPromptCollator and PromptDataset Signed-off-by: Guokai Ma <guokai.ma@gmail.com> * Remove unused shift_for_next_token_prediction helper and its test Signed-off-by: Guokai Ma <guokai.ma@gmail.com> --------- Signed-off-by: Guokai Ma <guokai.ma@intel.com> Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
1 parent 6eb2582 commit 79cdcf4

20 files changed

Lines changed: 2082 additions & 0 deletions

training/opsd/README.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# On-Policy Distillation (OPSD) on DeepSpeed
2+
3+
A DeepSpeed-native port of [HJSang/OPSD_OnPolicyDistillation](https://github.com/HJSang/OPSD_OnPolicyDistillation),
4+
removing the verl dependency and building directly on DeepSpeed primitives
5+
(ZeRO-3, hybrid engine, `deepspeed.initialize`).
6+
7+
On-policy distillation trains a small **student** model to imitate a large
8+
frozen **teacher** on the student's *own* generated rollouts. Each training
9+
step has three phases:
10+
11+
```
12+
┌────────────┐ prompts ┌──────────────────┐ prompt+response ┌────────────┐
13+
│ Dataloader │ ──────────▶ │ Student rollout │ ──────────────────▶ │ Teacher │
14+
└────────────┘ │ (hybrid engine) │ │ forward │
15+
└──────────────────┘ └─────┬──────┘
16+
│ logits → CPU cache
17+
18+
┌─────────────────────┐
19+
│ Student forward + │
20+
│ streamed KL / JSD + │
21+
│ backward / step │
22+
└─────────────────────┘
23+
```
24+
25+
Loss = per-token divergence (`forward_kl` | `reverse_kl` | `jsd`) between
26+
student and teacher distributions on the student's generated tokens, chunked
27+
over the sequence axis so the full `[B, T, V]` teacher tensor never
28+
co-resides with the student logits on the training device.
29+
30+
## Layout
31+
32+
```
33+
training/opsd/
34+
├── main.py # entry point (deepspeed launcher)
35+
├── trainer.py # three-phase OPSD training loop
36+
├── config.py # OPSDConfig dataclass (JSON-loaded)
37+
├── teacher.py # frozen teacher + TeacherLogitCache
38+
├── losses.py # streamed distillation loss (fwd/rev KL, JSD)
39+
├── data.py # PromptDataset + left-padded collator
40+
├── utils.py # response-mask helpers
41+
├── configs/
42+
│ ├── ds_zero3.json # base DeepSpeed ZeRO-3 + hybrid engine
43+
│ ├── opsd_hybrid_engine.json # production-ish hybrid-engine OPSD config
44+
│ ├── smoke_hybrid.json # 5-step smoke test with Qwen2.5-0.5B / 1.5B
45+
│ ├── smoke_hybrid_gc.json # smoke test with CUDA graph capture
46+
│ └── smoke_ds_zero0.json # ZeRO-0 DeepSpeed config for smoke runs
47+
├── data/
48+
│ └── prompts.jsonl # sample math prompts
49+
├── benchmarks/ # rollout / decode micro-benchmarks
50+
├── scripts/
51+
│ └── train_opsd_hybrid.sh # launch hybrid-engine training
52+
├── tests/ # CPU-only unit tests (run with pytest)
53+
└── requirements.txt
54+
```
55+
56+
## Quick start
57+
58+
### Install
59+
60+
This example uses DeepSpeed's `deepspeed.runtime.rollout` module (hybrid
61+
engine + rollout API), which landed on `master` after the 0.19.2 release and
62+
is not yet on PyPI. Until DeepSpeed 0.19.3 is released, install it from
63+
source:
64+
65+
```
66+
pip install git+https://github.com/deepspeedai/DeepSpeed.git@master
67+
pip install transformers>=5.0.0 datasets accelerate
68+
```
69+
70+
Once DeepSpeed 0.19.3 ships, a plain `pip install deepspeed` works and the
71+
source line above can be dropped.
72+
73+
### Hybrid-engine training
74+
75+
```
76+
cd training/opsd
77+
NUM_GPUS=8 bash scripts/train_opsd_hybrid.sh configs/opsd_hybrid_engine.json
78+
```
79+
80+
The hybrid engine path lives entirely within DeepSpeed: the student engine
81+
both trains and generates, sharing weights without a copy step.
82+
83+
### Smoke tests (5 steps, small models)
84+
85+
The `smoke_hybrid.json` config runs on 2 GPUs in a few minutes with Qwen2.5-0.5B
86+
(student) and Qwen2.5-1.5B (teacher), so the full pipeline can be validated
87+
end-to-end before scaling up.
88+
89+
```
90+
cd training/opsd
91+
deepspeed --num_gpus 2 main.py --config configs/smoke_hybrid.json
92+
```
93+
94+
## Unit tests
95+
96+
The CPU-runnable test suite exercises the loss math and teacher caching. Run with:
97+
98+
```
99+
cd training/opsd
100+
python -m pytest tests/ -v
101+
```
102+
103+
## Configuration
104+
105+
`OPSDConfig` is a plain dataclass loaded from JSON (no Hydra). The schema:
106+
107+
```json
108+
{
109+
"student": { "model_name_or_path": "...", "dtype": "bfloat16" },
110+
"teacher": { "model_name_or_path": "...", "dtype": "bfloat16", "offload_to_cpu": true },
111+
"rollout": { "engine": "hybrid_engine", ... },
112+
"distillation": { "loss_type": "reverse_kl", "temperature": 1.0, "chunk_size": 512 },
113+
"training": { "train_batch_size": 8, "learning_rate": 1e-6, ... },
114+
"data": { "path": "data/prompts.jsonl", "prompt_field": "prompt" },
115+
"deepspeed_config": "configs/ds_zero3.json"
116+
}
117+
```
118+
119+
See `configs/opsd_hybrid_engine.json` for a fully-populated example.
120+
121+
## Design notes
122+
123+
* **Why CPU-cache the teacher logits?** Holding both student and teacher
124+
`[B, T, V]` tensors on GPU at once doubles memory pressure. Staging the
125+
teacher to host between the teacher forward and the student backward halves
126+
the worst-case GPU footprint of the loss path. The streamed loss
127+
(`losses.streamed_distillation_loss`) pulls teacher chunks back to GPU
128+
one sequence slice at a time so the full tensor never re-materialises.
129+
130+
* **Why an abstract `RolloutEngine`?** The ABC keeps the trainer
131+
engine-agnostic so additional backends can be added without touching the
132+
training loop. DeepSpeed provides the `HybridEngineRollout` implementation;
133+
external frameworks may plug in their own.
134+
135+
* **Hybrid engine on Qwen-family models uses a ZeRO-3 fallback** (no
136+
hybrid-engine inference acceleration), since DeepSpeed's inference policy
137+
list only covers GPT2/GPT-NeoX/OPT/BLOOM/LLAMA/LLAMA2/InternLM as of 0.15.
138+
The fallback gathers params via `GatheredParameters` and calls the HF
139+
model's `generate` directly — correct, just ~3-5x slower than the
140+
accelerated path.
141+
142+
## Other known limitations
143+
144+
* **Reward-weighted distillation** (OPSD's `opd.reward_beta` knob) is not
145+
ported. Easy to add: scale `per_tok` by a reward weight in the loss path.
146+
* **GRPO and other on-policy RL recipes** are out of scope. The
147+
`RolloutEngine` abstraction is reusable, but a GRPO trainer would add its
148+
own advantage / KL-to-reference logic on top.
149+
150+
## References
151+
152+
* OPSD reference repo: <https://github.com/HJSang/OPSD_OnPolicyDistillation>
153+
* DeepSpeed hybrid engine: `deepspeed/runtime/hybrid_engine.py`
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# DeepSpeed Team
3+
"""Micro-benchmark for 1p1r HybridEngineRollout decode.
4+
5+
Measures time breakdown of each decode step:
6+
- model forward (attention + FFN)
7+
- sampling (softmax + multinomial)
8+
- Python overhead (mask concat, state update, etc.)
9+
10+
Usage:
11+
python examples/opsd/bench_decode_1p1r.py --model Qwen/Qwen2.5-0.5B-Instruct
12+
"""
13+
14+
import argparse
15+
import time
16+
17+
import torch
18+
from transformers import AutoModelForCausalLM, AutoTokenizer
19+
20+
from deepspeed.accelerator import get_accelerator
21+
22+
from deepspeed.runtime.rollout.hybrid_engine_rollout import HybridEngineRollout
23+
from deepspeed.runtime.rollout.base import RolloutRequest, SamplingConfig
24+
25+
26+
def bench_decode_raw(model, tokenizer, device, prompt_len=64, max_new_tokens=64, num_warmup=3, num_iters=10):
27+
"""Raw decode loop benchmark — measures each component separately."""
28+
model.eval()
29+
model_dtype = next(model.parameters()).dtype
30+
31+
input_ids = torch.randint(10, 1000, (1, prompt_len), device=device)
32+
attn_mask = torch.ones(1, prompt_len, dtype=torch.long, device=device)
33+
34+
results = {
35+
"prompt_len": prompt_len,
36+
"max_new_tokens": max_new_tokens,
37+
"model_dtype": str(model_dtype),
38+
}
39+
40+
timings = {"prefill": [], "decode_forward": [], "sampling": [], "overhead": [], "total": []}
41+
42+
for _ in range(num_warmup + num_iters):
43+
with torch.no_grad():
44+
t0 = time.perf_counter()
45+
out = model(input_ids, attention_mask=attn_mask, use_cache=True)
46+
past = out.past_key_values
47+
logits = out.logits[:, -1:, :]
48+
t_prefill = time.perf_counter()
49+
50+
generated = []
51+
cur_token = logits.argmax(dim=-1)
52+
generated.append(cur_token)
53+
cur_mask = attn_mask
54+
55+
decode_times = []
56+
sample_times = []
57+
overhead_times = []
58+
59+
for step in range(max_new_tokens):
60+
t_step = time.perf_counter()
61+
cur_mask = torch.cat([cur_mask, torch.ones(1, 1, dtype=torch.long, device=device)], dim=1)
62+
pos_ids = torch.tensor([[prompt_len + step]], device=device)
63+
64+
t_fwd = time.perf_counter()
65+
out = model(cur_token,
66+
attention_mask=cur_mask,
67+
position_ids=pos_ids,
68+
past_key_values=past,
69+
use_cache=True)
70+
past = out.past_key_values
71+
t_fwd_end = time.perf_counter()
72+
73+
next_logits = out.logits[:, -1, :]
74+
probs = torch.softmax(next_logits / 1.0, dim=-1)
75+
cur_token = torch.multinomial(probs, 1)
76+
t_sample = time.perf_counter()
77+
78+
generated.append(cur_token)
79+
t_overhead = time.perf_counter()
80+
81+
decode_times.append(t_fwd_end - t_fwd)
82+
sample_times.append(t_sample - t_fwd_end)
83+
overhead_times.append(t_overhead - t_sample)
84+
85+
t_total = time.perf_counter()
86+
87+
timings["prefill"].append(t_prefill - t0)
88+
timings["decode_forward"].append(decode_times)
89+
timings["sampling"].append(sample_times)
90+
timings["overhead"].append(overhead_times)
91+
timings["total"].append(t_total - t0)
92+
93+
import numpy as np
94+
95+
def avg_last_n(lst, n):
96+
return np.mean(lst[-n:])
97+
98+
def avg_of_avg(list_of_lists, n):
99+
arrs = [np.array(ls[-n:]) for ls in list_of_lists]
100+
return np.mean([a.mean() for a in arrs])
101+
102+
results["prefill_ms"] = avg_last_n(timings["prefill"], num_iters) * 1000
103+
results["decode_forward_ms_per_step"] = avg_of_avg(timings["decode_forward"], num_iters) * 1000
104+
results["sampling_ms_per_step"] = avg_of_avg(timings["sampling"], num_iters) * 1000
105+
results["overhead_ms_per_step"] = avg_of_avg(timings["overhead"], num_iters) * 1000
106+
results["total_ms"] = avg_last_n(timings["total"], num_iters) * 1000
107+
results["decode_steps_total_ms"] = results["decode_forward_ms_per_step"] * max_new_tokens
108+
results["sampling_total_ms"] = results["sampling_ms_per_step"] * max_new_tokens
109+
results["overhead_total_ms"] = results["overhead_ms_per_step"] * max_new_tokens
110+
111+
return results
112+
113+
114+
def bench_hybrid_rollout(rollout, tokenizer, device, prompt_len=64, max_new_tokens=64, num_warmup=3, num_iters=10):
115+
"""Benchmark the full HybridEngineRollout.generate() path."""
116+
input_ids = torch.randint(10, 1000, (1, prompt_len), device=device)
117+
attn_mask = torch.ones(1, prompt_len, dtype=torch.long, device=device)
118+
sampling = SamplingConfig(max_new_tokens=max_new_tokens, temperature=1.0, top_p=1.0)
119+
request = RolloutRequest(prompt_ids=input_ids, prompt_attention_mask=attn_mask)
120+
121+
times = []
122+
for _ in range(num_warmup + num_iters):
123+
get_accelerator().synchronize() #ignore-cuda
124+
t0 = time.perf_counter()
125+
with torch.no_grad():
126+
rollout.generate(request, sampling)
127+
get_accelerator().synchronize() #ignore-cuda
128+
times.append(time.perf_counter() - t0)
129+
130+
import numpy as np
131+
avg = np.mean(times[-num_iters:]) * 1000
132+
return {"rollout_total_ms": avg, "prompt_len": prompt_len, "max_new_tokens": max_new_tokens}
133+
134+
135+
def main():
136+
parser = argparse.ArgumentParser()
137+
parser.add_argument("--model", default="Qwen/Qwen2.5-0.5B-Instruct")
138+
parser.add_argument("--prompt-len", type=int, default=64)
139+
parser.add_argument("--max-new-tokens", type=int, default=64)
140+
parser.add_argument("--num-warmup", type=int, default=3)
141+
parser.add_argument("--num-iters", type=int, default=10)
142+
parser.add_argument("--graph-capture", action="store_true", help="Enable CUDA graph capture")
143+
args = parser.parse_args()
144+
145+
device = get_accelerator().current_device() #ignore-cuda
146+
147+
tokenizer = AutoTokenizer.from_pretrained(args.model, padding_side="left")
148+
if tokenizer.pad_token is None:
149+
tokenizer.pad_token = tokenizer.eos_token
150+
151+
model = AutoModelForCausalLM.from_pretrained(args.model, torch_dtype=torch.bfloat16).to(device)
152+
153+
print(f"=== Raw decode loop benchmark (model={args.model}) ===")
154+
raw = bench_decode_raw(model, tokenizer, device, args.prompt_len, args.max_new_tokens, args.num_warmup,
155+
args.num_iters)
156+
print(f" Prefill: {raw['prefill_ms']:.2f} ms")
157+
print(
158+
f" Decode forward/step: {raw['decode_forward_ms_per_step']:.3f} ms (total: {raw['decode_steps_total_ms']:.1f} ms)"
159+
)
160+
print(f" Sampling/step: {raw['sampling_ms_per_step']:.3f} ms (total: {raw['sampling_total_ms']:.1f} ms)")
161+
print(f" Overhead/step: {raw['overhead_ms_per_step']:.3f} ms (total: {raw['overhead_total_ms']:.1f} ms)")
162+
print(f" Total: {raw['total_ms']:.1f} ms")
163+
164+
print(f"\n=== HybridEngineRollout benchmark (graph_capture={args.graph_capture}) ===")
165+
engine = type('Engine', (), {'module': model})() # lightweight wrapper
166+
from deepspeed.runtime.rollout.hybrid_engine_rollout import HybridEngineRolloutConfig
167+
cfg = HybridEngineRolloutConfig(use_graph_capture=args.graph_capture)
168+
rollout = HybridEngineRollout(engine, tokenizer, cfg=cfg)
169+
rr = bench_hybrid_rollout(rollout, tokenizer, device, args.prompt_len, args.max_new_tokens, args.num_warmup,
170+
args.num_iters)
171+
print(f" Rollout generate: {rr['rollout_total_ms']:.1f} ms")
172+
173+
print(f"\n=== Summary ===")
174+
print(f" Raw decode loop: {raw['total_ms']:.1f} ms")
175+
print(f" HybridEngine rollout: {rr['rollout_total_ms']:.1f} ms")
176+
print(f" Overhead (rollout - raw): {rr['rollout_total_ms'] - raw['total_ms']:.1f} ms")
177+
print(
178+
f" Bottleneck: decode forward = {raw['decode_forward_ms_per_step']:.3f} ms/step x {args.max_new_tokens} steps = {raw['decode_steps_total_ms']:.1f} ms"
179+
)
180+
181+
182+
if __name__ == "__main__":
183+
main()

0 commit comments

Comments
 (0)