[Bugfix] Reject max_num_scheduled_tokens=0 at config time instead of hanging by vadiklyutiy · Pull Request #60714 · vllm-project/vllm · GitHub
Skip to content

[Bugfix] Reject max_num_scheduled_tokens=0 at config time instead of hanging - #60714

Merged
njhill merged 3 commits into
vllm-project:mainfrom
vadiklyutiy:lyutiydoctor/20261008-064734-vllm-60349
Oct 9, 2026
Merged

njhill merged 3 commits into
vllm-project:mainfrom
vadiklyutiy:lyutiydoctor/20261008-064734-vllm-60349

Conversation

@vadiklyutiy

Copy link
Copy Markdown
Member

Overview

max_num_scheduled_tokens=0 passed config validation. LLM.generate() then hung forever with no error. This PR changes the field's bound from ge=0 to ge=1, so 0 is now rejected when the config is built.

Fixes #60349. The bug was introduced by #44207.

Claims

  • max_num_scheduled_tokens=0 now raises a pydantic ValidationError (a subclass of ValueError) at config time, instead of a silent hang. The error names the field and the allowed range.
  • 0 is rejected on every user route: SchedulerConfig, EngineArgs, EngineArgs with speculative decoding, and --max-num-scheduled-tokens on the CLI.
  • None (the default), 1 and larger values work as before.
  • A new unit test fails on the old bound and passes with the fix.

Validation

Hardware and software: 1 × NVIDIA B200. vLLM at 43b4aaea3e40e20ef53ce2bfecfa1b72060bf2b5 (before), and the same commit plus this patch (after). Python 3.12, pydantic 2.13. The bug was also reproduced in the vllm/vllm-openai:nightly image at the same commit.

Reproducer (repro.py):

from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen3-0.6B",
    max_num_scheduled_tokens=0,
)
print(llm.generate(
    ["Hello, my name is"],
    SamplingParams(max_tokens=8),
))
VLLM_ENGINE_READY_TIMEOUT_S=3600 timeout 600 python3 repro.py; echo "EXIT_CODE=$?"
Tree Runs Result
Before (venv) 1 Engine ready after 158 s. Then generate() made no progress for ~362 s until timeout killed it. No exception, no error log. EXIT_CODE=124

Before:

Processed prompts:   0%|          | 0/1 [00:00<?, ?it/s, est. speed input: 0.00 toks/s, output: 0.00 toks/s]
(EngineCore pid=457933) INFO 10-08 07:22:45 [core.py:1423] [shutdown] EngineCore: trigger received signal=SIGTERM
(EngineCore pid=457933) INFO 10-08 07:22:45 [core.py:1581] [shutdown] EngineCore: aborting in-flight requests count=1
EXIT_CODE=124

After:

pydantic_core._pydantic_core.ValidationError: 1 validation error for SchedulerConfig
max_num_scheduled_tokens
  Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]
    For further information visit https://errors.pydantic.dev/2.13/v/greater_than_equal
EXIT_CODE=1

Other checks:

  • pytest tests/test_config.py -k scheduler_config: 2 passed, 439 deselected. With ge=0 put back, the new test fails with Failed: DID NOT RAISE ValidationError. I ran it with --noconftest because my local venv lacks the test dependencies for tests/conftest.py. The test is in tests/test_config.py, which Buildkite already runs (.buildkite/test_areas/engine.yaml).
  • pre-commit run --files vllm/config/scheduler.py tests/test_config.py: every hook passed or was skipped, including mypy.
