Skip to content

[Bug]: A multimodal item can still be split across prefill chunks with disable_chunked_mm_input=True #52306

Description

@chrisc36

Your current environment

The output of python collect_env.py GPU0 X SYS 3-63,131-191 0 N/A NIC0 SYS X

Legend:

X = Self
SYS = Connection traversing PCIe as well as the SMP interconnect between NUMA nodes (e.g., QPI/UPI)
NODE = Connection traversing PCIe as well as the interconnect between PCIe Host Bridges within a NUMA node
PHB = Connection traversing PCIe as well as a PCIe Host Bridge (typically the CPU)
PXB = Connection traversing multiple PCIe bridges (without traversing the PCIe Host Bridge)
PIX = Connection traversing at most a single PCIe bridge
NV# = Connection traversing a bonded set of # NVLinks

NIC Legend:

NIC0: mlx5_bond_0

==============================
Environment Variables

NVIDIA_VISIBLE_DEVICES=GPU-216bfdad-f232-5610-63d8-c1754594308c
VLLM_BUILD_URL=https://buildkite.com/vllm/release-v2/builds/4244
NVIDIA_REQUIRE_CUDA=cuda>=12.9 brand=unknown,driver>=535,driver<536 brand=grid,driver>=535,driver<536 brand=tesla,driver>=535,driver<536 brand=nvidia,driver>=535,driver<536 brand=quadro,driver>=535,driver<536 brand=quadrortx,driver>=535,driver<536 brand=nvidiartx,driver>=535,driver<536 brand=vapps,driver>=535,driver<536 brand=vpc,driver>=535,driver<536 brand=vcs,driver>=535,driver<536 brand=vws,driver>=535,driver<536 brand=cloudgaming,driver>=535,driver<536 brand=unknown,driver>=550,driver<551 brand=grid,driver>=550,driver<551 brand=tesla,driver>=550,driver<551 brand=nvidia,driver>=550,driver<551 brand=quadro,driver>=550,driver<551 brand=quadrortx,driver>=550,driver<551 brand=nvidiartx,driver>=550,driver<551 brand=vapps,driver>=550,driver<551 brand=vpc,driver>=550,driver<551 brand=vcs,driver>=550,driver<551 brand=vws,driver>=550,driver<551 brand=cloudgaming,driver>=550,driver<551 brand=unknown,driver>=560,driver<561 brand=grid,driver>=560,driver<561 brand=tesla,driver>=560,driver<561 brand=nvidia,driver>=560,driver<561 brand=quadro,driver>=560,driver<561 brand=quadrortx,driver>=560,driver<561 brand=nvidiartx,driver>=560,driver<561 brand=vapps,driver>=560,driver<561 brand=vpc,driver>=560,driver<561 brand=vcs,driver>=560,driver<561 brand=vws,driver>=560,driver<561 brand=cloudgaming,driver>=560,driver<561 brand=unknown,driver>=565,driver<566 brand=grid,driver>=565,driver<566 brand=tesla,driver>=565,driver<566 brand=nvidia,driver>=565,driver<566 brand=quadro,driver>=565,driver<566 brand=quadrortx,driver>=565,driver<566 brand=nvidiartx,driver>=565,driver<566 brand=vapps,driver>=565,driver<566 brand=vpc,driver>=565,driver<566 brand=vcs,driver>=565,driver<566 brand=vws,driver>=565,driver<566 brand=cloudgaming,driver>=565,driver<566 brand=unknown,driver>=570,driver<571 brand=grid,driver>=570,driver<571 brand=tesla,driver>=570,driver<571 brand=nvidia,driver>=570,driver<571 brand=quadro,driver>=570,driver<571 brand=quadrortx,driver>=570,driver<571 brand=nvidiartx,driver>=570,driver<571 brand=vapps,driver>=570,driver<571 brand=vpc,driver>=570,driver<571 brand=vcs,driver>=570,driver<571 brand=vws,driver>=570,driver<571 brand=cloudgaming,driver>=570,driver<571
TORCH_CUDA_ARCH_LIST=7.5 8.0 8.6 8.9 9.0 10.0 12.0
NVIDIA_DRIVER_CAPABILITIES=compute,utility
VLLM_IMAGE_TAG=vllm/vllm-openai:v0.26.0-cu129-ubuntu2404
VLLM_USAGE_SOURCE=production-docker-image
CUDA_VERSION=12.9.1
VLLM_ENABLE_CUDA_COMPATIBILITY=0
VLLM_BUILD_PIPELINE=019d130e-464e-4ff7-b84b-492992c0c06b
LD_LIBRARY_PATH=/usr/local/nvidia/lib64:/usr/local/cuda/lib64:/usr/local/cuda/lib64
OMP_NUM_THREADS=8
VLLM_BUILD_COMMIT=ffd46bfab2128bb84146050e98b51a617c6575ab
PYTORCH_NVML_BASED_CUDA_CHECK=1
TORCHINDUCTOR_COMPILE_THREADS=1
TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_root

🐛 Describe the bug

I think there is an off-by-one error in the scheduler's self.scheduler_config.disable_chunked_mm_input logic.

_try_schedule_encoder_inputs in Scheduler guards against splitting a multimodal item across prefill chunks like this:

if (
    self.scheduler_config.disable_chunked_mm_input
    and num_computed_tokens < start_pos                                            # (A)
    and (num_computed_tokens + num_new_tokens) < (start_pos + num_encoder_tokens)  # (B)
):
    num_new_tokens = max(0, start_pos - (num_computed_tokens + shift_computed_tokens))
    break

Check (A) is causing this statement to incorrectly return False when num_computed_tokens == start_pos.
If num_computed_tokens == start_pos then the tokens after num_computed_tokens are exactly
the start of the next multi-modal item, but (A) returns False so the (B) isn't checked and the
multi-modal item might get chunked.

This issue is made worse by the fact if the check does fire when num_computed_tokens < start_pos then
num_computed_tokens will become start_pos, and we will therefore hit this issue if we can't
fit the multi-modal item into the token budget on the second attempt.

This can silently break models with bidirectional attention, since incorrect multi-modal
splitting will prevent tokens that should be able to cross-attend from cross-attending.

Reproduction

We can observe the error by testing the function directly:

from types import SimpleNamespace
from vllm.v1.core.sched.scheduler import Scheduler

START, LENGTH = 3, 766

def request():
    # Fake multimodal item of length `LENGTH` offset at `START`
    pos = SimpleNamespace(offset=START, length=LENGTH, is_embed=None,
                          get_num_embeds=lambda: LENGTH,
                          get_embeds_indices_in_range=lambda a, b: (a, b))
    return SimpleNamespace(has_encoder_inputs=True, request_id="r0",
        mm_features=[SimpleNamespace(mm_position=pos, identifier="img0", modality="image")])

def sched():
    # Fake scheduler `self` with disable_chunked_mm_input=True
    return SimpleNamespace(
        scheduler_config=SimpleNamespace(disable_chunked_mm_input=True),
        is_encoder_decoder=False, ec_connector=None,
        encoder_cache_manager=SimpleNamespace(
            check_and_update_cache=lambda r, i: False,
            can_allocate=lambda r, i, b, n: True))

def ask(num_computed_tokens, num_new_tokens):
    # Ask how many tokens the scheduler will actually let this request run this step.
    # given we have done `num_computed_tokens` and can afford to do up to `num_new_tokens` more
    return Scheduler._try_schedule_encoder_inputs(
        sched(), request(), num_computed_tokens, num_new_tokens,
        100_000,   # stubbed encoder_compute_budget
        0,         # stubbed shift_computed_tokens
    )[1]

# Works: We can don't have room for the image so it only allows 3 new tokens
print(ask(0, 500))                      # 3

# Breaks: the scheduler says we can go ahead and compute up to token 500, but this will
# split the image since it ends on position 766
print(ask(3, 500))                  # 500

Fix

As far as I can tell changing it to num_computed_tokens <= start_pos will fix the issue

Claude thinks (A) can be deleted. I am hesitant to endorse that since I don't fully understand the
logic and I am not sure why (A) was put there in the first place, but I here is its reasoning:

(A) can also just be deleted, and I believe that is equivalent in normal operation. If the guard is
working then the only reachable states are:

num_computed_tokens meaning (B)
< start_pos before the item decides
== start_pos at the item, nothing of it computed decides
>= start_pos + length item already fully computed already False, since the left side alone is >= the right

So (B) rules out the "already processed" case by itself and (A) adds nothing except excluding the
boundary. The only state where the two forms differ is start_pos < num_computed_tokens < start_pos + length, i.e. resuming inside an item, which I think is only reachable with prefix
caching. Deferring there seems right too for a bidirectional model -- the remainder of the item
should not be split either -- but that path is reasoned about rather than tested, so <= is the
change I would suggest.

Either way it should not be possible to starve a request: compute_mm_encoder_budget guarantees
max_num_batched_tokens >= max_tokens_per_mm_item whenever disable_chunked_mm_input is set, so a
request scheduled first in a step always has room for a whole item, and a request given zero tokens
hits continue rather than break, so the rest of the batch keeps moving.

Before submitting a new issue...

  • Make sure you already searched for relevant issues, and asked the chatbot living at the bottom right corner of the documentation page, which can answer lots of frequently asked questions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions