Skip to content

Use vLLM's own server instead of TRL's - #6765

Merged
qgallouedec merged 21 commits into
mainfrom
vllm-serve-native-migration
Aug 26, 2026
Merged

qgallouedec merged 21 commits into
mainfrom
vllm-serve-native-migration

Conversation

@qgallouedec

@qgallouedec qgallouedec commented Aug 15, 2026 •

Copy link
Copy Markdown
Member

trl vllm-serve ran a custom FastAPI app around LLM, with its own data-parallel fan-out and a worker extension for
weight sync. vLLM's server covers all of it, so the app is gone and the command is now a deprecated wrapper that
translates its flags into vllm serve and prints the equivalent command.

Changes

  • Server: trl/scripts/vllm_serve.py goes from 1218 to ~130 lines! It adds the three settings TRL needs
    (--weight-transfer-config, --logprobs-mode processed_logprobs, --max-logprobs -1), sets
    VLLM_SERVER_DEV_MODE=1, and forwards any other argument to vllm serve. fastapi, uvicorn and pydantic leave the vllm extra.
  • Generation: /v1/completions for token IDs. No OpenAI-compatible endpoint takes token IDs and images, so
    multimodal prompts have their images processed on their own and the resulting features paired with the trainer's
    token IDs on /inference/v1/generate, which keeps multi-turn generation working with images.
  • Weight sync: vLLM's NCCL weight-transfer engine. Tensors are announced once and streamed in a single packed broadcast, instead of one HTTP request and one broadcast per tensor.
  • Distillation: teacher logprobs come from prompt_logprobs.
  • Docs: vllm serve is the documented way to start the server.

No change to training code: vllm_mode, vllm_server_base_url, vllm_group_port and the rest behave as before,
trl vllm-serve still accepts the same flags, and GRPO and RLOO are untouched.

Benchmark

GRPO in server mode on H100s, Qwen2.5-1.5B (text) and Qwen2.5-VL-3B (VLM), same seed and same data, server on its own GPUs and a two-process trainer.

Setup main (two runs) this PR (two runs)
Text, TP=1 1.08, 0.91 s/step 0.63, 0.62 s/step 1.59x
Text, TP=2 0.96, 0.96 s/step 0.62, 0.60 s/step 1.57x
VLM, TP=1 2.14, 2.13 s/step 1.48, 1.48 s/step 1.44x

Same behaviour: with a fixed generation seed the two implementations produce bit-identical completions (16/16 over 4 prompts x n=4, logprobs identical to 0.0 over 499 tokens). The training curves still do not lie on top of each
other, for reasons that have nothing to do with this PR.

Why the curves do not overlap exactly

The text curves diverge because each run talks to its own server process. Against the same server, two runs of
the same code are bit-identical, so the trainer itself is deterministic:

# repro_curve_divergence.py — needs two GPUs
#   VLLM_SERVER_DEV_MODE=1 CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen2.5-1.5B \
#       --weight-transfer-config '{"backend": "nccl"}' --logprobs-mode processed_logprobs --max-logprobs -1 &
#   CUDA_VISIBLE_DEVICES=1 python repro_curve_divergence.py
from datasets import load_dataset

from trl import GRPOConfig, GRPOTrainer

STEPS = 10


def reward_len(completions, **kwargs):
    return [-abs(40 - len(completion)) for completion in completions]


def run(output_dir, group_port):
    config = GRPOConfig(
        output_dir=output_dir,
        per_device_train_batch_size=8,
        num_generations=8,
        max_completion_length=64,
        max_steps=STEPS,
        use_vllm=True,
        vllm_mode="server",
        vllm_server_base_url="http://127.0.0.1:8000",
        vllm_group_port=group_port,  # one port per run, the first is still bound when the second starts
        generation_kwargs={"seed": 42},  # same sampling seed in both runs
        seed=0,
        data_seed=0,
        temperature=1.0,
        bf16=True,
        logging_steps=1,
        report_to=[],
        save_strategy="no",
    )
    trainer = GRPOTrainer(
        model="Qwen/Qwen2.5-1.5B",
        reward_funcs=reward_len,
        args=config,
        train_dataset=load_dataset("trl-internal-testing/zen", "standard_prompt_only", split="train"),
    )
    trainer.train()
    return [entry["reward"] for entry in trainer.state.log_history if "reward" in entry]