Config probe on the patched tree (no GPU work)
SchedulerConfig(max_num_scheduled_tokens=0): ValidationError ValueError=True: ... Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]
SchedulerConfig(max_num_scheduled_tokens=-1): ValidationError ValueError=True: ... Input should be greater than or equal to 1 [type=greater_than_equal, input_value=-1, input_type=int]
SchedulerConfig(max_num_scheduled_tokens=1): OK -> 1
SchedulerConfig(max_num_scheduled_tokens=2048): OK -> 2048
SchedulerConfig(max_num_scheduled_tokens=None): OK -> None
EngineArgs(max_num_scheduled_tokens=0).create_engine_config(): ValidationError ValueError=True: ...
EngineArgs(max_num_scheduled_tokens=1).create_engine_config(): OK -> 1
EngineArgs(max_num_scheduled_tokens=None).create_engine_config(): OK -> None
ngram + max_num_scheduled_tokens=0: ValidationError ValueError=True: ...
ngram + max_num_scheduled_tokens=1: OK -> 1
ngram + max_num_scheduled_tokens=None: OK -> 2048
CLI --max-num-scheduled-tokens 0: ValidationError ValueError=True: ...
CLI --max-num-scheduled-tokens 1: OK -> 1

With ngram speculative decoding and None, the value still resolves to max_num_batched_tokens (2048), as before.

Details

Root cause. SchedulerConfig.max_num_scheduled_tokens is Field(default=None, ge=0), so 0 passes. The only other check, <= 0 in VllmConfig._set_max_num_scheduled_tokens (vllm/config/vllm.py), runs only when speculative decoding is on. Scheduler.__init__ keeps the value because it is not None. So every schedule() call starts with token_budget = 0. Both scheduling loops (running and waiting) run only while token_budget > 0. No token is ever scheduled, the request stays in waiting, and nothing raises. A debug run on the old code, with prints added to the scheduler, showed this on every step:

LYUTIY-DEBUG Scheduler.__init__ config max_num_scheduled_tokens=0 max_num_batched_tokens=16384 -> self.max_num_scheduled_tokens=0 speculative_config=None
LYUTIY-DEBUG schedule step=1 start_budget=0 end_budget=0 waiting=1 running=0 total_num_scheduled_tokens=0

Where it came from. Before #44207 (commit 51e0c579b), the field had no bound and the scheduler used a truthiness test, so 0 quietly fell back to max_num_batched_tokens. #44207 added ge=0 and changed the test to is not None. Its goal was to make the non-speculative path reject bad values the same way the speculative path does. But the speculative path rejects <= 0 ("does not allow any tokens to be scheduled"), while the new bound rejects only < 0. This off-by-one turned 0 into a real zero budget.

Why a bound and not a scheduler change. The scheduler treats a zero budget as "paused" on purpose, so its behavior is correct. A budget of zero tokens per step can never make progress, on any model or hardware. The sibling fields max_num_batched_tokens and max_num_seqs already use ge=1. The issue asks for a config-time ValueError.

Not done.

  • I did not map 0 back to the default. That would silently replace a value the user set. The documented default is None, and the speculative path rejects 0.
  • I kept the speculative-path <= 0 check. A user value can no longer reach it, but removing it is cleanup outside this fix.

Limitation. Setting the field after construction is not validated, because the @config dataclass does not validate on assignment. Today only internal code does this (vllm/config/vllm.py sets it to max_num_batched_tokens, which is ge=1), so no user route can reach the scheduler with 0.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

…hanging

<!-- markdownlint-disable -->

## Overview

`max_num_scheduled_tokens=0` passed config validation. `LLM.generate()` then hung forever with no error. This PR changes the field's bound from `ge=0` to `ge=1`, so `0` is now rejected when the config is built.

Fixes vllm-project/vllm issue 60349. The bug was introduced by vllm-project/vllm pull request 44207.

## Claims

- `max_num_scheduled_tokens=0` now raises a pydantic `ValidationError` (a subclass of `ValueError`) at config time, instead of a silent hang. The error names the field and the allowed range.
- `0` is rejected on every user route: `SchedulerConfig`, `EngineArgs`, `EngineArgs` with speculative decoding, and `--max-num-scheduled-tokens` on the CLI.
- `None` (the default), `1` and larger values work as before.
- A new unit test fails on the old bound and passes with the fix.

## Validation

Hardware and software: 1 × NVIDIA B200. vLLM at `43b4aaea3e40e20ef53ce2bfecfa1b72060bf2b5` (before), and the same commit plus this patch (after). Python 3.12, pydantic 2.13. The bug was also reproduced in the `vllm/vllm-openai:nightly` image at the same commit.

