Skip to content

[BugFix][Frontend] Backport vLLM #54548 sparse multimodal placeholder masks - #15861

Open
QwertyJack wants to merge 2 commits into
vllm-project:mainfrom
QwertyJack:codex/backport-vllm-54548
Open

QwertyJack wants to merge 2 commits into
vllm-project:mainfrom
QwertyJack:codex/backport-vllm-54548

Conversation

@QwertyJack

@QwertyJack QwertyJack commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does / why we need it?

Backports the sparse multimodal placeholder-mask fix from vllm-project/vllm#54548 (upstream author Hotragn, reviewed source head 60fe831acefe1778b99b5e0198581a3236d4c1c1) into an Ascend compatibility patch for supported vLLM versions that do not yet carry it.

Render output currently serializes placeholder offset and length but loses PlaceholderRange.is_embed. For sparse placeholders, reconstructing a dense range in /inference/v1/generate can assign image embeddings to separator positions. A real DeepSeek V4 Vision request reproduced a 320-embedding versus 344-position shape mismatch through this route.

The patch adds the optional JSON mask field, rebuilds nested request schemas, preserves masks during render serialization, and reconstructs CPU boolean masks for token-input requests. It also covers the duplicated feature extraction in ServingDerender.

The serving method is a direct source backport: rebuild_mm_placeholders follows the upstream implementation, and its call site is applied to the supported vLLM main pin ba07e4a48. The three existing v0.27.1 differences (content-parts support, non-streaming output kind, and session routing) are gated explicitly. There is no FunctionType, ContextVar, source rewriting, or request-scoped module-global override. Unrelated features introduced between the supported pin and the upstream PR base are not pulled into this backport.

Installation occurs in the platform registration hook, after global/config imports and before API router construction. Versions with the native is_embed protocol field are left untouched. Remove this compatibility patch once all supported vLLM versions carry the upstream fix.

This PR is based directly on main and is independent of #15783. It does not include model-runner, operator, evaluation-helper, or local numerical-stability changes.

Does this PR introduce any user-facing change?

Yes. On affected vLLM versions, rendered multimodal features retain the optional is_embed mask through JSON serialization and token-input reconstruction. Dense placeholders remain supported when the mask is absent. Text-only and content-parts requests retain the supported version's preprocessing behavior.

How was this patch tested?

  • python -m pytest -q tests/ut/patch/platform/test_mm_placeholder_mask.py: 17 passed, 1 skipped against a clean vLLM 0.27.1 checkout (6e448d0ea9bf3d88d898b65449ca6dc2aec170ac, VLLM_VERSION=0.27.1). The skip is the content-parts test because that version has no such request field.

  • 18 tests passed against the pinned vLLM main reference ba07e4a48fc951300d97eb506217dd530583dea3 (VLLM_VERSION=0.28.0), using --noconftest to isolate this frontend suite from an unrelated missing attention.pcp import in the full Ascend test setup.

  • Coverage includes render/derender JSON round trips, dense/sparse masks, serialized tensors and cache hits, idempotent installation, concurrent requests, text/content-parts preprocessing, FastAPI schemas, engine-client handoff, streaming/non-streaming output kind, routing, sampling validation/defaults, and generation-error isolation. Engine calls are mocked; these are frontend unit tests.

  • The placeholder-builder body matches the upstream AST. The serving body was also compared with the supported pin to check that differences are limited to mask reconstruction and the explicit release compatibility branches.

  • A fresh CLI process using the clean vLLM 0.27.1 checkout successfully ran vllm serve --help; the platform hook installed the direct serving backport, and the actual token-input router's OpenAPI schema retained is_embed.

  • Prior real-weight token-input teacher-forcing controls returned HTTP 200 after a direct local vLLM mask fix. That is evidence for the defect/fix semantics, not a real-NPU end-to-end test of this new Ascend compatibility wrapper. The wrapper itself has the frontend/handler checks above; no full-model numerical or performance sign-off is claimed.

  • Formatting and local hooks passed with gitleaks skipped because its downloaded binary cannot execute on this ARM64 host (Exec format error), and actionlint skipped because no workflow files changed. Remote CI remains the merge gate.

  • vLLM main: vllm-project/vllm@ba07e4a

Signed-off-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a compatibility patch for Ascend-supported vLLM versions to address a bug where sparse multimodal placeholder masks were lost during serialization. By ensuring the 'is_embed' mask is preserved through the request lifecycle, the patch resolves shape mismatch issues in multimodal vision requests. The changes are applied dynamically at runtime to maintain compatibility with existing vLLM versions and include comprehensive unit tests to verify schema integrity and concurrent request handling.