first, second = run("/tmp/repro_run_1", 51216), run("/tmp/repro_run_2", 51217)
differing = [step for step, (a, b) in enumerate(zip(first, second, strict=True)) if abs(a - b) > 1e-9]
print(f"run 1: {[round(value, 2) for value in first]}")
print(f"run 2: {[round(value, 2) for value in second]}")
print(f"identical for the first {differing[0] if differing else STEPS} steps")
print(f"mean |difference|: {sum(abs(a - b) for a, b in zip(first, second, strict=True)) / STEPS:.2f}")
run 1: [-230.88, -224.12, -240.62, -259.38, -203.25, -280.38, -247.0, -232.5, -266.88, -246.25]
run 2: [-230.88, -224.12, -240.62, -259.38, -203.25, -280.38, -247.0, -232.5, -266.88, -246.25]
identical for the first 10 steps
mean |difference|: 0.00

Give each run its own server — which comparing two implementations necessarily does — and they drift apart, because a
fresh engine returns slightly different logits and one flipped token changes every later step. That drift is as large
between two runs of main as it is between main and this PR:

Comparison (text, TP=1, reward over 30 steps) identical steps mean abs difference
main vs main, rerun 0 5.99
this PR vs this PR, rerun 0 9.78
main vs this PR 5 6.05

The VLM curves sit apart for a real reason. The server returns the processed image already cast to the model
dtype, while handing the engine the image directly preprocesses in float32. The values are otherwise the same:

# repro_image_dtype.py — needs one GPU
#   VLLM_SERVER_DEV_MODE=1 vllm serve Qwen/Qwen2.5-VL-3B-Instruct &
#   python repro_image_dtype.py
import base64
import io

import requests
import torch
from PIL import Image
from transformers import AutoProcessor
from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import decode_mm_kwargs_item

MODEL = "Qwen/Qwen2.5-VL-3B-Instruct"

image = Image.new("RGB", (64, 64), color=(30, 200, 30))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
url = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}"

response = requests.post(
    "http://127.0.0.1:8000/v1/chat/completions/render",
    json={
        "model": MODEL,
        "max_tokens": 1,
        "messages": [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}}]}],
    },
    timeout=300,
)
response.raise_for_status()
served = decode_mm_kwargs_item(response.json()["features"]["kwargs_data"]["image"][0])["pixel_values"].data

processor = AutoProcessor.from_pretrained(MODEL)
local = processor(images=[image], text=["<|vision_start|><|image_pad|><|vision_end|>"], return_tensors="pt")[
    "pixel_values"
]

print(f"server: {tuple(served.shape)} {served.dtype}")
print(f"local : {tuple(local.shape)} {local.dtype}")
print(f"max |difference|: {(served.float() - local.float()).abs().max().item():.3e}")
print(f"equal after casting the local one: {torch.equal(served, local.to(served.dtype))}")
server: (16, 1176) torch.bfloat16
local : (16, 1176) torch.float32
max |difference|: 2.747e-03
equal after casting the local one: True

So the image is the same image, rounded to the dtype the model runs in. That moves logprobs by ~0.04 and occasionally flips a greedy argmax: over three greedy generations the two implementations agreed on two and differed by one word on the third, both naming the colour correctly.

Validation

On H100s against vLLM 0.27.1:

  • weight sync at TP=1, TP=2 and DP=2: a no-op sync leaves greedy output bit-identical, a perturbation propagates, restoring the weights restores the output
  • GRPO, RLOO, VLM GRPO and server distillation trained end-to-end with the server on a separate GPU, plus the client-level weight sync checks (no-op, perturb, restore, single-tensor push, prefix cache)
  • multimodal generation continued from token IDs across turns
  • tests/test_vllm_client_server.py -m slow: 34 passed

Two fixes came out of this: vllm-project/vllm#52399, where
/inference/v1/generate drops completions when n > 1 (the client sets output_kind explicitly until it lands), and
the client here no longer builds its NCCL communicator on an unindexed device, which on main breaks a
single-process trainer with this nccl communicator is created to work on cuda, but the input tensor is on cuda:0.


Note

High Risk
Server-mode training depends on new vLLM HTTP/NCCL paths and required serve flags; regressions would affect generation, weight sync, and distillation across GRPO/RLOO/Online DPO, though trainer config surface is unchanged.

Overview
Replaces TRL’s custom FastAPI vLLM server with upstream vllm serve. trl vllm-serve is deprecated: it only maps TRL flags to vllm serve, sets VLLM_SERVER_DEV_MODE=1, injects TRL-required options (NCCL weight transfer, processed_logprobs, unlimited logprobs), and forwards extra CLI args. The vllm extra drops FastAPI/uvicorn/pydantic.

