Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2ef3af6
compare vllm rollout probs to actor model
LeonEricsson Aug 7, 2025
302b981
prompt logprobs
LeonEricsson Aug 18, 2025
a57a70f
implemented TIS. only for testing, lacks docs and cleaning
LeonEricsson Aug 18, 2025
a8e27e5
rename
LeonEricsson Aug 18, 2025
caa1ed5
wip
LeonEricsson Aug 21, 2025
f38a38e
cleanup v1
LeonEricsson Aug 25, 2025
a20b75c
cleanup v2
LeonEricsson Aug 25, 2025
fd35ee7
cleanup v3
LeonEricsson Aug 25, 2025
d6fde00
added TIS as configurable option
LeonEricsson Aug 26, 2025
49841ea
cleanup
LeonEricsson Aug 26, 2025
47933ce
Merge branch 'main' into rollout_off_policy_importance_sampling
LeonEricsson Aug 26, 2025
e1630eb
test
LeonEricsson Aug 26, 2025
01a208d
fix logs and rename
LeonEricsson Aug 27, 2025
2ad229f
comment
LeonEricsson Aug 27, 2025
96baa2e
always return logprobs from vllm; sanitize logprob
LeonEricsson Aug 27, 2025
4d10db0
nit
LeonEricsson Aug 27, 2025
87c8e8e
Enhance VLLM integration: return logprobs in responses and update imp…
qgallouedec Aug 27, 2025
c3ffa44
nit
LeonEricsson Aug 28, 2025
5bb5704
nit
LeonEricsson Aug 28, 2025
d4d8908
fix logprob sanitization
LeonEricsson Aug 28, 2025
80e66b6
Merge branch 'main' into rollout_off_policy_importance_sampling
LeonEricsson Aug 28, 2025
a2171bf
Merge branch 'main' into rollout_off_policy_importance_sampling
qgallouedec Aug 29, 2025
15cc470
Merge branch 'main' into rollout_off_policy_importance_sampling
qgallouedec Sep 1, 2025
ebd6084
Merge branch 'main' into rollout_off_policy_importance_sampling
LeonEricsson Sep 1, 2025
fff82d8
nit
LeonEricsson Sep 1, 2025
dc89722
nit
LeonEricsson Sep 1, 2025
dca7e89
nit
LeonEricsson Sep 1, 2025
e06a3cc
Merge branch 'main' into rollout_off_policy_importance_sampling
qgallouedec Sep 3, 2025
84fc626
update comment regarding old_per_token_logps
LeonEricsson Sep 3, 2025
c2d4866
Merge branch 'main' into rollout_off_policy_importance_sampling
LeonEricsson Sep 3, 2025
d9723b6
Merge branch 'main' into rollout_off_policy_importance_sampling
LeonEricsson Sep 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions tests/test_grpo_trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,41 @@ def test_training_vllm_guided_decoding(self):
new_param = trainer.model.get_parameter(n)
self.assertFalse(torch.equal(param, new_param), f"Parameter {n} has not changed.")

@require_vllm
@unittest.skip("We should add a mock for the vLLM server.")
def test_training_vllm_importance_sampling_correction(self):
"""Test that training works with vLLM for generation with guided decoding."""
dataset = load_dataset("trl-internal-testing/zen", "standard_prompt_only", split="train")

training_args = GRPOConfig(
output_dir=self.tmp_dir,
learning_rate=0.1,
per_device_train_batch_size=3,
num_generations=3,
max_completion_length=8,
report_to="none",
use_vllm=True,
vllm_importance_sampling_correction=True,
vllm_importance_sampling_cap=3.0,
)
trainer = GRPOTrainer(
model="Qwen/Qwen2.5-0.5B-Instruct", # tiny model is too small for vLLM
reward_funcs="trl-internal-testing/tiny-Qwen2ForSequenceClassification-2.5",
args=training_args,
train_dataset=dataset,
)

previous_trainable_params = {n: param.clone() for n, param in trainer.model.named_parameters()}

trainer.train()

self.assertIsNotNone(trainer.state.log_history[-1]["train_loss"])

# Check that the params have changed
for n, param in previous_trainable_params.items():
new_param = trainer.model.get_parameter(n)
self.assertFalse(torch.equal(param, new_param), f"Parameter {n} has not changed.")

def test_training_with_additional_generation_kwargs(self):
"""Test that training works with additional generation kwargs."""
dataset = load_dataset("trl-internal-testing/zen", "standard_prompt_only", split="train")
Expand Down
10 changes: 7 additions & 3 deletions trl/extras/vllm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,11 @@ def generate(
will override them.

Returns:
`list[list[int]]`:
List of lists of token IDs representing the model-generated completions for each prompt.
`dict` with keys:
- `completion_ids` (`list[list[int]]`):
List of lists of token IDs representing the model-generated completions for each prompt.
- `logprobs` (`list[list[float]]`):
List of lists of log probabilities for each generated token.
"""
url = f"{self.base_url}/generate/"

Expand Down Expand Up @@ -240,7 +243,8 @@ def pil_to_base64(image):
},
)
if response.status_code == 200:
return response.json()["completion_ids"]
json_response = response.json()
return {"completion_ids": json_response["completion_ids"], "logprobs": json_response["logprobs"]}
else:
raise Exception(f"Request failed: {response.status_code}, {response.text}")

Expand Down
22 changes: 20 additions & 2 deletions trl/scripts/vllm_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,15 @@ def chunk_list(lst: list, n: int) -> list[list]:
return [lst[i * k + min(i, r) : (i + 1) * k + min(i + 1, r)] for i in range(n)]


def sanitize_logprob(logprob):
import math

value = logprob.logprob
if math.isnan(value):
logger.warning(f"Generated NaN logprob, token logprob '{logprob}' will be ignored")
return float("-inf")


def main(script_args: ScriptArguments):
if not is_fastapi_available():
raise ImportError(
Expand Down Expand Up @@ -468,6 +477,7 @@ class GenerateRequest(BaseModel):

class GenerateResponse(BaseModel):
completion_ids: list[list[int]]
logprobs: list[list[float]]

@app.post("/generate/", response_model=GenerateResponse)
async def generate(request: GenerateRequest):
Expand Down Expand Up @@ -500,6 +510,8 @@ async def generate(request: GenerateRequest):
Returns:
`GenerateResponse`:
- `completion_ids` (list of list of `int`): A list of lists of token IDs for each generated completion.
- `logprobs` (list of list of `float`): A list of lists of log probabilities for each token in the
generated completions.

Example request:
```json
Expand All @@ -508,7 +520,7 @@ async def generate(request: GenerateRequest):

Example response:
```json
{"completion_ids": [[101, 102, 103], [201, 202, 203]]}
{"completion_ids": [[101, 102, 103], [201, 202, 203]], "logprobs": [[-0.1, -0.2, -0.3], [-0.4, -0.5, -0.6]]}
```
"""
request.images = request.images or [None] * len(request.prompts)
Expand All @@ -535,6 +547,7 @@ async def generate(request: GenerateRequest):
"min_p": request.min_p,
"max_tokens": request.max_tokens,
"guided_decoding": guided_decoding,
"logprobs": 0,
}
generation_kwargs.update(request.generation_kwargs)
sampling_params = SamplingParams(**generation_kwargs)
Expand All @@ -561,7 +574,12 @@ async def generate(request: GenerateRequest):
# Flatten and combine all results
all_outputs = list(chain.from_iterable(all_outputs)) # from list of list to single list
completion_ids = [list(output.token_ids) for outputs in all_outputs for output in outputs.outputs]
return {"completion_ids": completion_ids}
logprobs: list[list[float]] = [
[sanitize_logprob(next(iter(logprob.values()))) for logprob in output.logprobs]
for outputs in all_outputs
for output in outputs.outputs
]
return {"completion_ids": completion_ids, "logprobs": logprobs}

class InitCommunicatorRequest(BaseModel):
host: str
Expand Down
26 changes: 26 additions & 0 deletions trl/trainer/grpo_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ class GRPOConfig(TrainingArguments):
`mask_truncated_completions=True`, only tokens from non-truncated completions are considered.
use_liger_loss (`bool`, *optional*, defaults to `False`):
Whether to use the Liger GRPO loss.
vllm_importance_sampling_correction (`bool`, *optional*, defaults to `True`):
Whether to apply Truncated Importance Sampling (TIS) between vLLM completion logprobs and recomputed
logprobs. [Your Efficient RL Framework Secretly Brings You Off-Policy RL
Training](https://fengyao.notion.site/off-policy-rl) highlights that using a separate generation framework
(such as vLLM) can introduce off-policy effects due to subtle implementation differences between generation
and training backends. TIS is proposed as a remedy for this issue.
vllm_importance_sampling_cap (`float`, *optional*, defaults to `2.0`):
Truncation parameter C for Truncated Importance Sampling (TIS). This sets an upper bound on the importance
sampling ratio, improving training stability.

> Parameters that control the logging

Expand Down Expand Up @@ -579,6 +588,23 @@ class GRPOConfig(TrainingArguments):
default=False,
metadata={"help": "Whether to use the Liger GRPO loss."},
)
vllm_importance_sampling_correction: bool = field(
default=True,
Comment thread
LeonEricsson marked this conversation as resolved.
metadata={
"help": "Whether to apply Truncated Importance Sampling (TIS) between vLLM completion logprobs and "
"recomputed logprobs. Your Efficient RL Framework Secretly Brings You Off-Policy RL "
"Training highlights that using a separate generation framework (such as vLLM) can introduce off-policy "
"effects due to subtle implementation differences between generation and training backends. TIS is "
"proposed as a remedy for this issue."
},
)
vllm_importance_sampling_cap: float = field(
default=2.0,
metadata={
"help": "Truncation parameter C for Truncated Importance Sampling (TIS). This sets an upper bound on the "
"importance sampling ratio, improving training stability."
},
)

