Skip to content

perf(vllm): return token metadata with chat completions - #3390

Merged
yuki-97 merged 4 commits into
NVIDIA-NeMo:mainfrom
jthomson04:jthomson04/fix-vllm-token-metadata
Aug 10, 2026
Merged

yuki-97 merged 4 commits into
NVIDIA-NeMo:mainfrom
jthomson04:jthomson04/fix-vllm-token-metadata

Conversation

@jthomson04

@jthomson04 jthomson04 commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR removes a redundant, awaited /tokenize round trip from every raw-vLLM model turn. Under a 64-concurrent multi-turn workload, that round trip was on the session critical path and accumulated into a significant model-serving bottleneck.

  • attach exact prompt token IDs, generation token IDs, and selected-token log probabilities to raw-vLLM chat-completion messages
  • match vLLM generation outputs to response choices by choice index and validate token/logprob alignment
  • preserve the dynamic token metadata in the serialized OpenAI-compatible response
  • retain Gym's existing /tokenize compatibility fallback unchanged

Performance impact

In a matched vLLM 0.23.0 benchmark using Gym eddd5e98, this change:

  • reduced summed model-request-wave span from 2,683.4s to 2,291.3s: a 392.1s (14.6%) reduction, or 1.17x speedup
  • eliminated /tokenize entirely across 18,078 successful model calls
  • brought raw vLLM to within 3.5% of Dynamo + vLLM on model-request span
  • closed 83% of the previously observed raw-vLLM-to-Dynamo serving gap

Because Gym awaited /tokenize before starting the next turn, this was not background bookkeeping overhead: its latency directly serialized multi-turn sessions. Returning metadata already available in vLLM's final RequestOutput removes that critical-path work without changing generation behavior.

Root cause

The raw-vLLM backend already had the exact prompt and generated token IDs in the final vLLM RequestOutput, but discarded them while serializing the chat-completion response. Gym therefore issued a separate awaited /tokenize request on every model turn to reconstruct prompt token IDs, adding inter-turn latency under concurrency.

Gym compatibility

No paired Gym change or submodule bump is required. NeMo-RL's pinned Gym revision, 473f446, already guards the compatibility fallback on the absence of native metadata:

if self.config.return_token_id_information and "prompt_token_ids" not in choice_dict["message"]:

Both client.create_tokenize(...) and the subsequent message_dict.update(...) are inside that block. Because this PR serializes prompt_token_ids on choice.message, pinned Gym skips the entire fallback. The fallback remains available for other servers that do not return native token metadata.

Validation

  • focused utility suite: 34 passed
  • Ruff check and format checks passed on all changed files
  • matched benchmark:
    • 320/320 valid trajectories
    • zero /tokenize calls
    • contiguous per-session multi-turn call sequences

The whole-run first-request-to-last-response span was 3,290.9s because this run accumulated 999.6s of between-wave environment delay, dominated by one 1,008s OpenHands trajectory. The benchmark therefore supports model-serving parity, not environment-inclusive wall-time parity.

Known Gym payload behavior

Gym's native-metadata path skips its compatibility cleanup, so the standard OpenAI choice.logprobs block remains alongside choice.message.generation_log_probs. NeMo-RL intentionally preserves choice.logprobs to maintain the endpoint's OpenAI response contract.

@copy-pr-bot

copy-pr-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test 5fc345f

@jthomson04 jthomson04 added the CI:L1 Run doctests, unit tests, and functional tests label Jul 28, 2026

@jthomson04 jthomson04 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nice change — the restructuring that decouples routed-experts attachment from token attachment is a clear improvement, and attach_token_information_to_chat_response_choices mirrors its routed-experts analog closely.

One blocking-ish issue (the gate on return_tokens_as_token_ids makes the feature a silent no-op for a request shape Gym itself supports), plus a new 500 path, some missing negative-test coverage, and a nit. Details inline.