VLLMClient now talks to vLLM’s HTTP API: text/token prompts via /v1/completions, chat/multimodal via /v1/chat/completions, and token-ID + image features via /inference/v1/generate after image_features (/v1/chat/completions/render). Teacher logprobs for distillation use prompt_logprobs on completions instead of a custom endpoint. Weight sync uses vLLM’s NCCL weight-transfer engine (init_weight_transfer_engine, batched update_weights, optional start_weight_update / finish_weight_update) with a weight_update() context for one sync per optimizer step.

Trainers and VLLMGeneration are updated to match (e.g. Online DPO multimodal → chat, packed param streaming, prefix-cache reset). Docs, examples, and config help text now document vllm serve instead of trl vllm-serve.

Reviewed by Cursor Bugbot for commit e48e5a6. Bugbot is set up for automated code reviews on this repo. Configure here.

@bot-ci-comment

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Comment thread trl/generation/vllm_generation.py Resolved
Comment thread trl/trainer/grpo_trainer.py Resolved

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 659619393f

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread trl/generation/vllm_client.py Resolved
Comment thread trl/generation/vllm_generation.py Resolved
Comment thread trl/generation/vllm_generation.py Resolved
Comment thread trl/generation/vllm_client.py Resolved
Comment thread trl/generation/vllm_generation.py Resolved
@qgallouedec qgallouedec changed the title vllm serve native migration Use vLLM's own server instead of TRL's Aug 15, 2026
@qgallouedec
qgallouedec force-pushed the vllm-serve-native-migration branch from 827c4df to 73a0661 Compare August 15, 2026 12:41
@qgallouedec
qgallouedec force-pushed the vllm-serve-native-migration branch from 73a0661 to 4284a70 Compare August 15, 2026 13:15
`trl vllm-serve` ran a custom FastAPI app around `LLM`, with its own
data-parallel fan-out and a worker extension for weight sync. vLLM's server
covers all of it, so the app is deleted and the command becomes a deprecated
wrapper that translates its flags into `vllm serve`, prints the equivalent
command, and forwards anything it does not know about.

- generation goes to `/v1/completions` for token IDs. Multimodal prompts have
  no OpenAI-compatible endpoint that takes token IDs and images at once, so the
  images are processed on their own and the resulting features are paired with
  the trainer's token IDs on `/inference/v1/generate`, which keeps multi-turn
  generation working with images
- weight sync uses vLLM's NCCL weight-transfer engine: the tensors are
  announced once and streamed in a single packed broadcast, rather than one
  HTTP request and one broadcast per tensor
- teacher logprobs for distillation come from `prompt_logprobs`
- the docs teach `vllm serve` directly, and the web-server dependencies go

The request sets `output_kind` explicitly, as the server would otherwise drop
completions when `n > 1` (vllm-project/vllm#52399).
@qgallouedec
qgallouedec force-pushed the vllm-serve-native-migration branch from 4284a70 to 92abffe Compare August 15, 2026 13:53
Comment thread trl/generation/vllm_client.py Resolved
The sections explaining vLLM's own workers, tensor parallelism and PagedAttention
belong to vLLM's documentation, more so now that TRL runs its server rather than
its own. The throughput plots went with them: they were measured on older vLLM
versions and the warning underneath already told readers not to follow the data
parallel advice they show.
The required settings are listed in the table below, the reward and loss steps
belong to the trainer docs, and the separate-GPUs example appears verbatim right
after the quickstart. What is left is the part specific to TRL: which endpoints
the trainer calls, and which way the weights travel.
kashif added a commit to kashif/trl that referenced this pull request Aug 16, 2026
`trl vllm-serve`'s custom app, and with it `/get_sequence_logprobs/`, goes
away in huggingface#6765, so the rollout worker teacher-forces the
student's completion through vLLM's own endpoint instead: `max_tokens=1`
with `prompt_logprobs=teacher_top_k`, sliced from `len(prompt_ids)`. Same
request `VLLMClient.get_sequence_logprobs` issues for the synchronous
server-teacher trainers.

The response is parsed here rather than through that client: it truncates
each position to exactly `top_logprobs` after rank-sorting and reports the
realized token in separate fields, which would drop that token wherever it
ranks below k, zeroing the teacher signal there at `beta=1.0`. Parsing the
raw `prompt_logprobs` mapping keeps it at any rank, so the candidate rows
stay `teacher_top_k + 1` wide and `_narrow_top1_actual_support` keeps
finding what it indexes.

`/v1/completions` names the model it addresses and, under MOPD, every
teacher serves a different one, so each teacher's served id is resolved
from its `/v1/models` at worker startup. That doubles as the teachers'
readiness wait.

Teacher servers are now plain `vllm serve` and need `--logprobs-mode
processed_logprobs` (or `teacher_temperature` never reaches their
logprobs) and `--max-logprobs -1` (or `teacher_top_k > 20` is rejected).
Documented, along with the pre-existing requirement that every teacher
share the student's tokenizer, since teacher candidate ids index the
student's vocabulary directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread trl/generation/vllm_generation.py Resolved
Comment thread trl/generation/vllm_generation.py Resolved
Comment thread trl/generation/vllm_client.py Resolved
kashif added a commit to kashif/trl that referenced this pull request Aug 18, 2026
Upstream huggingface#6715 reworked what AsyncGRPO measures; huggingface#6674 reworked how the sync
distillation trainer reads a model's logit post-processing. Both apply here.

Metrics, following huggingface#6715's namespaces (`rollout/`, `completions/`, `sample/`,
`batch/`, `perf/`) and its reduction rule — the value's shape and the key's
suffix pick the reduction, so a rate is stored as a `(num, den)` pair and
reduced as Σnum/Σden instead of as a mean of ratios:

