Skip to content

[Bugfix][Frontend] Truncate pooling prompts before padding them - #54364

Merged
vllm-bot merged 2 commits into
vllm-project:mainfrom
Hotragn:fix/pad-after-truncate
Aug 31, 2026
Merged

vllm-bot merged 2 commits into
vllm-project:mainfrom
Hotragn:fix/pad-after-truncate

Conversation

@Hotragn

@Hotragn Hotragn commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Purpose

TokenizeParams._validate_tokens runs its validators in this order:

for validator in (
    self._token_padding,
    self._token_truncation,
    self._token_len_check,
):

Padding runs before truncation. _token_padding appends pad tokens up to
max_input_tokens, and _token_truncation with truncation_side="left" then
keeps only the last N tokens — which are precisely the pad tokens that padding
just appended. The prompt's actual content is discarded, the request succeeds,
and the model embeds nothing but padding. No error, no warning.

padding is documented as "using the same names as the Transformers tokenizer",
but the Transformers pipeline truncates and then pads
(pad_truncation).

Reachability

padding became settable over HTTP in #51157 (merged 2026-08-27).
PoolingBasicRequestMixin (vllm/entrypoints/pooling/base/protocol.py:41-63)
exposes truncate_prompt_tokens, padding and truncation_side on the same
request model, and routes padding through
tok_params.with_kwargs(padding=self.padding)pad_prompt_tokens = max_length
(vllm/renderers/params.py:273-277). All three are therefore settable on one
request:

POST /v1/embeddings
{
  "model": "<embedding model>",
  "input": "Hello",
  "padding": "max_length",
  "truncate_prompt_tokens": 8,
  "truncation_side": "left"
}

With max_model_len=512: the tokenizer returns ~3 ids → _token_padding
extends to 512 → _token_truncation (left) returns tokens[-8:]8 pad
tokens, zero content tokens
_token_len_check passes → 200 OK with an
embedding of pure padding.

truncation_side does not have to be sent explicitly: _token_truncation falls
back to tokenizer.truncation_side (params.py:428-430), so any tokenizer
whose own default is "left" hits this with just padding +
truncate_prompt_tokens.

This is also why it is easy to miss — with truncation_side="right" the result
coincidentally matches Transformers, so the bug only shows on the left side.

Approach

Run _token_truncation before _token_padding, matching the Transformers
pipeline the parameters are named after. Truncation then applies to real
content, and padding fills out the remainder.

The reordering is safe with respect to _token_len_check, which still runs
last: _token_padding pads to at most max_input_tokens, and __post_init__
already rejects truncate_prompt_tokens > max_input_tokens
(params.py:239-251), so neither step can push the sequence past the limit.

Test Plan

pytest tests/renderers/test_completions.py -v

Two tests added to the existing TestRenderPrompt class:

  • test_padding_with_left_truncation_keeps_the_prompt — the regression. The
    sentinel is set(token_ids) != {pad_id}: it asserts the prompt is not made
    entirely of pad tokens, which is what proves the degenerate value reached
    the consumer, rather than asserting that some exception was raised (nothing
    raises here — that is the point). DummyTokenizer.pad_token_id is 99999,
    deliberately outside the range of ids encode returns, so a pad token can
    never be confused with a real one.
  • test_padding_without_truncation_is_unchanged — pins the behaviour of
    padding on its own, so the reorder cannot silently change the case [Bugfix][Frontend] Let pooling requests set padding #51157
    added.

The existing tests/entrypoints/pooling/embed/test_online.py::test_padding
only checks usage.prompt_tokens grows and never combines padding with
truncate_prompt_tokens, so this interaction was untested.

Test Result

Run on ubuntu-24.04 x86_64 / Python 3.12.3, installed with
VLLM_USE_PRECOMPILED=1 uv pip install -e . --torch-backend=auto.

Before — the new tests against vllm/renderers/params.py from main
(4fc943b). The prompt is 100% pad tokens:

