[Feat][Frontend] structured decisions: DiffusionGemma read strategy (#60466)

Signed-off-by: Matt Mastracci <matthew@mastracci.com>
Signed-off-by: Simon Veitner <sveitner@redhat.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Simon Veitner <sveitner@redhat.com>
Co-authored-by: Misha Goin <mgoin64@gmail.com>
This commit is contained in:
Matt Mastracci
2026-10-10 09:33:22 +08:00
committed by GitHub
co-authored by Claude Opus 5.5 Simon Veitner Misha Goin
parent 7d1d8660d6
commit 3c802af3cf
6 changed files with 193 additions and 40 deletions
@@ -20,7 +20,10 @@ log probabilities.
Thinking is off unless `chat_template_kwargs` turns it on.
This endpoint does not serve diffusion models yet.
Probabilities can differ between identical requests. They depend on which
requests share a batch and on whether a read hits the prefix cache. Set
`VLLM_BATCH_INVARIANT=1` for reads that repeat exactly, at a cost in
throughput.
Every read of a request starts with the state, so with
`--enable-prefix-caching` the state is prefilled once and each further
@@ -105,7 +105,7 @@ def test_noul_labels_and_prompt():
assert q.labels == ("yes", "no")
assert [a.name for a in q.options] == ["yes", "no"]
assert q.type.prompt(q) == (
"Question: Reply within the hour?\nAnswer with yes or no only."
"Question: Reply within the hour?\nyes\nno\nAnswer with yes or no only."
)
described = noul(criteria={"true": "same day", "false": "later"})
assert described.type.prompt(described) == (
@@ -17,12 +17,16 @@ from vllm.entrypoints.generate.structured_decisions.protocol import (
StructuredDecisionRequest,
StructuredDecisionResponse,
)
from vllm.entrypoints.generate.structured_decisions.question_types import LABELS
from vllm.entrypoints.generate.structured_decisions.question_types import (
LABELS,
StructuredDecisionError,
)
from vllm.entrypoints.generate.structured_decisions.serving import (
ServingStructuredDecisions,
state_text,
)
from vllm.entrypoints.generate.structured_decisions.strategies import (
DiffusionGemmaCanvasStrategy,
NextTokenStrategy,
ReadContext,
reply_label_ids,
@@ -52,6 +56,10 @@ def test_strategy_selection():
qwen = "Qwen3ForCausalLM"
assert select_read_strategy(model(qwen)) is NextTokenStrategy
assert select_read_strategy(model(qwen, "processed_logprobs")) is NextTokenStrategy
assert (
select_read_strategy(model("DiffusionGemmaForBlockDiffusion"))
is DiffusionGemmaCanvasStrategy
)
with pytest.raises(ValueError, match="does not support LlamaForCausalLM"):
select_read_strategy(model("LlamaForCausalLM"))
with pytest.raises(ValueError, match="not raw_logits"):
@@ -220,3 +228,30 @@ async def test_request_metadata_reaches_every_question(
assert call.kwargs["trace_headers"] == (
TRACE_HEADERS if tracing_enabled and request_headers else None
)
def test_canvas_read():
strategy = DiffusionGemmaCanvasStrategy.__new__(DiffusionGemmaCanvasStrategy)
strategy.thought, strategy.end, strategy.pad = [10, 11, 12, 13], 106, 0
strategy.width, strategy.vocab_size = 16, 1000
strategy.max_model_len = 23
params = strategy._sampling_params([65, 66], prompt_ids=[1, 2, 3])
assert params.extra_args is not None
canvas = params.extra_args["diffusion_seed_canvas"]
# The label slot as noise, the end of the turn, padding.
assert canvas[1:] == [106] + [0] * 14
assert params.max_tokens == 2 and params.logprob_token_ids == [65, 66]
again = strategy._sampling_params([65, 66], prompt_ids=[1, 2, 3])
assert again.extra_args == params.extra_args
read_input = strategy._read_input(
{"type": "token", "prompt_token_ids": [1, 2, 3]}, [1, 2, 3]
)
assert read_input == {
"type": "token",
"prompt_token_ids": [1, 2, 3, 10, 11, 12, 13],
}
# One token more and the thought and the canvas no longer fit.
with pytest.raises(StructuredDecisionError, match="max_model_len=23"):
strategy._read_input(
{"type": "token", "prompt_token_ids": [1, 2, 3, 4]}, [1, 2, 3, 4]
)
+6 -1
View File
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any
from fastapi import FastAPI
import vllm.envs as envs
from vllm.logger import init_logger
if TYPE_CHECKING:
from argparse import Namespace
@@ -17,6 +18,8 @@ if TYPE_CHECKING:
else:
RequestLogger = object
logger = init_logger(__name__)
def register_generate_api_routers(app: FastAPI):
from vllm.entrypoints.openai.chat_completion.api_router import (
@@ -243,7 +246,9 @@ async def init_generate_state(
default_chat_template_kwargs=default_chat_template_kwargs,
)
)
except ValueError:
except ValueError as e:
# Info, since every model without a read strategy lands here.
logger.info("/v1/systemone is disabled: %s", e)
strategy = None
state.serving_structured_decisions = (
ServingStructuredDecisions(
@@ -55,8 +55,7 @@ class QuestionType(ABC):
``question.labels[i]`` among the labels, and the list sums to 1.
``label_mass`` is the labels' total probability over the vocabulary."""
def reply_line(self, label: str, option: Option) -> str | None:
"""The option's line in the prompt, or None to leave it out."""
def reply_line(self, label: str, option: Option) -> str:
return (
f"{label}: {option.name} - {option.description}"
if option.description
@@ -67,9 +66,7 @@ class QuestionType(ABC):
"""The question as the model reads it, after the state."""
lines = [f"Question: {question.instructions}"] if question.instructions else []
for label, o in zip(question.labels, question.options):
line = self.reply_line(label, o)
if line is not None:
lines.append(line)
lines.append(self.reply_line(label, o))
lines.append(self.reply_instruction)
return "\n".join(lines)
@@ -192,8 +189,10 @@ class NoulQuestion(QuestionType):
Option("no", None if false is None else str(false)),
]
def reply_line(self, label: str, option: Option) -> str | None:
return f"{label}: {option.description}" if option.description else None
def reply_line(self, label: str, option: Option) -> str:
# A side without a description still shows its label. Without the
# label in the prompt, models answer "Yes" or "No" instead.
return f"{label}: {option.description}" if option.description else label
def answer(
self, question: Question, probs: list[float], label_mass: float
@@ -2,10 +2,13 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Read strategies: how a model is asked for label probabilities.
Only models in NEXT_TOKEN_ARCHITECTURES are currently supported.
Only models in READ_STRATEGIES are currently supported.
"""
import hashlib
import json
import math
import random
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
@@ -15,7 +18,7 @@ from vllm.config import ModelConfig
from vllm.engine.protocol import EngineClient
from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption
from vllm.entrypoints.generate.label_reads import next_token_label_reads
from vllm.inputs import EngineInput
from vllm.inputs import EngineInput, tokens_input
from vllm.lora.request import LoRARequest
from vllm.renderers.inputs.preprocess import extract_prompt_components
from vllm.renderers.online_renderer import OnlineRenderer
@@ -80,6 +83,13 @@ class ReadStrategy(ABC):
"""One read per question, in order."""
def prompt_tail(tokenizer: TokenizerLike, prompt_ids: Sequence[int]) -> list[int]:
"""The prompt's ids after its last added token."""
added = set(tokenizer.get_added_vocab().values())
start = max((i + 1 for i, t in enumerate(prompt_ids) if t in added), default=0)
return list(prompt_ids[start:])
def reply_label_ids(
tokenizer: TokenizerLike,
prompt_ids: Sequence[int],
@@ -88,9 +98,7 @@ def reply_label_ids(
"""The prompt's ids after its last added token, and the token each of
``labels`` adds after them as the first token of the reply. Raises
ValueError if a label is not one distinct token there."""
added = set(tokenizer.get_added_vocab().values())
start = max((i + 1 for i, t in enumerate(prompt_ids) if t in added), default=0)
tail = list(prompt_ids[start:])
tail = prompt_tail(tokenizer, prompt_ids)
text = tokenizer.decode(tail)
ids: list[int] = []
for label in labels:
@@ -104,6 +112,18 @@ def reply_label_ids(
return tail, ids
def type_label_ids(
tokenizer: TokenizerLike, prompt_ids: Sequence[int]
) -> dict[str, int]:
"""The reply's first token for each label of every registered question
type. Raises ValueError as reply_label_ids does."""
label_ids: dict[str, int] = {}
for qtype in QUESTION_TYPES.values():
_, ids = reply_label_ids(tokenizer, prompt_ids, qtype.label_set)
label_ids.update(zip(qtype.label_set, ids))
return label_ids
class NextTokenStrategy(ReadStrategy):
"""Autoregressive models. Each question is one request: the state, then the
question with its labeled options, and the logprobs of the label tokens as
@@ -112,7 +132,18 @@ class NextTokenStrategy(ReadStrategy):
def __init__(self, context: ReadContext):
super().__init__(context)
tokenizer = context.online_renderer.renderer.get_tokenizer()
# Every prompt ends with the same generation prompt, so a label's token
# is the same for every question.
prompt = self._generation_prompt()
self.tail = prompt_tail(self._tokenizer(), prompt)
self.label_ids = type_label_ids(self._tokenizer(), prompt)
def _tokenizer(self) -> TokenizerLike:
return self.context.online_renderer.renderer.get_tokenizer()
def _generation_prompt(self) -> list[int]:
"""A chat prompt's ids up to the generation prompt, with thinking off."""
context, tokenizer = self.context, self._tokenizer()
probe = tokenizer.apply_chat_template(
[{"role": "user", "content": "x"}],
**{
@@ -126,13 +157,20 @@ class NextTokenStrategy(ReadStrategy):
)
if isinstance(probe, str):
probe = tokenizer.encode(probe, add_special_tokens=False)
# Every prompt ends with the same generation prompt, so a label's token
# is the same for every question.
self.tail, _ = reply_label_ids(tokenizer, probe, ())
self.label_ids: dict[str, int] = {}
for qtype in QUESTION_TYPES.values():
_, ids = reply_label_ids(tokenizer, probe, qtype.label_set)
self.label_ids.update(zip(qtype.label_set, ids))
return list(probe)
def _sampling_params(
self, label_ids: list[int], prompt_ids: list[int]
) -> SamplingParams:
return SamplingParams(
max_tokens=1, temperature=0.0, logprob_token_ids=label_ids
)
def _read_input(
self, engine_input: EngineInput, prompt_ids: list[int]
) -> EngineInput:
"""The engine input for a read of the rendered prompt."""
return engine_input
def limits(self) -> DecisionLimits:
return DecisionLimits(max_questions=64, max_options=len(LABELS))
@@ -180,7 +218,7 @@ class NextTokenStrategy(ReadStrategy):
ctx = self.context
read_request = self._read_request(chat_template_kwargs, cache_salt)
slots, engine_inputs = [], []
slots, engine_inputs, params = [], [], []
for q in questions:
messages = [{"role": "user", "content": f"{state}\n\n{q.type.prompt(q)}"}]
if instructions:
@@ -192,15 +230,13 @@ class NextTokenStrategy(ReadStrategy):
"labels' tokens are unknown"
)
slots.append([self.label_ids[label] for label in q.labels])
engine_inputs.append(engine_input)
engine_inputs.append(self._read_input(engine_input, prompt_ids))
params.append(self._sampling_params(slots[-1], prompt_ids))
label_reads = await next_token_label_reads(
ctx.engine_client,
engine_inputs,
[
SamplingParams(max_tokens=1, temperature=0.0, logprob_token_ids=ids)
for ids in slots
],
params,
request_id,
lora_request=lora_request,
trace_headers=trace_headers,
@@ -223,13 +259,87 @@ class NextTokenStrategy(ReadStrategy):
return reads
NEXT_TOKEN_ARCHITECTURES = frozenset(
{
"Qwen3ForCausalLM",
"Qwen3_5ForConditionalGeneration",
"Qwen3_5MoeForConditionalGeneration",
}
)
class DiffusionGemmaCanvasStrategy(NextTokenStrategy):
"""DiffusionGemma. Each question is one read-only request with one denoising
step."""
THOUGHT = "<|channel>thought\n<channel|>"
END_OF_TURN = "<turn|>"
CANVAS_STEP = 16
def __init__(self, context: ReadContext):
ReadStrategy.__init__(self, context)
tokenizer = self._tokenizer()
self.tail = prompt_tail(tokenizer, self._generation_prompt())
self.thought = tokenizer.encode(self.THOUGHT, add_special_tokens=False)
self.label_ids = type_label_ids(tokenizer, self.thought)
end = tokenizer.encode(self.END_OF_TURN, add_special_tokens=False)
self.pad = tokenizer.pad_token_id
if self.pad is None or len(end) != 1:
raise ValueError(
"Structured decisions need the model's "
f"{self.END_OF_TURN!r} and pad tokens"
)
self.end = end[0]
vllm_config = context.engine_client.vllm_config
served = (
vllm_config.diffusion_config.canvas_length
if vllm_config.diffusion_config
and vllm_config.diffusion_config.canvas_length
else vllm_config.model_config.hf_config.canvas_length
)
self.width = self.CANVAS_STEP
if self.width > served:
raise ValueError(
f"Structured decisions need a canvas of {self.width}, "
f"and the served canvas is {served}"
)
self.vocab_size = vllm_config.model_config.get_vocab_size()
self.max_model_len = vllm_config.model_config.max_model_len
def _sampling_params(
self, label_ids: list[int], prompt_ids: list[int]
) -> SamplingParams:
# The noise at the label slot is drawn from the prompt, so a repeated
# read gets the same canvas.
rng = random.Random(hashlib.sha256(json.dumps(prompt_ids).encode()).digest())
canvas = [rng.randrange(self.vocab_size), self.end]
canvas += [self.pad] * (self.width - len(canvas))
return SamplingParams(
max_tokens=2,
logprob_token_ids=label_ids,
extra_args={
"diffusion_seed_canvas": canvas,
"diffusion_canvas_length": self.width,
"diffusion_max_steps": 1,
"diffusion_read_only": True,
},
)
def _read_input(
self, engine_input: EngineInput, prompt_ids: list[int]
) -> EngineInput:
extra = len(self.thought) + self.width
if len(prompt_ids) + extra > self.max_model_len:
raise StructuredDecisionError(
f"the prompt has {len(prompt_ids)} tokens and a canvas read adds "
f"{extra}, more than max_model_len={self.max_model_len}"
)
salt = engine_input.get("cache_salt")
# The thought block _must_ appear outside of the canvas (far worse results
# otherwise)
return tokens_input(
prompt_ids + self.thought,
cache_salt=salt if isinstance(salt, str) else None,
)
READ_STRATEGIES: dict[str, type[ReadStrategy]] = {
"Qwen3ForCausalLM": NextTokenStrategy,
"Qwen3_5ForConditionalGeneration": NextTokenStrategy,
"Qwen3_5MoeForConditionalGeneration": NextTokenStrategy,
"DiffusionGemmaForBlockDiffusion": DiffusionGemmaCanvasStrategy,
}
# label_mass sums the labels' full-vocabulary probabilities. The logprobs modes
@@ -239,15 +349,16 @@ LOGPROBS_MODES = frozenset({"raw_logprobs", "processed_logprobs"})
def select_read_strategy(model_config: ModelConfig) -> type[ReadStrategy]:
"""Raise ValueError when the model cannot serve structured decisions."""
if model_config.architecture not in NEXT_TOKEN_ARCHITECTURES:
strategy = READ_STRATEGIES.get(model_config.architecture)
if strategy is None:
raise ValueError(
"The structured decisions API does not support "
f"{model_config.architecture}. Supported architectures: "
f"{sorted(NEXT_TOKEN_ARCHITECTURES)}"
f"{sorted(READ_STRATEGIES)}"
)
if model_config.logprobs_mode not in LOGPROBS_MODES:
raise ValueError(
"Structured decisions need --logprobs-mode raw_logprobs or "
f"processed_logprobs, not {model_config.logprobs_mode}"
)
return NextTokenStrategy
return strategy