Reproducer (`repro.py`):

```python
from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen3-0.6B",
    max_num_scheduled_tokens=0,
)
print(llm.generate(
    ["Hello, my name is"],
    SamplingParams(max_tokens=8),
))
```

```bash
VLLM_ENGINE_READY_TIMEOUT_S=3600 timeout 600 python3 repro.py; echo "EXIT_CODE=$?"
```

| Tree | Runs | Result |
| --- | --- | --- |
| Before (venv) | 1 | Engine ready after 158 s. Then `generate()` made no progress for ~362 s until `timeout` killed it. No exception, no error log. `EXIT_CODE=124` |
| Before (nightly container, `timeout 180`) | 1 | Same hang. `EXIT_CODE=124` |
| After | 2 (one by me, one by a separate review check) | `ValidationError` about 1 s after start. `EXIT_CODE=1` |
| After, with `max_num_scheduled_tokens=1` | 1 | Generated 8 tokens (`finish_reason=length`). `EXIT_CODE=0` |

Before:

```
Processed prompts:   0%|          | 0/1 [00:00<?, ?it/s, est. speed input: 0.00 toks/s, output: 0.00 toks/s]
(EngineCore pid=457933) INFO 10-08 07:22:45 [core.py:1423] [shutdown] EngineCore: trigger received signal=SIGTERM
(EngineCore pid=457933) INFO 10-08 07:22:45 [core.py:1581] [shutdown] EngineCore: aborting in-flight requests count=1
EXIT_CODE=124
```

After:

```
pydantic_core._pydantic_core.ValidationError: 1 validation error for SchedulerConfig
max_num_scheduled_tokens
  Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]
    For further information visit https://errors.pydantic.dev/2.13/v/greater_than_equal
EXIT_CODE=1
```

Other checks:

- `pytest tests/test_config.py -k scheduler_config`: `2 passed, 439 deselected`. With `ge=0` put back, the new test fails with `Failed: DID NOT RAISE ValidationError`. I ran it with `--noconftest` because my local venv lacks the test dependencies for `tests/conftest.py`. The test is in `tests/test_config.py`, which Buildkite already runs (`.buildkite/test_areas/engine.yaml`).
- `pre-commit run --files vllm/config/scheduler.py tests/test_config.py`: every hook passed or was skipped, including mypy.

<details>
<summary>Config probe on the patched tree (no GPU work)</summary>

```
SchedulerConfig(max_num_scheduled_tokens=0): ValidationError ValueError=True: ... Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]
SchedulerConfig(max_num_scheduled_tokens=-1): ValidationError ValueError=True: ... Input should be greater than or equal to 1 [type=greater_than_equal, input_value=-1, input_type=int]
SchedulerConfig(max_num_scheduled_tokens=1): OK -> 1
SchedulerConfig(max_num_scheduled_tokens=2048): OK -> 2048
SchedulerConfig(max_num_scheduled_tokens=None): OK -> None
EngineArgs(max_num_scheduled_tokens=0).create_engine_config(): ValidationError ValueError=True: ...
EngineArgs(max_num_scheduled_tokens=1).create_engine_config(): OK -> 1
EngineArgs(max_num_scheduled_tokens=None).create_engine_config(): OK -> None
ngram + max_num_scheduled_tokens=0: ValidationError ValueError=True: ...
ngram + max_num_scheduled_tokens=1: OK -> 1
ngram + max_num_scheduled_tokens=None: OK -> 2048
CLI --max-num-scheduled-tokens 0: ValidationError ValueError=True: ...
CLI --max-num-scheduled-tokens 1: OK -> 1
```

With ngram speculative decoding and `None`, the value still resolves to `max_num_batched_tokens` (2048), as before.

</details>

## Details