Highlights

  • Multimodal Placeholder Mask Backport: Implemented a compatibility patch to backport sparse multimodal placeholder-mask support (vLLM #54548) for Ascend-compatible versions, preventing shape mismatches in multimodal requests.
  • Schema and Serialization Updates: Added the optional 'is_embed' field to request schemas and updated serialization logic to preserve mask information during render/derender processes.
  • Request-Local Isolation: Introduced a request-local input builder to ensure concurrent requests remain isolated without mutating global module state.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

Suggested PR Title:\n\nmarkdown\n[Platform][BugFix] Backport multimodal placeholder mask preservation\n\n\nSuggested PR Summary:\n\nmarkdown\n### What this PR does / why we need it?\nThis pull request introduces a compatibility backport (`patch_mm_placeholder_mask.py`) of `vllm-project/vllm#54548` to preserve sparse `PlaceholderRange.is_embed` masks through render/derender serialization and token-input reconstruction. It installs this patch dynamically during platform registration.\n\nFeedback on the implementation:\n1. In `_wrap_extract_features`, `engine_input` is assumed to be a dictionary, which might cause `TypeError` if it is a custom object in some vLLM versions. Additionally, `p.is_embed.tolist()` can raise an `AttributeError` if `is_embed` is already a list. A more defensive extraction is recommended.\n2. The use of `FunctionType` in `_wrap_serve_tokens` to dynamically recreate the function is fragile and can bypass the patch if the function is decorated. Using Python's `contextvars` is a much more robust and standard approach for managing per-request state in concurrent async tasks.\n3. The unit tests should be updated accordingly to reflect the `contextvars` approach.\n\n### Does this PR introduce _any_ user-facing change?\nNo, this is an internal compatibility patch to preserve multimodal placeholder masks.\n\n### How was this patch tested?\nUnit tests have been added in `tests/ut/patch/platform/test_mm_placeholder_mask.py` to verify serialization, idempotency, schema preservation, and concurrent request isolation.\n

Comment on lines +8 to +27
def _wrap_extract_features(original, placeholder_type):
@wraps(original)
def extract(engine_input):
features = original(engine_input)
if features is None:
return None
features.mm_placeholders = {
modality: [
placeholder_type(
offset=p.offset,
length=p.length,
is_embed=None if p.is_embed is None else p.is_embed.tolist(),
)
for p in ranges
]
for modality, ranges in engine_input["mm_placeholders"].items()
}
return features

return extract

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.

high

The current implementation of _wrap_extract_features assumes engine_input is always a dictionary and directly subscripts it with engine_input["mm_placeholders"]. However, in newer or different versions of vLLM, engine_input can be a custom object (such as MultiModalInputs or ExecutorInput), which will raise a TypeError when subscripted. Additionally, if p.is_embed is already a list rather than a PyTorch tensor, calling p.is_embed.tolist() will raise an AttributeError.

To ensure robust compatibility and defensive programming, we should safely extract mm_placeholders using both dictionary and attribute access, and handle list/tensor types for is_embed safely.

def _wrap_extract_features(original, placeholder_type):
    @wraps(original)
    def extract(engine_input):
        features = original(engine_input)
        if features is None:
            return None
        mm_placeholders = (
            engine_input.get("mm_placeholders", {})
            if isinstance(engine_input, dict)
            else getattr(engine_input, "mm_placeholders", {})
        )
        features.mm_placeholders = {
            modality: [
                placeholder_type(
                    offset=p.offset,
                    length=p.length,
                    is_embed=(
                        None if p.is_embed is None
                        else (p.is_embed.tolist() if hasattr(p.is_embed, "tolist") else list(p.is_embed))
                    ),
                )
                for p in ranges
            ]
            for modality, ranges in mm_placeholders.items()
        }
        return features

    return extract

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Keeping the upstream contract here: both supported references use MultiModalInput as a TypedDict and PlaceholderRange.is_embed as Tensor | None. The original extractor itself also uses dictionary access, so adding an object fallback after calling it would not support the proposed alternative. The serialization expression matches vllm-project/vllm#54548 and is covered for sparse and dense placeholders on both references.

Comment on lines +30 to +67
def _wrap_serve_tokens(original):
@wraps(original)
async def serve(self, request, raw_request=None):
if request.features is None or getattr(request, "content_parts", None):
return await original(self, request, raw_request)

import torch
from vllm.multimodal.inputs import PlaceholderRange

original_mm_input = original.__globals__["mm_input"]

def mm_input(**kwargs):
kwargs["mm_placeholders"] = {
modality: [
PlaceholderRange(
offset=p.offset,
length=p.length,
is_embed=None if p.is_embed is None else torch.tensor(p.is_embed, dtype=torch.bool),
)
for p in ranges
]
for modality, ranges in request.features.mm_placeholders.items()
}
return original_mm_input(**kwargs)

# Bind only this request's input builder without mutating module globals
# across concurrent awaits or copying the version-specific async handler.
scoped = FunctionType(
original.__code__,
dict(original.__globals__, mm_input=mm_input),
original.__name__,
original.__defaults__,
original.__closure__,
)
scoped.__kwdefaults__ = original.__kwdefaults__
return await scoped(self, request, raw_request)

return serve

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.

high

The current implementation of _wrap_serve_tokens uses FunctionType to dynamically recreate the original function with a modified globals dictionary. This approach is highly fragile and will fail if original is decorated (which is very common in web frameworks or other patches), because original.__code__ and original.__globals__ would belong to the decorator's wrapper rather than the actual function. Consequently, the mm_input override would be completely bypassed and ignored.

A much more robust, standard, and elegant way to handle per-request state in concurrent async tasks is using Python's contextvars module. We can patch mm_input in the module's globals once (idempotently) and use a ContextVar to dynamically apply the placeholder mask only when active for the current request context.

def _wrap_serve_tokens(original):
    import contextvars

    if not hasattr(_wrap_serve_tokens, "ctx_var"):
        _wrap_serve_tokens.ctx_var = contextvars.ContextVar("mm_placeholder_mask_ctx", default=None)
    ctx_var = _wrap_serve_tokens.ctx_var

    globals_dict = original.__globals__
    if "mm_input" in globals_dict and not getattr(globals_dict["mm_input"], "_is_patched_mask", False):
        original_mm_input = globals_dict["mm_input"]

        @wraps(original_mm_input)
        def patched_mm_input(**kwargs):
            mask_dict = ctx_var.get()
            if mask_dict is not None:
                import torch
                from vllm.multimodal.inputs import PlaceholderRange
                kwargs["mm_placeholders"] = {
                    modality: [
                        PlaceholderRange(
                            offset=p.offset,
                            length=p.length,
                            is_embed=None if p.is_embed is None else torch.tensor(p.is_embed, dtype=torch.bool),
                        )
                        for p in ranges
                    ]
                    for modality, ranges in mask_dict.items()
                }
            return original_mm_input(**kwargs)

        patched_mm_input._is_patched_mask = True
        globals_dict["mm_input"] = patched_mm_input

    @wraps(original)
    async def serve(self, request, raw_request=None):
        if request.features is None or getattr(request, "content_parts", None):
            return await original(self, request, raw_request)

        token = ctx_var.set(request.features.mm_placeholders)
        try:
            return await original(self, request, raw_request)
        finally:
            ctx_var.reset(token)

    return serve

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 06ab7d8. Removed FunctionType and directly backported the upstream rebuild_mm_placeholders call into the serving method from our supported vLLM pin. No ContextVar or per-request globals override is needed. The only release compatibility branches cover content_parts, non-streaming output kind, and session routing in v0.27.1. Tests now exercise the direct handler through mocked engine-client handoff, including concurrency, routing, streaming, validation, and exception isolation: 18 passed on the main pin; 17 passed and 1 unsupported-content-parts skip on v0.27.1.

Comment on lines +111 to +114
results = asyncio.run(run())
assert [r["mm_placeholders"]["image"][0].get_num_embeds() for r in results] == [3, 1, 4]
assert results[0]["mm_placeholders"]["image"][0].is_embed.dtype == torch.bool
assert _upstream_handler.__globals__["mm_input"] is original_builder

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.

high

Since we are replacing the fragile FunctionType recreation with a robust contextvars approach, the global mm_input in the module's globals will be patched once (idempotently) rather than recreated dynamically per request. Therefore, the assertion assert _upstream_handler.__globals__["mm_input"] is original_builder should be removed or updated to assert that it is the patched wrapper.

Suggested change
results = asyncio.run(run())
assert [r["mm_placeholders"]["image"][0].get_num_embeds() for r in results] == [3, 1, 4]
assert results[0]["mm_placeholders"]["image"][0].is_embed.dtype == torch.bool
assert _upstream_handler.__globals__["mm_input"] is original_builder
results = asyncio.run(run())
assert [r["mm_placeholders"]["image"][0].get_num_embeds() for r in results] == [3, 1, 4]
assert results[0]["mm_placeholders"]["image"][0].is_embed.dtype == torch.bool

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

👋 Hi! Thank you for contributing to the vLLM Ascend project. The following points will speed up your PR merge:‌‌

  • A PR should do only one thing, smaller PRs enable faster reviews.
  • Every PR should include unit tests and end-to-end tests ‌to ensure it works and is not broken by other future PRs.
  • Write the commit message by fulfilling the PR description to help reviewer and future developers understand.

If CI fails, you can run linting and testing checks locally according Contributing and Testing.


Tip

💡 Consider Linking a Related Issue or RFC

Your PR title contains the [BugFix] tag, indicating a bug fix or new feature.

Linking a related issue or RFC in the PR description is strongly encouraged — it gives reviewers helpful context and speeds up the review. You can use any of these keywords:

  • Fixes #<issue_number>
  • Closes #<issue_number>
  • Resolves #<issue_number>
  • Refs #<rfc_or_issue_number> (for RFCs)

🙏 Thanks for helping us keep the project well-organized!

@QwertyJack QwertyJack added the ready-precise run selected e2e test for pr label Sep 5, 2026
…mentation

Signed-off-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com>
@QwertyJack

QwertyJack commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

/e2e tests/e2e/pull_request/four_card/test_pipeline_parallel.py
[Bot]: e2e command triggered. See workflow run for details.
[Bot]: e2e command failed.

@QwertyJack

QwertyJack commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

/rerun
[Bot]: rerun completed.

Rerun (failed jobs only):

  • E2E

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant