Skip to main content

cortiq_engine/
zimagegen.rs

1//! Z-Image / Z-Image-Turbo text-to-image pipeline: prompt → Qwen3 chat
2//! template → Qwen3-4B encoder (35 layers, `hidden_states[-2]`) → DiT
3//! Euler loop (static-shift flow matching, optional CFG) → Flux VAE → RGB.
4//!
5//! Mirrors diffusers `ZImagePipeline.__call__` (0.40):
6//! - σ = shift·s/(1+(shift−1)·s) over linspace(1, 1/N, N), terminal 0
7//!   (Turbo shift 3, base shift 6 — the container stores its scheduler);
8//! - the model is called at t = (1000 − 1000σ)/1000 and its output v is
9//!   negated before the Euler step: x ← x + (σᵢ₊₁ − σᵢ)·(−pred);
10//! - CFG (guidance > 0): pred = pos + g·(pos − neg) — NOT Lumina's
11//!   uncond + g(cond − uncond); optional `cfg_normalization` c > 0 clips
12//!   ‖pred‖ (the norm over the whole tensor) to c·‖pos‖; `cfg_truncation`
13//!   τ ≤ 1 turns CFG off at steps whose t_norm = (1000 − t)/1000 > τ.
14//! - negative prompt default "" through the same chat template.
15//!
16//! Stages load and drop in sequence (text encoder → DiT → VAE), each under
17//! `gpu::image_stage_scope()`; several images of one prompt share the
18//! text encoding and the prepared caption, and every image's latent is
19//! finished before the VAE loads.
20//!
21//! Profiling: `CMF_ZIMAGE_PROF=1` prints in-process stage times (tokenize ·
22//! text-encode · load · prepare · steps · vae · total) and the median step.
23//! `CMF_INIT_LATENT=<raw f32 [1,16,H/8,W/8]>` injects the oracle noise.
24//! `CMF_ZIMAGE_DIT_DIR=<diffusers transformer dir>` runs the DiT from the
25//! source weights instead of the container (parity work only).
26
27use crate::tokenizer::Tokenizer;
28use crate::zimage::{ZImageDit, ZShape};
29use std::path::Path;
30use std::sync::Arc;
31use std::time::Instant;
32
33/// `header.arch.arch_name` of a Z-Image container.
34pub const ARCH_NAME: &str = "z_image";
35/// Prompt token cap (diffusers `max_sequence_length`), applied to the
36/// TEMPLATED string.
37pub const MAX_TOKENS: usize = 512;
38
39/// Per-model defaults stored in the container (`zimage.config_json`), so
40/// the CLI needs no flags. Missing keys fall back to the Turbo recipe.
41#[derive(Clone, Debug)]
42pub struct ZDefaults {
43    /// "turbo" or "base".
44    pub variant: String,
45    pub steps: usize,
46    pub guidance: f32,
47    pub shift: f32,
48    pub height: usize,
49    pub width: usize,
50    /// 0 = off; c > 0 clips ‖pred‖ to c·‖pos‖.
51    pub cfg_normalization: f32,
52    pub cfg_truncation: f32,
53    pub negative_prompt: String,
54    pub max_sequence_length: usize,
55}
56
57impl Default for ZDefaults {
58    fn default() -> Self {
59        Self {
60            variant: "turbo".into(),
61            steps: crate::zimage::DEFAULT_STEPS,
62            guidance: 0.0,
63            shift: crate::zimage::DEFAULT_SHIFT,
64            height: 1024,
65            width: 1024,
66            cfg_normalization: 0.0,
67            cfg_truncation: 1.0,
68            negative_prompt: String::new(),
69            max_sequence_length: MAX_TOKENS,
70        }
71    }
72}
73
74impl ZDefaults {
75    pub fn from_json(v: &serde_json::Value) -> Self {
76        let d = Self::default();
77        let f = |k: &str, dv: f32| v[k].as_f64().map(|x| x as f32).unwrap_or(dv);
78        let u = |k: &str, dv: usize| v[k].as_u64().map(|x| x as usize).unwrap_or(dv);
79        Self {
80            variant: v["variant"].as_str().unwrap_or(&d.variant).to_string(),
81            steps: u("steps", d.steps),
82            guidance: f("guidance", d.guidance),
83            shift: f("shift", d.shift),
84            height: u("height", d.height),
85            width: u("width", d.width),
86            cfg_normalization: match &v["cfg_normalization"] {
87                serde_json::Value::Bool(b) => *b as u8 as f32,
88                x => x.as_f64().map(|x| x as f32).unwrap_or(d.cfg_normalization),
89            },
90            cfg_truncation: f("cfg_truncation", d.cfg_truncation),
91            negative_prompt: v["negative_prompt"]
92                .as_str()
93                .unwrap_or(&d.negative_prompt)
94                .to_string(),
95            max_sequence_length: u("max_sequence_length", d.max_sequence_length),
96        }
97    }
98
99    /// The defaults a container carries (`zimage.config_json`).
100    pub fn of(model: &cortiq_core::CmfModel) -> Self {
101        model
102            .tensor_bytes("zimage.config_json")
103            .ok()
104            .and_then(|b| serde_json::from_slice::<serde_json::Value>(b).ok())
105            .map(|v| Self::from_json(&v))
106            .unwrap_or_default()
107    }
108}
109
110/// Generation parameters. `ZParams::from_defaults` fills them from the
111/// container; every field can be overridden.
112#[derive(Clone, Debug)]
113pub struct ZParams {
114    pub height: usize,
115    pub width: usize,
116    pub steps: usize,
117    pub seed: u64,
118    pub shift: f32,
119    pub max_tokens: usize,
120    /// Classifier-free guidance scale; 0 disables CFG (one forward/step).
121    pub guidance: f32,
122    /// None = the container's default negative prompt ("" by default).
123    pub negative_prompt: Option<String>,
124    /// 0 = off; c > 0 clips ‖pred‖ to c·‖pos‖ (diffusers True = 1.0).
125    pub cfg_normalization: f32,
126    /// CFG only while t_norm ≤ this (1.0 = every step).
127    pub cfg_truncation: f32,
128    /// Images per prompt; image i uses seed + i.
129    pub num_images: usize,
130}
131
132impl Default for ZParams {
133    fn default() -> Self {
134        Self::from_defaults(&ZDefaults::default())
135    }
136}
137
138impl ZParams {
139    pub fn from_defaults(d: &ZDefaults) -> Self {
140        Self {
141            height: d.height,
142            width: d.width,
143            steps: d.steps,
144            seed: 42,
145            shift: d.shift,
146            max_tokens: d.max_sequence_length,
147            guidance: d.guidance,
148            negative_prompt: None,
149            cfg_normalization: d.cfg_normalization,
150            cfg_truncation: d.cfg_truncation,
151            num_images: 1,
152        }
153    }
154}
155
156/// One generated image: RGB u8 [height, width, 3] row-major.
157pub struct ZImage {
158    pub rgb: Vec<u8>,
159    pub height: usize,
160    pub width: usize,
161    pub seed: u64,
162}
163
164impl ZImage {
165    /// PNG/JPEG by extension; `.ppm` writes P6.
166    pub fn save(&self, path: &Path) -> Result<(), String> {
167        if path
168            .extension()
169            .and_then(|s| s.to_str())
170            .is_some_and(|s| s.eq_ignore_ascii_case("ppm"))
171        {
172            let mut ppm = format!("P6\n{} {}\n255\n", self.width, self.height).into_bytes();
173            ppm.extend_from_slice(&self.rgb);
174            return std::fs::write(path, ppm).map_err(|e| e.to_string());
175        }
176        image::RgbImage::from_raw(self.width as u32, self.height as u32, self.rgb.clone())
177            .ok_or("output image dimensions overflow")?
178            .save(path)
179            .map_err(|e| format!("{}: {e}", path.display()))
180    }
181}
182
183/// `"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"` —
184/// the Qwen3 template with `enable_thinking=True` (no think block), no
185/// system prompt.
186pub fn chat_template(prompt: &str) -> String {
187    format!("<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n")
188}
189
190/// Template → ids (Qwen2 BPE, NFC, no BOS), truncated to `max_tokens`.
191pub fn prompt_ids(tok: &Tokenizer, prompt: &str, max_tokens: usize) -> Vec<u32> {
192    let mut ids = tok.encode(&chat_template(prompt));
193    ids.truncate(max_tokens);
194    ids
195}
196
197/// Standard normal draw (SplitMix64 + Box-Muller), or the raw f32 file in
198/// `CMF_INIT_LATENT`.
199fn gauss_latent(n: usize, seed: u64) -> Result<Vec<f32>, String> {
200    if let Ok(path) = std::env::var("CMF_INIT_LATENT") {
201        let b = std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?;
202        if b.len() != n * 4 {
203            return Err(format!("{path}: {} floats, the latent needs {n}", b.len() / 4));
204        }
205        return Ok(b
206            .chunks_exact(4)
207            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
208            .collect());
209    }
210    let mut rng = crate::sampler::SplitMix64::new(seed);
211    let mut u = || (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
212    let mut out = Vec::with_capacity(n);
213    while out.len() < n {
214        let (a, b) = (u().max(1e-300), u());
215        let r = (-2.0 * a.ln()).sqrt();
216        let ang = 2.0 * std::f64::consts::PI * b;
217        out.push((r * ang.cos()) as f32);
218        if out.len() < n {
219            out.push((r * ang.sin()) as f32);
220        }
221    }
222    Ok(out)
223}
224
225fn trace_write(dir: &str, img: usize, name: &str, v: &[f32]) -> Result<(), String> {
226    let path = if img == 0 {
227        format!("{dir}/{name}.f32")
228    } else {
229        format!("{dir}/img{img}_{name}.f32")
230    };
231    let _ = std::fs::create_dir_all(dir);
232    let b: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
233    std::fs::write(&path, b).map_err(|e| format!("{path}: {e}"))
234}
235
236fn prof_on() -> bool {
237    std::env::var("CMF_ZIMAGE_PROF").is_ok_and(|v| v != "0")
238}
239
240/// Classifier-free guidance combine, in place on `pos` (diffusers order
241/// and f32 arithmetic): pred = pos + g·(pos − neg); with c > 0 the whole
242/// tensor is scaled down to ‖pred‖ ≤ c·‖pos‖.
243pub fn cfg_combine(pos: &mut [f32], neg: &[f32], g: f32, norm_clip: f32) {
244    let pos_norm = if norm_clip > 0.0 {
245        pos.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>().sqrt()
246    } else {
247        0.0
248    };
249    for (p, &n) in pos.iter_mut().zip(neg) {
250        *p += g * (*p - n);
251    }
252    if norm_clip > 0.0 {
253        let new_norm = pos.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>().sqrt();
254        let max_norm = (pos_norm as f32 * norm_clip) as f64;
255        if new_norm > max_norm {
256            let f = (max_norm / new_norm) as f32;
257            for p in pos.iter_mut() {
258                *p *= f;
259            }
260        }
261    }
262}
263
264/// Stage times of one `generate_images` call (seconds).
265#[derive(Clone, Debug, Default)]
266pub struct ZTimings {
267    pub text_encode: f64,
268    pub dit_load: f64,
269    pub prepare: f64,
270    pub steps: Vec<f64>,
271    pub vae: f64,
272    pub total: f64,
273}
274
275impl ZTimings {
276    pub fn median_step(&self) -> f64 {
277        let mut s = self.steps.clone();
278        if s.is_empty() {
279            return 0.0;
280        }
281        s.sort_by(|a, b| a.partial_cmp(b).unwrap());
282        s[s.len() / 2]
283    }
284}
285
286/// Generate one image (the first of `generate_images`). RGB u8
287/// [height, width, 3].
288pub fn generate(
289    model_path: &Path,
290    prompt: &str,
291    p: &ZParams,
292    mut progress: impl FnMut(usize, usize),
293) -> Result<Vec<u8>, String> {
294    let p1 = ZParams {
295        num_images: 1,
296        ..p.clone()
297    };
298    let (mut imgs, _) = generate_images(model_path, prompt, &p1, |_, i, n| progress(i, n))?;
299    Ok(imgs.remove(0).rgb)
300}
301
302/// Generate `p.num_images` images for one prompt. `progress(image, step,
303/// steps)` is called after every DiT step.
304pub fn generate_images(
305    model_path: &Path,
306    prompt: &str,
307    p: &ZParams,
308    mut progress: impl FnMut(usize, usize, usize),
309) -> Result<(Vec<ZImage>, ZTimings), String> {
310    if p.height % 16 != 0 || p.width % 16 != 0 || p.height == 0 || p.width == 0 {
311        return Err(format!(
312            "height and width must be positive multiples of 16 (got {}x{})",
313            p.width, p.height
314        ));
315    }
316    // the models are made for ~1024²; past 4096 a side the device paths run
317    // out of rows or buffer length and the host path takes hours
318    if p.height > 4096 || p.width > 4096 {
319        return Err(format!(
320            "height and width must be at most 4096 (got {}x{})",
321            p.width, p.height
322        ));
323    }
324    if p.steps == 0 {
325        return Err("steps must be at least 1".into());
326    }
327    let t_all = Instant::now();
328    let mut tm = ZTimings::default();
329    let model = Arc::new(
330        cortiq_core::CmfModel::open(model_path)
331            .map_err(|e| format!("{}: {e}", model_path.display()))?,
332    );
333    if model.header.arch.arch_name != ARCH_NAME {
334        return Err(format!(
335            "{}: architecture '{}' is not {ARCH_NAME}",
336            model_path.display(),
337            model.header.arch.arch_name
338        ));
339    }
340    let vocab = model
341        .vocab
342        .as_deref()
343        .ok_or("Z-Image .cmf has no embedded tokenizer")?;
344    let tok = Tokenizer::from_bytes(vocab).map_err(|e| format!("tokenizer: {e}"))?;
345    let defaults = ZDefaults::of(&model);
346    let do_cfg = p.guidance > 0.0;
347    // Device up + kernels compiled beside the host-side loading below.
348    let warm = crate::zimage::gpu_allowed().then(|| std::thread::spawn(crate::gpu::zimage_warmup));
349
350    // ── DiT host weights, then the text encoder on the CPU while a helper
351    // thread uploads the device planes (they do not depend on the caption).
352    let t0 = Instant::now();
353    let _stage = crate::gpu::image_stage_scope();
354    let dit = match std::env::var("CMF_ZIMAGE_DIT_DIR") {
355        Ok(dir) => ZImageDit::load_dir(Path::new(&dir))?,
356        Err(_) => ZImageDit::from_cmf(&model)?,
357    };
358    tm.dit_load = t0.elapsed().as_secs_f64();
359    let ids = prompt_ids(&tok, prompt, p.max_tokens);
360    let neg_text = p
361        .negative_prompt
362        .clone()
363        .unwrap_or_else(|| defaults.negative_prompt.clone());
364    let neg_ids = do_cfg.then(|| prompt_ids(&tok, &neg_text, p.max_tokens));
365    let t0 = Instant::now();
366    let overlap = crate::zimage::gpu_allowed()
367        && std::env::var("CMF_ZIMAGE_OVERLAP").as_deref() != Ok("0")
368        && std::env::var("CMF_ZIMAGE_TE_GPU").as_deref() != Ok("1")
369        && std::env::var("CMF_ZIMAGE_TE_DEV").is_err();
370    let (te, preload) = std::thread::scope(|sc| {
371        let helper = overlap.then(|| {
372            sc.spawn(|| {
373                let t = Instant::now();
374                let ok = dit.preload_device();
375                (ok, t.elapsed().as_secs_f64())
376            })
377        });
378        let t = Instant::now();
379        let te = (|| -> Result<(Vec<f32>, Option<Vec<f32>>), String> {
380            // The text encoder runs on the CPU (measured, B2): on the 3090
381            // the per-op device path took 3.2–4.2 s against 0.6 s here and
382            // moved v_0 by 4–10 %. vk2 traced that to its host a8w8 arm
383            // (the probe's CPU turns and the sub-gate projections), not the
384            // device; with exact host fallback the device projections land
385            // at the DiT's own floor, but stay 3–4 s (docs/ZIMAGE.md, "Text
386            // encoder on the device"). `pause_gpu` is process-wide, so
387            // the pool's workers stay off the device too (`cpu_scope` would
388            // not); the plane upload beside it goes straight to the device
389            // context, which the pause does not gate.
390            // `CMF_ZIMAGE_TE_GPU=1` restores the device arm for A/B work.
391            // `CMF_ZIMAGE_TE_DEV=all|q,k,…`: those projections through the
392            // device GEMM of their codec, the rest exact on the host (the
393            // device-TE experiment, vk2; see qwen3te `set_device_ops`).
394            let te_dev = std::env::var("CMF_ZIMAGE_TE_DEV").ok();
395            let _cpu = (std::env::var("CMF_ZIMAGE_TE_GPU").as_deref() != Ok("1") && te_dev.is_none())
396                .then(crate::gpu::pause_gpu);
397            let mut enc = crate::qwen3te::Qwen3Encoder::from_cmf(&model)?;
398            // Weight-only exact q8 projections (B2): the default a8w8
399            // kernel triples the caption error (h_m2 1.35e-2 vs 4.1e-3).
400            // `CMF_ZIMAGE_TE_EXACT=0` = the a8w8 arm.
401            enc.set_exact_q8(std::env::var("CMF_ZIMAGE_TE_EXACT").as_deref() != Ok("0"));
402            if let Some(spec) = &te_dev {
403                enc.set_device_ops(spec);
404            }
405            let cap = enc.encode(&ids);
406            let ncap = neg_ids.as_ref().map(|n| enc.encode(n));
407            Ok((cap, ncap))
408        })();
409        let te_s = t.elapsed().as_secs_f64();
410        (te.map(|v| (v, te_s)), helper.map(|h| h.join().unwrap_or((false, 0.0))))
411    });
412    let ((cap, ncap), te_s) = te?;
413    if let Some(w) = warm {
414        let _ = w.join();
415    }
416    tm.text_encode = te_s;
417    let overlapped = t0.elapsed().as_secs_f64();
418    if prof_on() {
419        match preload {
420            Some((ok, s)) => eprintln!(
421                "zimage: text-encode {te_s:.3}s beside the plane upload {s:.3}s (ok {ok}) -> {overlapped:.3}s"
422            ),
423            None => eprintln!("zimage: text-encode {te_s:.3}s"),
424        }
425    }
426    let t0 = Instant::now();
427    let sig = crate::zimage::sigmas_torch_f32(p.steps, p.shift);
428    let t_models: Vec<f32> = sig[..p.steps]
429        .iter()
430        .map(|&s| crate::zimage::t_model(s))
431        .collect();
432    let mods = dit.mods_for_steps(&t_models);
433    let fscale = dit.final_scale_for_steps(&t_models);
434    let t_mods = t0.elapsed().as_secs_f64();
435    let per_mod = dit.cfg.n_mod_blocks() * 4 * dit.cfg.dim;
436    let dim = dit.cfg.dim;
437    static KEY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
438    let next_key = || KEY.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
439    let shape = ZShape::new(p.height, p.width, ids.len());
440    // Which steps run CFG (cfg truncation: t_norm = (1000 − t)/1000 > τ
441    // turns it off).
442    let cfg_at: Vec<bool> = (0..p.steps)
443        .map(|i| do_cfg && !(p.cfg_truncation <= 1.0 && t_models[i] > p.cfg_truncation))
444        .collect();
445    let dev = crate::zimage::gpu_allowed();
446    let mods_all = Some((&mods[..], &fscale[..]));
447    let mut prep = dit.prepare_host(&cap, shape, next_key(), dev)?;
448    let mut nprep = match (&ncap, &neg_ids) {
449        (Some(nc), Some(ni)) => Some(dit.prepare_host(
450            nc,
451            ZShape::new(p.height, p.width, ni.len()),
452            next_key(),
453            dev,
454        )?),
455        _ => None,
456    };
457    let t_caps = t0.elapsed().as_secs_f64();
458    // CFG steps run cond + uncond as ONE batch-2 device forward; steps
459    // without CFG (Turbo, or past the truncation) use the single program.
460    let pair_key = next_key();
461    let pair_dev = match &nprep {
462        Some(np) if dev && cfg_at.iter().any(|&c| c) => {
463            dit.attach_device_pair(&prep, np, pair_key, mods_all)
464        }
465        _ => false,
466    };
467    let mut on_device = pair_dev;
468    if dev && (cfg_at.iter().any(|&c| !c) || (do_cfg && !pair_dev)) {
469        on_device |= dit.attach_device(&mut prep, mods_all);
470    }
471    if let Some(np) = nprep.as_mut() {
472        if dev && !pair_dev {
473            on_device |= dit.attach_device(np, mods_all);
474        }
475    }
476    if dev && !on_device {
477        // the device module says why (once); this is the consequence
478        eprintln!(
479            "zimage: the DiT runs on the CPU — expect minutes per image; \
480             CMF_ZIMAGE_PROF=1 prints the stages"
481        );
482    }
483    tm.prepare = t0.elapsed().as_secs_f64();
484    if prof_on() {
485        eprintln!(
486            "zimage prepare: mods {:.3}s · captions {:.3}s · device {:.3}s",
487            t_mods,
488            t_caps - t_mods,
489            tm.prepare - t_caps
490        );
491    }
492    let c = dit.cfg.in_channels;
493    let (lh, lw) = (shape.h_lat, shape.w_lat);
494    let pd = dit.geom().patch_dim;
495    // `CMF_ZIMAGE_TRACE=<dir>`: raw f32 [16,h,w] per step — `v_i` (the
496    // guided prediction before the negation), `lat_{i+1}`, and under CFG
497    // `vpos_i`/`vneg_i` — the oracle `run_*` names, for parity scripts.
498    let trace = std::env::var("CMF_ZIMAGE_TRACE").ok();
499    // The VAE loads (host) and uploads/compiles (device) on a helper thread
500    // while the steps run — the device is busy with the DiT and the CPU is
501    // idle then (B2: 0.3–0.4 s off the critical path).
502    let vae_warm = {
503        let m = model.clone();
504        let dev = crate::zimage::gpu_allowed();
505        std::thread::spawn(move || -> Result<crate::vae::VaeDecoder, String> {
506            let vae = crate::vae::VaeDecoder::from_cmf(&m)?;
507            if dev && crate::gpu::enabled() {
508                crate::gpu::vae_prewarm(&vae.chain_args());
509            }
510            Ok(vae)
511        })
512    };
513    let mut latents: Vec<Vec<f32>> = Vec::with_capacity(p.num_images);
514    for img in 0..p.num_images {
515        let mut lat = gauss_latent(c * lh * lw, p.seed.wrapping_add(img as u64))?;
516        for i in 0..p.steps {
517            let ts = Instant::now();
518            let x_tok = crate::zimage::pad_rows_repeat_last(
519                &crate::zimage::patchify(&lat, c, lh, lw),
520                shape.n_img,
521                shape.n_img_p,
522                pd,
523            );
524            let m = &mods[i * per_mod..(i + 1) * per_mod];
525            let fs = &fscale[i * dim..(i + 1) * dim];
526            let apply_cfg = cfg_at[i];
527            let pair = if apply_cfg && pair_dev {
528                dit.step_pair_device(pair_key, shape.n_img, i, &x_tok, m, fs)
529            } else {
530                None
531            };
532            let (mut pred, neg) = match pair {
533                Some((a, b)) => (a, Some(b)),
534                None => (dit.step(&prep, i, &x_tok, m, fs), None),
535            };
536            if apply_cfg {
537                let neg = match neg {
538                    Some(n) => n,
539                    None => dit.step(nprep.as_ref().expect("negative prepared"), i, &x_tok, m, fs),
540                };
541                if let Some(d) = &trace {
542                    trace_write(d, img, &format!("vpos_{i}"), &crate::zimage::unpatchify(&pred, c, lh, lw))?;
543                    trace_write(d, img, &format!("vneg_{i}"), &crate::zimage::unpatchify(&neg, c, lh, lw))?;
544                }
545                cfg_combine(&mut pred, &neg, p.guidance, p.cfg_normalization);
546            }
547            let v = crate::zimage::unpatchify(&pred, c, lh, lw);
548            let dt = sig[i + 1] - sig[i];
549            for (x, &vv) in lat.iter_mut().zip(&v) {
550                *x += dt * (-vv);
551            }
552            if let Some(d) = &trace {
553                trace_write(d, img, &format!("v_{i}"), &v)?;
554                trace_write(d, img, &format!("lat_{}", i + 1), &lat)?;
555            }
556            tm.steps.push(ts.elapsed().as_secs_f64());
557            if prof_on() {
558                eprintln!(
559                    "zimage: image {} step {}/{} {:.3}s{}",
560                    img + 1,
561                    i + 1,
562                    p.steps,
563                    ts.elapsed().as_secs_f64(),
564                    if apply_cfg { " (cfg)" } else { "" }
565                );
566            }
567            progress(img, i + 1, p.steps);
568        }
569        if let Ok(path) = std::env::var("CMF_ZIMAGE_LATENT_OUT") {
570            let path = if p.num_images > 1 {
571                format!("{path}.{img}")
572            } else {
573                path
574            };
575            let b: Vec<u8> = lat.iter().flat_map(|v| v.to_le_bytes()).collect();
576            std::fs::write(&path, b).map_err(|e| format!("{path}: {e}"))?;
577        }
578        latents.push(lat);
579    }
580    drop(prep);
581    drop(nprep);
582    drop(dit);
583    crate::gpu::zimage_release_dit();
584    drop(_stage);
585
586    // ── VAE ──
587    let t0 = Instant::now();
588    let mut out = Vec::with_capacity(latents.len());
589    {
590        let _stage = crate::gpu::image_stage_scope();
591        let vae = vae_warm.join().map_err(|_| "VAE loader panicked".to_string())??;
592        for (img, lat) in latents.iter().enumerate() {
593            if lat.iter().any(|v| !v.is_finite()) {
594                return Err(format!(
595                    "image {img}: the final latent is not finite (an f16 overflow on the \
596                     device path, or a corrupt file); rerun with CMF_ZIMAGE_GPU=0 to \
597                     use the CPU DiT"
598                ));
599            }
600            let rgb = vae.decode_fast(lat, lh, lw);
601            let (h, w) = (p.height, p.width);
602            let plane = h * w;
603            let mut u8s = vec![0u8; plane * 3];
604            for px in 0..plane {
605                for ch in 0..3 {
606                    let v = (rgb[ch * plane + px] / 2.0 + 0.5).clamp(0.0, 1.0);
607                    // numpy `(x*255).round()`: half to even
608                    u8s[px * 3 + ch] = (v * 255.0).round_ties_even() as u8;
609                }
610            }
611            out.push(ZImage {
612                rgb: u8s,
613                height: h,
614                width: w,
615                seed: p.seed.wrapping_add(img as u64),
616            });
617        }
618    }
619    tm.vae = t0.elapsed().as_secs_f64();
620    tm.total = t_all.elapsed().as_secs_f64();
621    if crate::zimage::gpu_allowed() {
622        crate::gpu::zimage_flush_pipelines();
623    }
624    if prof_on() {
625        eprintln!(
626            "zimage stages: text-encode {:.2}s · dit-load {:.2}s · prepare {:.2}s · steps {:.2}s (median {:.3}s, {} forwards/step) · vae {:.2}s · total {:.2}s",
627            tm.text_encode,
628            tm.dit_load,
629            tm.prepare,
630            tm.steps.iter().sum::<f64>(),
631            tm.median_step(),
632            if do_cfg { 2 } else { 1 },
633            tm.vae,
634            tm.total
635        );
636    }
637    Ok((out, tm))
638}