diff --git a/examples/discrete_diffusion/README.md b/examples/discrete_diffusion/README.md index a3a8253b1927..a2701063ab6f 100644 --- a/examples/discrete_diffusion/README.md +++ b/examples/discrete_diffusion/README.md @@ -48,3 +48,54 @@ python examples/discrete_diffusion/sample_llada2.py \ --use_chat_template \ --add_generation_prompt ``` + +## Visualizing the sampling process + +`animate_sampling.py` records the intermediate canvas of every denoising step (through each pipeline's +`callback_on_step_end`, without changing the pipelines) and renders it as an animation. It makes the difference +between the two families of discrete diffusion easy to see: + +* Masked diffusion (LLaDA2): every slot starts as `[MASK]` and is progressively unmasked, revealing text from blanks. +* Uniform block diffusion (DiffusionGemma): every slot always holds a real token, so a canvas of random tokens is + sharpened into text, one cached block at a time. + +The script has two subcommands. `capture` runs a real pipeline and saves one trajectory as JSON. `render` turns one or +more trajectories into a self-contained HTML animation (and, with `--gif`, an animated GIF). Highlighted states use a +color-blind-safe palette that also stays legible in grayscale. + +### Capture a trajectory + +```bash +# LLaDA2 (masked diffusion) +python examples/discrete_diffusion/animate_sampling.py capture \ + --method llada2 --model_id inclusionAI/LLaDA2.1-mini \ + --prompt "Why is the sky blue? Explain in detail." --use_chat_template \ + --gen_length 512 --block_length 32 --num_inference_steps 32 \ + --out traj_llada2.json + +# DiffusionGemma (uniform block diffusion); --scheduler picks one of the three supported samplers +python examples/discrete_diffusion/animate_sampling.py capture \ + --method diffusion_gemma --model_id google/diffusiongemma-26B-A4B-it \ + --prompt "Why is the sky blue?" --gen_length 512 --num_inference_steps 32 \ + --scheduler entropy_bound \ + --out traj_dg.json +``` + +`--scheduler` accepts `entropy_bound` (the released checkpoint's sampler), `discrete_ddim` (exact D3PM posterior, +ancestral), or `block_refinement` (confidence-committed refinement). Capture one trajectory per scheduler to compare +them on the same prompt and seed. + +### Render an animation + +```bash +# One or more trajectories become a single side-by-side animation +python examples/discrete_diffusion/animate_sampling.py render \ + traj_llada2.json traj_dg.json \ + --out sampling_animation.html --gif sampling_animation.gif +``` + +Trajectories with different step counts are resampled onto a shared timeline, so every panel starts and finishes +together regardless of how many steps each sampler took. Use `--max_frames` to cap the GIF length and `--cols` to set +the characters per line. + +The script is also runnable with [uv](https://docs.astral.sh/uv/): `uv run examples/discrete_diffusion/animate_sampling.py ...`. diff --git a/examples/discrete_diffusion/animate_sampling.py b/examples/discrete_diffusion/animate_sampling.py new file mode 100644 index 000000000000..b48bb4c0b00e --- /dev/null +++ b/examples/discrete_diffusion/animate_sampling.py @@ -0,0 +1,603 @@ +# Copyright 2026 The HuggingFace Team. 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. +# +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "accelerate", +# "diffusers", +# "pillow", +# "sentencepiece", +# "torch", +# "transformers>=5.12", +# ] +# /// +""" +Animate how discrete diffusion language models write text. + +Two families of discrete diffusion sample very differently, and the animation is built to make the contrast obvious: + +* `llada2` -- *masked* (absorbing) diffusion. Every generated position starts as `[MASK]` and is progressively + unmasked. Low-confidence positions can be remasked and rewritten later, which is where its error correction + comes from. +* `diffusion_gemma` -- *uniform* (mask-free) block diffusion. Every position always holds a real vocabulary token: + the canvas starts as uniform random tokens and is sharpened into text. Blocks ("canvases") are appended + left to right, and finished blocks are cached like a KV cache. + +Both pipelines expose `callback_on_step_end`, so the intermediate canvas of every denoising step is captured +without touching the pipelines themselves. + +Usage: + # 1. capture a trajectory per method (run separately, the checkpoints are large) + python animate_sampling.py capture --method llada2 \ + --model_id inclusionAI/LLaDA2.1-mini --prompt "Why is the sky blue?" --out traj_llada2.json + + python animate_sampling.py capture --method diffusion_gemma \ + --model_id google/diffusiongemma-26B-A4B-it --prompt "Why is the sky blue?" --out traj_dg.json + + # 2. render one or both trajectories into a single self-contained HTML animation + python animate_sampling.py render traj_llada2.json traj_dg.json --out sampling_animation.html +""" + +import argparse +import html +import json +from pathlib import Path + +import torch + + +PENDING = -1 # a generated position the block window has not reached yet + + +def _decode_map(tokenizer, ids: set[int], mask_token_id: int | None) -> dict[str, str]: + """Decode every token id used by the trajectory once, so the HTML stays small.""" + table = {} + for i in sorted(ids): + if i == PENDING: + continue + if mask_token_id is not None and i == mask_token_id: + table[str(i)] = "" # rendered as a blank slot + continue + table[str(i)] = tokenizer.decode([i]) + return table + + +def capture_llada2(args) -> dict: + from transformers import AutoModelForCausalLM, AutoTokenizer + + from diffusers import BlockRefinementScheduler, LLaDA2Pipeline + + tokenizer = AutoTokenizer.from_pretrained(args.model_id, trust_remote_code=True) + model = AutoModelForCausalLM.from_pretrained( + args.model_id, trust_remote_code=True, dtype=getattr(torch, args.dtype), low_cpu_mem_usage=True + ).eval() + pipe = LLaDA2Pipeline(model=model, scheduler=BlockRefinementScheduler(), tokenizer=tokenizer) + pipe.set_progress_bar_config(disable=False) + + mask_token_id = pipe.mask_token_id if pipe.mask_token_id is not None else args.mask_token_id + if mask_token_id is None: + raise ValueError("LLaDA2 needs a `mask_token_id`; pass --mask_token_id if the tokenizer lacks one.") + + # Tokenize the prompt exactly as the pipeline does, so `block_x[prompt_length:]` is only the generated tokens. + if args.use_chat_template and getattr(tokenizer, "chat_template", None): + prompt_ids = tokenizer.apply_chat_template( + [{"role": "user", "content": args.prompt}], + add_generation_prompt=True, + tokenize=True, + return_tensors="pt", + return_dict=True, + )["input_ids"] + else: + prompt_ids = tokenizer(args.prompt, return_tensors="pt").input_ids + prompt_length = prompt_ids.shape[1] + frames: list[dict] = [] + + def callback(pipe, global_step, step_idx, kwargs): + # `block_x` is the full sequence so far: prompt + the blocks opened up to now. + block_x = kwargs["block_x"][0] + gen = block_x[prompt_length:].tolist() + gen = gen + [mask_token_id] * (args.gen_length - len(gen)) # positions the window has not opened yet + frames.append({"step": global_step, "ids": gen[: args.gen_length]}) + return {} + + # Sampling knobs match the pipeline docs (docs/source/en/api/pipelines/llada2.md). + pipe( + prompt=args.prompt, + use_chat_template=args.use_chat_template, + gen_length=args.gen_length, + block_length=args.block_length, + num_inference_steps=args.num_inference_steps, + temperature=args.temperature, + threshold=0.7, + editing_threshold=0.5, + max_post_steps=16, + mask_token_id=mask_token_id, + eos_early_stop=False, + generator=torch.Generator().manual_seed(args.seed), + callback_on_step_end=callback, + callback_on_step_end_tensor_inputs=["block_x"], + ) + + used = {i for f in frames for i in f["ids"]} + return { + "method": "llada2", + "label": "LLaDA2 · masked diffusion", + "subtitle": "every slot starts as [MASK] and is progressively unmasked", + "model_id": args.model_id, + "prompt": args.prompt, + "gen_length": args.gen_length, + "block_length": args.block_length, + "mask_token_id": mask_token_id, + "frames": frames, + "vocab": _decode_map(tokenizer, used, mask_token_id), + } + + +# The three schedulers DiffusionGemma supports, and a short tag for the animation subtitle. +DG_SCHEDULERS = { + "entropy_bound": ("EntropyBoundScheduler", "entropy-bounded acceptance (the released checkpoint's sampler)"), + "discrete_ddim": ("DiscreteDDIMScheduler", "exact D3PM posterior, ancestral"), + "block_refinement": ("BlockRefinementScheduler", "confidence-committed refinement"), +} + + +def capture_diffusion_gemma(args, model=None, processor=None) -> dict: + from transformers import AutoProcessor, DiffusionGemmaForBlockDiffusion + + import diffusers + from diffusers import DiffusionGemmaPipeline + + if processor is None: + processor = AutoProcessor.from_pretrained(args.model_id) + if model is None: + model = DiffusionGemmaForBlockDiffusion.from_pretrained( + args.model_id, dtype=getattr(torch, args.dtype), attn_implementation="eager" + ).eval() + + scheduler_cls, scheduler_desc = DG_SCHEDULERS[args.scheduler] + pipe = DiffusionGemmaPipeline(model=model, scheduler=getattr(diffusers, scheduler_cls)(), processor=processor) + pipe.set_progress_bar_config(disable=False) + + canvas_length = model.config.canvas_length + num_canvases = (args.gen_length + canvas_length - 1) // canvas_length + committed: list[list[int]] = [] + frames: list[dict] = [] + state = {"step_idx": None} + + def callback(pipe, global_step, step_idx, kwargs): + canvas = kwargs["canvas"][0].tolist() + # A step_idx that restarts means the previous canvas was committed and a fresh random canvas was drawn. + if state["step_idx"] is not None and step_idx <= state["step_idx"]: + committed.append(frames[-1]["ids"][len(committed) * canvas_length :][:canvas_length]) + state["step_idx"] = step_idx + + ids = [t for block in committed for t in block] + canvas + ids = ids + [PENDING] * (num_canvases * canvas_length - len(ids)) + frames.append({"step": global_step, "ids": ids[: args.gen_length]}) + return {} + + pipe( + prompt=args.prompt, + gen_length=num_canvases * canvas_length, + num_inference_steps=args.num_inference_steps, + temperature=args.temperature, + eos_early_stop=False, + # Keep every denoising step in the trajectory: adaptive stopping would commit a canvas mid-loop. + confidence_threshold=None, + generator=torch.Generator().manual_seed(args.seed), + output_type="seq", + callback_on_step_end=callback, + callback_on_step_end_tensor_inputs=["canvas"], + ) + + used = {i for f in frames for i in f["ids"]} + return { + "method": "diffusion_gemma", + "label": f"DiffusionGemma · {scheduler_cls}", + "subtitle": scheduler_desc, + "model_id": args.model_id, + "prompt": args.prompt, + "gen_length": args.gen_length, + "block_length": canvas_length, + "mask_token_id": None, + "frames": frames, + "vocab": _decode_map(processor.tokenizer, used, None), + } + + +PAGE = """ + + +
Both models refine the whole sequence instead of committing left to right. LLaDA2 treats
+absence as noise: slots start as [MASK] and get filled in. DiffusionGemma is mask-free: every slot always
+holds a real token, starting as uniform noise and sharpening into text, one cached block at a time.