One cross-repo consequence worth calling out here since it isn't visible in this diff: at the pinned Gym commit 473f446, choice_dict.pop("logprobs") lives inside if self.config.return_token_id_information and "prompt_token_ids" not in choice_dict["message"]: (responses_api_models/vllm_model/app.py:446, :499). Now that the server always supplies prompt_token_ids, that block is skipped entirely, so the duplicated per-token logprobs payload (token string + logprob + bytes + top_logprobs) stays on every returned message and flows into logged trajectories — the data the comment there calls "duplicated information". It doesn't break validation (NeMoGymChoice inherits Choice, which declares logprobs), but it partially offsets the payload win. Worth a companion Gym change, or at least a note in the PR description.

Comment thread nemo_rl/models/generation/vllm/utils.py Resolved
Comment thread nemo_rl/models/generation/vllm/utils.py Resolved
Comment thread nemo_rl/models/generation/vllm/utils.py Resolved
@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test c312907

@jthomson04
jthomson04 marked this pull request as ready for review July 29, 2026 18:31
@jthomson04
jthomson04 requested review from a team as code owners July 29, 2026 18:31
@copy-pr-bot

copy-pr-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

Auto-sync is disabled for ready for review pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test e5fa780

@jthomson04
jthomson04 force-pushed the jthomson04/fix-vllm-token-metadata branch from e5fa780 to a7d20e9 Compare August 3, 2026 18:30
@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test a7d20e9

@jthomson04
jthomson04 force-pushed the jthomson04/fix-vllm-token-metadata branch from a7d20e9 to 8e67f82 Compare August 4, 2026 17:22
@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test 8e67f82

@yuki-97 yuki-97 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall LGTM on the code — self-review pass already resolved the substantive issues (server-default fallback for return_tokens_as_token_ids, HTTP 400 for top_logprobs=null instead of 500, native CompletionOutput.logprobs instead of the token_id: string parse, and comprehensive error-branch coverage on the new helper).

The 3 attached fields (prompt_token_ids, generation_token_ids, generation_log_probs) map to the existing Gym consumer contract at responses_converter.py:166 and :431. Until now those fields were synthesized upstream by Gym's vllm_model server (app.py:462-496) via a per-turn /tokenize round-trip + token_id: prefix parsing on choice.logprobs.content, guarded by a START TODO / END TODO block whose exit condition is exactly "NeMo-RL vLLM natively supports prompt token ids". This PR is that upgrade — the producer now attaches natively, enabling a follow-up Gym change to delete the TODO block.

[BLOCKER] perf gain requires the paired Gym change. The currently pinned Gym (473f446) unconditionally calls /tokenize and .update()-overwrites the natively-attached fields on message_dict, so this PR by itself is perf-neutral (negligible extra serialization — see breakdown below). The 14.6% / 392.1s / "zero /tokenize calls" numbers land only after app.py:462-496 is deleted on the Gym side and the submodule pin is bumped — currently the PR description reads as if this PR is standalone-shippable for perf.

Payload size breakdown (per response, ~1000-token prompt + ~1000-token generation, for context on the "extra serialization" above):

Component Size Share of total
Plain text response (no logprobs, no metadata) ~2 KB 0.1%
+ native choice.logprobs.content (1000 positions × ~50 B) +50 KB 2.4%
+ prompt_token_ids (1000 int, this PR) +5 KB 0.2%
+ generation_token_ids (1000 int, this PR) +5 KB 0.2%
+ generation_log_probs (1000 float, this PR) +10-15 KB 0.6%
+ routed_experts for Qwen3-30BA3B (48 MoE layers × topk=8 × int16, base64 envelope) +2000 KB 96.5%
Total ~2.1 MB 100%

The 3 new fields sum to ~20 KB — <1% of a Qwen3-30BA3B response and ~40% growth on top of a non-MoE baseline — both dwarfed by the per-turn /tokenize round-trip latency they replace.

Two nits attached separately.

Comment thread nemo_rl/models/generation/vllm/utils.py Resolved
@yuki-97

yuki-97 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

hi @jthomson04 , nice feature!

[BLOCKER] perf gain requires the paired Gym change. The currently pinned Gym (473f446) unconditionally calls /tokenize and .update()-overwrites the natively-attached fields on message_dict, so this PR by itself is perf-neutral (mildly negative from the extra serialization). The 14.6% / 392.1s / "zero /tokenize calls" numbers land only after app.py:462-496 is deleted on the Gym side and the submodule pin is bumped — currently the PR description reads as if this PR is standalone-shippable for perf.

for this one, want to confirm are you using a different version nemo-gym, or am I missing something?

@jthomson04

Copy link
Copy Markdown
Contributor Author

Thanks for checking this. The benchmark did use Gym eddd5e98, but a paired Gym change is not required for the optimization.

At the pinned Gym revision 473f446, the /tokenize compatibility path is conditional:

if self.config.return_token_id_information and "prompt_token_ids" not in choice_dict["message"]:

Both client.create_tokenize(...) and the subsequent message_dict.update(...) are inside that block. This PR preserves prompt_token_ids directly on choice.message during serialization, so the condition is false and pinned Gym skips the entire fallback; it does not overwrite the native fields.

The standard choice.logprobs payload remains because that cleanup is also inside the skipped compatibility block. That duplication is intentional here to preserve the OpenAI response contract and is now called out separately in the PR description.

I updated the description to disclose the benchmark Gym revision and make the pinned-Gym behavior explicit. No Gym PR or submodule bump is needed.

@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test 9d9676f

Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
@jthomson04
jthomson04 force-pushed the jthomson04/fix-vllm-token-metadata branch from 9d9676f to 60c006d Compare August 6, 2026 23:15
@copy-pr-bot

copy-pr-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test 60c006d

@yuki-97 yuki-97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, thanks for the improvement and the clear clarify!

@yuki-97
yuki-97 merged commit 037fb36 into NVIDIA-NeMo:main Aug 10, 2026
99 checks passed
hchings added a commit to hchings/RL that referenced this pull request Aug 14, 2026
…probs, real logprobs

- Strip trailing whitespace from content before asserting tool parser
  contracts on both backends; canonical expected value is now "before"
- Add top_logprobs=0 to all three vLLM scenario request bodies to satisfy
  the NVIDIA-NeMo#3390 precondition check (VLLMValidationError when top_logprobs is None)
- Replace _extract_generation_token_ids string-parsing with msg field reads;
  use actual generation_log_probs from the response instead of fabricated zeros

Signed-off-by: Erin Ho <14718778+hchings@users.noreply.github.com>
copy-pr-bot Bot pushed a commit that referenced this pull request Aug 14, 2026
…probs, real logprobs

- Strip trailing whitespace from content before asserting tool parser
  contracts on both backends; canonical expected value is now "before"
- Add top_logprobs=0 to all three vLLM scenario request bodies to satisfy
  the #3390 precondition check (VLLMValidationError when top_logprobs is None)
- Replace _extract_generation_token_ids string-parsing with msg field reads;
  use actual generation_log_probs from the response instead of fabricated zeros

Signed-off-by: Erin Ho <14718778+hchings@users.noreply.github.com>
ananthsub added a commit to NVIDIA-NeMo/Gym that referenced this pull request Aug 18, 2026
#1558)

## Summary

- Consume a complete token bundle from `choice.message` without making a
second `/tokenize` request.
- Support opt-in vLLM response token IDs for endpoints that return
top-level `prompt_token_ids` and choice-level `token_ids`.
- Retain `/tokenize` for integrations that return neither inline
representation.
- Reject partial or conflicting sources. Token capture supports one
completion choice per request (`n` omitted or set to `1`).

## Why

Gym needs the prompt token IDs, generated token IDs, and selected-token
log probabilities used during generation. The previous adapter
reconstructed prompt IDs through a second awaited `/tokenize` request.
That request adds inter-turn latency and can reproduce a different
prompt when prompt-affecting inputs are omitted.