=========== BEFORE  fix/pad-after-truncate ===========
$ python -m pytest -v tests/renderers/test_completions.py -k padding_with_left_truncation_keeps_the_prompt or padding_without_truncation_is_unchanged
HEAD = 4fc943b86  ([Multimodal] Deprecate PyAV video decoder backend (#54231))

collected 28 items / 26 deselected / 2 selected

tests/renderers/test_completions.py::TestRenderPrompt::test_padding_with_left_truncation_keeps_the_prompt FAILED [ 50%]
tests/renderers/test_completions.py::TestRenderPrompt::test_padding_without_truncation_is_unchanged PASSED [100%]

        # The sentinel: on a padding-first pipeline every surviving id is a
        # pad token, so the prompt reaches the model with no content at all.
>       assert set(token_ids) != {pad_id}
E       assert {99999} != {99999}
E
E         Both sets are equal

tests/renderers/test_completions.py:464: AssertionError
================== 1 failed, 1 passed, 26 deselected in 0.83s ==================

After — this branch, whole file:

=========== AFTER   fix/pad-after-truncate ===========
$ python -m pytest -v tests/renderers/test_completions.py
HEAD = 48d3b41a2  ([Bugfix][Frontend] Truncate pooling prompts before padding them)

collected 28 items

tests/renderers/test_completions.py::TestRenderPrompt::test_truncation_left PASSED [ 35%]
tests/renderers/test_completions.py::TestRenderPrompt::test_truncation_right PASSED [ 39%]
tests/renderers/test_completions.py::TestRenderPrompt::test_explicit_side_left_text PASSED [ 64%]
tests/renderers/test_completions.py::TestRenderPrompt::test_explicit_side_right_text PASSED [ 67%]
tests/renderers/test_completions.py::TestRenderPrompt::test_padding_with_left_truncation_keeps_the_prompt PASSED [ 71%]
tests/renderers/test_completions.py::TestRenderPrompt::test_padding_without_truncation_is_unchanged PASSED [ 75%]

============================== 28 passed in 9.61s ==============================

Every pre-existing test in the file — including all four truncation-side tests —
passes unchanged, which is the evidence that the reorder does not alter
truncation-only or padding-only behaviour.

pre-commit hooks applicable to the two changed files — ruff-check,
ruff-format, typos, check-spdx-header — pass.

Not fixed here

_token_padding uses tokenizer.pad_token_id unguarded, so a tokenizer with no
configured pad token would splice None into prompt_token_ids. I could not
identify a commonly served pooling model whose tokenizer reports
pad_token_id is None, so I have left it alone rather than add a guard for a
path I cannot demonstrate. Happy to add it if maintainers want it covered.


AI assistance was used to research and draft this change. I have reviewed every
changed line and run the tests above.


Follow-up commit ac5d07e1a — keep token_type_ids aligned

vllm/entrypoints/pooling/scoring/io_processor.py keeps a second copy of the
pad/truncate logic for the token_type_ids array that runs parallel to
prompt_token_ids on cross-encoder scoring requests
(_apply_post_tokenization_to_token_type_ids). It also padded before
truncating, so once _validate_tokens was reordered the two arrays no longer
described the same positions.

compress_token_type_ids reduces that array to one index — where the document
segment begins — and the model applies it to prompt_token_ids. With a
20-token query + 30-token document, padding to 100, truncate_prompt_tokens=40
and truncation_side="left":

len(prompt_token_ids) len(token_type_ids) boundary
main 40 (all pad) 40 0
this PR, before ac5d07e1a 100 40 0
this PR, after ac5d07e1a 100 100 10

10 is correct: left-truncating to 40 drops the first 10 query tokens, so 10
query tokens survive and the document starts at index 10. A boundary of 0 tells
the cross-encoder the query segment is empty.

tests/entrypoints/pooling/scoring/test_io_processor_unit.py asserts both the
matching lengths and the boundary. The boundary assertion is the load-bearing
one — the length check alone passes on main, where both arrays are wrong in
the same way (assert 0 == 10 is the failure on main).

`_validate_tokens` padded to the full input length before applying
`truncate_prompt_tokens`. With `truncation_side="left"` the truncation then
kept only the pad tokens that padding had just appended, so the request
succeeded and the model embedded a prompt with no content in it.

Run truncation first, matching the Transformers pipeline these parameters are
named after.

Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in>

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added the bug Something isn't working label Aug 29, 2026
@DarkLight1337
DarkLight1337 requested a review from noooop August 29, 2026 16:11
@DarkLight1337 DarkLight1337 added the ready ONLY add when PR is ready to merge/full CI is needed label Aug 29, 2026
@DarkLight1337

Copy link
Copy Markdown
Member

@claude review

@github-actions

Copy link
Copy Markdown

@Hotragn, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@claude claude 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 vllm/entrypoints/pooling/scoring/io_processor.py — The diff reorders truncate-then-pad in TokenizeParams._validate_tokens, but the duplicate pad/truncate logic here for token_type_ids (used for cross-encoder query/doc segment boundaries) still pads before truncating. For scoring/rerank requests combining padding + left truncate_prompt_tokens, prompt_token_ids and token_type_ids are now computed with different orders, so the segment boundary passed to the model via compress_token_type_ids/compressed_token_type_ids no longer matches the actual (now-correct) token positions -- previously both were wrong the same way and stayed aligned; now they silently diverge, corrupting cross-encoder scores. …

    Extended reasoning...

    …Fix: make _apply_post_tokenization_to_token_type_ids apply truncation before padding, mirroring the corrected order in TokenizeParams._validate_tokens.

    io_processor.py:541 calls tok_params.apply_post_tokenization (params.py, new order: truncate then pad) on prompt_token_ids. io_processor.py:543-549 then calls _apply_post_tokenization_to_token_type_ids (still pad-then-truncate, lines 58-83) on the parallel token_type_ids array using the same tok_params. Example: query+doc real content 50 tokens, pad_prompt_tokens=-1 (pad to max_input_tokens=100), truncate_prompt_tokens=5, truncation_side=left. prompt_token_ids: truncate to last 5 real tokens, then pad to 100 -> last 5 are real content, rest pad. token_type_ids: pad first to 100 (appending copies of the last real type id, e.g. 1), then left-truncate to last 5 -> all become the pad-appended value (all 1s). compress_token_type_ids then returns first_one=0 for the token_type array, meaning the reconstructed torch.arange(len(prompt_token_ids)) >= 0 marks every one of the 100 final tokens (including the 5 real ones and the doc-side…

    Verification: Severity: pre-existing (real divergence introduced, but the affected scenario already produced wrong output on base by the same route — not merge-blocking). The candidate's core technical claim is correct. After the diff, TokenizeParams._validate_tokens runs truncate-then-pad (params.py:469-474), but the parallel handler for cross-encoder segment ids,… | pre-existing severity — real, reachable…

@DarkLight1337

Copy link
Copy Markdown
Member

PTAL at @claude 's comment

`_apply_post_tokenization_to_token_type_ids` padded before truncating,
so it no longer matched `TokenizeParams._validate_tokens`. The two
arrays are parallel and the compressed boundary is what tells the model
where the document starts, so a scoring request combining `padding`
with left `truncate_prompt_tokens` reported an empty query segment.

Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in>
@mergify mergify Bot added the frontend label Aug 30, 2026
Comment thread tests/entrypoints/pooling/scoring/test_io_processor_unit.py
@Hotragn

Hotragn commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — I checked that finding and it is correct, so I've pushed a fix (ac5d07e1a).

vllm/entrypoints/pooling/scoring/io_processor.py has its own copy of the
pad/truncate logic for the parallel token_type_ids array
(_apply_post_tokenization_to_token_type_ids, lines 53-84), and it was still
padding before truncating. create_pooling_engine_input calls
tok_params.apply_post_tokenization(...) on prompt_token_ids (line 541) and
then that helper on token_type_ids (lines 543-549), so after this PR the two
arrays were being reduced in different orders.

That matters because compress_token_type_ids collapses the array to a single
index — where the document segment starts — and the model applies that index to
prompt_token_ids. Concretely, with a 20-token query + 30-token document,
padding to 100 and truncate_prompt_tokens=40, truncation_side="left":

len(prompt_token_ids) len(token_type_ids) boundary
main 40 (all pad) 40 0
this PR, before ac5d07e1a 100 40 0
this PR, after ac5d07e1a 100 100 10

10 is right: left-truncating to 40 drops the first 10 query tokens, so 10 query
tokens survive and the document starts at index 10. A boundary of 0 tells the
cross-encoder the whole sequence is document and the query segment is empty.

The fix reorders the helper to truncate-then-pad, matching
TokenizeParams._validate_tokens, and adds a comment saying the two must stay
in step. I also added
tests/entrypoints/pooling/scoring/test_io_processor_unit.py, which asserts
both that the arrays are the same length and that the boundary lands at 10 —
the length check alone would have passed on main, since there both arrays were
wrong in the same way.

Test result (ubuntu-24.04 x86_64 / Python 3.12.3, VLLM_USE_PRECOMPILED=1 uv pip install -e . --torch-backend=auto).

Against vllm/entrypoints/pooling/scoring/io_processor.py from main
(b383e16):

$ python -m pytest -v tests/entrypoints/pooling/scoring/test_io_processor_unit.py \
    -k token_type_ids_stay_aligned_with_a_truncated_padded_prompt

tests/.../test_io_processor_unit.py::test_token_type_ids_stay_aligned_with_a_truncated_padded_prompt FAILED

        first_doc = compress_token_type_ids(token_type_ids)
>       assert first_doc == 10
E       assert 0 == 10

============================== 1 failed in 0.05s ===============================

On this branch (ac5d07e):

$ python -m pytest -v tests/entrypoints/pooling/scoring/test_io_processor_unit.py

tests/.../test_io_processor_unit.py::test_token_type_ids_stay_aligned_with_a_truncated_padded_prompt PASSED

============================== 1 passed in 0.03s ===============================

tests/renderers/test_completions.py still passes in full (28 passed) with the
new commit on top. ruff check, ruff format, typos and check-spdx-header
pass on both changed files.

Happy to split the io_processor.py change into its own PR if you would rather
keep this one to the single reorder — it is only reachable because of this
diff, so I defaulted to bundling it.

@DarkLight1337

Copy link
Copy Markdown
Member

/ci run

@DarkLight1337
DarkLight1337 enabled auto-merge (squash) August 30, 2026 08:55
@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #86210 for commit ac5d07e1a4e5.

@Hotragn

Hotragn commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Build 86210 on ac5d07e1a is green except for one job:
(H200) Entrypoints Integration (API Server OpenAI - Part 1),
which ended 1 failed, 199 passed. The failure is
test_openai_schema.py::test_openapi_stateless[POST /v1/chat/completions] --
schemathesis got a 500.

It is not this diff

This PR only swaps the order of _token_truncation and _token_padding inside
TokenizeParams._validate_tokens. ChatCompletionRequest.build_tok_params
never sets pad_prompt_tokens (padding is pooling-only --
vllm/entrypoints/openai/chat_completion/protocol.py:615-624), and the
generated body sets no truncate_prompt_tokens, so on that request both
validators are identity functions and their order is unobservable.

What actually failed

The generated body includes "bad_words": [..., "\u0085", ...]. U+0085 (NEL) is
whitespace to str.lstrip(), so in SamplingParams.update_from_tokenizer:

  • vllm/sampling_params.py:705 builds prompt = "" + "\u0085".lstrip(), i.e.
    the empty string, and tokenizer.encode("") returns [], which is appended
    to self._bad_words_token_ids;
  • the add_prefix_space=True pass then reads
    self._bad_words_token_ids[-1][0] (vllm/sampling_params.py:715) off that
    empty list, raising IndexError.

IndexError is not a VLLMClientError, so AsyncLLM.generate re-raises it as
raise EngineGenerateError() from e (vllm/v1/engine/async_llm.py:661). That
exception is constructed with no arguments, which is exactly why the response
body carries an empty message:

{"error":{"message":"","type":"InternalServerError","param":null,"code":500}}

No fuzzing needed to reproduce -- a plain space has the same effect:

curl -X POST http://localhost:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model": "<model>", "messages": [{"role": "user", "content": "hi"}], "bad_words": [" "]}'

It is already being fixed elsewhere -- #53433, #47841 and #49898 all target this
bad_words path -- so I have deliberately not touched it here.

On retrying

The ci hypothesis profile runs with derandomize=True, so the corpus is
seeded, but this is not reproducing uniformly: the same suite passed on
(MI300) in this very build, and on (H200) in build 86211 an hour later.
So a retry may or may not draw the same example. If it is easier, I am happy to
merge current main into the branch to re-roll the corpus -- just say the word.

@vllm-bot
vllm-bot merged commit 687db59 into vllm-project:main Aug 31, 2026
78 of 79 checks passed
am-cohere pushed a commit to am-cohere/vllm that referenced this pull request Sep 1, 2026
…-project#54364)

Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in>
mylibrar pushed a commit to tanyuqian/vllm that referenced this pull request Sep 3, 2026
…-project#54364)

Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in>
D-G-Dimitrov pushed a commit to D-G-Dimitrov/vllm that referenced this pull request Sep 6, 2026
…-project#54364)

Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in>
(cherry picked from commit 687db59)
sheralskumar pushed a commit to sheralskumar/vllm that referenced this pull request Sep 8, 2026
…-project#54364)

Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working frontend ready ONLY add when PR is ready to merge/full CI is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants