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    if p.steps == 0 {
317        return Err("steps must be at least 1".into());
318    }
319    let t_all = Instant::now();
320    let mut tm = ZTimings::default();
321    let model = Arc::new(
322        cortiq_core::CmfModel::open(model_path)
323            .map_err(|e| format!("{}: {e}", model_path.display()))?,
324    );
325    if model.header.arch.arch_name != ARCH_NAME {
326        return Err(format!(
327            "{}: architecture '{}' is not {ARCH_NAME}",
328            model_path.display(),
329            model.header.arch.arch_name
330        ));
331    }
332    let vocab = model
333        .vocab
334        .as_deref()
335        .ok_or("Z-Image .cmf has no embedded tokenizer")?;
336    let tok = Tokenizer::from_bytes(vocab).map_err(|e| format!("tokenizer: {e}"))?;
337    let defaults = ZDefaults::of(&model);
338    let do_cfg = p.guidance > 0.0;
339    // Device up + kernels compiled beside the host-side loading below.
340    let warm = crate::zimage::gpu_allowed().then(|| std::thread::spawn(crate::gpu::zimage_warmup));
341
342    // ── DiT host weights, then the text encoder on the CPU while a helper
343    // thread uploads the device planes (they do not depend on the caption).
344    let t0 = Instant::now();
345    let _stage = crate::gpu::image_stage_scope();
346    let dit = match std::env::var("CMF_ZIMAGE_DIT_DIR") {
347        Ok(dir) => ZImageDit::load_dir(Path::new(&dir))?,
348        Err(_) => ZImageDit::from_cmf(&model)?,
349    };
350    tm.dit_load = t0.elapsed().as_secs_f64();
351    let ids = prompt_ids(&tok, prompt, p.max_tokens);
352    let neg_text = p
353        .negative_prompt
354        .clone()
355        .unwrap_or_else(|| defaults.negative_prompt.clone());
356    let neg_ids = do_cfg.then(|| prompt_ids(&tok, &neg_text, p.max_tokens));
357    let t0 = Instant::now();
358    let overlap = crate::zimage::gpu_allowed()
359        && std::env::var("CMF_ZIMAGE_OVERLAP").as_deref() != Ok("0")
360        && std::env::var("CMF_ZIMAGE_TE_GPU").as_deref() != Ok("1");
361    let (te, preload) = std::thread::scope(|sc| {
362        let helper = overlap.then(|| {
363            sc.spawn(|| {
364                let t = Instant::now();
365                let ok = dit.preload_device();
366                (ok, t.elapsed().as_secs_f64())
367            })
368        });
369        let t = Instant::now();
370        let te = (|| -> Result<(Vec<f32>, Option<Vec<f32>>), String> {
371            // The text encoder runs on the CPU (measured, B2): on the 3090
372            // the per-op device path took 3.2–4.2 s against 0.6 s here and
373            // moved the caption by 3–7 %. `pause_gpu` is process-wide, so
374            // the pool's workers stay off the device too (`cpu_scope` would
375            // not); the plane upload beside it goes straight to the device
376            // context, which the pause does not gate.
377            // `CMF_ZIMAGE_TE_GPU=1` restores the device arm for A/B work.
378            let _cpu = (std::env::var("CMF_ZIMAGE_TE_GPU").as_deref() != Ok("1"))
379                .then(crate::gpu::pause_gpu);
380            let mut enc = crate::qwen3te::Qwen3Encoder::from_cmf(&model)?;
381            // Weight-only exact q8 projections (B2): the default a8w8
382            // kernel triples the caption error (h_m2 1.35e-2 vs 4.1e-3).
383            // `CMF_ZIMAGE_TE_EXACT=0` = the a8w8 arm.
384            enc.set_exact_q8(std::env::var("CMF_ZIMAGE_TE_EXACT").as_deref() != Ok("0"));
385            let cap = enc.encode(&ids);
386            let ncap = neg_ids.as_ref().map(|n| enc.encode(n));
387            Ok((cap, ncap))
388        })();
389        let te_s = t.elapsed().as_secs_f64();
390        (te.map(|v| (v, te_s)), helper.map(|h| h.join().unwrap_or((false, 0.0))))
391    });
392    let ((cap, ncap), te_s) = te?;
393    if let Some(w) = warm {
394        let _ = w.join();
395    }
396    tm.text_encode = te_s;
397    let overlapped = t0.elapsed().as_secs_f64();
398    if prof_on() {
399        match preload {
400            Some((ok, s)) => eprintln!(
401                "zimage: text-encode {te_s:.3}s beside the plane upload {s:.3}s (ok {ok}) -> {overlapped:.3}s"
402            ),
403            None => eprintln!("zimage: text-encode {te_s:.3}s"),
404        }
405    }
406    let t0 = Instant::now();
407    let sig = crate::zimage::sigmas_torch_f32(p.steps, p.shift);
408    let t_models: Vec<f32> = sig[..p.steps]
409        .iter()
410        .map(|&s| crate::zimage::t_model(s))
411        .collect();
412    let mods = dit.mods_for_steps(&t_models);
413    let fscale = dit.final_scale_for_steps(&t_models);
414    let t_mods = t0.elapsed().as_secs_f64();
415    let per_mod = dit.cfg.n_mod_blocks() * 4 * dit.cfg.dim;
416    let dim = dit.cfg.dim;
417    static KEY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
418    let next_key = || KEY.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
419    let shape = ZShape::new(p.height, p.width, ids.len());
420    // Which steps run CFG (cfg truncation: t_norm = (1000 − t)/1000 > τ
421    // turns it off).
422    let cfg_at: Vec<bool> = (0..p.steps)
423        .map(|i| do_cfg && !(p.cfg_truncation <= 1.0 && t_models[i] > p.cfg_truncation))
424        .collect();
425    let dev = crate::zimage::gpu_allowed();
426    let mods_all = Some((&mods[..], &fscale[..]));
427    let mut prep = dit.prepare_host(&cap, shape, next_key(), dev)?;
428    let mut nprep = match (&ncap, &neg_ids) {
429        (Some(nc), Some(ni)) => Some(dit.prepare_host(
430            nc,
431            ZShape::new(p.height, p.width, ni.len()),
432            next_key(),
433            dev,
434        )?),
435        _ => None,
436    };
437    let t_caps = t0.elapsed().as_secs_f64();
438    // CFG steps run cond + uncond as ONE batch-2 device forward; steps
439    // without CFG (Turbo, or past the truncation) use the single program.
440    let pair_key = next_key();
441    let pair_dev = match &nprep {
442        Some(np) if dev && cfg_at.iter().any(|&c| c) => {
443            dit.attach_device_pair(&prep, np, pair_key, mods_all)
444        }
445        _ => false,
446    };
447    let mut on_device = pair_dev;
448    if dev && (cfg_at.iter().any(|&c| !c) || (do_cfg && !pair_dev)) {
449        on_device |= dit.attach_device(&mut prep, mods_all);
450    }
451    if let Some(np) = nprep.as_mut() {
452        if dev && !pair_dev {
453            on_device |= dit.attach_device(np, mods_all);
454        }
455    }
456    if dev && !on_device {
457        // the device module says why (once); this is the consequence
458        eprintln!(
459            "zimage: the DiT runs on the CPU — expect minutes per image; \
460             CMF_ZIMAGE_PROF=1 prints the stages"
461        );
462    }
463    tm.prepare = t0.elapsed().as_secs_f64();
464    if prof_on() {
465        eprintln!(
466            "zimage prepare: mods {:.3}s · captions {:.3}s · device {:.3}s",
467            t_mods,
468            t_caps - t_mods,
469            tm.prepare - t_caps
470        );
471    }
472    let c = dit.cfg.in_channels;
473    let (lh, lw) = (shape.h_lat, shape.w_lat);
474    let pd = dit.geom().patch_dim;
475    // `CMF_ZIMAGE_TRACE=<dir>`: raw f32 [16,h,w] per step — `v_i` (the
476    // guided prediction before the negation), `lat_{i+1}`, and under CFG
477    // `vpos_i`/`vneg_i` — the oracle `run_*` names, for parity scripts.
478    let trace = std::env::var("CMF_ZIMAGE_TRACE").ok();
479    // The VAE loads (host) and uploads/compiles (device) on a helper thread
480    // while the steps run — the device is busy with the DiT and the CPU is
481    // idle then (B2: 0.3–0.4 s off the critical path).
482    let vae_warm = {
483        let m = model.clone();
484        let dev = crate::zimage::gpu_allowed();
485        std::thread::spawn(move || -> Result<crate::vae::VaeDecoder, String> {
486            let vae = crate::vae::VaeDecoder::from_cmf(&m)?;
487            if dev && crate::gpu::enabled() {
488                crate::gpu::vae_prewarm(&vae.chain_args());
489            }
490            Ok(vae)
491        })
492    };
493    let mut latents: Vec<Vec<f32>> = Vec::with_capacity(p.num_images);
494    for img in 0..p.num_images {
495        let mut lat = gauss_latent(c * lh * lw, p.seed.wrapping_add(img as u64))?;
496        for i in 0..p.steps {
497            let ts = Instant::now();
498            let x_tok = crate::zimage::pad_rows_repeat_last(
499                &crate::zimage::patchify(&lat, c, lh, lw),
500                shape.n_img,
501                shape.n_img_p,
502                pd,
503            );
504            let m = &mods[i * per_mod..(i + 1) * per_mod];
505            let fs = &fscale[i * dim..(i + 1) * dim];
506            let apply_cfg = cfg_at[i];
507            let pair = if apply_cfg && pair_dev {
508                dit.step_pair_device(pair_key, shape.n_img, i, &x_tok, m, fs)
509            } else {
510                None
511            };
512            let (mut pred, neg) = match pair {
513                Some((a, b)) => (a, Some(b)),
514                None => (dit.step(&prep, i, &x_tok, m, fs), None),
515            };
516            if apply_cfg {
517                let neg = match neg {
518                    Some(n) => n,
519                    None => dit.step(nprep.as_ref().expect("negative prepared"), i, &x_tok, m, fs),
520                };
521                if let Some(d) = &trace {
522                    trace_write(d, img, &format!("vpos_{i}"), &crate::zimage::unpatchify(&pred, c, lh, lw))?;
523                    trace_write(d, img, &format!("vneg_{i}"), &crate::zimage::unpatchify(&neg, c, lh, lw))?;
524                }
525                cfg_combine(&mut pred, &neg, p.guidance, p.cfg_normalization);
526            }
527            let v = crate::zimage::unpatchify(&pred, c, lh, lw);
528            let dt = sig[i + 1] - sig[i];
529            for (x, &vv) in lat.iter_mut().zip(&v) {
530                *x += dt * (-vv);
531            }
532            if let Some(d) = &trace {
533                trace_write(d, img, &format!("v_{i}"), &v)?;
534                trace_write(d, img, &format!("lat_{}", i + 1), &lat)?;
535            }
536            tm.steps.push(ts.elapsed().as_secs_f64());
537            if prof_on() {
538                eprintln!(
539                    "zimage: image {} step {}/{} {:.3}s{}",
540                    img + 1,
541                    i + 1,
542                    p.steps,
543                    ts.elapsed().as_secs_f64(),
544                    if apply_cfg { " (cfg)" } else { "" }
545                );
546            }
547            progress(img, i + 1, p.steps);
548        }
549        if let Ok(path) = std::env::var("CMF_ZIMAGE_LATENT_OUT") {
550            let path = if p.num_images > 1 {
551                format!("{path}.{img}")
552            } else {
553                path
554            };
555            let b: Vec<u8> = lat.iter().flat_map(|v| v.to_le_bytes()).collect();
556            std::fs::write(&path, b).map_err(|e| format!("{path}: {e}"))?;
557        }
558        latents.push(lat);
559    }
560    drop(prep);
561    drop(nprep);
562    drop(dit);
563    crate::gpu::zimage_release_dit();
564    drop(_stage);
565
566    // ── VAE ──
567    let t0 = Instant::now();
568    let mut out = Vec::with_capacity(latents.len());
569    {
570        let _stage = crate::gpu::image_stage_scope();
571        let vae = vae_warm.join().map_err(|_| "VAE loader panicked".to_string())??;
572        for (img, lat) in latents.iter().enumerate() {
573            if lat.iter().any(|v| !v.is_finite()) {
574                return Err(format!(
575                    "image {img}: the final latent is not finite (an f16 overflow on the \
576                     device path, or a corrupt file); rerun with CMF_ZIMAGE_GPU=0 to \
577                     use the CPU DiT"
578                ));
579            }
580            let rgb = vae.decode_fast(lat, lh, lw);
581            let (h, w) = (p.height, p.width);
582            let plane = h * w;
583            let mut u8s = vec![0u8; plane * 3];
584            for px in 0..plane {
585                for ch in 0..3 {
586                    let v = (rgb[ch * plane + px] / 2.0 + 0.5).clamp(0.0, 1.0);
587                    // numpy `(x*255).round()`: half to even
588                    u8s[px * 3 + ch] = (v * 255.0).round_ties_even() as u8;
589                }
590            }
591            out.push(ZImage {
592                rgb: u8s,
593                height: h,
594                width: w,
595                seed: p.seed.wrapping_add(img as u64),
596            });
597        }
598    }
599    tm.vae = t0.elapsed().as_secs_f64();
600    tm.total = t_all.elapsed().as_secs_f64();
601    if crate::zimage::gpu_allowed() {
602        crate::gpu::zimage_flush_pipelines();
603    }
604    if prof_on() {
605        eprintln!(
606            "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",
607            tm.text_encode,
608            tm.dit_load,
609            tm.prepare,
610            tm.steps.iter().sum::<f64>(),
611            tm.median_step(),
612            if do_cfg { 2 } else { 1 },
613            tm.vae,
614            tm.total
615        );
616    }
617    Ok((out, tm))
618}