- the worker gets a `metrics_queue` the trainer drains in `log()`, so it can
  report what only it can see: `rollout/duration_s`, `rollout/score_s`,
  `rollout/generated_tok_s` (windowed), `rollout/inflight`,
  `rollout/backpressure_s`, `rollout/vllm_retry_total` and the
  `completions/*` family.
- per-sample gauges no longer ride on `RolloutSample.metrics` and are no
  longer NaN-padded into the batch, broadcast to every rank and reduced back
  to compute a number rank 0 already had. The collator logs the sample and
  packing metrics directly; `sample.enqueued_at` gives the trainer
  `sample/time_in_queue_s`.
- new per-step accounting (`perf/step_s`, `fwd_bwd_s`, `fwd_s`,
  `optimizer_s`, `rollout_wait_s`, the three weight-sync phases) plus
  throughput and MFU on both a compute and a wall-clock basis, which is what
  separates a slow trainer from a starved one.
- trainer-side queue metrics (`sample/rollout_queue_size`,
  `staleness_*`, `dropped_stale_total`, `batch/dropped_oversize_total`).
- traces are sampled per policy version rather than per N samples, which at
  a few hundred samples/step buried the real metrics under empty rows.

Two metrics are distillation's own, since the teacher call sits on the
critical path of every rollout rather than in a separate scoring loop:
`rollout/score_s`, and `teacher_score_s/{teacher_id}` under MOPD — one slow
expert throttles only the rollouts routed to it, which the blended mean
hides. `docs/source/async_distillation_trainer.md` documents every metric.

Also in this commit:

- read `logit_scale` / `final_logit_softcapping` through `get_text_config()`
  and fall back to Muse Glimmer's `output_multiplier`, matching
  `DistillationTrainer`.
- drop `# docstyle-ignore` from `AsyncDistillationConfig` (huggingface#6785) and let the
  styler reflow it. Two pre-existing line-break artifacts it exposed — a
  broken markdown link and a broken class reference — are fixed rather than
  reflowed into literal spaces.
- raise `AsyncRolloutWorker`'s vLLM gate from 0.17.1 to 0.22.0, which is what
  `weight_transfer.py` and the docs already require and which is above TRL's
  own supported floor.
- drop a stale `trl vllm-serve` mention left over from the huggingface#6765 migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread trl/generation/vllm_client.py Resolved
Comment thread trl/generation/vllm_client.py Resolved
Comment thread trl/generation/vllm_client.py Resolved

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e48e5a6. Configure here.

messages = [
prepare_multimodal_messages(prompt, images=[image] if image is not None else None)
for prompt, image in zip(prompts, images, strict=True)
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None-image rows break multimodal chat

Medium Severity

Passing images=None into prepare_multimodal_messages for None-image rows does not work with the earlier training_step rewrite that inserts {"type": "image"} placeholders on every conversational prompt whenever an image column is present. prepare_multimodal_messages then sees one unfilled placeholder and zero images and raises, so Online DPO server-mode VLM batches that mix real images with None fail. The old generate(..., images=...) path tolerated those rows.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e48e5a6. Configure here.

@qgallouedec

Copy link
Copy Markdown
Member Author

Merging without review. This one is quite big and important so please feel free to give late review share if anything is missing.

@qgallouedec
qgallouedec merged commit 67290a0 into main Aug 26, 2026
11 checks passed
@qgallouedec
qgallouedec deleted the vllm-serve-native-migration branch August 26, 2026 17:20
@verma8076 verma8076 mentioned this pull request Aug 28, 2026
5 of 8 tasks
@DaoyuanLi2816 DaoyuanLi2816 mentioned this pull request Aug 29, 2026
5 of 8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants