Skip to main content

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}