minimax-h3 / diffusers /pipelines /diffusion_gemma /pipeline_diffusion_gemma.py
multimodalart's picture
multimodalart HF Staff
Sync the split MiniMax-H3 Spaces (part 3)
7398d7c verified
Raw
History Blame Contribute Delete
24.9 kB
# Copyright 2025 The Google and HuggingFace Teams. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import inspect
from typing import Any, Callable
import torch
import torch.nn.functional as F
from transformers import DynamicCache, StaticCache
from ...callbacks import MultiPipelineCallbacks, PipelineCallback
from ...schedulers import BlockRefinementScheduler, DiscreteDDIMScheduler, EntropyBoundScheduler
from ...utils import logging, replace_example_docstring
from ..pipeline_utils import DiffusionPipeline
from .pipeline_output import DiffusionGemmaPipelineOutput
logger = logging.get_logger(__name__)
EXAMPLE_DOC_STRING = """
Examples:
```python
>>> import torch
>>> from transformers import AutoProcessor, DiffusionGemmaForBlockDiffusion
>>> from diffusers import BlockRefinementScheduler, DiffusionGemmaPipeline
>>> model_id = "google/diffusiongemma-26B-A4B-it"
>>> model = DiffusionGemmaForBlockDiffusion.from_pretrained(model_id, dtype=torch.bfloat16, device_map="auto")
>>> processor = AutoProcessor.from_pretrained(model_id)
>>> scheduler = BlockRefinementScheduler()
>>> pipe = DiffusionGemmaPipeline(model=model, scheduler=scheduler, processor=processor)
>>> output = pipe(prompt="Why is the sky blue?", gen_length=256)
>>> print(output.texts[0])
```
"""
class DiffusionGemmaPipeline(DiffusionPipeline):
r"""
Pipeline for DiffusionGemma block-diffusion text generation.
DiffusionGemma is a block-diffusion encoder-decoder model: a causal encoder reads the clean prompt (and any
previously generated blocks) into a KV cache, and a bidirectional decoder denoises a fixed-size "canvas" of
`canvas_length` tokens by cross-attending to that cache. Generation alternates an outer autoregressive loop over
canvases with an inner denoising loop, where each step samples candidate tokens, commits the most confident ones
via [`BlockRefinementScheduler`] (uniform corruption mode, `mask_token_id=None`), and renoises the rest.
The model is expected to be a `DiffusionGemmaForBlockDiffusion` instance exposing `forward(input_ids,
decoder_input_ids=..., self_conditioning_logits=..., ...)` and returning logits of shape `[batch, canvas_length,
vocab_size]` over the canvas. See the model card at https://huggingface.co/google/diffusiongemma-26B-A4B-it.
Args:
model ([`~transformers.DiffusionGemmaForBlockDiffusion`]):
The block-diffusion denoiser (causal encoder + bidirectional decoder with tied weights).
scheduler ([`BlockRefinementScheduler`], [`DiscreteDDIMScheduler`] or [`EntropyBoundScheduler`]):
The sampler that commits and renoises canvas tokens each denoising step.
processor ([`~transformers.ProcessorMixin`]):
The processor used to apply the chat template and decode the generated tokens.
"""
_callback_tensor_inputs = ["canvas", "logits"]
def __init__(
self,
model: Any,
scheduler: BlockRefinementScheduler | DiscreteDDIMScheduler | EntropyBoundScheduler,
processor: Any,
):
super().__init__()
self.register_modules(model=model, scheduler=scheduler, processor=processor)
tokenizer = getattr(processor, "tokenizer", processor)
self.eos_token_id = getattr(tokenizer, "eos_token_id", None) if tokenizer is not None else None
@property
def num_timesteps(self):
return self._num_timesteps
# --- Prompt encoding ---
def _prepare_inputs(
self,
*,
prompt: str | list[str] | None,
messages: list[dict] | None,
image: Any | list[Any] | None,
add_generation_prompt: bool,
) -> tuple[torch.LongTensor, torch.LongTensor, dict[str, torch.Tensor]]:
"""Tokenize a raw `prompt` (optionally with an `image`) or a raw `messages` conversation into
`(input_ids, attention_mask, multimodal_inputs)`, where `multimodal_inputs` holds the image tensors the
processor produced for the encoder prefill."""
def build_content(text, img):
if img is None:
return text
return [{"type": "image", "image": img}, {"type": "text", "text": text}]
if messages is None:
if isinstance(prompt, list):
images = image if isinstance(image, list) else [image] * len(prompt)
messages = [[{"role": "user", "content": build_content(p, im)}] for p, im in zip(prompt, images)]
else:
messages = [{"role": "user", "content": build_content(prompt, image)}]
encoded = self.processor.apply_chat_template(
messages,
add_generation_prompt=add_generation_prompt,
tokenize=True,
return_tensors="pt",
return_dict=True,
)
ids = encoded["input_ids"]
mask = encoded.get("attention_mask")
if mask is None:
mask = torch.ones_like(ids, dtype=torch.long)
multimodal_keys = ("pixel_values", "image_position_ids", "mm_token_type_ids")
multimodal_inputs = {k: encoded[k] for k in multimodal_keys if k in encoded}
return ids, mask.to(dtype=torch.long), multimodal_inputs
def check_inputs(
self,
prompt: str | list[str] | None,
messages: list[dict] | None,
gen_length: int,
num_inference_steps: int,
output_type: str,
callback_on_step_end_tensor_inputs: list[str] | None,
):
if output_type not in {"seq", "text"}:
raise ValueError(f"`output_type` must be 'seq' or 'text', got {output_type!r}.")
if gen_length <= 0:
raise ValueError(f"`gen_length` must be > 0, got {gen_length}.")
if num_inference_steps <= 0:
raise ValueError(f"`num_inference_steps` must be > 0, got {num_inference_steps}.")
if prompt is None and messages is None:
raise ValueError("Provide either `prompt` or `messages`.")
if prompt is not None and messages is not None:
raise ValueError("Provide either `prompt` or `messages`, not both.")
if self.processor is None:
raise ValueError("`processor` is required to encode the prompt.")
if callback_on_step_end_tensor_inputs is not None and not all(
k in self._callback_tensor_inputs for k in callback_on_step_end_tensor_inputs
):
raise ValueError(
f"`callback_on_step_end_tensor_inputs` has to be in {self._callback_tensor_inputs}, but found "
f"{[k for k in callback_on_step_end_tensor_inputs if k not in self._callback_tensor_inputs]}"
)
@torch.no_grad()
@replace_example_docstring(EXAMPLE_DOC_STRING)
def __call__(
self,
prompt: str | list[str] | None = None,
messages: list[dict] | None = None,
image: Any | list[Any] | None = None,
add_generation_prompt: bool = True,
gen_length: int = 256,
num_inference_steps: int = 48,
temperature: float = 0.0,
cache_implementation: str | None = None,
eos_early_stop: bool = True,
eos_token_id: int | None = None,
stability_threshold: int = 1,
confidence_threshold: float | None = 0.005,
generator: torch.Generator | None = None,
output_type: str = "text",
return_dict: bool = True,
callback_on_step_end: Callable[[Any, int, int, dict], dict]
| PipelineCallback
| MultiPipelineCallbacks
| None = None,
callback_on_step_end_tensor_inputs: list[str] | None = None,
) -> DiffusionGemmaPipelineOutput | tuple[torch.LongTensor, list[str] | None]:
"""
Generate text with block diffusion.
Args:
prompt (`str` or `List[str]`, *optional*):
Prompt text, wrapped in a chat template and tokenized by the processor. Provide either this or
`messages`.
messages (`List[Dict]`, *optional*):
A raw chat conversation to encode, e.g. `[{"role": "user", "content": "Hello"}]` or a multi-turn /
multimodal conversation. Use this instead of `prompt` for anything beyond a single user turn.
image (`PIL.Image.Image` or `List`, *optional*):
Image(s) to pair with `prompt` for multimodal generation; the processor turns them into the model's
image inputs. For richer layouts, put the image content directly in `messages`.
add_generation_prompt (`bool`, defaults to `True`):
Whether to add the generation prompt when applying the chat template.
gen_length (`int`, defaults to `256`):
Number of tokens to generate, rounded up to a multiple of the model's `canvas_length`.
num_inference_steps (`int`, defaults to `48`):
Number of denoising steps per canvas.
temperature (`float`, defaults to `0.0`):
Sampling temperature for `DiscreteDDIMScheduler`/`BlockRefinementScheduler` (`0.0` is greedy);
`EntropyBoundScheduler` ignores it and anneals its own temperature. Other sampling knobs (e.g. `top_k`,
`threshold`, `t_min`/`t_max`) are scheduler config; set them on the scheduler, e.g. `pipe.scheduler =
BlockRefinementScheduler.from_config(pipe.scheduler.config, top_k=...)`.
cache_implementation (`str`, *optional*):
Set to `"static"` to prefill the encoder once per block into a persistent `StaticCache` and run the
decoder against it with fixed shapes, instead of re-encoding the full sequence on every step. The fixed
shapes also let you compile the decoder, e.g. `pipe.model.model.decoder =
torch.compile(pipe.model.model.decoder, fullgraph=True)`.
eos_early_stop (`bool`, defaults to `True`):
Whether to stop generating further canvases once every sequence has emitted EOS.
eos_token_id (`int`, *optional*):
EOS token ID for early stopping. Falls back to the processor's tokenizer.
stability_threshold (`int`, defaults to `1`):
Number of consecutive steps the argmax prediction must be unchanged for a block to count as stable.
Only used when `confidence_threshold` is set.
confidence_threshold (`float`, *optional*, defaults to `0.005`):
Leave a block's denoising loop early once every example is stable (see `stability_threshold`) and the
mean per-token entropy of the prediction is below this value. Speeds up generation at matched quality;
the default matches the released checkpoint. Set to `None` to always run all `num_inference_steps`.
generator (`torch.Generator`, *optional*):
RNG for sampling.
output_type (`str`, defaults to `"text"`):
`"text"` decodes sequences into strings (requires a processor); `"seq"` returns token IDs only.
return_dict (`bool`, defaults to `True`):
Whether to return a [`DiffusionGemmaPipelineOutput`] instead of a tuple.
callback_on_step_end (`Callable` or `PipelineCallback`, *optional*):
Callback run after each denoising step with signature `callback_on_step_end(self, step, timestep,
callback_kwargs)`. Allowed tensor keys: `canvas`, `logits`.
callback_on_step_end_tensor_inputs (`List[str]`, *optional*):
Tensor keys to pass to the callback.
Examples:
Returns:
[`~pipelines.diffusion_gemma.pipeline_diffusion_gemma.DiffusionGemmaPipelineOutput`] or `tuple`:
The generated token IDs (`sequences`) and, for `output_type="text"`, the decoded `texts`.
"""
if callback_on_step_end is not None and isinstance(
callback_on_step_end, (PipelineCallback, MultiPipelineCallbacks)
):
callback_on_step_end_tensor_inputs = callback_on_step_end.tensor_inputs
if callback_on_step_end_tensor_inputs is None:
callback_on_step_end_tensor_inputs = ["canvas"]
self.check_inputs(
prompt=prompt,
messages=messages,
gen_length=gen_length,
num_inference_steps=num_inference_steps,
output_type=output_type,
callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs,
)
prompt_ids, prompt_attention_mask, multimodal_inputs = self._prepare_inputs(
prompt=prompt,
messages=messages,
image=image,
add_generation_prompt=add_generation_prompt,
)
device = self._execution_device
prompt_ids = prompt_ids.to(device=device)
prompt_attention_mask = prompt_attention_mask.to(device=device)
multimodal_inputs = {k: v.to(device=device) for k, v in multimodal_inputs.items()}
batch_size, prompt_length = prompt_ids.shape
if eos_token_id is None:
eos_token_id = self.eos_token_id
canvas_length = self.model.config.canvas_length
num_canvases = (gen_length + canvas_length - 1) // canvas_length
# `num_inference_steps` is the per-block budget of model forwards. With a corrector, fold its sweeps into that
# budget (as in https://huggingface.co/papers/2605.22765) instead of adding them on top: the first
# `corrected_steps` predictor steps each run `corrector_steps` extra forwards, so the total stays
# `num_inference_steps` and the predictor-corrector costs the same as plain ancestral sampling.
corrector_steps = getattr(self.scheduler.config, "corrector_steps", 0)
if corrector_steps > 0:
corrected_steps = (num_inference_steps - 1) // (1 + corrector_steps)
predictor_steps = num_inference_steps - corrected_steps * corrector_steps
else:
corrected_steps = 0
predictor_steps = num_inference_steps
# Only `BlockRefinementScheduler` takes a per-call `block_length`; the DiscreteDDIM/EntropyBound schedulers do
# not, so we pass scheduler-specific kwargs by signature.
set_timesteps_kwargs = {"device": device}
if "block_length" in inspect.signature(self.scheduler.set_timesteps).parameters:
set_timesteps_kwargs["block_length"] = canvas_length
self.scheduler.set_timesteps(predictor_steps, **set_timesteps_kwargs)
step_param_names = set(inspect.signature(self.scheduler.step).parameters)
self._num_timesteps = predictor_steps * num_canvases
cur_input_ids = prompt_ids
cur_attention_mask = prompt_attention_mask
finished = torch.zeros(batch_size, dtype=torch.bool, device=device)
global_step = 0
# Encode each block of context once into a reusable KV cache and run the decoder against it, rather than
# re-encoding the whole sequence on every denoising step. The default `DynamicCache` grows with the context;
# `cache_implementation="static"` uses a fixed-shape `StaticCache` so the decoder can be `torch.compile`-d.
use_static_cache = cache_implementation == "static"
text_config = self.model.config.get_text_config(decoder=True)
max_cache_len = prompt_length + num_canvases * canvas_length
if use_static_cache:
past_key_values = StaticCache(config=text_config, max_cache_len=max_cache_len)
else:
past_key_values = DynamicCache(config=text_config)
progress_bar = self.progress_bar(total=self._num_timesteps)
for _ in range(num_canvases):
cur_len = cur_input_ids.shape[1]
decoder_position_ids = torch.arange(cur_len, cur_len + canvas_length, device=device).unsqueeze(0)
# Encode the tokens not yet in the cache (the whole prompt on the first block, the last committed canvas
# afterwards), so the decoder reuses the encoder KV cache instead of re-encoding the full sequence.
cached_len = past_key_values.get_seq_length()
torch.compiler.cudagraph_mark_step_begin()
self.model.model.encoder(
input_ids=cur_input_ids[:, cached_len:],
attention_mask=cur_attention_mask,
past_key_values=past_key_values,
position_ids=torch.arange(cached_len, cur_len, device=device).unsqueeze(0),
# Image tensors are consumed by the prompt prefill only; later blocks encode text-only canvases.
**(multimodal_inputs if cached_len == 0 else {}),
)
# Decoder attends bidirectionally over the populated cache (the live padding mask) plus the always-visible
# canvas; the mask builder sizes this to the cache internally, including the static buffer for a StaticCache.
decoder_attention_mask = torch.nn.functional.pad(cur_attention_mask.bool(), (0, canvas_length), value=True)
mask_mapping = self.model.model.decoder.create_diffusion_decoder_attention_mask(
config=self.model.config,
inputs_embeds=torch.empty((batch_size, canvas_length, 0), device=device),
past_key_values=past_key_values,
decoder_attention_mask=decoder_attention_mask,
)
# Start from a fully random canvas and denoise it; the scheduler resets its committed state at step 0.
canvas = torch.randint(
0, text_config.vocab_size, (batch_size, canvas_length), device=device, generator=generator
)
self_conditioning_logits = None
# Adaptive stopping history: the last `stability_threshold` argmax predictions of this block's canvas.
argmax_history = torch.full(
(max(stability_threshold, 1), batch_size, canvas_length), -1, dtype=torch.long, device=device
)
# Denoise the predictor steps of this canvas; the first `corrected_steps` also run corrector sweeps.
for step_idx in range(predictor_steps):
if corrected_steps:
progress_bar.set_description(
"denoising (corrector)" if step_idx < corrected_steps else "denoising"
)
# Mark a fresh step and clone the logits so a cudagraph-compiled decoder (`mode="reduce-overhead"`)
# does not overwrite the tensors that self-conditioning and the scheduler read next. Both are no-ops
# when the decoder is not cudagraph-compiled.
torch.compiler.cudagraph_mark_step_begin()
logits = self.model(
decoder_input_ids=canvas,
past_key_values=past_key_values,
self_conditioning_logits=self_conditioning_logits,
decoder_attention_mask=mask_mapping,
decoder_position_ids=decoder_position_ids,
).logits.clone()
# Pass only the kwargs the chosen scheduler accepts, so any of the schedulers can drive the pipeline.
# Sampling knobs (temperature annealing, thresholds, top-k, ...) live on the scheduler config, not here.
step_kwargs = {"mask_token_id": None, "temperature": temperature, "generator": generator}
step_kwargs = {k: v for k, v in step_kwargs.items() if k in step_param_names}
scheduler_output = self.scheduler.step(
model_output=logits, timestep=step_idx, sample=canvas, return_dict=True, **step_kwargs
)
canvas = scheduler_output.prev_sample
# Self-condition on the logits the scheduler sampled from: temperature-shaped for the reference
# EntropyBound sampler, the raw denoiser logits for the others.
self_conditioning_logits = scheduler_output.pred_logits
# Predictor-corrector (https://huggingface.co/papers/2605.22765): a scheduler exposing `corrector_steps`
# + `step_correct` refines the canvas with extra Gibbs sweeps on the first `corrected_steps` predictor
# steps (the budget split computed above). Each sweep needs fresh logits on the updated canvas.
if step_idx < corrected_steps:
for _ in range(corrector_steps):
torch.compiler.cudagraph_mark_step_begin()
corrector_logits = self.model(
decoder_input_ids=canvas,
past_key_values=past_key_values,
self_conditioning_logits=self_conditioning_logits,
decoder_attention_mask=mask_mapping,
decoder_position_ids=decoder_position_ids,
).logits.clone()
canvas = self.scheduler.step_correct(
model_output=corrector_logits, timestep=step_idx, sample=canvas, generator=generator
).prev_sample
if callback_on_step_end is not None:
callback_kwargs = {}
for k in callback_on_step_end_tensor_inputs:
callback_kwargs[k] = locals()[k]
callback_outputs = callback_on_step_end(self, global_step, step_idx, callback_kwargs)
canvas = callback_outputs.pop("canvas", canvas)
global_step += 1
progress_bar.update()
# Adaptive stopping: leave this block early once every example's argmax prediction is stable across
# `stability_threshold` steps and confident (mean per-token entropy below `confidence_threshold`).
if confidence_threshold is not None:
argmax_canvas = logits.argmax(dim=-1)
stable = (argmax_history == argmax_canvas[None]).all(dim=-1).all(dim=0)
argmax_history = torch.roll(argmax_history, shifts=-1, dims=0)
argmax_history[-1] = argmax_canvas
confident = torch.distributions.Categorical(logits=logits.float()).entropy().mean(-1) < (
confidence_threshold
)
if bool((stable & confident).all()):
# Commit the converged prediction. Ancestral schedulers (e.g. DiscreteDDIM) only clean the
# canvas on their final step, so the in-progress canvas may still hold noise tokens; the
# denoiser argmax is the converged answer (and equals the canvas for commit-style schedulers).
canvas = argmax_canvas
break
# Append the denoised canvas and extend the context for the next block.
cur_input_ids = torch.cat([cur_input_ids, canvas], dim=-1)
cur_attention_mask = F.pad(cur_attention_mask, (0, canvas_length), value=1)
if eos_early_stop and eos_token_id is not None:
finished = finished | (canvas == eos_token_id).any(dim=-1)
if finished.all():
break
progress_bar.close()
sequences = cur_input_ids[:, prompt_length:]
# Trim each row at its first EOS so post-EOS canvas tokens don't leak into the decoded text.
decode_sequences: list[torch.LongTensor] | torch.LongTensor = sequences
if eos_token_id is not None:
decode_sequences = [
seq[: int((seq == eos_token_id).nonzero(as_tuple=True)[0][0]) + 1]
if (seq == eos_token_id).any()
else seq
for seq in sequences
]
texts = None
if output_type == "text" and self.processor is not None:
texts = self.processor.batch_decode(decode_sequences, skip_special_tokens=True)
if not return_dict:
return sequences, texts
return DiffusionGemmaPipelineOutput(sequences=sequences, texts=texts)
__all__ = ["DiffusionGemmaPipeline", "DiffusionGemmaPipelineOutput"]