[Core] structured generation mode for DiffusionGemma model (Jev-like) (#57250)

Signed-off-by: Matt Mastracci <matthew@mastracci.com>
Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com>
Signed-off-by: Razorback16 <razorback16@protonmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Razorback16 <razorback16@protonmail.com>
This commit is contained in:
Matt Mastracci
2026-09-22 20:51:17 +00:00
committed by GitHub
co-authored by Claude Fable 5.1 Lucas Wilkinson Codex Razorback16
parent fae5cbfc8b
commit 1b3b88ec2b
16 changed files with 2795 additions and 155 deletions
@@ -0,0 +1,74 @@
# Structured reads on DiffusionGemma
A discrete diffusion model denoises a whole canvas per forward pass. If the
canvas is seeded with the answer's fixed text and only the answer slots are
left as noise, one denoise step gives a distribution over each slot. These
`extra_args` fields (`vllm_xargs` on the OpenAI server) expose that:
| field | type | meaning |
| --- | --- | --- |
| `diffusion_seed_canvas` | `list[int]`, exactly `canvas_length` ids | replaces the random initial canvas after prefill |
| `diffusion_pinned` | `list[int]` of canvas positions | held at their seed value on every denoise step, so a read past one step keeps its template |
| `diffusion_max_steps` | `int` | denoise steps before the canvas is emitted |
| `diffusion_read_only` | `bool` | emit the argmax canvas as soon as the cap is reached, end the request there, and return temperature-1 logprobs at every position |
`structured_server.py` turns a question schema into those fields. It serves
`/v1/chat/completions`: the system message is the schema, the user message
is the state JSON, and the reply content is one distribution per question
with a standard error over a few noise draws.
```bash
vllm serve google/diffusiongemma-26B-A4B-it \
--diffusion-config '{"canvas_length": 64}' --max-logprobs 32 --enable-prefix-caching
python examples/features/structured_diffusion/structured_server.py \
--upstream http://127.0.0.1:8000 --tokenizer google/diffusiongemma-26B-A4B-it --canvas 64
curl -s localhost:8011/v1/chat/completions -H 'content-type: application/json' -d '{
"messages": [
{"role": "system", "content": "{\"questions\": [{\"id\": \"urgent\", \"type\": \"noul\", \"instructions\": \"Does the customer need a reply within the hour?\"}]}"},
{"role": "user", "content": "{\"ticket\": \"Everything is down and we have a demo at noon.\"}"}
]}'
```
The attention backend is picked as for Gemma 4: FlashAttention 4 on every
layer when available, otherwise Triton. FlashInfer cannot serve this model (a
batch mixes causal prefill with bidirectional denoising), and
`--attention-backend FLASHINFER` is rejected.
Per-request canvas widths smaller than the served canvas require async
scheduling. The diffusion async scheduler is selected automatically; no
`--scheduler-cls` argument is needed. Synchronous execution supports full-width
canvases only.
Question types: `noul` (yes/no), `choice` with `options`, `score` with
ordered `levels`. Each label must be a single token in the answer template,
which the server checks with the tokenizer when a request uses the schema.
`POST /v1/systemone` implements the Jev decision API. The body holds
`state`, `questions` (a map of id to `type`, `instructions` and `criteria`)
and `model`. Answers come back in that API's shapes: a `noul` probability,
a `choice` with `probabilities` and `confidence`, or a `score` with a
0-indexed `legend`. The schema options above go in the same body as
extensions.
A question may declare `depends_on` (read in a later stage with those
answers in its prompt), `ask_if` (asked only when a named question's answer
is among the listed ones, otherwise null) and `alone` (a read of its own).
Images attach as `multipart/form-data`, with the JSON in a part named
`request` and each image as a file part, or as an `images` array of data
URLs.
```bash
curl -s localhost:8011/v1/systemone -H 'content-type: application/json' -d '{
"model": "jev-latest",
"state": {"ticket": "Everything is down and we have a demo at noon."},
"questions": {"urgent": {"type": "noul", "instructions": "Does the customer need a reply within the hour?"}}}'
```
`"think": N` in the schema lets the model write up to N tokens in its
thought channel before the read. The thought is an ordinary generation with
the chat template's thinking marker on, and the read then runs with the
thought in its prompt, so the answer slots condition on it. The noise draws
of a decision share one thought. `diagnostics.thought` returns the text, its
length in tokens, whether the model closed the channel itself and the
generation time.
File diff suppressed because it is too large Load Diff
+25
View File
@@ -15,8 +15,10 @@ from transformers.models.gemma4.configuration_gemma4 import Gemma4TextConfig
from vllm.config import ModelConfig, ParallelConfig, SpeculativeConfig
from vllm.config.model_arch import ModelArchitectureConfig
from vllm.transformers_utils.configs.diffusion_gemma import DiffusionGemmaConfig
from vllm.transformers_utils.configs.gemma4 import gemma4_layer_config
from vllm.transformers_utils.model_arch_config_convertor import (
MODEL_ARCH_CONFIG_CONVERTORS,
Gemma4ModelArchConfigConvertor,
ModelArchConfigConvertorBase,
)
@@ -496,3 +498,26 @@ def test_deepseek_v4_convertor_splits_vision_architecture():
conv = DeepseekV4ModelArchConfigConvertor(draft_cfg, draft_cfg)
assert conv.get_architectures() == [draft_arch]
assert draft_cfg.architectures == [draft_arch]
def test_diffusion_gemma_head_dims_vary_by_layer_type():
"""Checkpoints carry model_type `diffusion_gemma` at the top level. Under
the base convertor every layer reports the sliding head dim, Gemma4Config
skips its head-dim handling, and the full-attention KV-cache group falls to
FlashInfer, which cannot take a per-request causal tensor."""
text_config = dict(
num_hidden_layers=6,
hidden_size=64,
num_attention_heads=8,
num_key_value_heads=4,
head_dim=16,
global_head_dim=32,
layer_types=["sliding_attention"] * 5 + ["full_attention"],
)
hf_config = DiffusionGemmaConfig(text_config=text_config)
convertor_cls = MODEL_ARCH_CONFIG_CONVERTORS[hf_config.model_type]
assert convertor_cls is Gemma4ModelArchConfigConvertor
arch = convertor_cls(hf_config, hf_config.text_config).convert()
assert [arch[i].head_size for i in range(6)] == [16] * 5 + [32]
+182
View File
@@ -1,11 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from dataclasses import dataclass
from types import SimpleNamespace
import pytest
from vllm import SamplingParams
from vllm.config.diffusion import DiffusionConfig
from vllm.exceptions import VLLMValidationError
from vllm.utils.diffusion import validate_diffusion_sampling_params
from vllm.v1.engine.input_processor import InputProcessor
@dataclass
@@ -13,6 +17,7 @@ class MockModelConfig:
is_diffusion: bool = False
max_logprobs: int = 20
logits_processors: list | None = None
return_sampling_mask: bool = False
def get_vocab_size(self) -> int:
return 1024
@@ -59,6 +64,183 @@ def test_verify_leaves_logits_processors_to_admission():
)
def _verify_diffusion(params: SamplingParams, canvas_length: int | None = None):
model_config = MockModelConfig(is_diffusion=True)
params.verify(model_config, None, None, None)
validate_diffusion_sampling_params(
params,
canvas_length=canvas_length,
vocab_size=model_config.get_vocab_size(),
async_scheduling=True,
)
def test_diffusion_extra_args_are_validated_without_a_served_canvas():
# No --diffusion-config: the canvas is unknown, the ids are still checked
# and a read-only request is still normalised.
params = SamplingParams(
max_tokens=64,
extra_args={"diffusion_seed_canvas": [0, 1], "diffusion_read_only": True},
)
_verify_diffusion(params, canvas_length=None)
assert params.ignore_eos is True
bad = SamplingParams(extra_args={"diffusion_seed_canvas": [0, 10**9]})
with pytest.raises(VLLMValidationError, match="ids must be in"):
_verify_diffusion(bad, canvas_length=None)
@pytest.mark.parametrize("async_scheduling", [False, True])
@pytest.mark.parametrize(
"extra_args",
[
{},
{"diffusion_canvas_length": None},
{"diffusion_canvas_length": 4},
{"diffusion_canvas_length": 8},
],
)
def test_narrow_diffusion_canvas_requires_async_scheduling(
async_scheduling, extra_args
):
processor = SimpleNamespace(
model_config=MockModelConfig(is_diffusion=True),
vllm_config=SimpleNamespace(
scheduler_config=SimpleNamespace(async_scheduling=async_scheduling)
),
speculative_config=None,
structured_outputs_config=None,
diffusion_config=DiffusionConfig(canvas_length=8),
tokenizer=None,
validate_logits_processors_params=lambda params: None,
)
params = SamplingParams(extra_args=extra_args)
if not async_scheduling and extra_args.get("diffusion_canvas_length") == 4:
with pytest.raises(VLLMValidationError, match="requires --async-scheduling"):
InputProcessor._validate_params(processor, params, ("generate",))
else:
InputProcessor._validate_params(processor, params, ("generate",))
@pytest.mark.parametrize(
"extra_args, match",
[
({"diffusion_seed_canvas": "abc"}, "list of token ids"),
({"diffusion_seed_canvas": [1, 2.5]}, "list of token ids"),
({"diffusion_seed_canvas": [1, True]}, "list of token ids"),
({"diffusion_seed_canvas": [0, 1024]}, r"in \[0, 1024\)"),
({"diffusion_seed_canvas": [0, -1]}, r"in \[0, 1024\)"),
({"diffusion_max_steps": 0}, "positive integer"),
({"diffusion_max_steps": "1"}, "positive integer"),
({"diffusion_max_steps": True}, "positive integer"),
({"diffusion_read_only": "yes"}, "boolean"),
({"diffusion_read_only": 2}, "boolean"),
({"diffusion_pinned": "0,1"}, "list of canvas positions"),
({"diffusion_pinned": [0, True]}, "list of canvas positions"),
({"diffusion_pinned": [0]}, "needs a diffusion_seed_canvas"),
],
)
def test_diffusion_rejects_bad_extra_args(extra_args: dict, match: str):
with pytest.raises(VLLMValidationError, match=match):
_verify_diffusion(SamplingParams(extra_args=extra_args))
def test_diffusion_seed_canvas_must_fill_the_canvas():
params = SamplingParams(extra_args={"diffusion_seed_canvas": [0] * 7})
with pytest.raises(VLLMValidationError, match="exactly 8 ids, got 7"):
_verify_diffusion(params, canvas_length=8)
# Without the served diffusion config the canvas length is unknown.
_verify_diffusion(params)
@pytest.mark.parametrize("max_tokens, expected", [(100, 8), (5, 5), (None, 8)])
@pytest.mark.parametrize("flag", [True, 1])
def test_diffusion_read_only_ends_after_one_canvas(max_tokens, expected, flag):
params = SamplingParams(
max_tokens=max_tokens, extra_args={"diffusion_read_only": flag}
)
_verify_diffusion(params, canvas_length=8)
assert params.max_tokens == expected
assert params.ignore_eos
@pytest.mark.parametrize(
"width, match",
[
(0, "positive integer"),
("4", "positive integer"),
(True, "positive integer"),
(9, "no larger"),
],
)
def test_diffusion_rejects_bad_canvas_length(width, match):
with pytest.raises(VLLMValidationError, match=match):
_verify_diffusion(
SamplingParams(extra_args={"diffusion_canvas_length": width}),
canvas_length=8,
)
def test_diffusion_canvas_length_sizes_the_seed_and_the_read():
params = SamplingParams(
max_tokens=100,
extra_args={
"diffusion_canvas_length": 4,
"diffusion_seed_canvas": [1, 2, 3, 4],
"diffusion_read_only": True,
},
)
_verify_diffusion(params, canvas_length=8)
assert params.max_tokens == 4
params = SamplingParams(
extra_args={"diffusion_canvas_length": 4, "diffusion_seed_canvas": [0] * 8}
)
with pytest.raises(VLLMValidationError, match="exactly 4 ids, got 8"):
_verify_diffusion(params, canvas_length=8)
def test_diffusion_pinned_positions_stay_inside_the_canvas():
seed = [0] * 8
with pytest.raises(VLLMValidationError, match="inside the canvas"):
_verify_diffusion(
SamplingParams(
extra_args={"diffusion_seed_canvas": seed, "diffusion_pinned": [7, 8]}
),
canvas_length=8,
)
with pytest.raises(VLLMValidationError, match="inside the canvas"):
_verify_diffusion(
SamplingParams(
extra_args={"diffusion_seed_canvas": seed, "diffusion_pinned": [-1]}
)
)
# A narrower request canvas bounds the positions.
with pytest.raises(VLLMValidationError, match="inside the canvas"):
_verify_diffusion(
SamplingParams(
extra_args={
"diffusion_canvas_length": 4,
"diffusion_seed_canvas": seed[:4],
"diffusion_pinned": [4],
}
),
canvas_length=8,
)
def test_diffusion_accepts_extra_args():
params = SamplingParams(
extra_args={
"diffusion_seed_canvas": list(range(8)),
"diffusion_pinned": [0, 1, 7],
"diffusion_max_steps": 4,
"diffusion_read_only": True,
}
)
_verify_diffusion(params, canvas_length=8)
@pytest.mark.parametrize("value", [-(2**63) - 1, 2**64])
def test_extra_args_rejects_nested_integer_overflow(value):
"""Reject extension values before they reach the engine transport."""
+130
View File
@@ -40,6 +40,10 @@ from vllm.utils.hashing import sha256
from vllm.v1.core.encoder_cache_manager import EncoderCacheManager
from vllm.v1.core.kv_cache_coordinator import HybridKVCacheCoordinator
from vllm.v1.core.kv_cache_utils import get_request_block_hasher, init_none_hash
from vllm.v1.core.sched.diffusion_scheduler import (
DiffusionAsyncScheduler,
diffusion_canvas_width,
)
from vllm.v1.core.sched.interface import PauseState
from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput
from vllm.v1.core.sched.scheduler import Scheduler
@@ -6872,3 +6876,129 @@ def test_update_draft_token_ids_in_output_strips_padding():
-1,
]
assert scheduler_output.num_invalid_spec_tokens == {request.request_id: 2}
def test_diffusion_canvas_width_defaults_to_the_served_canvas():
def req(extra):
return SimpleNamespace(sampling_params=SimpleNamespace(extra_args=extra))
assert diffusion_canvas_width(req(None), 64) == 64
assert diffusion_canvas_width(req({}), 64) == 64
assert diffusion_canvas_width(req({"diffusion_canvas_length": 16}), 64) == 16
assert diffusion_canvas_width(SimpleNamespace(sampling_params=None), 64) == 64
def _diffusion_request(req_id: str, extra_args: dict) -> Request:
(request,) = create_requests(
num_requests=1, num_tokens=8, max_tokens=64, req_ids=[req_id]
)
request.sampling_params.extra_args = extra_args
return request
def _diffusion_scheduler(**kwargs) -> DiffusionAsyncScheduler:
scheduler = create_scheduler(
async_scheduling=True,
diffusion_canvas_length=8,
scheduler_cls=DiffusionAsyncScheduler,
**kwargs,
)
assert isinstance(scheduler, DiffusionAsyncScheduler)
return scheduler
@pytest.mark.parametrize("async_scheduling", [False, True])
def test_diffusion_scheduler_is_selected_by_default(async_scheduling):
config = create_scheduler(
async_scheduling=async_scheduling, diffusion_canvas_length=8
).vllm_config.scheduler_config
assert config.get_scheduler_cls() is (
DiffusionAsyncScheduler if config.async_scheduling else Scheduler
)
def test_diffusion_scheduler_narrows_the_canvas_per_request():
scheduler = _diffusion_scheduler()
wide = _diffusion_request("wide", {})
narrow = _diffusion_request("narrow", {"diffusion_canvas_length": 4})
scheduler.add_request(wide)
scheduler.add_request(narrow)
# The prefill step lays down the served canvas as placeholders.
scheduler.schedule()
output = scheduler.schedule()
assert output.scheduled_spec_decode_tokens["wide"] == [-1] * 8
assert output.scheduled_spec_decode_tokens["narrow"] == [-1] * 4
assert output.num_scheduled_tokens["narrow"] == 4
assert narrow.num_output_placeholders == 4
@pytest.mark.parametrize("structured", [False, True])
def test_diffusion_scheduler_trims_full_width_worker_drafts(structured):
"""Padded worker drafts must be narrowed before scheduling or grammar validation."""
scheduler = _diffusion_scheduler()
wide = _diffusion_request("wide", {})
narrow = _diffusion_request("narrow", {"diffusion_canvas_length": 4})
for request in (wide, narrow):
scheduler.add_request(request)
prefill = scheduler.schedule()
_model_output(scheduler, prefill, [[], []])
if structured:
for request in (wide, narrow):
request.structured_output_request = SimpleNamespace(
grammar=_RecordingGrammar(), reasoning_ended=True
)
tokens = list(range(8)) if structured else [-1] * 8
drafts = DraftTokenIds(["wide", "narrow"], [tokens.copy(), tokens.copy()])
output = scheduler.schedule()
scheduler.update_draft_token_ids_in_output(drafts, output)
assert output.scheduled_spec_decode_tokens == {
"wide": tokens,
"narrow": tokens[:4],
}
assert output.num_scheduled_tokens == {"wide": 8, "narrow": 4}
if structured:
assert wide.structured_output_request.grammar.seen == [tokens]
assert narrow.structured_output_request.grammar.seen == [tokens[:4]]
def test_diffusion_scheduler_defers_a_read_with_every_step_in_flight():
scheduler = _diffusion_scheduler()
one = _diffusion_request(
"one", {"diffusion_read_only": True, "diffusion_max_steps": 1}
)
two = _diffusion_request(
"two", {"diffusion_read_only": True, "diffusion_max_steps": 2}
)
# Only reads are deferred. A capped generation keeps its extra async step.
gen = _diffusion_request("gen", {"diffusion_max_steps": 1})
for request in (one, two, gen):
scheduler.add_request(request)
scheduler.schedule()
assert set(scheduler.schedule().num_scheduled_tokens) == {"one", "two", "gen"}
assert one.num_output_placeholders == 8
assert set(scheduler.schedule().num_scheduled_tokens) == {"two", "gen"}
assert set(scheduler.schedule().num_scheduled_tokens) == {"gen"}
# The deferral is renewed every call.
assert set(scheduler.schedule().num_scheduled_tokens) == {"gen"}
def test_diffusion_read_deferral_keeps_a_longer_pp_wait():
scheduler = _diffusion_scheduler(pipeline_parallel_size=3, use_v2_model_runner=True)
read = _diffusion_request(
"read", {"diffusion_read_only": True, "diffusion_max_steps": 1}
)
scheduler.add_request(read)
scheduler.schedule()
assert read.next_decode_eligible_step == 4
for _ in range(2):
assert "read" not in scheduler.schedule().num_scheduled_tokens
assert "read" in scheduler.schedule().num_scheduled_tokens
assert read.next_decode_eligible_step == 7
# Deferring this step alone would ask for 6. The PP wait to 7 stands.
assert "read" not in scheduler.schedule().num_scheduled_tokens
assert read.next_decode_eligible_step == 7
+12 -1
View File
@@ -7,6 +7,7 @@ import vllm.envs as envs
from tests.v1.kv_connector.unit.utils import MockKVConfig
from vllm.config import (
CacheConfig,
DiffusionConfig,
ECTransferConfig,
KVTransferConfig,
ModelConfig,
@@ -77,6 +78,8 @@ def create_scheduler(
kv_cache_spec: KVCacheSpec | None = None,
per_request_spec_decode_metrics: str = "none",
scheduling_policy: SchedulerPolicy = "fcfs",
diffusion_canvas_length: int | None = None,
scheduler_cls: type[Scheduler] | None = None,
) -> Scheduler | AsyncScheduler:
"""Create scheduler under test.
@@ -178,6 +181,12 @@ def create_scheduler(
else None
)
diffusion_config: DiffusionConfig | None = None
if diffusion_canvas_length is not None:
# A diffusion checkpoint declares its canvas in the HF config.
model_config.hf_config.canvas_length = diffusion_canvas_length
diffusion_config = DiffusionConfig(canvas_length=diffusion_canvas_length)
vllm_config = VllmConfig(
scheduler_config=scheduler_config,
model_config=model_config,
@@ -188,6 +197,7 @@ def create_scheduler(
),
kv_transfer_config=kv_transfer_config,
speculative_config=speculative_config,
diffusion_config=diffusion_config,
ec_transfer_config=ec_transfer_config,
observability_config=ObservabilityConfig(
per_request_spec_decode_metrics=per_request_spec_decode_metrics,
@@ -207,7 +217,8 @@ def create_scheduler(
)
cache_config.num_gpu_blocks = num_blocks
register_all_kvcache_specs(vllm_config)
scheduler_cls = AsyncScheduler if async_scheduling else Scheduler
if scheduler_cls is None:
scheduler_cls = AsyncScheduler if async_scheduling else Scheduler
scheduler = scheduler_cls(
vllm_config=vllm_config,
kv_cache_config=kv_cache_config,
@@ -80,10 +80,12 @@ def _validate(enable_trace_replay: bool) -> None:
model_config=SimpleNamespace(
return_sampling_mask=False,
enable_trace_replay=enable_trace_replay,
is_diffusion=False,
),
vllm_config=SimpleNamespace(reasoning_config=None),
speculative_config=None,
structured_outputs_config=None,
diffusion_config=None,
tokenizer=None,
validate_logits_processors_params=lambda params: None,
)
@@ -0,0 +1,319 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Per-request DiffusionGemma state behind structured reads: seed canvases,
pinned positions, read-only slots and the per-slot step cap."""
import numpy as np
import pytest
import torch
from vllm.model_executor.models.diffusion_gemma import (
DiffusionGemmaRequestStates,
_compiled_sample_step,
_concat_logprob_stashes,
)
from vllm.platforms import current_platform
from vllm.v1.outputs import LogprobsTensors
pytestmark = pytest.mark.skipif(
not current_platform.is_cuda(), reason="the sampler state lives on the GPU"
)
CL = 8
VOCAB = 64
MAX_REQS = 4
MAX_STEPS = 48
def _states() -> DiffusionGemmaRequestStates:
return DiffusionGemmaRequestStates(
max_num_reqs=MAX_REQS,
canvas_length=CL,
vocab_size=VOCAB,
max_denoising_steps=MAX_STEPS,
device=torch.device("cuda"),
hidden_size=4,
stability_threshold=2,
)
def _slots(*idx: int) -> tuple[np.ndarray, torch.Tensor]:
slots = np.array(idx, dtype=np.int64)
return slots, torch.tensor(slots, device="cuda")
def test_seed_canvas_replaces_only_seeded_slots():
states = _states()
for slot in range(3):
states.add_request(slot)
seed = list(range(CL))
states.set_seed_canvas(1, seed)
slots, slots_gpu = _slots(0, 1, 2)
states.init_canvas(slots_gpu)
before = states.canvas[slots_gpu].clone()
states.apply_seed_canvases(slots, slots_gpu)
after = states.canvas[slots_gpu]
assert after[1].tolist() == seed
assert torch.equal(after[0], before[0])
assert torch.equal(after[2], before[2])
def test_apply_seed_canvases_leaves_unseeded_batches_alone():
states = _states()
states.add_request(0)
slots, slots_gpu = _slots(0)
states.init_canvas(slots_gpu)
before = states.canvas[0].clone()
states.apply_seed_canvases(slots, slots_gpu)
assert torch.equal(states.canvas[0], before)
def test_add_request_clears_seed_and_read_only():
states = _states()
states.add_request(0)
states.set_seed_canvas(0, [1] * CL)
states.set_read_only(0)
assert states.seeded_slots == {0}
assert states.read_only_slots == {0}
states.add_request(0)
assert not states.seeded_slots
assert not states.read_only_slots
assert not bool(states.has_seed[0])
assert not bool(states.read_only[0])
def test_canvas_width_resets_with_the_slot():
states = _states()
states.add_request(0)
states.canvas_width_np[0] = 4
states.set_seed_canvas(0, [7, 7, 7, 7])
assert states.seed_canvas[0, :4].tolist() == [7, 7, 7, 7]
states.add_request(0)
assert states.canvas_width_np[0] == CL
def test_remove_request_forgets_the_slot():
states = _states()
states.add_request(0)
states.set_seed_canvas(0, [1] * CL)
states.set_read_only(0)
states.remove_request(0)
assert not states.seeded_slots
assert not states.read_only_slots
def _denoise_once(
states: DiffusionGemmaRequestStates,
slots: list[int],
compute_sc: bool = True,
width: int = CL,
embed_weight: torch.Tensor | None = None,
embed_dtype: torch.dtype = torch.float32,
) -> tuple[torch.Tensor, torch.Tensor]:
"""One compiled denoise step over ``slots`` with flat logits, so nothing
converges by stability or confidence and only the step cap can end it.
``width`` below CL runs the step on [:, :width] views, as the sampler
does for a narrow tile."""
n = len(slots)
device = states.device
decode_slots = torch.tensor(slots, dtype=torch.int64, device=device)
decode_idx = torch.arange(n, dtype=torch.int64, device=device)
sampled = torch.zeros(n, CL, dtype=torch.int32, device=device)[:, :width]
num_sampled = torch.zeros(n, dtype=torch.int32, device=device)
_compiled_sample_step(
torch.zeros(n * width, VOCAB, device=device),
decode_slots,
decode_idx,
decode_slots,
torch.full((n,), width, dtype=torch.int64, device=device),
states.canvas[:, :width],
states.argmax_canvas[:, :width],
states.step,
states.is_encoder_phase,
states.confident,
states.self_conditioning_embeds[:, :width],
(
torch.zeros(VOCAB, 4, dtype=embed_dtype, device=device)
if embed_weight is None
else embed_weight
),
torch.tensor(1.0, dtype=embed_dtype, device=device),
states.accepted_canvas_history[:, :, :width],
states.accepted_canvas_history_len,
states.max_steps,
states.pin_mask[:, :width],
states.seed_canvas[:, :width],
states.read_only,
sampled,
num_sampled,
torch.zeros(MAX_REQS, CL, dtype=torch.int64, device=device),
max_denoising_steps=float(MAX_STEPS),
t_min=0.5,
t_max=1.0,
confidence_threshold=0.1,
vocab_size=VOCAB,
CL=width,
ST=states.stability_threshold,
entropy_bound=0.1,
sc_vocab_start=0,
sc_vocab_end=VOCAB,
tp_size=1,
tp_group_name="",
compute_sc=compute_sc,
)
return sampled, num_sampled
def test_pinned_positions_hold_their_seed_through_a_step():
states = _states()
states.add_request(0)
states.is_encoder_phase[0] = False
seed = list(range(10, 10 + CL))
states.set_seed_canvas(0, seed)
states.canvas[0] = torch.tensor(seed, device="cuda")
states.set_pins(0, [0, 1, 2, 3])
# Flat logits accept nothing, so every free position is renoised.
_denoise_once(states, [0], embed_weight=torch.ones(VOCAB, 4, device="cuda"))
assert states.canvas[0, :4].tolist() == seed[:4]
# The soft embed is zero at pinned positions and non-zero elsewhere.
sc = states.self_conditioning_embeds[0]
assert not sc[:4].any()
assert sc[4:].any()
def test_add_request_clears_pins():
states = _states()
states.add_request(0)
states.set_seed_canvas(0, [1] * CL)
states.set_pins(0, [2, 3])
assert states.pin_mask[0].tolist() == [False, False, True, True] + [False] * 4
states.add_request(0)
assert not states.pin_mask[0].any()
def test_single_step_tile_skips_self_conditioning():
states = _states()
states.add_request(0)
states.is_encoder_phase[0] = False
states.self_conditioning_embeds[0] = 1.0
_denoise_once(states, [0], compute_sc=False)
assert not states.self_conditioning_embeds[0].any()
@pytest.mark.parametrize("stance", ["default", "force_eager"])
def test_self_conditioning_stores_a_bf16_model_in_the_fp32_buffer(stance):
# The model's embeddings are bf16 while the buffer is fp32. Compiled code
# casts on the store, but eager does not, and the step runs eager once
# torch.compile hits its recompile limit.
states = _states()
states.add_request(0)
states.is_encoder_phase[0] = False
with torch.compiler.set_stance(stance):
_denoise_once(
states,
[0],
embed_weight=torch.ones(VOCAB, 4, dtype=torch.bfloat16, device="cuda"),
embed_dtype=torch.bfloat16,
)
assert states.self_conditioning_embeds.dtype == torch.float32
# uniform probs @ an all-ones embedding
assert torch.allclose(
states.self_conditioning_embeds[0], torch.ones(CL, 4, device="cuda")
)
def test_narrow_tile_leaves_the_rest_of_the_canvas_alone():
states = _states()
states.add_request(0)
states.is_encoder_phase[0] = False
states.canvas[0] = 5
states.argmax_canvas[0] = 5
_denoise_once(states, [0], width=4)
# Columns past the width are untouched; the step counted.
assert states.canvas[0, 4:].tolist() == [5] * (CL - 4)
assert states.argmax_canvas[0, 4:].tolist() == [5] * (CL - 4)
assert states.step[0] == 1
def test_step_cap_is_per_slot():
states = _states()
for slot in (0, 1):
states.add_request(slot)
states.is_encoder_phase[slot] = False
states.max_steps[0] = 1
_denoise_once(states, [0, 1])
# Slot 0 hit its cap and moves to commit. Slot 1 keeps denoising.
assert states.is_encoder_phase[:2].tolist() == [True, False]
assert states.step[:2].tolist() == [1, 1]
def _stash(rows: int, width: int, first_id: int) -> LogprobsTensors:
ids = torch.arange(first_id, first_id + rows * width).reshape(rows, width)
return LogprobsTensors(
logprob_token_ids=ids,
logprobs=-ids.float(),
selected_token_ranks=torch.zeros(rows, dtype=torch.int64),
)
def test_stashes_of_different_widths_join():
# A read that asked for 10 label ids and a generation that asked for 2
# logprobs converged in different steps, so their stashes differ in width.
wide, narrow = _stash(2, 11, 100), _stash(3, 3, 0)
out = _concat_logprob_stashes([narrow, wide], [0, 3])
assert out.logprob_token_ids.shape == (5, 11)
assert out.logprobs.shape == (5, 11)
assert out.cu_num_generated_tokens == [0, 3]
# The narrow rows keep their values and pad with id 0 at -inf.
assert torch.equal(out.logprob_token_ids[:3, :3], narrow.logprob_token_ids)
assert not out.logprob_token_ids[:3, 3:].any()
assert torch.isneginf(out.logprobs[:3, 3:]).all()
assert torch.equal(out.logprobs[3:], wide.logprobs)
@pytest.mark.parametrize("width", [4, CL])
@pytest.mark.parametrize("steps", [1, 2])
def test_read_emits_at_convergence_while_generation_waits_for_commit(width, steps):
states = _states()
for slot in (2, 0):
states.add_request(slot)
states.is_encoder_phase[slot] = False
states.max_steps[slot] = steps
states.set_read_only(2)
for step in range(steps):
sampled, counts = _denoise_once(states, [2, 0], width=width)
assert counts.tolist() == ([width, 0] if step == steps - 1 else [0, 0])
assert torch.equal(sampled[0], states.argmax_canvas[2, :width].int())
assert not states.is_encoder_phase[2]
assert states.is_encoder_phase[0]
assert not states.self_conditioning_embeds[2].any()
sampled, counts = _denoise_once(states, [0], width=width)
assert counts.tolist() == [width]
assert torch.equal(sampled[0], states.argmax_canvas[0, :width].int())
+11 -7
View File
@@ -232,13 +232,17 @@ class SchedulerConfig:
# The first half of this warning can be removed once the Scheduler interface is
# finalized and we can maintain support for scheduler classes that implement it
logger.warning_once(
"Using custom scheduler class %s. This scheduler interface is not public "
"and compatibility may not be maintained. If you have subclassed Scheduler "
"instead of AsyncScheduler, you will see degraded performance due to async "
"scheduling being disabled.",
self.scheduler_cls, # type: ignore[arg-type]
)
if not (
isinstance(self.scheduler_cls, str)
and self.scheduler_cls.startswith("vllm.")
):
logger.warning_once(
"Using custom scheduler class %s. This scheduler interface is not "
"public and compatibility may not be maintained. If you have "
"subclassed Scheduler instead of AsyncScheduler, you will see "
"degraded performance due to async scheduling being disabled.",
self.scheduler_cls, # type: ignore[arg-type]
)
if not isinstance(self.scheduler_cls, str):
return cast(type["SchedulerInterface"], self.scheduler_cls)
return resolve_obj_by_qualname(self.scheduler_cls)
+10
View File
@@ -1894,6 +1894,16 @@ class VllmConfig:
)
current_platform.check_and_update_config(self)
# After the platform hook, which has the last word on async scheduling.
if (
self.diffusion_config is not None
and self.scheduler_config.async_scheduling
and self.scheduler_config.scheduler_cls is None
):
self.scheduler_config.scheduler_cls = (
"vllm.v1.core.sched.diffusion_scheduler.DiffusionAsyncScheduler"
)
self._normalize_piecewise_cudagraph_mode(
breakable_cudagraph_enabled=breakable_cudagraph_enabled
)
+6 -3
View File
@@ -280,11 +280,14 @@ class DiffusionGemmaModelForBlockDiffusionConfig(VerifyAndUpdateConfig):
"FLASH_ATTN or TRITON_ATTN instead."
)
if attention_config.backend is None and not attention_config.use_non_causal:
# Only the sm_100 default backend order reads this flag (it puts
# FlashInfer first for causal models). Elsewhere Gemma4Config above
# keeps FlashInfer out: FA4 on every layer, else TRITON_ATTN.
attention_config.use_non_causal = True
logger.info(
"DiffusionGemma uses mixed causal/bidirectional attention "
"within a batch; setting use_non_causal=True to exclude "
"FlashInfer from auto-selection."
"DiffusionGemma mixes causal and bidirectional attention in "
"one batch; setting use_non_causal=True so the default backend "
"order prefers FlashAttention over FlashInfer."
)
# Auto-create DiffusionConfig from HF config if not provided.
+319 -144
View File
@@ -22,6 +22,7 @@ from typing import Any
import numpy as np
import torch
import torch._dynamo
from torch import nn
from torch.nn import functional as F
from transformers import AutoModel
@@ -371,6 +372,34 @@ def _compute_num_rejected(
return torch.where(is_denoise, query_lens, num_rejected)
def _concat_logprob_stashes(
parts: list[LogprobsTensors], cu_num_generated_tokens: list[int]
) -> LogprobsTensors:
"""Join the logprobs stashed for the requests committing this step.
Each stash is as wide as the widest logprobs request in the batch at the
step that request converged, so stashes from different steps can differ
in width. Pad the narrow ones the way compute_topk_scores pads a mixed
batch: token id 0 at -inf, which the output processor never reports.
"""
width = max(p.logprob_token_ids.shape[1] for p in parts)
def pad(t: torch.Tensor, value: float) -> torch.Tensor:
return F.pad(t, (0, width - t.shape[1]), value=value)
return LogprobsTensors(
logprob_token_ids=torch.cat([pad(p.logprob_token_ids, 0) for p in parts]),
logprobs=torch.cat([pad(p.logprobs, float("-inf")) for p in parts]),
selected_token_ranks=torch.cat([p.selected_token_ranks for p in parts]),
cu_num_generated_tokens=cu_num_generated_tokens,
)
# Tiles specialize the graph on batch size 1, on a tile as wide as the canvas,
# on compute_sc and on sizes that coincide with the state buffers. That set
# is small but passes Dynamo's default of 8, after which every step would run
# eager: about twice as slow for a self-conditioned step.
@torch._dynamo.config.patch(recompile_limit=64)
@torch.compile(dynamic=True, backend=current_platform.simple_compile_backend)
def _compiled_sample_step(
# Logits from the model [num_decode * CL, vocab]
@@ -391,6 +420,10 @@ def _compiled_sample_step(
normalizer: torch.Tensor,
history: torch.Tensor, # [max_num_reqs, ST, CL]
history_len_tensor: torch.Tensor, # [max_num_reqs]
max_steps_tensor: torch.Tensor, # [max_num_reqs] int32, per-slot step cap
pin_mask: torch.Tensor, # [max_num_reqs, CL] bool, positions held at the seed
seed_canvas: torch.Tensor, # [max_num_reqs, CL]
read_only: torch.Tensor, # [max_num_reqs] bool, emit without a commit forward
# Output tensors (modified in-place)
sampled: torch.Tensor, # [num_reqs, CL]
num_sampled: torch.Tensor, # [num_reqs]
@@ -412,6 +445,7 @@ def _compiled_sample_step(
sc_vocab_end: int,
tp_size: int,
tp_group_name: str,
compute_sc: bool = True,
) -> torch.Tensor:
"""Compiled decode step: temperature → Gumbel sample → probs/confidence →
accept/renoise → convergence, all as vectorized PyTorch ops.
@@ -478,8 +512,11 @@ def _compiled_sample_step(
0, vocab_size, (num_decode, CL), device=device, dtype=canvas.dtype
)
# Compute denoise canvas (accept/renoise)
# Compute denoise canvas (accept/renoise). Pinned positions keep the seed.
denoise_canvas = torch.where(eb_mask, new_tokens, random_tokens)
denoise_canvas = torch.where(
pin_mask[decode_slots], seed_canvas[decode_slots], denoise_canvas
)
# Canvas: commit → random reinit, denoise → accept/renoise result
canvas[decode_slots] = torch.where(
@@ -504,16 +541,6 @@ def _compiled_sample_step(
new_hist_len = torch.where(is_denoise, hist_len + 1, hist_len.new_zeros(num_decode))
history_len_tensor[decode_slots] = new_hist_len
# Sampled output: commit → emit argmax_canvas, denoise → 0 (pre-zeroed)
sampled[decode_idx] = argmax_canvas[decode_slots].to(
sampled.dtype
) * is_commit.unsqueeze(1).to(sampled.dtype)
# Commit only the real canvas length (== CL except for a canvas truncated
# near max_model_len); the padded tail positions are never emitted.
num_sampled[decode_idx] = is_commit.to(num_sampled.dtype) * valid_canvas_len.to(
num_sampled.dtype
)
# ---- Phase 6: Stability + convergence ----
ref = history[decode_slots, 0]
mismatch = torch.zeros(num_decode, device=device, dtype=torch.int32)
@@ -523,13 +550,17 @@ def _compiled_sample_step(
step_after = step_tensor[decode_slots]
converged = (stable & confident_tensor[decode_slots] & (new_hist_len >= ST)) | (
step_after >= max_denoising_steps
step_after >= max_steps_tensor[decode_slots]
)
# Commit done → denoise next (False); denoise converged → commit next (True)
is_encoder_phase[decode_slots] = torch.where(
is_commit, is_commit.new_zeros(num_decode), converged
)
emit = is_commit | (is_denoise & converged & read_only[decode_slots])
sampled[decode_idx] = argmax_canvas[decode_slots].to(sampled.dtype) * emit[:, None]
num_sampled[decode_idx] = (emit * valid_canvas_len).to(num_sampled.dtype)
# SC soft embedding: store ``probs @ embed_weight`` (the value the next step's
# self-conditioning MLP consumes) only for slots that will denoise next — i.e.
# this step denoised AND it isn't about to commit (is_encoder_phase now False).
@@ -537,24 +568,41 @@ def _compiled_sample_step(
# sc_embeds directly. Storing the [.., hidden] soft embed instead of the full
# [.., vocab] probs avoids a giant persistent buffer.
sc_keep = (is_denoise & ~is_encoder_phase[decode_slots])[:, None, None]
# Self-conditioning soft embed = probs @ embed_tokens.weight. Under tensor
# parallelism the embedding is vocab-sharded ([vocab/tp, hidden]) while
# probs spans the full vocab, so each rank multiplies its local vocab slice
# [sc_vocab_start, sc_vocab_end) and the partials are summed across ranks.
local_probs = probs[..., sc_vocab_start:sc_vocab_end].to(embed_weight.dtype)
soft_embeds = torch.matmul(
local_probs, embed_weight[: sc_vocab_end - sc_vocab_start]
)
if tp_size > 1:
soft_embeds = torch.ops.vllm.all_reduce(soft_embeds, group_name=tp_group_name)
soft_embeds = soft_embeds * normalizer
sc_embeds[decode_slots] = (soft_embeds * sc_keep).to(sc_embeds.dtype)
if compute_sc:
# Self-conditioning soft embed = probs @ embed_tokens.weight. Under
# tensor parallelism the embedding is vocab-sharded ([vocab/tp,
# hidden]) while probs spans the full vocab, so each rank multiplies
# its local vocab slice [sc_vocab_start, sc_vocab_end) and the
# partials are summed across ranks.
local_probs = probs[..., sc_vocab_start:sc_vocab_end].to(embed_weight.dtype)
soft_embeds = torch.matmul(
local_probs, embed_weight[: sc_vocab_end - sc_vocab_start]
)
if tp_size > 1:
soft_embeds = torch.ops.vllm.all_reduce(
soft_embeds, group_name=tp_group_name
)
soft_embeds = soft_embeds * normalizer
# A pinned position holds its seed token as input. Zero its soft embed
# too, or the model's own prediction there reaches the next step
# through self-conditioning. The buffer is fp32 while the embedding is
# in the model dtype: compiled code casts on the store but eager does
# not, and the step runs eager once torch.compile hits its recompile
# limit.
sc_pin = (~pin_mask[decode_slots]).unsqueeze(-1)
sc_embeds[decode_slots] = (soft_embeds * sc_keep * sc_pin).to(sc_embeds.dtype)
else:
# Every slot in this tile ends after this step, so the soft embed
# would never be read. The matmul is a full pass over the vocabulary
# matrix, so skip it.
sc_embeds[decode_slots] = 0
# Overwrite canvas with argmax for newly converged denoise requests
newly_converged = (converged & is_denoise).unsqueeze(1)
canvas[decode_slots] = torch.where(
newly_converged, argmax_canvas[decode_slots], canvas[decode_slots]
)
is_encoder_phase[decode_slots] &= ~read_only[decode_slots]
# ---- Phase 7: Copy canvas → draft_tokens for all slots ----
draft_tokens[all_slots, :CL] = canvas[all_slots]
@@ -628,6 +676,32 @@ class DiffusionGemmaRequestStates:
# Per-slot confidence flag, set by the sampler each step.
self.confident = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
# Per-slot step cap, lowered per request from extra_args.
self.max_steps = torch.full(
(max_num_reqs,), max_denoising_steps, dtype=torch.int32, device=device
)
# Seed canvases replace the random canvas after prefill. The host-side
# sets gate the seed and read-only work so plain generation skips it.
self.seed_canvas = torch.zeros(
max_num_reqs, canvas_length, dtype=torch.int64, device=device
)
self.has_seed = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
self.seeded_slots: set[int] = set()
# Pinned positions keep their seed value on every denoise step, so a
# multi-step read denoises the canvas the request seeded.
self.pin_mask = torch.zeros(
max_num_reqs, canvas_length, dtype=torch.bool, device=device
)
# Read-only slots emit on their converging step and skip the commit
# forward.
self.read_only = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
self.read_only_slots: set[int] = set()
# Slots capped at one denoise step never consume a soft embed.
self.single_step_slots: set[int] = set()
# Per-slot canvas width, at most canvas_length. The scheduler schedules
# this many draft tokens for the slot and the sampler pads the rest.
self.canvas_width_np = np.full(max_num_reqs, canvas_length, dtype=np.int32)
# Per-slot self-conditioning soft embedding (probs @ embed_weight) from
# the previous denoise step. Storing the [.., hidden] soft embed instead
# of the full [.., vocab] distribution shrinks this buffer by
@@ -657,11 +731,57 @@ class DiffusionGemmaRequestStates:
self.step[slot_idx].fill_(0)
self.accepted_canvas_history_len[slot_idx].fill_(0)
self.self_conditioning_embeds[slot_idx] = 0
self.max_steps[slot_idx].fill_(self.max_denoising_steps)
self.has_seed[slot_idx].fill_(False)
self.seeded_slots.discard(slot_idx)
self.pin_mask[slot_idx].fill_(False)
self.read_only[slot_idx].fill_(False)
self.read_only_slots.discard(slot_idx)
self.single_step_slots.discard(slot_idx)
self.canvas_width_np[slot_idx] = self.canvas_length
def remove_request(self, slot_idx: int) -> None:
# add_request resets the GPU flags before a slot is reused. The host
# sets gate work on every step, so they clear here.
self.is_encoder_phase[slot_idx].fill_(False)
self.accepted_canvas_history_len[slot_idx].fill_(0)
self.self_conditioning_embeds[slot_idx] = 0
self.seeded_slots.discard(slot_idx)
self.read_only_slots.discard(slot_idx)
self.single_step_slots.discard(slot_idx)
def set_seed_canvas(self, slot_idx: int, ids: list[int]) -> None:
"""``ids`` covers the slot's canvas width; positions past it are never
scheduled."""
self.seed_canvas[slot_idx, : len(ids)] = async_tensor_h2d(
ids, dtype=torch.int64, device=self.device
)
self.has_seed[slot_idx].fill_(True)
self.seeded_slots.add(slot_idx)
def set_pins(self, slot_idx: int, positions: list[int]) -> None:
"""Hold ``positions`` of the slot's seed canvas through every denoise
step. Validated against the request's canvas width upstream."""
self.pin_mask[slot_idx].fill_(False)
self.pin_mask[
slot_idx, async_tensor_h2d(positions, dtype=torch.int64, device=self.device)
] = True
def set_read_only(self, slot_idx: int) -> None:
self.read_only[slot_idx].fill_(True)
self.read_only_slots.add(slot_idx)
def apply_seed_canvases(
self, slots_np: np.ndarray, slots_gpu: torch.Tensor
) -> None:
"""Replace the canvas of every seeded slot among ``slots_gpu``."""
if self.seeded_slots.isdisjoint(slots_np.tolist()):
return
self.canvas[slots_gpu] = torch.where(
self.has_seed[slots_gpu, None],
self.seed_canvas[slots_gpu],
self.canvas[slots_gpu],
)
class DiffusionGemmaModelState(ModelState):
@@ -1030,6 +1150,33 @@ class DiffusionSampler:
self._pending_logprobs.pop(req_idx, None)
self.sampling_states.add_request(req_idx, sampling_params)
self.logprob_token_ids_state.add_request(req_idx, sampling_params)
extra = getattr(sampling_params, "extra_args", None) or {}
states = self.diffusion_states
cap = extra.get("diffusion_max_steps")
if cap is not None:
cap = max(1, min(int(cap), states.max_denoising_steps))
states.max_steps[req_idx].fill_(cap)
if cap == 1:
states.single_step_slots.add(req_idx)
width = extra.get("diffusion_canvas_length")
if width:
states.canvas_width_np[req_idx] = max(
1, min(int(width), self.canvas_length)
)
width = int(states.canvas_width_np[req_idx])
seed = extra.get("diffusion_seed_canvas")
if seed is not None:
if len(seed) != width:
raise ValueError(
f"diffusion_seed_canvas must hold exactly {width} ids, "
f"got {len(seed)}"
)
states.set_seed_canvas(req_idx, seed)
pins = extra.get("diffusion_pinned")
if pins and seed is not None:
states.set_pins(req_idx, [int(p) for p in pins])
if extra.get("diffusion_read_only"):
states.set_read_only(req_idx)
def apply_staged_writes(self) -> None:
self.sampling_states.apply_staged_writes()
@@ -1071,6 +1218,7 @@ class DiffusionSampler:
ps.astype(np.int64), device=states.is_encoder_phase.device
)
states.init_canvas(ps_gpu)
states.apply_seed_canvases(ps, ps_gpu)
self.req_states.draft_tokens[ps_gpu, : self.canvas_length] = states.canvas[
ps_gpu
]
@@ -1151,7 +1299,6 @@ class DiffusionSampler:
# --- CPU/NumPy setup (outside compile): split decode vs prefill, init
# canvas for any new prefills, and stage decode slot indices to GPU. ---
states = self.diffusion_states
CL = self.canvas_length
slots_np = input_batch.idx_mapping_np[:num_reqs]
per_req_nlogits_np = np.diff(input_batch.cu_num_logits_np[: num_reqs + 1])
@@ -1194,18 +1341,12 @@ class DiffusionSampler:
if top_k is not None or top_p is not None:
logits = apply_top_k_top_p(logits.float(), top_k, top_p)
# Pad any truncated canvas back to CL so the uniform-CL sampler math
# holds. Phantom (padded) positions are zeroed → uniform logits → high
# entropy (no premature convergence) and argmax 0 (stable); they are
# never committed (num_sampled == real length). masked_fill (not
# multiply) so -inf entries from top_k/top_p filtering above don't
# turn phantom rows into NaN.
if num_decode > 0 and valid_canvas_len_np.min() < CL:
ar = torch.arange(CL, device=device)
starts = valid_canvas_len.cumsum(0) - valid_canvas_len # row offset per req
valid = ar.unsqueeze(0) < valid_canvas_len.unsqueeze(1) # [num_decode, CL]
src = (starts.unsqueeze(1) + ar.unsqueeze(0)).clamp_max(logits.shape[0] - 1)
logits = logits[src.reshape(-1)].masked_fill_(~valid.reshape(-1, 1), 0)
# Where each decode request's rows start in the flat logits. Tiles
# below gather and pad a request to the tile's width.
row_starts_np = np.concatenate(
([0], np.cumsum(valid_canvas_len_np)[:-1])
).astype(np.int64)
row_starts = async_tensor_h2d(row_starts_np, device=device)
# Clear once: the tiled loop below only scatters its own decode slots,
# so it must not re-clear earlier tiles' writes.
@@ -1228,125 +1369,159 @@ class DiffusionSampler:
want_logprobs = max_num_logprobs >= 0 or max_token_ids > 0
num_logprobs = max(max_num_logprobs, 0)
# Sample over the [num_decode * CL, vocab] logits. The fp32 pipeline in
# _compiled_sample_step keeps several live [group * CL, vocab] copies, so
# size each tile to a fraction of free memory to bound the transient at
# high concurrency. Tiling is bit-identical to a single pass.
group = max(num_decode, 1)
if num_decode > 0:
free, _ = current_platform.mem_get_info()
# ~10 transient fp32 copies of [group * CL, vocab] inside the step
# Sample per tile. Decode requests are grouped by canvas width and each
# tile runs the compiled step at that width over [:, :W] views of the
# state, so a narrow read pays for its own rows rather than the served
# canvas. Widths ascend, so the last tile's canvas-to-draft copy (over
# all slots) is the widest. The fp32 pipeline keeps several live
# [tile * W, vocab] copies, so a tile is also bounded by free memory.
widths_np = states.canvas_width_np[decode_slots_np]
order = np.argsort(widths_np, kind="stable")
free = current_platform.mem_get_info()[0] if num_decode > 0 else 0
run_start = 0
while run_start < num_decode:
W = int(widths_np[order[run_start]])
run_end = run_start
while run_end < num_decode and widths_np[order[run_end]] == W:
run_end += 1
# ~10 transient fp32 copies of [tile * W, vocab] inside the step
# (eager peaks at ~8; pad for allocator overhead and small tensors).
bytes_per_req = CL * self.vocab_size * 4 * 10
budget = int(free * 0.5) // max(bytes_per_req, 1)
group = max(1, min(num_decode, budget))
budget = max(1, int(free * 0.5) // max(W * self.vocab_size * 4 * 10, 1))
for t0 in range(run_start, run_end, budget):
sel_np = order[t0 : min(t0 + budget, run_end)]
n = len(sel_np)
sel = async_tensor_h2d(sel_np.astype(np.int64), device=device)
tile_slots = decode_slots[sel]
tile_valid = valid_canvas_len[sel]
tile_valid_np = valid_canvas_len_np[sel_np]
contiguous = bool((sel_np == np.arange(sel_np[0], sel_np[0] + n)).all())
if contiguous and tile_valid_np.min() == W:
r0 = int(row_starts_np[sel_np[0]])
tile_logits = logits[r0 : r0 + n * W]
else:
# Pad each request to W. Phantom positions are zeroed:
# uniform logits, high entropy, argmax 0, never committed.
# masked_fill, not multiply, so -inf from top_k/top_p
# filtering above cannot turn a phantom row into NaN.
ar = torch.arange(W, device=device)
src = (row_starts[sel].unsqueeze(1) + ar.unsqueeze(0)).clamp_max(
logits.shape[0] - 1
)
valid = ar.unsqueeze(0) < tile_valid.unsqueeze(1)
tile_logits = logits[src.reshape(-1)].masked_fill_(
~valid.reshape(-1, 1), 0
)
compute_sc = (
not states.single_step_slots
or not states.single_step_slots.issuperset(
decode_slots_np[sel_np].tolist()
)
)
for start_req in range(0, num_decode, group):
end_req = min(start_req + group, num_decode)
tile = slice(start_req, end_req)
tile_slots = decode_slots[tile]
scaled = _compiled_sample_step(
tile_logits,
tile_slots,
decode_idx[sel],
all_slots,
tile_valid,
# State, viewed at this tile's width
states.canvas[:, :W],
states.argmax_canvas[:, :W],
states.step,
states.is_encoder_phase,
states.confident,
states.self_conditioning_embeds[:, :W],
self.embed_weight,
self.normalizer,
states.accepted_canvas_history[:, :, :W],
states.accepted_canvas_history_len,
states.max_steps,
states.pin_mask[:, :W],
states.seed_canvas[:, :W],
states.read_only,
# Output
sampled[:, :W],
num_sampled,
self.req_states.draft_tokens,
# Config
max_denoising_steps=float(states.max_denoising_steps),
t_min=self.t_min,
t_max=self.t_max,
confidence_threshold=self.confidence_threshold,
vocab_size=self.vocab_size,
CL=W,
ST=states.stability_threshold,
entropy_bound=self.entropy_bound,
sc_vocab_start=self.sc_vocab_start,
sc_vocab_end=self.sc_vocab_end,
tp_size=self.tp_size,
tp_group_name=self.tp_group_name,
compute_sc=compute_sc,
)
scaled = _compiled_sample_step(
logits[start_req * CL : end_req * CL],
tile_slots,
decode_idx[tile],
all_slots,
valid_canvas_len[tile],
# State
states.canvas,
states.argmax_canvas,
states.step,
states.is_encoder_phase,
states.confident,
states.self_conditioning_embeds,
self.embed_weight,
self.normalizer,
states.accepted_canvas_history,
states.accepted_canvas_history_len,
# Output
sampled,
num_sampled,
self.req_states.draft_tokens,
# Config
max_denoising_steps=float(states.max_denoising_steps),
t_min=self.t_min,
t_max=self.t_max,
confidence_threshold=self.confidence_threshold,
vocab_size=self.vocab_size,
CL=CL,
ST=states.stability_threshold,
entropy_bound=self.entropy_bound,
sc_vocab_start=self.sc_vocab_start,
sc_vocab_end=self.sc_vocab_end,
tp_size=self.tp_size,
tp_group_name=self.tp_group_name,
)
# Stash newly converged logprobs, including reads that emit now.
if want_logprobs:
converged_mask = states.is_encoder_phase[tile_slots] | (
num_sampled[decode_idx[sel]] > 0
)
just_converged = converged_mask & ~is_committing[sel]
if just_converged.any():
flat_logits = scaled.reshape(-1, scaled.shape[-1])
argmax_tokens = scaled.argmax(dim=-1)
raw_flat: torch.Tensor | None = None
for local_idx in just_converged.nonzero(as_tuple=True)[0]:
li = local_idx.item()
slot = tile_slots[local_idx].item()
# Stash only the real canvas positions; padded tail
# positions are never emitted.
k_i = int(tile_valid_np[li])
pos = li * W
src = flat_logits
if slot in states.read_only_slots:
# Read-only slots report logprobs at temperature 1.
# The schedule-tempered logits share the argmax.
if raw_flat is None:
raw_flat = tile_logits.float()
src = raw_flat
per_req_ids = max_token_ids > 0
self._pending_logprobs[slot] = compute_topk_scores(
src[pos : pos + k_i],
num_logprobs,
argmax_tokens[local_idx][:k_i],
logprob_token_ids_state=(
self.logprob_token_ids_state
if per_req_ids
else None
),
# every row of this stash belongs to one slot
expanded_idx_mapping=(
torch.full(
(k_i,), slot, dtype=torch.int32, device=device
)
if per_req_ids
else None
),
max_per_req_token_ids=max_token_ids,
logits_mode=self.logits_mode,
)
run_start = run_end
# Logprobs for denoise steps that just converged (is_encoder_phase
# flipped False→True), stashed per tile so `scaled` is freed each tile.
if want_logprobs:
converged_mask = states.is_encoder_phase[tile_slots]
just_converged = converged_mask & ~is_committing[tile]
if just_converged.any():
flat_logits = scaled.reshape(-1, scaled.shape[-1])
argmax_tokens = scaled.argmax(dim=-1)
for local_idx in just_converged.nonzero(as_tuple=True)[0]:
li = local_idx.item()
slot = tile_slots[local_idx]
# Stash only the real canvas positions (== CL unless this
# canvas was truncated near max_model_len); padded tail
# positions are never emitted.
k_i = int(valid_canvas_len_np[start_req + li])
pos = li * CL
per_req_ids = max_token_ids > 0
self._pending_logprobs[slot.item()] = compute_topk_scores(
flat_logits[pos : pos + k_i],
num_logprobs,
argmax_tokens[local_idx][:k_i],
logprob_token_ids_state=(
self.logprob_token_ids_state if per_req_ids else None
),
# every row of this stash belongs to one slot
expanded_idx_mapping=(
torch.full(
(k_i,),
slot.item(),
dtype=torch.int32,
device=flat_logits.device,
)
if per_req_ids
else None
),
max_per_req_token_ids=max_token_ids,
logits_mode=self.logits_mode,
)
# Commit steps: is_committing was True at entry. Reassemble previously
# stashed logprobs and attach to SamplerOutput.
# Only emitting requests consume their stashed logprobs.
logprobs_tensors = None
if want_logprobs and is_committing.any() and self._pending_logprobs:
committing_slots = set(
decode_slots_np[is_committing.cpu().numpy()].tolist()
)
parts_ids, parts_lp, parts_ranks = [], [], []
if want_logprobs and self._pending_logprobs:
emitting_slots = set(slots_np[num_sampled.cpu().numpy() > 0].tolist())
parts: list[LogprobsTensors] = []
cu_gen: list[int] = []
flat_offset = 0
for i in range(num_reqs):
cu_gen.append(flat_offset)
slot = int(slots_np[i])
if slot in committing_slots and slot in self._pending_logprobs:
if slot in emitting_slots and slot in self._pending_logprobs:
lp = self._pending_logprobs.pop(slot)
parts_ids.append(lp.logprob_token_ids)
parts_lp.append(lp.logprobs)
parts_ranks.append(lp.selected_token_ranks)
parts.append(lp)
flat_offset += lp.logprobs.shape[0]
if parts_ids:
logprobs_tensors = LogprobsTensors(
logprob_token_ids=torch.cat(parts_ids),
logprobs=torch.cat(parts_lp),
selected_token_ranks=torch.cat(parts_ranks),
cu_num_generated_tokens=cu_gen,
)
if parts:
logprobs_tensors = _concat_logprob_stashes(parts, cu_gen)
return self._build_output(
input_batch,
@@ -813,6 +813,7 @@ MODEL_ARCH_CONFIG_CONVERTORS = {
"dbrx": DbrxModelArchConfigConvertor,
"deepseek_mtp": DeepSeekMTPModelArchConfigConvertor,
"deepseek_v4": DeepseekV4ModelArchConfigConvertor,
"diffusion_gemma": Gemma4ModelArchConfigConvertor,
"diffusion_gemma_text": Gemma4ModelArchConfigConvertor,
"ernie_mtp": ErnieMTPModelArchConfigConvertor,
"falcon": FalconModelArchConfigConvertor,
+104
View File
@@ -0,0 +1,104 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Request validation for discrete diffusion models."""
from typing import TYPE_CHECKING
from vllm.exceptions import VLLMValidationError
if TYPE_CHECKING:
from vllm.sampling_params import SamplingParams
def validate_diffusion_sampling_params(
params: "SamplingParams",
*,
canvas_length: int | None,
vocab_size: int,
async_scheduling: bool | None,
) -> None:
"""Validate request options and normalize one-canvas reads at admission."""
extra = params.extra_args
if not extra:
return
def check(valid: bool, message: str) -> None:
if not valid:
raise VLLMValidationError(message, parameter="extra_args")
width = extra.get("diffusion_canvas_length")
if width is not None:
check(
isinstance(width, int)
and not isinstance(width, bool)
and width > 0
and (canvas_length is None or width <= canvas_length),
"diffusion_canvas_length must be a positive integer no larger "
"than the served canvas.",
)
# The request's canvas: its own width, else the served one.
expected_len = width or canvas_length
seed = extra.get("diffusion_seed_canvas")
if seed is not None:
check(
isinstance(seed, (list, tuple))
and all(isinstance(t, int) and not isinstance(t, bool) for t in seed),
"diffusion_seed_canvas must be a list of token ids.",
)
check(
all(0 <= t < vocab_size for t in seed),
f"diffusion_seed_canvas ids must be in [0, {vocab_size}).",
)
check(
expected_len is None or len(seed) == expected_len,
f"diffusion_seed_canvas must hold exactly {expected_len} ids, "
f"got {len(seed)}.",
)
pins = extra.get("diffusion_pinned")
if pins is not None:
check(
isinstance(pins, (list, tuple))
and all(isinstance(p, int) and not isinstance(p, bool) for p in pins),
"diffusion_pinned must be a list of canvas positions.",
)
check(
seed is not None,
"diffusion_pinned needs a diffusion_seed_canvas to hold.",
)
check(
all(p >= 0 and (expected_len is None or p < expected_len) for p in pins),
"diffusion_pinned positions must lie inside the canvas.",
)
cap = extra.get("diffusion_max_steps")
if cap is not None:
check(
isinstance(cap, int) and not isinstance(cap, bool) and cap > 0,
"diffusion_max_steps must be a positive integer.",
)
# The OpenAI server's vllm_xargs narrows JSON booleans to 0/1.
read_only = extra.get("diffusion_read_only")
if read_only is not None:
check(
isinstance(read_only, (bool, int)) and read_only in (0, 1),
"diffusion_read_only must be a boolean (or 0/1).",
)
if read_only:
# One canvas is the whole output. Cap max_tokens so the scheduler
# ends the request there, and ignore EOS so an end-of-turn token
# drawn into a noise slot does not end it early.
if expected_len is not None:
params.max_tokens = min(params.max_tokens or expected_len, expected_len)
params.ignore_eos = True
check(
bool(async_scheduling)
or width is None
or canvas_length is None
or width == canvas_length,
"A diffusion_canvas_length smaller than the served canvas "
"requires --async-scheduling.",
)
+56
View File
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Async scheduling for diffusion requests.
These rules live here so Scheduler.schedule() and AsyncScheduler stay
unchanged. VllmConfig selects this class for a diffusion model under async
scheduling. A sync scheduler creates no output placeholders, which both rules
read.
"""
from vllm.v1.core.sched.async_scheduler import AsyncScheduler
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.request import Request
def diffusion_canvas_width(request: Request, canvas_length: int) -> int:
"""The canvas width a diffusion request asked for, else the served one."""
params = request.sampling_params
extra = params.extra_args if params is not None else None
width = extra.get("diffusion_canvas_length") if extra else None
return int(width) if width else canvas_length
def _read_in_flight(request: Request, width: int) -> bool:
"""True when a read-only request has all its denoise steps in flight.
The request emits its canvas on the last step and ends, so a further
step is discarded.
"""
params = request.sampling_params
extra = params.extra_args if params is not None else None
if not extra or not extra.get("diffusion_read_only"):
return False
steps = extra.get("diffusion_max_steps")
if not steps:
return False
# Each in-flight denoise step holds one canvas of placeholders.
return request.num_output_placeholders >= steps * width
class DiffusionAsyncScheduler(AsyncScheduler):
def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
for request in self.running:
width = diffusion_canvas_width(request, self.num_spec_tokens)
# The placeholders are as wide as the served canvas.
if len(request.spec_token_ids) > width:
request.spec_token_ids = request.spec_token_ids[:width]
if _read_in_flight(request, width):
# Scheduler.schedule()'s max_tokens guard cannot be reached
# from a subclass. schedule() advances current_step before it
# checks decode eligibility, so current_step + 2 skips this
# call alone. max keeps a longer pipeline-parallel wait.
request.next_decode_eligible_step = max(
request.next_decode_eligible_step, self.current_step + 2
)
return super().schedule(throttle_prefills)
+16
View File
@@ -30,6 +30,7 @@ from vllm.tasks import GENERATION_TASKS, POOLING_TASKS, SupportedTask
from vllm.tokenizers import TokenizerLike
from vllm.utils import length_from_prompt_token_ids_or_embeds, random_uuid
from vllm.utils.async_utils import make_async
from vllm.utils.diffusion import validate_diffusion_sampling_params
from vllm.utils.jsontree import json_iter_leaves
from vllm.v1.engine import EngineCoreRequest
from vllm.v1.kv_hints import KvHintsEnvelope
@@ -53,6 +54,7 @@ class InputProcessor:
self.speculative_config = vllm_config.speculative_config
self.structured_outputs_config = vllm_config.structured_outputs_config
self.observability_config = vllm_config.observability_config
self.diffusion_config = vllm_config.diffusion_config
# Load the custom logits processor classes once; the returned callable
# runs their validate_params hooks per request at admission.
self.validate_logits_processors_params = (
@@ -129,6 +131,20 @@ class InputProcessor:
self.validate_logits_processors_params(params)
if self.model_config.is_diffusion:
# Without --diffusion-config the served canvas is unknown here;
# the ids and the read-only normalisation are still checked.
validate_diffusion_sampling_params(
params,
canvas_length=(
self.diffusion_config.canvas_length
if self.diffusion_config is not None
else None
),
vocab_size=self.model_config.get_vocab_size(),
async_scheduling=self.vllm_config.scheduler_config.async_scheduling,
)
if self.model_config.return_sampling_mask:
if params.temperature <= 0:
raise ValueError(