candle_mi/diffusion/sample.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Masked-diffusion ancestral sampler (`SUBS` parameterization).
4//!
5//! A faithful port of the noflash `sample.py` (Sahoo et al., `NeurIPS` 2024):
6//! absorbing/masked diffusion on a linear schedule `t: 1 → 0`. Starting from a
7//! fully-masked sequence (with an optional carried-over prompt prefix), each of
8//! `num_steps` steps predicts the clean token at every position, forbids the
9//! `[MASK]` token (`SUBS`), and reveals each currently-masked position to a
10//! sampled token with probability `(t - s) / t`. Once revealed, a position is
11//! carried over (never re-masked); the final step reveals everything remaining.
12//!
13//! The sampler is **backend-agnostic** — it drives any
14//! [`MIBackend`] whose forward returns `[batch, seq, vocab]`
15//! logits — so it serves both the `MDLM` `DiT` and (later) decoder-style
16//! diffusion models. Determinism is by seed: the same `seed` reproduces the
17//! same unmasking schedule and tokens.
18
19use candle_core::{DType, Device, IndexOp, Tensor};
20use rand::rngs::StdRng;
21use rand::{Rng, SeedableRng};
22
23use crate::backend::MIBackend;
24use crate::error::Result;
25use crate::hooks::HookSpec;
26
27/// Configuration for masked-diffusion ancestral sampling.
28#[derive(Debug, Clone)]
29pub struct DiffusionSamplingConfig {
30 /// Total sequence length, including any prompt prefix.
31 pub seq_len: usize,
32 /// Number of denoising steps `K` (the time grid has `K + 1` knots).
33 pub num_steps: usize,
34 /// Softmax temperature (clamped to `>= 1e-6`).
35 pub temperature: f32,
36 /// Optional top-k truncation of the per-position distribution.
37 pub top_k: Option<usize>,
38 /// RNG seed (fixes the unmasking schedule and sampled tokens).
39 pub seed: u64,
40}
41
42impl Default for DiffusionSamplingConfig {
43 fn default() -> Self {
44 Self {
45 seq_len: 64,
46 num_steps: 128,
47 temperature: 1.0,
48 top_k: None,
49 seed: 0,
50 }
51 }
52}
53
54/// Generate one sequence by masked-diffusion ancestral sampling.
55///
56/// Returns the final token ids (length `config.seq_len`, no `[MASK]` left).
57/// `prompt_ids` is clamped to the prefix and carried over unchanged.
58///
59/// # Errors
60///
61/// Returns [`MIError::Model`](crate::MIError::Model) on a forward-pass or tensor
62/// failure.
63// TRAIT_OBJECT: the sampler is backend-agnostic (any diffusion `MIBackend`).
64pub fn generate(
65 model: &dyn MIBackend,
66 device: &Device,
67 mask_token_id: u32,
68 prompt_ids: &[u32],
69 config: &DiffusionSamplingConfig,
70) -> Result<Vec<u32>> {
71 let mut trajectory = generate_trajectory(model, device, mask_token_id, prompt_ids, config)?;
72 Ok(trajectory.pop().unwrap_or_default())
73}
74
75/// Run ancestral sampling and return the token state after **every** step.
76///
77/// The returned vector has length `config.num_steps + 1`: index `0` is the
78/// initial (prompt + `[MASK]`) state, index `k` is the state after denoising
79/// step `k`, and the last entry is fully revealed. This is the artifact the
80/// diffusion-time MI examples consume: re-running the model's forward on each
81/// state (with hooks) yields the per-`(k, layer, position)` activations.
82///
83/// # Errors
84///
85/// Returns [`MIError::Model`](crate::MIError::Model) on a forward-pass or tensor
86/// failure.
87// TRAIT_OBJECT: the sampler is backend-agnostic (any diffusion `MIBackend`).
88pub fn generate_trajectory(
89 model: &dyn MIBackend,
90 device: &Device,
91 mask_token_id: u32,
92 prompt_ids: &[u32],
93 config: &DiffusionSamplingConfig,
94) -> Result<Vec<Vec<u32>>> {
95 let seq_len = config.seq_len;
96 let mut rng = StdRng::seed_from_u64(config.seed);
97
98 // Initial state: fully masked, with the prompt carried into the prefix.
99 let mut x = vec![mask_token_id; seq_len];
100 for (i, &token) in prompt_ids.iter().take(seq_len).enumerate() {
101 if let Some(slot) = x.get_mut(i) {
102 *slot = token;
103 }
104 }
105
106 let mut trajectory = Vec::with_capacity(config.num_steps + 1);
107 trajectory.push(x.clone());
108
109 let hooks = HookSpec::new();
110 for step in 0..config.num_steps {
111 // Linear time grid 1 -> 0: t = times[step], s = times[step + 1].
112 // CAST: usize -> f64, step counts fit in f64 mantissa
113 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
114 let (t, s) = {
115 let n = config.num_steps as f64;
116 (1.0 - step as f64 / n, 1.0 - (step + 1) as f64 / n)
117 };
118 let unmask_prob = if t > 0.0 { (t - s) / t } else { 1.0 };
119
120 let input = Tensor::new(x.as_slice(), device)?.unsqueeze(0)?; // [1, seq]
121 let cache = model.forward(&input, &hooks)?;
122 // Raw logits at every position: [seq, vocab] on the host in F32.
123 let logits = cache
124 .output()
125 .i(0)?
126 .to_dtype(DType::F32)?
127 .to_vec2::<f32>()?;
128
129 for (pos, row) in logits.iter().enumerate() {
130 // Carry-over: only currently-masked positions can be revealed.
131 if x.get(pos).copied() != Some(mask_token_id) {
132 continue;
133 }
134 if rng.r#gen::<f64>() < unmask_prob {
135 let token = sample_token_from_logits(
136 row,
137 mask_token_id,
138 config.temperature,
139 config.top_k,
140 &mut rng,
141 );
142 if let Some(slot) = x.get_mut(pos) {
143 *slot = token;
144 }
145 }
146 }
147 trajectory.push(x.clone());
148 }
149
150 Ok(trajectory)
151}
152
153/// Sample a token from one position's logits: forbid `[MASK]` (`SUBS`), apply
154/// temperature, optional top-k truncation, then multinomial sampling.
155fn sample_token_from_logits(
156 row: &[f32],
157 mask_token_id: u32,
158 temperature: f32,
159 top_k: Option<usize>,
160 rng: &mut StdRng,
161) -> u32 {
162 // CAST: u32 -> usize, vocab index used to forbid the [MASK] logit
163 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
164 let mask_idx = mask_token_id as usize;
165 let inv_temp = 1.0 / temperature.max(1e-6);
166
167 let mut logits: Vec<f32> = row
168 .iter()
169 .enumerate()
170 .map(|(i, &l)| {
171 if i == mask_idx {
172 f32::NEG_INFINITY
173 } else {
174 l * inv_temp
175 }
176 })
177 .collect();
178
179 if let Some(k) = top_k.filter(|&k| k >= 1 && k < logits.len()) {
180 let mut ranked = logits.clone();
181 // Partition so the (k-1)-th element is the k-th largest logit.
182 ranked.select_nth_unstable_by(k - 1, |a, b| {
183 b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
184 });
185 let threshold = ranked.get(k - 1).copied().unwrap_or(f32::NEG_INFINITY);
186 for l in &mut logits {
187 if *l < threshold {
188 *l = f32::NEG_INFINITY;
189 }
190 }
191 }
192
193 // Numerically stable softmax.
194 let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
195 let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
196 let sum: f32 = exps.iter().sum();
197
198 // Multinomial draw over the (unnormalized) weights.
199 let target = rng.r#gen::<f32>() * sum;
200 let mut cumulative = 0.0_f32;
201 for (i, &e) in exps.iter().enumerate() {
202 cumulative += e;
203 if target < cumulative {
204 // CAST: usize -> u32, vocab index fits in u32
205 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
206 return i as u32;
207 }
208 }
209 // Floating-point edge case: fall back to the last index.
210 // CAST: usize -> u32, vocab index fits in u32
211 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
212 {
213 exps.len().saturating_sub(1) as u32
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 /// `SUBS` must forbid the `[MASK]` token even when it has the largest logit;
222 /// sampling then concentrates on the dominant *non-mask* token.
223 #[test]
224 fn subs_never_samples_mask() {
225 let mask = 7u32;
226 let mut row = vec![0.0f32; 8];
227 row[2] = 50.0; // dominant non-mask token
228 row[7] = 100.0; // [MASK] — higher, but must be forbidden
229 let mut rng = StdRng::seed_from_u64(0);
230 for _ in 0..32 {
231 assert_eq!(sample_token_from_logits(&row, mask, 1.0, None, &mut rng), 2);
232 }
233 }
234
235 /// `top_k = 1` collapses the support to the single largest *non-mask* logit.
236 #[test]
237 fn top_k_one_is_greedy_over_non_mask() {
238 let mask = 7u32;
239 let mut row = vec![0.0f32; 8];
240 row[5] = 10.0; // argmax among non-mask
241 row[3] = 9.0;
242 row[7] = 20.0; // [MASK] — highest overall, forbidden
243 let mut rng = StdRng::seed_from_u64(123);
244 for _ in 0..16 {
245 assert_eq!(
246 sample_token_from_logits(&row, mask, 1.0, Some(1), &mut rng),
247 5
248 );
249 }
250 }
251
252 /// Same seed ⇒ same draw (reproducible schedules).
253 #[test]
254 fn deterministic_given_seed() {
255 let row = vec![0.1f32, 0.5, 0.2, 0.9, 0.3, 0.7, 0.4, 0.6];
256 let mask = 7u32;
257 let mut r1 = StdRng::seed_from_u64(7);
258 let mut r2 = StdRng::seed_from_u64(7);
259 assert_eq!(
260 sample_token_from_logits(&row, mask, 1.0, None, &mut r1),
261 sample_token_from_logits(&row, mask, 1.0, None, &mut r2)
262 );
263 }
264}