[Perf] DiffusionGemma: constrained reads over the request's logprob_token_ids (#58216)

Signed-off-by: Matt Mastracci <matthew@mastracci.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Lucas Wilkinson <LucasWilkinson@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Misha Goin <mgoin64@gmail.com>

Matt Mastracci committed Sep 24, 2026 at 14:15 UTC ab3de6edf2b34507e066d998c09f2361a5c1d8ce
6 files changed +262 -6
examples/features/structured_diffusion/README.md
+3 -1
@@ -11,8 +11,10 @@ left as noise, one denoise step gives a distribution over each slot. These
11 | `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 |
12 | `diffusion_max_steps` | `int` | denoise steps before the canvas is emitted |
13 | `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 |
14 +| `diffusion_constrained` | `bool` | run the unembedding, sampler and self-conditioning over the request's `logprob_token_ids` only. Logprobs are normalized over that set. A step uses this only when every read in it has the same set |
15
15 -`structured_server.py` turns a question schema into those fields. It serves
16 +`structured_server.py` turns a question schema into those fields, with
17 +`diffusion_constrained` on for every read (`--no-constrained` turns it off). It serves
18 `/v1/chat/completions`: the system message is the schema, the user message
19 is the state JSON, and the reply content is one distribution per question
20 with a standard error over a few noise draws.
examples/features/structured_diffusion/structured_server.py
+16
@@ -399,6 +399,14 @@ def canvas_width(template):
399 return min(CANVAS_LEN, -(-need // CANVAS_STEP) * CANVAS_STEP)
400
401
402 +def constrained_xargs():
403 + """Read over the labels only. The engine runs the unembedding, sampler and
404 + self-conditioning over the request's logprob_token_ids instead of the whole
405 + vocabulary. Same argmax, about a quarter less GPU time per read. Logprobs
406 + come back normalized over the labels."""
407 + return {"diffusion_constrained": True} if ARGS.constrained else {}
408 +
409 +
410 def pin_xargs(template, slots, steps):
411 """Past one denoise step the template must be held, or accept/renoise
412 rewrites it: pin every canvas position that is not an answer slot."""
@@ -552,6 +560,7 @@ def one_read(
560 "diffusion_max_steps": schema["steps"],
561 "diffusion_read_only": True,
562 **pin_xargs(template, slots, schema["steps"]),
563 + **constrained_xargs(),
564 },
565 }
566 d = upstream_chat(body)
@@ -601,6 +610,7 @@ def one_read_continuation(schema, template, slots, prompt_ids, seed):
610 "diffusion_max_steps": schema["steps"],
611 "diffusion_read_only": True,
612 **pin_xargs(template, slots, schema["steps"]),
613 + **constrained_xargs(),
614 },
615 }
616 d = upstream_completions(body)
@@ -1496,6 +1506,12 @@ def main():
1506 default=16,
1507 help="request widths round up to a multiple of this",
1508 )
1509 + p.add_argument(
1510 + "--no-constrained",
1511 + dest="constrained",
1512 + action="store_false",
1513 + help="read over the whole vocabulary instead of the labels",
1514 + )
1515 p.add_argument("--host", default="0.0.0.0")
1516 p.add_argument("--port", type=int, default=8011)
1517 p.add_argument(
tests/test_sampling_params.py
+23
@@ -145,6 +145,29 @@ def test_diffusion_rejects_bad_extra_args(extra_args: dict, match: str):
145 _verify_diffusion(SamplingParams(extra_args=extra_args))
146
147
148 +@pytest.mark.parametrize("flag", [True, 1])
149 +def test_diffusion_constrained_needs_logprob_token_ids(flag):
150 + bad = SamplingParams(extra_args={"diffusion_constrained": flag})
151 + with pytest.raises(VLLMValidationError, match="needs logprob_token_ids"):
152 + _verify_diffusion(bad)
153 +
154 + ok = SamplingParams(
155 + logprob_token_ids=[3, 5], extra_args={"diffusion_constrained": flag}
156 + )
157 + _verify_diffusion(ok)
158 +
159 + # An unset or false flag needs no ids.
160 + _verify_diffusion(SamplingParams(extra_args={"diffusion_constrained": False}))
161 + _verify_diffusion(SamplingParams(extra_args={"diffusion_constrained": 0}))
162 +
163 +
164 +@pytest.mark.parametrize("value", ["yes", 2, [1]])
165 +def test_diffusion_constrained_must_be_a_bool(value):
166 + params = SamplingParams(extra_args={"diffusion_constrained": value})
167 + with pytest.raises(VLLMValidationError, match="must be a boolean"):
168 + _verify_diffusion(params)
169 +
170 +
171 def test_diffusion_seed_canvas_must_fill_the_canvas():
172 params = SamplingParams(extra_args={"diffusion_seed_canvas": [0] * 7})
173 with pytest.raises(VLLMValidationError, match="exactly 8 ids, got 7"):
tests/v1/sample/test_diffusion_gemma_reads.py
+49
@@ -8,9 +8,11 @@ import pytest
8 import torch
9
10 from vllm.model_executor.models.diffusion_gemma import (
11 + _MASKED_LOGIT,
12 DiffusionGemmaRequestStates,
13 _compiled_sample_step,
14 _concat_logprob_stashes,
15 + _mask_rows_to_allowed,
16 )
17 from vllm.platforms import current_platform
18 from vllm.v1.outputs import LogprobsTensors
@@ -317,3 +319,50 @@ def test_read_emits_at_convergence_while_generation_waits_for_commit(width, step
319 sampled, counts = _denoise_once(states, [0], width=width)
320 assert counts.tolist() == [width]
321 assert torch.equal(sampled[0], states.argmax_canvas[0, :width].int())
322 +
323 +
324 +def test_batch_allowed_needs_one_shared_set():
325 + states = _states()
326 + states.constrained[0] = (1, 2, 3)
327 + states.constrained[1] = (4, 5)
328 +
329 + # Mixed sets, or a slot with no set, fall back to per-row masking.
330 + assert states.batch_allowed([0, 1]) is None
331 + assert states.batch_allowed([0, 2]) is None
332 + assert states.batch_allowed([]) is None
333 +
334 + shared = states.batch_allowed([0])
335 + assert shared.tolist() == [1, 2, 3]
336 + assert shared.dtype == torch.int64
337 + assert states.batch_allowed([0, 0]) is shared
338 + assert states.allowed_tensor((1, 2, 3)) is shared
339 +
340 +
341 +def test_mask_rows_to_allowed_masks_only_constrained_rows():
342 + logits = torch.randn(5, VOCAB, device="cuda")
343 + before = logits.clone()
344 + first = torch.tensor([3, 7], device="cuda")
345 + third = torch.tensor([0], device="cuda")
346 +
347 + out = _mask_rows_to_allowed(logits, [0, 2, 3], [2, 1, 2], [first, None, third])
348 +
349 + assert out is not logits
350 + assert torch.equal(logits, before)
351 + # Masked columns hold the finite sentinel, so entropy stays finite.
352 + kept = out > _MASKED_LOGIT
353 + assert torch.isfinite(out).all()
354 + assert kept[0:2].sum(dim=1).tolist() == [2, 2]
355 + assert kept[0:2][:, first].all()
356 + assert torch.equal(out[0:2][:, first], before[0:2][:, first])
357 + assert torch.equal(out[2], before[2])
358 + assert kept[3:5].sum(dim=1).tolist() == [1, 1]
359 + assert torch.equal(out[3:5][:, third], before[3:5][:, third])
360 + # The masked row's softmax is the distribution renormalized over the set.
361 + torch.testing.assert_close(
362 + out[0].softmax(dim=-1)[first], before[0, first].softmax(dim=-1)
363 + )
364 +
365 +
366 +def test_mask_rows_to_allowed_is_a_no_op_without_constrained_rows():
367 + logits = torch.randn(3, VOCAB, device="cuda")
368 + assert _mask_rows_to_allowed(logits, [0, 1], [1, 2], [None, None]) is logits
vllm/model_executor/models/diffusion_gemma.py
+159 -5
@@ -16,7 +16,7 @@ via Gemma4MultimodalEmbedder.
16
17 from __future__ import annotations
18
19 -from collections.abc import Iterable, Mapping
19 +from collections.abc import Iterable, Mapping, Sequence
20 from types import SimpleNamespace
21 from typing import Any
22
@@ -339,6 +339,17 @@ class DiffusionGemmaForConditionalGeneration(
339 )
340
341 def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
342 + states = getattr(self, "diffusion_states", None)
343 + allowed = states.step_allowed if states is not None else None
344 + if allowed is not None:
345 + # One column per allowed id: a [rows, K] GEMM over the K gathered
346 + # rows of the tied embedding.
347 + logits = torch.nn.functional.linear(
348 + hidden_states, self.lm_head.weight[allowed]
349 + ).float()
350 + if self.final_logit_softcapping is not None:
351 + logits = _softcap_logits(logits, self.final_logit_softcapping)
352 + return logits
353 logits = self.logits_processor(self.lm_head, hidden_states)
354 if logits is not None and self.final_logit_softcapping is not None:
355 logits = _softcap_logits(logits, self.final_logit_softcapping)
@@ -395,6 +406,39 @@ def _concat_logprob_stashes(
406 )
407
408
409 +_MASKED_LOGIT = -1e20
410 +
411 +
412 +def _mask_rows_to_allowed(
413 + logits: torch.Tensor,
414 + row_starts: Sequence[int] | np.ndarray,
415 + row_lens: Sequence[int] | np.ndarray,
416 + allowed_per_row: Sequence[torch.Tensor | None],
417 +) -> torch.Tensor:
418 + """Mask every column outside a request's allowed ids on that request's
419 + rows. Request i owns rows [row_starts[i], +row_lens[i]); None leaves its
420 + rows alone. Returns a copy when any row is masked, so the caller's tensor
421 + (possibly the runner's) is never written.
422 +
423 + Softmax over a masked row equals the K-space distribution the shared
424 + fast path computes, so both paths give the same reads. The mask value is
425 + a large finite negative rather than -inf: the entropy is probs times
426 + log-probs, and 0 * -inf is NaN, while 0 * -1e20 is 0. It stays finite
427 + after the greedy temperature clamp (1e-10) scales it by 1e10.
428 + """
429 + out: torch.Tensor | None = None
430 + keep = torch.zeros(logits.shape[-1], dtype=torch.bool, device=logits.device)
431 + for start, n, allowed in zip(row_starts, row_lens, allowed_per_row):
432 + if allowed is None:
433 + continue
434 + if out is None:
435 + out = logits.clone()
436 + keep.zero_()
437 + keep[allowed] = True
438 + out[int(start) : int(start) + int(n)].masked_fill_(~keep, _MASKED_LOGIT)
439 + return logits if out is None else out
440 +
441 +
442 # Tiles specialize the graph on batch size 1, on a tile as wide as the canvas,
443 # on compute_sc and on sizes that coincide with the state buffers. That set
444 # is small but passes Dynamo's default of 8, after which every step would run
@@ -446,6 +490,10 @@ def _compiled_sample_step(
490 tp_size: int,
491 tp_group_name: str,
492 compute_sc: bool = True,
493 + # Constrained reads: ``logits`` has one column per allowed id and
494 + # ``embed_weight`` holds those ids' rows, so every argmax is an index
495 + # into ``allowed``. ``vocab_size`` stays the real vocabulary for renoise.
496 + allowed: torch.Tensor | None = None,
497 ) -> torch.Tensor:
498 """Compiled decode step: temperature → Gumbel sample → probs/confidence →
499 accept/renoise → convergence, all as vectorized PyTorch ops.
@@ -473,6 +521,9 @@ def _compiled_sample_step(
521 argmax_tokens = (
522 scaled.view(-1, scaled.shape[-1]).argmax(dim=-1).view(num_decode, CL)
523 )
524 + if allowed is not None:
525 + new_tokens = allowed[new_tokens]
526 + argmax_tokens = allowed[argmax_tokens]
527
528 # ---- Phase 3: Probs, self-conditioning, confidence ----
529 log_probs = scaled.log_softmax(dim=-1)
@@ -508,6 +559,10 @@ def _compiled_sample_step(
559 step_tensor[decode_slots] = new_step_val
560
561 # Random tokens for renoise / canvas reinit
562 + # Renoise draws from the whole vocabulary even for a constrained read.
563 + # The model was trained on random-vocabulary noise. Noise drawn from the
564 + # allowed set looks like garbled text, and later steps denoise it into
565 + # more garbled text.
566 random_tokens = torch.randint(
567 0, vocab_size, (num_decode, CL), device=device, dtype=canvas.dtype
568 )
@@ -695,6 +750,12 @@ class DiffusionGemmaRequestStates:
750 # Read-only slots emit on their converging step and skip the commit
751 # forward.
752 self.read_only = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
753 + # Constrained reads: slot -> the allowed ids (the request's
754 + # logprob_token_ids). A step whose decode slots all share one set
755 + # runs the unembedding, sampler and self-conditioning over that set.
756 + self.constrained: dict[int, tuple[int, ...]] = {}
757 + self.step_allowed: torch.Tensor | None = None
758 + self._allowed_cache: dict[tuple[int, ...], torch.Tensor] = {}
759 self.read_only_slots: set[int] = set()
760 # Slots capped at one denoise step never consume a soft embed.
761 self.single_step_slots: set[int] = set()
@@ -738,6 +799,7 @@ class DiffusionGemmaRequestStates:
799 self.read_only[slot_idx].fill_(False)
800 self.read_only_slots.discard(slot_idx)
801 self.single_step_slots.discard(slot_idx)
802 + self.constrained.pop(slot_idx, None)
803 self.canvas_width_np[slot_idx] = self.canvas_length
804
805 def remove_request(self, slot_idx: int) -> None:
@@ -749,6 +811,7 @@ class DiffusionGemmaRequestStates:
811 self.seeded_slots.discard(slot_idx)
812 self.read_only_slots.discard(slot_idx)
813 self.single_step_slots.discard(slot_idx)
814 + self.constrained.pop(slot_idx, None)
815
816 def set_seed_canvas(self, slot_idx: int, ids: list[int]) -> None:
817 """``ids`` covers the slot's canvas width; positions past it are never
@@ -767,6 +830,30 @@ class DiffusionGemmaRequestStates:
830 slot_idx, async_tensor_h2d(positions, dtype=torch.int64, device=self.device)
831 ] = True
832
833 + def allowed_tensor(self, ids: tuple[int, ...]) -> torch.Tensor:
834 + """``ids`` as an int64 tensor on the device, built once per tuple."""
835 + t = self._allowed_cache.get(ids)
836 + if t is None:
837 + t = torch.tensor(ids, dtype=torch.int64, device=self.device)
838 + if len(self._allowed_cache) > 64:
839 + self._allowed_cache.clear()
840 + self._allowed_cache[ids] = t
841 + return t
842 +
843 + def batch_allowed(self, slots: list[int]) -> torch.Tensor | None:
844 + """The allowed ids shared by every one of ``slots``, or None.
845 +
846 + None means the step runs over the full vocabulary. The sampler then
847 + masks each constrained slot's logit rows to its own set, so a
848 + constrained request reads the same way whoever shares its batch.
849 + """
850 + if not slots or not self.constrained:
851 + return None
852 + first = self.constrained.get(slots[0])
853 + if first is None or any(self.constrained.get(s) != first for s in slots[1:]):
854 + return None
855 + return self.allowed_tensor(first)
856 +
857 def set_read_only(self, slot_idx: int) -> None:
858 self.read_only[slot_idx].fill_(True)
859 self.read_only_slots.add(slot_idx)
@@ -828,6 +915,8 @@ class DiffusionGemmaModelState(ModelState):
915 # the current step, so we add 1 to match the same behavior.
916 stability_threshold=self.gen_config["stability_threshold"] + 1,
917 )
918 + # compute_logits reads the step's shared allowed set from here.
919 + self.model.diffusion_states = self.diffusion_states
920 self._req_id_to_index: dict[str, int] = {}
921
922 # Persistent buffer for per-request causal flags, updated in-place
@@ -979,10 +1068,14 @@ class DiffusionGemmaModelState(ModelState):
1068 inputs_embeds[:num_tokens].copy_(self.model.embed_input_ids(input_ids))
1069
1070 # Apply self-conditioning ONLY for denoising decode requests.
1071 + states.step_allowed = None
1072 if input_batch.num_draft_tokens > 0 and self._req_id_to_index:
1073 slots_np = input_batch.idx_mapping_np[:num_reqs]
1074 num_logits_np = np.diff(input_batch.cu_num_logits_np[: num_reqs + 1])
1075 is_decode_indices_np = np.where(num_logits_np > 0)[0]
1076 + states.step_allowed = states.batch_allowed(
1077 + slots_np[is_decode_indices_np].tolist()
1078 + )
1079 self._apply_self_conditioning(
1080 slots_np[is_decode_indices_np],
1081 is_decode_indices_np,
@@ -1177,6 +1270,16 @@ class DiffusionSampler:
1270 states.set_pins(req_idx, [int(p) for p in pins])
1271 if extra.get("diffusion_read_only"):
1272 states.set_read_only(req_idx)
1273 + if extra.get("diffusion_constrained"):
1274 + ids = list(getattr(sampling_params, "logprob_token_ids", None) or [])
1275 + if not ids:
1276 + raise ValueError(
1277 + "diffusion_constrained needs logprob_token_ids: they are "
1278 + "the allowed set."
1279 + )
1280 + if self.tp_size > 1:
1281 + raise ValueError("diffusion_constrained needs tensor parallel 1.")
1282 + states.constrained[req_idx] = tuple(int(t) for t in ids)
1283
1284 def apply_staged_writes(self) -> None:
1285 self.sampling_states.apply_staged_writes()
@@ -1361,6 +1464,30 @@ class DiffusionSampler:
1464 # since it mutates is_encoder_phase (commit→False, converge→True).
1465 is_committing = states.is_encoder_phase[decode_slots].clone()
1466
1467 + # Constrained step: logits are [rows, K] over the shared allowed set,
1468 + # so the self-conditioning matmul only needs those K embedding rows.
1469 + allowed = states.step_allowed
1470 + embed_weight = self.embed_weight
1471 + vocab_size = self.vocab_size
1472 + sc_vocab_start, sc_vocab_end = self.sc_vocab_start, self.sc_vocab_end
1473 + if allowed is not None:
1474 + assert logits.shape[-1] == allowed.numel()
1475 + embed_weight = self.embed_weight[allowed]
1476 + sc_vocab_start, sc_vocab_end = 0, allowed.numel()
1477 + elif states.constrained and num_decode > 0:
1478 + # The decode slots do not share one set, so this step runs over
1479 + # the full vocabulary. Mask each constrained request's rows to
1480 + # its own set so it reads the same as on the shared path. The
1481 + # helper masks a copy, since the runner owns `logits`: one
1482 + # full-vocab copy per step, as for top_k/top_p above.
1483 + per_row = [
1484 + None if ids is None else states.allowed_tensor(ids)
1485 + for ids in (states.constrained.get(s) for s in decode_slots_np.tolist())
1486 + ]
1487 + logits = _mask_rows_to_allowed(
1488 + logits, row_starts_np, valid_canvas_len_np, per_row
1489 + )
1490 +
1491 slots_np = input_batch.idx_mapping_np[:num_reqs]
1492 max_num_logprobs = self.sampling_states.max_num_logprobs(slots_np)
1493 # Requests may ask for specific token ids' logprobs instead of, or as
@@ -1431,7 +1558,7 @@ class DiffusionSampler:
1558 states.is_encoder_phase,
1559 states.confident,
1560 states.self_conditioning_embeds[:, :W],
1434 - self.embed_weight,
1561 + embed_weight,
1562 self.normalizer,
1563 states.accepted_canvas_history[:, :, :W],
1564 states.accepted_canvas_history_len,
@@ -1448,15 +1575,16 @@ class DiffusionSampler:
1575 t_min=self.t_min,
1576 t_max=self.t_max,
1577 confidence_threshold=self.confidence_threshold,
1451 - vocab_size=self.vocab_size,
1578 + vocab_size=vocab_size,
1579 CL=W,
1580 ST=states.stability_threshold,
1581 entropy_bound=self.entropy_bound,
1455 - sc_vocab_start=self.sc_vocab_start,
1456 - sc_vocab_end=self.sc_vocab_end,
1582 + sc_vocab_start=sc_vocab_start,
1583 + sc_vocab_end=sc_vocab_end,
1584 tp_size=self.tp_size,
1585 tp_group_name=self.tp_group_name,
1586 compute_sc=compute_sc,
1587 + allowed=allowed,
1588 )
1589
1590 # Stash newly converged logprobs, including reads that emit now.
@@ -1483,6 +1611,32 @@ class DiffusionSampler:
1611 if raw_flat is None:
1612 raw_flat = tile_logits.float()
1613 src = raw_flat
1614 + if allowed is not None:
1615 + # Column j of the K-space logits is allowed[j]:
1616 + # report every allowed id, renormalized over
1617 + # the set, with the argmax in column 0.
1618 + rows = src[pos : pos + k_i]
1619 + lp = rows.log_softmax(dim=-1)
1620 + am = argmax_tokens[local_idx][:k_i]
1621 + sel_lp = lp.gather(1, am.unsqueeze(1))
1622 + # Same convention as _ranks_kernel: 1-based,
1623 + # ties count, so the rank is the number of
1624 + # columns at or above the selected one. Out
1625 + # of set ids have zero probability, so the
1626 + # rank within the set is the real rank.
1627 + ranks = (lp >= sel_lp).sum(dim=1)
1628 + self._pending_logprobs[slot] = LogprobsTensors(
1629 + logprob_token_ids=torch.cat(
1630 + (
1631 + allowed[am].unsqueeze(1),
1632 + allowed.unsqueeze(0).expand(k_i, -1),
1633 + ),
1634 + dim=1,
1635 + ),
1636 + logprobs=torch.cat((sel_lp, lp), dim=1),
1637 + selected_token_ranks=ranks.to(torch.int64),
1638 + )
1639 + continue
1640 per_req_ids = max_token_ids > 0
1641 self._pending_logprobs[slot] = compute_topk_scores(
1642 src[pos : pos + k_i],
vllm/utils/diffusion.py
+12
@@ -94,6 +94,18 @@ def validate_diffusion_sampling_params(
94 params.max_tokens = min(params.max_tokens or expected_len, expected_len)
95 params.ignore_eos = True
96
97 + constrained = extra.get("diffusion_constrained")
98 + if constrained is not None:
99 + check(
100 + isinstance(constrained, (bool, int)) and constrained in (0, 1),
101 + "diffusion_constrained must be a boolean (or 0/1).",
102 + )
103 + if constrained:
104 + check(
105 + bool(params.logprob_token_ids),
106 + "diffusion_constrained needs logprob_token_ids: they are the allowed set.",
107 + )
108 +
109 check(
110 bool(async_scheduling)
111 or width is None