**Root cause.** `SchedulerConfig.max_num_scheduled_tokens` is `Field(default=None, ge=0)`, so `0` passes. The only other check, `<= 0` in `VllmConfig._set_max_num_scheduled_tokens` (`vllm/config/vllm.py`), runs only when speculative decoding is on. `Scheduler.__init__` keeps the value because it `is not None`. So every `schedule()` call starts with `token_budget = 0`. Both scheduling loops (running and waiting) run only `while token_budget > 0`. No token is ever scheduled, the request stays in `waiting`, and nothing raises. A debug run on the old code, with prints added to the scheduler, showed this on every step:

```
LYUTIY-DEBUG Scheduler.__init__ config max_num_scheduled_tokens=0 max_num_batched_tokens=16384 -> self.max_num_scheduled_tokens=0 speculative_config=None
LYUTIY-DEBUG schedule step=1 start_budget=0 end_budget=0 waiting=1 running=0 total_num_scheduled_tokens=0
```

**Where it came from.** Before vllm-project#44207 (commit `51e0c579b`), the field had no bound and the scheduler used a truthiness test, so `0` quietly fell back to `max_num_batched_tokens`. vllm-project#44207 added `ge=0` and changed the test to `is not None`. Its goal was to make the non-speculative path reject bad values the same way the speculative path does. But the speculative path rejects `<= 0` ("does not allow any tokens to be scheduled"), while the new bound rejects only `< 0`. This off-by-one turned `0` into a real zero budget.

**Why a bound and not a scheduler change.** The scheduler treats a zero budget as "paused" on purpose, so its behavior is correct. A budget of zero tokens per step can never make progress, on any model or hardware. The sibling fields `max_num_batched_tokens` and `max_num_seqs` already use `ge=1`. The issue asks for a config-time `ValueError`.

**Not done.**

- I did not map `0` back to the default. That would silently replace a value the user set. The documented default is `None`, and the speculative path rejects `0`.
- I kept the speculative-path `<= 0` check. A user value can no longer reach it, but removing it is cleanup outside this fix.

**Limitation.** Setting the field after construction is not validated, because the `@config` dataclass does not validate on assignment. Today only internal code does this (`vllm/config/vllm.py` sets it to `max_num_batched_tokens`, which is `ge=1`), so no user route can reach the scheduler with `0`.

---

<details>
<summary> Pull Request Checklist </summary>

- [x] I used vLLM's `/pr-checklist` skill. (Mandatory for agents, optional for humans).
- [x] AI assistance was used during the creation of this PR.

- [ ] **Design Fit:** Minimizes impact on core components, reuses existing functionality, and justifies added complexity.
- [ ] **Testing and Validation:** Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.
- [ ] **Code Quality and Style:** Keeps code and comments clear and concise, and updates relevant documentation and examples.
- [ ] **Pull Request Contents:** Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.
</details>

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Vadim Gimpelson <vadim.gimpelson@gmail.com>

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

@njhill njhill added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 8, 2026
@njhill

njhill commented Oct 8, 2026

Copy link
Copy Markdown
Member

/ci run

@njhill
njhill enabled auto-merge (squash) October 8, 2026 23:48
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #93730 for commit 2d076ab92350.

@untrall

untrall commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for the nudge — regression test now added to #60399 (opened first, same fix). Suggest closing this as duplicate to keep review in one place.

@yewentao256 yewentao256 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks for the work!

@njhill
njhill disabled auto-merge October 9, 2026 18:13
@njhill

njhill commented Oct 9, 2026

Copy link
Copy Markdown
Member

/ci retry

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #93895 for commit 2cdcdc3d8537, running 2 failed step(s) from Buildkite CI #93730.

@njhill
njhill enabled auto-merge (squash) October 9, 2026 18:14
@njhill

njhill commented Oct 9, 2026

Copy link
Copy Markdown
Member

@njhill
njhill merged commit 0446273 into vllm-project:main Oct 9, 2026
17 checks passed
michael-gpu pushed a commit to practical-inference/vllm-ampere-extended that referenced this pull request Oct 20, 2026
…hanging (vllm-project#60714)

Signed-off-by: Vadim Gimpelson <vadim.gimpelson@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Hui Li <40770106+untrall@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working 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.

[Bug]: LLM.generate() never returns when max_num_scheduled_tokens=0

4 participants