# Parameters that control the logging
log_completions: bool = field(
Expand Down
76 changes: 70 additions & 6 deletions trl/trainer/grpo_trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,8 @@ def __init__(
self.vllm_mode = args.vllm_mode
self.vllm_gpu_memory_utilization = args.vllm_gpu_memory_utilization # only applies to colocation mode
self.vllm_tensor_parallel_size = args.vllm_tensor_parallel_size # only applies to colocation mode
self.vllm_importance_sampling_correction = args.vllm_importance_sampling_correction
self.vllm_importance_sampling_cap = args.vllm_importance_sampling_cap
self.use_liger_loss = args.use_liger_loss
self.loss_type = args.loss_type
self.scale_rewards = {True: "group", False: "none"}.get(args.scale_rewards, args.scale_rewards)
Expand Down Expand Up @@ -1449,7 +1451,7 @@ def _generate_and_score_completions(
ordered_set_of_images = None

with profiling_context(self, "vLLM.generate"):
completion_ids = self.vllm_client.generate(
output = self.vllm_client.generate(
prompts=ordered_set_of_prompts,
images=ordered_set_of_images,
n=self.num_generations,
Expand All @@ -1461,17 +1463,23 @@ def _generate_and_score_completions(
max_tokens=self.max_completion_length,
guided_decoding_regex=self.guided_decoding_regex,
generation_kwargs=self.args.generation_kwargs,
logprobs=0, # only return the logprob of the generated token
)
payload = (output["completion_ids"], output["logprobs"])
else:
completion_ids = [None] * len(all_prompts_text)
# Broadcast the completions from the main process to all processes, ensuring each process receives its
# corresponding slice.
completion_ids = broadcast_object_list(completion_ids, from_process=0)
payload = None

# Broadcast the completions from the main process to all processes, ensuring each process receives its corresponding slice.
obj_list = [payload]
broadcast_object_list(obj_list, from_process=0)
completion_ids, logprobs = obj_list[0]

process_slice = slice(
self.accelerator.process_index * len(prompts),
(self.accelerator.process_index + 1) * len(prompts),
)
completion_ids = completion_ids[process_slice]
logprobs = logprobs[process_slice]

# Generate completions using colocated vLLM instances: each device holds vLLM copy and work on their own batch of prompts
elif self.vllm_mode == "colocate":
Expand All @@ -1489,6 +1497,7 @@ def _generate_and_score_completions(
"min_p": 0.0 if self.min_p is None else self.min_p,
"max_tokens": self.max_completion_length,
"guided_decoding": guided_decoding,
"logprobs": 0, # only return the logprob of the generated token
}
if self.args.generation_kwargs is not None:
generation_kwargs.update(self.args.generation_kwargs)
Expand Down Expand Up @@ -1526,18 +1535,26 @@ def _generate_and_score_completions(
all_outputs = self.llm.generate(vllm_inputs, sampling_params=sampling_params, use_tqdm=False)

completion_ids = [output.token_ids for outputs in all_outputs for output in outputs.outputs]
all_logprobs = [
[next(iter(lp.values())).logprob for lp in output.logprobs]
for outputs in all_outputs
for output in outputs.outputs
]

if self.vllm_tensor_parallel_size > 1:
# Slice completions for this rank within its TP group.
# Each rank generates all outputs — we keep only our share.
local_rank_in_group = torch.distributed.get_rank(group=self.tp_group)
tp_slice = slice(local_rank_in_group * orig_size, (local_rank_in_group + 1) * orig_size)
completion_ids = completion_ids[tp_slice]
all_logprobs = all_logprobs[tp_slice]

# Pad the completions, and concatenate them with the prompts
completion_ids = [torch.tensor(ids, device=device) for ids in completion_ids]
completion_ids = pad(completion_ids, padding_value=self.pad_token_id)
prompt_completion_ids = torch.cat([prompt_ids, completion_ids], dim=1)
sampling_per_token_logps = [torch.tensor(logprobs, device=device) for logprobs in all_logprobs]
sampling_per_token_logps = pad(sampling_per_token_logps, padding_value=0.0)

elif self.use_transformers_paged:
# Re-process inputs for paged generation if needed
Expand Down Expand Up @@ -1626,8 +1643,10 @@ def _generate_and_score_completions(
# samples may come from an earlier version of the model. In that case, we need to track old_per_token_logps
# for importance sampling. If the steps are aligned, importance sampling isn't necessary and we set
# old_per_token_logps to None.
# When using vLLM, we always compute old_per_token_logps for importance sampling, it was shown that the
# distribution mismatch between vLLM and the training model can be large and harm the training.
generate_every = self.args.steps_per_generation * self.num_iterations # generation frequency
if self.args.gradient_accumulation_steps % generate_every != 0:
if self.args.gradient_accumulation_steps % generate_every != 0 or self.use_vllm:
Comment thread
LeonEricsson marked this conversation as resolved.
Outdated
old_per_token_logps, _ = self._get_per_token_logps_and_entropies(
self.model,
prompt_completion_ids,
Expand All @@ -1642,6 +1661,13 @@ def _generate_and_score_completions(
else:
old_per_token_logps = None

# Compute the importance sampling ratio when using vLLM, to correct for potential distribution mismatch
if self.use_vllm and self.vllm_importance_sampling_correction:
importance_sampling_ratio = torch.exp(old_per_token_logps - sampling_per_token_logps)
importance_sampling_ratio = torch.clamp(
importance_sampling_ratio, max=self.vllm_importance_sampling_cap
)

# Compute the per-token log probabilities for the reference model
if self.beta != 0.0:
if self.ref_model is not None:
Expand Down Expand Up @@ -1762,6 +1788,38 @@ def _generate_and_score_completions(
if has_images:
self._logs["image"].extend(gather_object(images))

if self.vllm_importance_sampling_correction:
delta = torch.abs(old_per_token_logps - sampling_per_token_logps)
delta = delta[completion_mask.bool()]
mean_delta = torch.mean(delta) if delta.numel() > 0 else torch.tensor(0.0, device=device)
max_delta = torch.max(delta) if delta.numel() > 0 else torch.tensor(0.0, device=device)
self._metrics[mode]["sampling/sampling_logp_difference/mean"].append(
self.accelerator.gather(mean_delta).mean().item()
)
self._metrics[mode]["sampling/sampling_logp_difference/max"].append(
self.accelerator.gather(max_delta).max().item()
)

flat_is_ratio = importance_sampling_ratio[completion_mask.bool()]
min_importance_sampling_ratio = (
torch.min(flat_is_ratio) if flat_is_ratio.numel() > 0 else torch.tensor(0.0, device=device)
)
mean_importance_sampling_ratio = (
torch.mean(flat_is_ratio) if flat_is_ratio.numel() > 0 else torch.tensor(0.0, device=device)
)
max_importance_sampling_ratio = (
torch.max(flat_is_ratio) if flat_is_ratio.numel() > 0 else torch.tensor(0.0, device=device)
)
self._metrics[mode]["sampling/importance_sampling_ratio/min"].append(
nanmin(self.accelerator.gather(min_importance_sampling_ratio)).item()
)
self._metrics[mode]["sampling/importance_sampling_ratio/mean"].append(
self.accelerator.gather(mean_importance_sampling_ratio).nanmean().item()
)
self._metrics[mode]["sampling/importance_sampling_ratio/max"].append(
nanmax(self.accelerator.gather(max_importance_sampling_ratio)).item()
)

output = {
"prompt_ids": prompt_ids,
"prompt_mask": prompt_mask,
Expand All @@ -1772,6 +1830,8 @@ def _generate_and_score_completions(
}
if old_per_token_logps is not None:
output["old_per_token_logps"] = old_per_token_logps
if self.vllm_importance_sampling_correction:
output["importance_sampling_ratio"] = importance_sampling_ratio
if ref_per_token_logps is not None:
output["ref_per_token_logps"] = ref_per_token_logps
if "pixel_values" in prompt_inputs:
Expand Down Expand Up @@ -1904,6 +1964,10 @@ def _compute_loss(self, model, inputs):
per_token_loss = -torch.min(per_token_loss1, per_token_loss2)
if entropy_mask is not None:
per_token_loss = per_token_loss * entropy_mask

if self.vllm_importance_sampling_correction:
per_token_loss = per_token_loss * inputs["importance_sampling_ratio"]

if self.beta != 0.0:
per_token_loss = per_token_loss + self.beta * per_token_kl

Expand Down