NeMo RL [#3390](NVIDIA-NeMo/RL#3390) attaches
the token bundle to the assistant message. Gym reads that bundle
directly and does not request duplicate response-level IDs by default.

Gym cannot require that message shape from every training integration.
Other integrations, including Verl, can request vLLM's response token
IDs or continue using the compatibility fallback.

## Source precedence

1. A complete token bundle on `choice.message`.
2. Top-level `prompt_token_ids`, choice-level `token_ids`, and
`choice.logprobs` from the vLLM response.
3. Generation metadata from `choice.logprobs` and prompt IDs from
`/tokenize`.

`request_prompt_and_generation_token_ids: true` enables the second
source by sending `return_token_ids=true` to compatible vLLM endpoints.
The option defaults to `false`. If both inline sources are present,
their token IDs must agree. The selected training message is validated
once to avoid repeated list scans and copies at long sequence lengths.

The fallback forwards `model`, `messages`, `tools`,
`chat_template_kwargs`, `mm_processor_kwargs`, and
`required_prefix_token_ids`. This keeps fallback tokenization aligned
with the generation prompt.

`prompt_logprobs` is not requested. vLLM returns prompt IDs
independently when `return_token_ids` is enabled, and Gym does not
consume prompt-token log probabilities.

## Related work

- Gym [#700](#700) is superseded
by this change.
- Gym [#2576](#2576) is
superseded by this change.
- NeMo RL [#3390](NVIDIA-NeMo/RL#3390) produces
the message-level token bundle consumed by the first source.
- NeMo RL [#3581](NVIDIA-NeMo/RL#3581) reuses
replayed token metadata across turns.
- Gym [#1784](#1784) can add the
Dynamo `nvext.engine_data` source independently.
- Gym [#2324](#2324) can retain
its video-specific schema, conversion, and adapter behavior
independently.

---------

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
copy-pr-bot Bot pushed a commit that referenced this pull request Aug 22, 2026
…probs, real logprobs

- Strip trailing whitespace from content before asserting tool parser
  contracts on both backends; canonical expected value is now "before"
- Add top_logprobs=0 to all three vLLM scenario request bodies to satisfy
  the #3390 precondition check (VLLMValidationError when top_logprobs is None)
- Replace _extract_generation_token_ids string-parsing with msg field reads;
  use actual generation_log_probs from the response instead of fabricated zeros

Signed-off-by: Erin Ho <14718778+hchings@users.noreply.github.com>
hchings added a commit to hchings/RL that referenced this pull request Aug 25, 2026
…probs, real logprobs

- Strip trailing whitespace from content before asserting tool parser
  contracts on both backends; canonical expected value is now "before"
- Add top_logprobs=0 to all three vLLM scenario request bodies to satisfy
  the NVIDIA-NeMo#3390 precondition check (VLLMValidationError when top_logprobs is None)
- Replace _extract_generation_token_ids string-parsing with msg field reads;
  use actual generation_log_probs from the response instead of fabricated zeros

Signed-off-by: Erin Ho <14718778+hchings@users.noreply.github.com>
copy-pr-bot Bot pushed a commit that referenced this pull request Sep 1, 2026
…probs, real logprobs

- Strip trailing whitespace from content before asserting tool parser
  contracts on both backends; canonical expected value is now "before"
- Add top_logprobs=0 to all three vLLM scenario request bodies to satisfy
  the #3390 precondition check (VLLMValidationError when top_logprobs is None)
- Replace _extract_generation_token_ids string-parsing with msg field reads;
  use actual generation_log_probs from the response instead of fabricated zeros

Signed-off-by: Erin Ho <14718778+hchings@users.noreply.github.com>
hchings added a commit to hchings/RL that referenced this pull request Sep 3, 2026
…probs, real logprobs

- Strip trailing whitespace from content before asserting tool parser
  contracts on both backends; canonical expected value is now "before"
- Add top_logprobs=0 to all three vLLM scenario request bodies to satisfy
  the NVIDIA-NeMo#3390 precondition check (VLLMValidationError when top_logprobs is None)
- Replace _extract_generation_token_ids string-parsing with msg field reads;
  use actual generation_log_probs from the response instead of fabricated zeros

Signed-off-by: Erin Ho <14718778+hchings@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI:L1 Run doctests, unit tests, and functional tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants