makepad/libs/ai/models/flux/src/flux2_pipeline.rs
Admin e37c263b9c ai backbone: the hub era — makepad_ai deleted, every backend is a hub pipe; machine residency elections, job leases, ETA placement; the store only stores; creator pipelines run in the app (aicore)
Squashed from work:
- asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire
- asset-ui: loop video chains — text→image→video that ends where it began
- h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers
- h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor
- asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits
- asset-ai realtime: a feedback loop survives a resize and travels by default
- asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener
- asset-ai realtime: the outbound encode leaves the loop's critical path
- asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned
- llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own
- vision tower on CUDA: the encode leg gets its two missing kernels
- llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error
- vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away
- live: a feed that moves box takes its trip with it — one seed image
- vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go
- llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs
- asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels
- Merge branch 'ocr-perf-integration' into work
- asset-ai: the live anchor can follow the trip, and text leaves the 5090
- asset-ai: the camera moves the world, and the world starts still
- asset-import: the EA strategy classics, in the one 2D contract
- rtsmap: one seeded generator for tiled strategy maps
- asset-ui: one card for the strategy classics, with a pack dropdown
- asset-ai: music3 reference-audio path, ocr/h3 backends, registry
- asset: mp4 sample index for range-streaming, chat tools, import profiles
- cnc: tiberium is twelve growth frames, not twelve empty variants
- platform: native file and save dialogs, in-house on all three desktops
- chat: the scan holds out for a lane home
- chat: a full home queues you — take the free lane
- chat: the preload has a percentage, and the boundless cap stops showing
- llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA
- sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin
- sqlite_query: anti-join regression test
- td import: HARV's second frame block is its harvesting cycle, not a turret
- asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling
- ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin
- asset-ui: test health fixtures grow the realtime field they were born without
- ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re
- ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo
- ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic
- strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move
- ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore
- ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4)
- ai-hub: job leases — work lives only while it is renewed (aicore §8)
- asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9)
- ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3)
- ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor
- ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w
- ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w
- asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct
- ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle
- asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9)
- ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6)
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
- ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4)
- asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state (
- ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path
- vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1)
- asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore
- chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5)
- asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F
- asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8)
- asset-store: the shrink — the store stores (aicore P7)
- importer + asset-server host: the coordination era ends (aicore P7)
- store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- ai-hub: 0.3.0 — the health version says which era a node runs
- ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing
- ai-hub: the preload note percents the prefill, not the job bar
- ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7)
- ai-hub: an open-think model is thinking from its first token
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples
- zero-warning sweep, round four — the last stragglers
- zero-warning sweep, round five — vj and chat-ui
- zero-warning sweep, round six — three cascades

Co-authored-by: Claude <info@makepad.nl>
2026-09-01 16:46:31 +02:00

1085 lines
42 KiB
Rust

//! FLUX.2-klein-4B image-edit pipeline.
//!
//! Instruction + one or more reference images → packed latents on T-axis
//! offsets 10, 20, … → 4-step distilled Euler denoise → VAE decode.
//! Fail-closed without CUDA.
use crate::backend::gpu_device_available;
use crate::flux2::{
flux2_concat_ref_tokens, flux2_image_ids, flux2_ref_time_offset, flux2_schedule, flux2_text_ids,
Flux2PackedLatents, Flux2PosId, Flux2WeightFile, Mistral3TextConfig, Qwen3TextConfig,
FLUX2_SYSTEM_MESSAGE,
};
use crate::flux2_dev_text::{flux2_dev_text_encode, Flux2DevTextPrepared};
use crate::flux2_klein_text::{
flux2_klein_text_encode, flux2_klein_text_release, flux2_klein_tokenize, flux2_klein_tokenizer_load,
Flux2KleinTextPrepared,
};
use crate::flux2_tokenizer::{Flux2Tokenizer, FLUX2_MAX_SEQUENCE_LENGTH};
use crate::flux2_transformer::{
flux2_dit_clear_pool, flux2_euler_step, flux2_transformer_forward, Flux2TransformerWeights,
};
use crate::flux2_vae::{
flux2_image_to_rgb_u8, flux2_vae_decode, flux2_vae_encode, Flux2VaeImage,
Flux2VaeWeights,
};
use crate::flux_pipeline::encode_png_rgb;
use crate::{DiffusionError, Result};
use std::path::{Path, PathBuf};
pub const FLUX2_KLEIN_DEFAULT_STEPS: usize = 4;
pub const FLUX2_KLEIN_DEFAULT_SIZE: u32 = 512;
pub struct Flux2KleinPaths {
pub transformer: PathBuf,
pub text_encoder: PathBuf,
pub tokenizer: PathBuf,
pub vae: PathBuf,
}
pub struct Flux2KleinPipeline {
pub paths: Flux2KleinPaths,
pub transformer: Flux2TransformerWeights,
pub text_encoder: Flux2WeightFile,
pub text_prepared: Flux2KleinTextPrepared,
pub tokenizer: makepad_ai_h3::h3_tokenizer::H3Tokenizer,
pub vae: Flux2VaeWeights,
}
/// img2img init for the FLUX.2 samplers (Klein edit, dev generate/edit): the
/// image to start from (same size as the output, multiple of 16) and the
/// denoise `strength` in `[0, 1]`. ComfyUI semantics for a flow model with
/// CONST noise scaling: the init is VAE-encoded to packed latents `z0`, the
/// sampler starts at sigma index `k = floor((1 - strength) * steps)` of the
/// full schedule with `x = sigma_k * noise + (1 - sigma_k) * z0`, and runs
/// the remaining `steps - k` steps. `strength = 1` is the plain t2i/edit run
/// (`k = 0`, x = noise), `strength = 0` returns the init re-encoded
/// (`k = steps`, no denoise). Fewer steps run at lower strength — the live
/// feed loop relies on that.
#[derive(Clone, Debug)]
pub struct Flux2Img2Img {
pub image: Flux2VaeImage,
pub strength: f32,
}
/// Resolve the img2img start: `(start_step, sample)`. `noise` is the full
/// sigma-1 noise (`[gen_tokens, 128]` token-major); `sigmas` the full
/// `steps + 1` schedule.
fn flux2_img2img_start(
vae: &Flux2VaeWeights,
init: &Flux2Img2Img,
noise: Vec<f32>,
sigmas: &[f32],
steps: usize,
packed_w: usize,
packed_h: usize,
) -> Result<(usize, Vec<f32>)> {
if !(0.0..=1.0).contains(&init.strength) || !init.strength.is_finite() {
return Err(DiffusionError::workflow(format!(
"flux2 img2img strength must be in [0, 1], got {}",
init.strength
)));
}
if init.image.width != packed_w * 16 || init.image.height != packed_h * 16 {
return Err(DiffusionError::workflow(format!(
"flux2 img2img init is {}x{}, output is {}x{} — the init must match the output size",
init.image.width,
init.image.height,
packed_w * 16,
packed_h * 16
)));
}
let start = (((1.0 - init.strength) * steps as f32).floor() as usize).min(steps);
if start == 0 {
return Ok((0, noise));
}
let packed = flux2_vae_encode(vae, &init.image)?;
if packed.width != packed_w || packed.height != packed_h || packed.channels != 128 {
return Err(DiffusionError::workflow(format!(
"flux2 img2img init encoded to {}x{}x{}, expected {packed_w}x{packed_h}x128",
packed.width, packed.height, packed.channels
)));
}
let z0 = packed.to_tokens();
if z0.len() != noise.len() {
return Err(DiffusionError::workflow(format!(
"flux2 img2img init has {} latent values, noise has {}",
z0.len(),
noise.len()
)));
}
let sigma = sigmas[start];
let sample: Vec<f32> = noise
.iter()
.zip(z0.iter())
.map(|(n, z)| sigma * n + (1.0 - sigma) * z)
.collect();
Ok((start, sample))
}
#[derive(Clone, Debug)]
pub struct Flux2EditRequest {
pub prompt: String,
pub width: u32,
pub height: u32,
pub steps: usize,
pub seed: u64,
/// RGB8 reference images, already cropped to a multiple of 16.
pub references: Vec<Flux2VaeImage>,
/// Optional teacher noise (token-major `[gen_tokens, 128]`) from an
/// oracle dump. When absent a host SplitMix64 fill is used (NOT
/// torch-philox; pin against a dumped `noise` for numeric parity).
pub noise: Option<Vec<f32>>,
/// Optional oracle ref tokens (token-major `[ref_tokens, 128]`) to
/// isolate the DiT from VAE encode error.
pub teacher_ref_tokens: Option<Vec<f32>>,
/// Optional oracle prompt embeds (token-major `[512, 7680]`).
pub teacher_embeds: Option<Vec<f32>>,
/// Optional img2img init (see [`Flux2Img2Img`]): start the sampler from
/// this image at the given strength instead of from pure noise. The
/// `references` still condition the edit as before.
pub init: Option<Flux2Img2Img>,
}
#[derive(Clone, Debug)]
pub struct Flux2EditResult {
pub image: Flux2VaeImage,
pub png: Vec<u8>,
pub packed_latents: Flux2PackedLatents,
pub ref_packed: Flux2PackedLatents,
pub input_ids: Vec<u32>,
pub real_len: usize,
pub prompt_embeds: Vec<f32>,
pub step_residuals: Vec<Vec<f32>>,
pub sigmas: Vec<f32>,
pub warm_ms: f64,
/// Per-stage wall times for the same call `warm_ms` covers. Every stage
/// ends in a device sync (each finishes with a gpu_download or a host
/// step), so these are honest per-stage walls, not queue-depth artifacts.
pub te_ms: f64,
pub encode_ms: f64,
pub decode_ms: f64,
pub png_ms: f64,
pub total_ms: f64,
}
impl Flux2KleinPipeline {
pub fn load(paths: Flux2KleinPaths) -> Result<Self> {
if !gpu_device_available() {
return Err(DiffusionError::workflow(
"flux2-klein-4b requires CUDA (MAKEPAD_GGML_REQUIRE_CUDA=1)",
));
}
let tokenizer = flux2_klein_tokenizer_load(&paths.tokenizer)?;
let text_encoder = Flux2WeightFile::load(&paths.text_encoder)?;
let text_prepared =
Flux2KleinTextPrepared::prepare(&text_encoder, Qwen3TextConfig::flux2_klein_4b())?;
let vae = Flux2VaeWeights::load(&paths.vae)?;
let transformer = Flux2TransformerWeights::load(&paths.transformer)?;
if transformer.config.guidance_embed {
return Err(DiffusionError::model(
"flux2-klein-4b loader received a guidance_embed=true transformer",
));
}
Ok(Self {
paths,
transformer,
text_encoder,
text_prepared,
tokenizer,
vae,
})
}
pub fn edit(&mut self, request: &Flux2EditRequest) -> Result<Flux2EditResult> {
if request.width % 16 != 0 || request.height % 16 != 0 {
return Err(DiffusionError::workflow(format!(
"flux2 edit size must be a multiple of 16, got {}x{}",
request.width, request.height
)));
}
if request.references.is_empty() && request.teacher_ref_tokens.is_none() {
return Err(DiffusionError::workflow(
"flux2 edit requires at least one reference image",
));
}
let total_started = std::time::Instant::now();
let tokenized = flux2_klein_tokenize(&self.tokenizer, &request.prompt)?;
let prompt_embeds = if let Some(embeds) = &request.teacher_embeds {
embeds.clone()
} else {
let embeds =
flux2_klein_text_encode(&self.text_encoder, &self.text_prepared, &tokenized)?;
// TE weights stay device-resident by default (re-uploading ~8 GB
// per call dominated warm TE). MAKEPAD_FLUX2_TE_RELEASE=1 restores
// the evict-after-encode behavior for VRAM-constrained flows.
if std::env::var("MAKEPAD_FLUX2_TE_RELEASE").as_deref() == Ok("1") {
let _ = flux2_klein_text_release();
}
embeds
};
let te_ms = total_started.elapsed().as_secs_f64() * 1000.0;
let encode_started = std::time::Instant::now();
let mut ref_packed = Vec::new();
if let Some(tokens) = &request.teacher_ref_tokens {
let packed_w = (request.width / 16) as usize;
let packed_h = (request.height / 16) as usize;
let packed = Flux2PackedLatents::from_tokens(tokens, packed_w, packed_h, 128)?;
let ids = flux2_image_ids(packed.width, packed.height, flux2_ref_time_offset(0));
ref_packed.push((tokens.clone(), ids, packed));
} else {
for (index, image) in request.references.iter().enumerate() {
let packed = flux2_vae_encode(&self.vae, image)?;
let tokens = packed.to_tokens();
let ids = flux2_image_ids(packed.width, packed.height, flux2_ref_time_offset(index));
ref_packed.push((tokens, ids, packed));
}
}
let encode_ms = encode_started.elapsed().as_secs_f64() * 1000.0;
let packed_w = (request.width / 16) as usize;
let packed_h = (request.height / 16) as usize;
let gen_tokens = packed_w * packed_h;
let channels = 128usize;
let mut sample = match &request.noise {
Some(noise) => {
if noise.len() != gen_tokens * channels {
return Err(DiffusionError::workflow(format!(
"flux2 noise expected {} values, got {}",
gen_tokens * channels,
noise.len()
)));
}
noise.clone()
}
None => splitmix_noise(request.seed, gen_tokens * channels),
};
let gen_ids = flux2_image_ids(packed_w, packed_h, 0);
let refs: Vec<(Vec<f32>, Vec<Flux2PosId>)> = ref_packed
.iter()
.map(|(tokens, ids, _)| (tokens.clone(), ids.clone()))
.collect();
let txt_ids = flux2_text_ids(tokenized.token_ids.len());
let sigmas = flux2_schedule(request.steps, gen_tokens)?;
let start_step = match &request.init {
Some(init) => {
let (start, mixed) =
flux2_img2img_start(&self.vae, init, sample, &sigmas, request.steps, packed_w, packed_h)?;
sample = mixed;
start
}
None => 0,
};
let prof = std::env::var_os("MAKEPAD_GPU_PROF").is_some();
if prof {
// Discard text-encode/VAE-encode counters so the denoise report
// covers only the loop below.
let _ = makepad_ai_common::backend::prof::report_and_reset("");
}
let started = std::time::Instant::now();
let mut step_residuals = Vec::new();
for step in start_step..request.steps {
let step_started = std::time::Instant::now();
let (img_tokens, img_ids) = flux2_concat_ref_tokens(&sample, &gen_ids, &refs);
let run = flux2_transformer_forward(
&self.transformer,
&img_tokens,
&img_ids,
&prompt_embeds,
&txt_ids,
sigmas[step],
None,
gen_tokens,
)?;
if step == request.steps / 2 || step == start_step {
step_residuals.push(run.prediction.clone());
}
flux2_euler_step(&mut sample, &run.prediction, sigmas[step], sigmas[step + 1])?;
if prof {
eprintln!(
"flux2 prof step{step} ms={:.1}",
step_started.elapsed().as_secs_f64() * 1000.0
);
}
}
let warm_ms = started.elapsed().as_secs_f64() * 1000.0;
if prof {
eprint!(
"{}",
makepad_ai_common::backend::prof::report_and_reset("flux2 prof denoise ")
);
}
flux2_dit_clear_pool();
let packed = Flux2PackedLatents::from_tokens(&sample, packed_w, packed_h, channels)?;
let decode_started = std::time::Instant::now();
let image = flux2_vae_decode(&self.vae, &packed)?;
let decode_ms = decode_started.elapsed().as_secs_f64() * 1000.0;
if prof {
eprintln!("flux2 prof vae_decode ms={decode_ms:.1}");
eprint!(
"{}",
makepad_ai_common::backend::prof::report_and_reset("flux2 prof vae ")
);
}
let png_started = std::time::Instant::now();
let rgb = flux2_image_to_rgb_u8(&image);
let png = encode_png_rgb(
&planar_rgb_to_whcb(&rgb, image.width, image.height),
image.width,
image.height,
)?;
let png_ms = png_started.elapsed().as_secs_f64() * 1000.0;
Ok(Flux2EditResult {
image,
png,
packed_latents: packed,
ref_packed: ref_packed[0].2.clone(),
input_ids: tokenized.token_ids,
real_len: tokenized.real_len,
prompt_embeds,
step_residuals,
sigmas,
warm_ms,
te_ms,
encode_ms,
decode_ms,
png_ms,
total_ms: total_started.elapsed().as_secs_f64() * 1000.0,
})
}
}
fn planar_rgb_to_whcb(rgb: &[u8], width: usize, height: usize) -> Vec<f32> {
// encode_png_rgb's to_u8 maps [-1,1] -> [0,255] (flux1 hands it raw
// decoder output). Feed it [-1,1] so the u8 roundtrip is exact; the
// earlier [0,1] feed applied the (x+1)/2 remap TWICE and washed out
// every saved PNG (the validator's u8 gates never read the file, so
// they were unaffected).
let plane = width * height;
let mut out = vec![0.0f32; plane * 3];
for i in 0..plane {
out[i] = rgb[i * 3] as f32 * (2.0 / 255.0) - 1.0;
out[plane + i] = rgb[i * 3 + 1] as f32 * (2.0 / 255.0) - 1.0;
out[2 * plane + i] = rgb[i * 3 + 2] as f32 * (2.0 / 255.0) - 1.0;
}
out
}
fn splitmix_noise(seed: u64, n: usize) -> Vec<f32> {
let mut state = seed.wrapping_add(0x9E3779B97F4A7C15);
let mut out = Vec::with_capacity(n);
while out.len() < n {
state = state.wrapping_add(0x9E3779B97F4A7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^= z >> 31;
let u1 = ((z >> 11) as f64) / ((1u64 << 53) as f64);
state = state.wrapping_add(0x9E3779B97F4A7C15);
let mut z2 = state;
z2 = (z2 ^ (z2 >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z2 = (z2 ^ (z2 >> 27)).wrapping_mul(0x94D049BB133111EB);
z2 ^= z2 >> 31;
let u2 = ((z2 >> 11) as f64) / ((1u64 << 53) as f64);
let r = (-2.0 * u1.max(1e-12).ln()).sqrt();
let theta = 2.0 * std::f64::consts::PI * u2;
out.push((r * theta.cos()) as f32);
if out.len() < n {
out.push((r * theta.sin()) as f32);
}
}
out
}
/// Resolve the Klein-4B weight layout used by the validator and the
/// asset-ai backend (`transformer/`, `text_encoder/`, `tokenizer/`, `vae/`
/// or a single `flux-2-klein-4b.safetensors` next to those dirs).
pub fn flux2_klein_paths_from_root(root: impl AsRef<Path>) -> Result<Flux2KleinPaths> {
let root = root.as_ref();
// Prefer the oracle's diffusers `transformer/` over the BFL single file.
// Names are mapped in the DiT loader; the two files are identical except
// `norm_out.linear` which is the AdaLN [scale,shift] swap of BFL.
let transformer = [
root.join("transformer/diffusion_pytorch_model.safetensors"),
root.join("transformer"),
root.join("flux-2-klein-4b.safetensors"),
]
.into_iter()
.find(|p| p.exists())
.ok_or_else(|| {
DiffusionError::workflow(format!(
"flux2-klein-4b transformer not found under {}",
root.display()
))
})?;
let text_encoder = root.join("text_encoder");
let tokenizer = root.join("tokenizer");
let vae = [
root.join("vae/diffusion_pytorch_model.safetensors"),
root.join("vae"),
root.join("flux2-vae.safetensors"),
root.join("ae.safetensors"),
]
.into_iter()
.find(|p| p.exists())
.ok_or_else(|| {
DiffusionError::workflow(format!("flux2-klein-4b vae not found under {}", root.display()))
})?;
if !text_encoder.is_dir() {
return Err(DiffusionError::workflow(format!(
"flux2-klein-4b text_encoder dir missing: {}",
text_encoder.display()
)));
}
if !tokenizer.is_dir() {
return Err(DiffusionError::workflow(format!(
"flux2-klein-4b tokenizer dir missing: {}",
tokenizer.display()
)));
}
Ok(Flux2KleinPaths {
transformer,
text_encoder,
tokenizer,
vae,
})
}
// --- FLUX.2-dev text-to-image -----------------------------------------------
pub const FLUX2_DEV_DEFAULT_STEPS: usize = 20;
pub const FLUX2_DEV_DEFAULT_SIZE: u32 = 1024;
pub const FLUX2_DEV_DEFAULT_GUIDANCE: f32 = 4.0;
pub struct Flux2DevPaths {
/// `flux2_dev_fp8mixed.safetensors` (Comfy fp8mixed single file).
pub transformer: PathBuf,
/// `mistral_3_small_flux2_fp8.safetensors` (Comfy pruned fp8 TE).
pub text_encoder: PathBuf,
/// HF `tokenizer/` dir (tokenizer.json, Tekken).
pub tokenizer: PathBuf,
/// `flux2-vae.safetensors`.
pub vae: PathBuf,
}
pub struct Flux2DevPipeline {
pub paths: Flux2DevPaths,
pub transformer: Flux2TransformerWeights,
pub text_encoder: Flux2WeightFile,
pub text_prepared: Flux2DevTextPrepared,
pub tokenizer: Flux2Tokenizer,
pub vae: Flux2VaeWeights,
/// Conditioning cache: prompt -> (padded 512x15360 embeds, token ids).
/// Mirrors the reference server's node cache — a warm generate with an
/// unchanged prompt never re-runs the TE.
cached_prompt: Option<(String, Vec<f32>, Vec<u32>)>,
}
#[derive(Clone, Debug)]
pub struct Flux2GenerateRequest {
pub prompt: String,
pub width: u32,
pub height: u32,
pub steps: usize,
pub guidance: f32,
pub seed: u64,
/// Optional teacher noise (token-major `[gen_tokens, 128]`) from an
/// oracle dump (the oracle's step-0 x IS the noise at sigma 1.0).
pub noise: Option<Vec<f32>>,
/// Optional oracle conditioning, ALREADY zero-left-padded `[512, 15360]`.
pub teacher_embeds: Option<Vec<f32>>,
/// Optional teacher forcing: the oracle's latent `x` at EVERY step
/// (token-major `[gen_tokens, 128]`, `len() == steps`). When set, step
/// `i` runs the transformer on the oracle's `x_i` instead of the
/// natively integrated sample, and `step_predictions` carries all
/// steps — the per-step parity metric that is not polluted by the
/// trajectory's chaotic amplification of fp8/bf16 ulps.
pub teacher_steps: Option<Vec<Vec<f32>>>,
/// Optional img2img init (see [`Flux2Img2Img`]).
pub init: Option<Flux2Img2Img>,
}
#[derive(Clone, Debug)]
pub struct Flux2GenerateResult {
pub image: Flux2VaeImage,
pub png: Vec<u8>,
pub packed_latents: Flux2PackedLatents,
pub input_ids: Vec<u32>,
/// The DiT-side conditioning (512 rows, zero-left-padded).
pub prompt_embeds: Vec<f32>,
/// Predictions captured at step 0 and the final step (oracle gates).
pub step_predictions: Vec<(usize, Vec<f32>)>,
pub sigmas: Vec<f32>,
pub te_ms: f64,
pub denoise_ms: f64,
pub decode_ms: f64,
pub png_ms: f64,
pub total_ms: f64,
}
/// Zero-LEFT-pad `(seq, width)` conditioning rows to `(target, width)` —
/// comfy `Flux2.extra_conds`: pad rows FIRST, real conditioning last.
pub fn flux2_dev_pad_conditioning(
conditioning: &[f32],
seq: usize,
width: usize,
target: usize,
) -> Result<Vec<f32>> {
if conditioning.len() != seq * width {
return Err(DiffusionError::workflow(format!(
"flux2 dev conditioning expected {} values, got {}",
seq * width,
conditioning.len()
)));
}
if seq > target {
return Err(DiffusionError::workflow(format!(
"flux2 dev conditioning {seq} rows exceeds the {target} window"
)));
}
let mut out = vec![0.0f32; target * width];
out[(target - seq) * width..].copy_from_slice(conditioning);
Ok(out)
}
impl Flux2DevPipeline {
pub fn load(paths: Flux2DevPaths) -> Result<Self> {
if !gpu_device_available() {
return Err(DiffusionError::workflow(
"flux2-dev requires CUDA (MAKEPAD_GGML_REQUIRE_CUDA=1)",
));
}
let tokenizer = Flux2Tokenizer::load(&paths.tokenizer)?;
let text_encoder = Flux2WeightFile::load(&paths.text_encoder)?;
let text_prepared =
Flux2DevTextPrepared::prepare(&text_encoder, Mistral3TextConfig::flux2_dev())?;
let vae = Flux2VaeWeights::load(&paths.vae)?;
let transformer = Flux2TransformerWeights::load(&paths.transformer)?;
if !transformer.config.guidance_embed {
return Err(DiffusionError::model(
"flux2-dev loader received a guidance_embed=false transformer",
));
}
Ok(Self {
paths,
transformer,
text_encoder,
text_prepared,
tokenizer,
vae,
cached_prompt: None,
})
}
pub fn generate(&mut self, request: &Flux2GenerateRequest) -> Result<Flux2GenerateResult> {
self.generate_with_hooks(request, None)
}
pub fn generate_with_hooks(
&mut self,
request: &Flux2GenerateRequest,
mut on_stage: Option<&mut dyn FnMut(&str, usize, usize)>,
) -> Result<Flux2GenerateResult> {
if request.width % 16 != 0 || request.height % 16 != 0 {
return Err(DiffusionError::workflow(format!(
"flux2 dev size must be a multiple of 16, got {}x{}",
request.width, request.height
)));
}
if request.steps == 0 {
return Err(DiffusionError::workflow("flux2 dev needs at least 1 step"));
}
let total_started = std::time::Instant::now();
let width = FLUX2_MAX_SEQUENCE_LENGTH;
let cond_width = Mistral3TextConfig::flux2_dev().conditioning_dim() as usize;
let (prompt_embeds, input_ids) = if let Some(embeds) = &request.teacher_embeds {
if embeds.len() != width * cond_width {
return Err(DiffusionError::workflow(format!(
"flux2 dev teacher embeds expected {} values, got {}",
width * cond_width,
embeds.len()
)));
}
(embeds.clone(), Vec::new())
} else if let Some((prompt, embeds, ids)) = self
.cached_prompt
.as_ref()
.filter(|(prompt, _, _)| *prompt == request.prompt)
{
let _ = prompt;
(embeds.clone(), ids.clone())
} else {
let ids = self
.tokenizer
.encode_t2i_unpadded(FLUX2_SYSTEM_MESSAGE, &request.prompt);
let mut te_hook = on_stage
.as_deref_mut()
.map(|hook| move |done: usize, total: usize| hook("text-encode", done, total));
let (conditioning, _) = flux2_dev_text_encode(
&self.text_encoder,
&self.text_prepared,
&ids,
te_hook
.as_mut()
.map(|hook| hook as &mut dyn FnMut(usize, usize)),
false,
)?;
drop(te_hook);
let padded = flux2_dev_pad_conditioning(&conditioning, ids.len(), cond_width, width)?;
self.cached_prompt = Some((request.prompt.clone(), padded.clone(), ids.clone()));
(padded, ids)
};
let te_ms = total_started.elapsed().as_secs_f64() * 1000.0;
let packed_w = (request.width / 16) as usize;
let packed_h = (request.height / 16) as usize;
let gen_tokens = packed_w * packed_h;
let channels = 128usize;
let mut sample = match &request.noise {
Some(noise) => {
if noise.len() != gen_tokens * channels {
return Err(DiffusionError::workflow(format!(
"flux2 dev noise expected {} values, got {}",
gen_tokens * channels,
noise.len()
)));
}
noise.clone()
}
None => splitmix_noise(request.seed, gen_tokens * channels),
};
let gen_ids = flux2_image_ids(packed_w, packed_h, 0);
let txt_ids = flux2_text_ids(width);
let sigmas = flux2_schedule(request.steps, gen_tokens)?;
let start_step = match &request.init {
Some(_) if request.teacher_steps.is_some() => {
return Err(DiffusionError::workflow(
"flux2 dev: img2img init and teacher_steps are mutually exclusive",
))
}
Some(init) => {
let (start, mixed) =
flux2_img2img_start(&self.vae, init, sample, &sigmas, request.steps, packed_w, packed_h)?;
sample = mixed;
start
}
None => 0,
};
let prof = std::env::var_os("MAKEPAD_GPU_PROF").is_some();
if prof {
let _ = makepad_ai_common::backend::prof::report_and_reset("");
}
let denoise_started = std::time::Instant::now();
let mut step_predictions = Vec::new();
if let Some(teacher) = &request.teacher_steps {
if teacher.len() != request.steps {
return Err(DiffusionError::workflow(format!(
"flux2 dev teacher_steps has {} entries for {} steps",
teacher.len(),
request.steps
)));
}
}
for step in start_step..request.steps {
let step_started = std::time::Instant::now();
if let Some(hook) = on_stage.as_deref_mut() {
hook("denoise", step + 1, request.steps);
}
if let Some(teacher) = &request.teacher_steps {
if teacher[step].len() != sample.len() {
return Err(DiffusionError::workflow(format!(
"flux2 dev teacher_steps[{step}] has {} values, expected {}",
teacher[step].len(),
sample.len()
)));
}
sample.copy_from_slice(&teacher[step]);
}
let run = flux2_transformer_forward(
&self.transformer,
&sample,
&gen_ids,
&prompt_embeds,
&txt_ids,
sigmas[step],
Some(request.guidance),
gen_tokens,
)?;
if step == start_step || step + 1 == request.steps || request.teacher_steps.is_some() {
step_predictions.push((step, run.prediction.clone()));
}
flux2_euler_step(&mut sample, &run.prediction, sigmas[step], sigmas[step + 1])?;
if prof {
eprintln!(
"flux2dev prof step{step} ms={:.1}",
step_started.elapsed().as_secs_f64() * 1000.0
);
}
}
let denoise_ms = denoise_started.elapsed().as_secs_f64() * 1000.0;
if prof {
eprint!(
"{}",
makepad_ai_common::backend::prof::report_and_reset("flux2dev prof denoise ")
);
flux2_dev_prof_mem("after denoise");
}
flux2_dit_clear_pool();
// Decode transients (~2-3GB at 1024px) plus the resident DiT sit at
// the 32GB WDDM cliff; the ring slots are the cheapest headroom —
// freed here, re-primed on the next forward.
let _ = crate::backend::gpu_stream_ring_release_slots();
if prof {
flux2_dev_prof_mem("before decode (pool cleared, ring slots released)");
}
let packed = Flux2PackedLatents::from_tokens(&sample, packed_w, packed_h, channels)?;
let decode_started = std::time::Instant::now();
if let Some(hook) = on_stage.as_deref_mut() {
hook("decode", 0, 1);
}
let image = flux2_vae_decode(&self.vae, &packed)?;
let decode_ms = decode_started.elapsed().as_secs_f64() * 1000.0;
if prof {
eprintln!("flux2dev prof vae_decode ms={decode_ms:.1}");
flux2_dev_prof_mem("after decode");
eprint!(
"{}",
makepad_ai_common::backend::prof::report_and_reset("flux2dev prof vae ")
);
}
let png_started = std::time::Instant::now();
let rgb = flux2_image_to_rgb_u8(&image);
let png = encode_png_rgb(
&planar_rgb_to_whcb(&rgb, image.width, image.height),
image.width,
image.height,
)?;
let png_ms = png_started.elapsed().as_secs_f64() * 1000.0;
Ok(Flux2GenerateResult {
image,
png,
packed_latents: packed,
input_ids,
prompt_embeds,
step_predictions,
sigmas,
te_ms,
denoise_ms,
decode_ms,
png_ms,
total_ms: total_started.elapsed().as_secs_f64() * 1000.0,
})
}
}
impl Flux2DevPipeline {
/// Instruction edit: `request.prompt` + reference images → edited image.
/// Same mechanism as Klein (`flux2_concat_ref_tokens`: reference latents
/// packed on T-axis offsets 10, 20, …, the DiT predicts only the first
/// `gen_tokens`), with dev's Mistral conditioning and guidance embed.
/// `guidance` = the distilled guidance value (dev default 4.0).
pub fn edit_with_hooks(
&mut self,
request: &Flux2EditRequest,
guidance: f32,
mut on_stage: Option<&mut dyn FnMut(&str, usize, usize)>,
) -> Result<Flux2EditResult> {
if request.width % 16 != 0 || request.height % 16 != 0 {
return Err(DiffusionError::workflow(format!(
"flux2 dev edit size must be a multiple of 16, got {}x{}",
request.width, request.height
)));
}
if request.steps == 0 {
return Err(DiffusionError::workflow("flux2 dev edit needs at least 1 step"));
}
if request.references.is_empty() && request.teacher_ref_tokens.is_none() {
return Err(DiffusionError::workflow(
"flux2 dev edit requires at least one reference image",
));
}
let total_started = std::time::Instant::now();
// Conditioning: identical to generate (system message + instruction,
// zero-left-padded to the 512 window), served from the prompt cache.
let width = FLUX2_MAX_SEQUENCE_LENGTH;
let cond_width = Mistral3TextConfig::flux2_dev().conditioning_dim() as usize;
let (prompt_embeds, input_ids) = if let Some(embeds) = &request.teacher_embeds {
if embeds.len() != width * cond_width {
return Err(DiffusionError::workflow(format!(
"flux2 dev teacher embeds expected {} values, got {}",
width * cond_width,
embeds.len()
)));
}
(embeds.clone(), Vec::new())
} else if let Some((_, embeds, ids)) = self
.cached_prompt
.as_ref()
.filter(|(prompt, _, _)| *prompt == request.prompt)
{
(embeds.clone(), ids.clone())
} else {
let ids = self
.tokenizer
.encode_t2i_unpadded(FLUX2_SYSTEM_MESSAGE, &request.prompt);
let mut te_hook = on_stage
.as_deref_mut()
.map(|hook| move |done: usize, total: usize| hook("text-encode", done, total));
let (conditioning, _) = flux2_dev_text_encode(
&self.text_encoder,
&self.text_prepared,
&ids,
te_hook
.as_mut()
.map(|hook| hook as &mut dyn FnMut(usize, usize)),
false,
)?;
drop(te_hook);
let padded = flux2_dev_pad_conditioning(&conditioning, ids.len(), cond_width, width)?;
self.cached_prompt = Some((request.prompt.clone(), padded.clone(), ids.clone()));
(padded, ids)
};
let te_ms = total_started.elapsed().as_secs_f64() * 1000.0;
// Reference images → packed latents on successive T offsets.
let encode_started = std::time::Instant::now();
if let Some(hook) = on_stage.as_deref_mut() {
hook("encode-refs", 0, request.references.len().max(1));
}
let mut ref_packed = Vec::new();
if let Some(tokens) = &request.teacher_ref_tokens {
let packed_w = (request.width / 16) as usize;
let packed_h = (request.height / 16) as usize;
let packed = Flux2PackedLatents::from_tokens(tokens, packed_w, packed_h, 128)?;
let ids = flux2_image_ids(packed.width, packed.height, flux2_ref_time_offset(0));
ref_packed.push((tokens.clone(), ids, packed));
} else {
for (index, image) in request.references.iter().enumerate() {
let packed = flux2_vae_encode(&self.vae, image)?;
let tokens = packed.to_tokens();
let ids = flux2_image_ids(packed.width, packed.height, flux2_ref_time_offset(index));
ref_packed.push((tokens, ids, packed));
if let Some(hook) = on_stage.as_deref_mut() {
hook("encode-refs", index + 1, request.references.len());
}
}
}
let encode_ms = encode_started.elapsed().as_secs_f64() * 1000.0;
let packed_w = (request.width / 16) as usize;
let packed_h = (request.height / 16) as usize;
let gen_tokens = packed_w * packed_h;
let channels = 128usize;
let mut sample = match &request.noise {
Some(noise) => {
if noise.len() != gen_tokens * channels {
return Err(DiffusionError::workflow(format!(
"flux2 dev edit noise expected {} values, got {}",
gen_tokens * channels,
noise.len()
)));
}
noise.clone()
}
None => splitmix_noise(request.seed, gen_tokens * channels),
};
let gen_ids = flux2_image_ids(packed_w, packed_h, 0);
let refs: Vec<(Vec<f32>, Vec<Flux2PosId>)> = ref_packed
.iter()
.map(|(tokens, ids, _)| (tokens.clone(), ids.clone()))
.collect();
let txt_ids = flux2_text_ids(width);
let sigmas = flux2_schedule(request.steps, gen_tokens)?;
let start_step = match &request.init {
Some(init) => {
let (start, mixed) =
flux2_img2img_start(&self.vae, init, sample, &sigmas, request.steps, packed_w, packed_h)?;
sample = mixed;
start
}
None => 0,
};
let denoise_started = std::time::Instant::now();
let mut step_residuals = Vec::new();
for step in start_step..request.steps {
if let Some(hook) = on_stage.as_deref_mut() {
hook("denoise", step + 1, request.steps);
}
let (img_tokens, img_ids) = flux2_concat_ref_tokens(&sample, &gen_ids, &refs);
let run = flux2_transformer_forward(
&self.transformer,
&img_tokens,
&img_ids,
&prompt_embeds,
&txt_ids,
sigmas[step],
Some(guidance),
gen_tokens,
)?;
if step == start_step || step + 1 == request.steps {
step_residuals.push(run.prediction.clone());
}
flux2_euler_step(&mut sample, &run.prediction, sigmas[step], sigmas[step + 1])?;
}
let warm_ms = denoise_started.elapsed().as_secs_f64() * 1000.0;
flux2_dit_clear_pool();
let _ = crate::backend::gpu_stream_ring_release_slots();
let packed = Flux2PackedLatents::from_tokens(&sample, packed_w, packed_h, channels)?;
let decode_started = std::time::Instant::now();
if let Some(hook) = on_stage.as_deref_mut() {
hook("decode", 0, 1);
}
let image = flux2_vae_decode(&self.vae, &packed)?;
let decode_ms = decode_started.elapsed().as_secs_f64() * 1000.0;
let png_started = std::time::Instant::now();
let rgb = flux2_image_to_rgb_u8(&image);
let png = encode_png_rgb(
&planar_rgb_to_whcb(&rgb, image.width, image.height),
image.width,
image.height,
)?;
let png_ms = png_started.elapsed().as_secs_f64() * 1000.0;
Ok(Flux2EditResult {
image,
png,
packed_latents: packed,
ref_packed: ref_packed[0].2.clone(),
input_ids,
real_len: 0,
prompt_embeds,
step_residuals,
sigmas,
warm_ms,
te_ms,
encode_ms,
decode_ms,
png_ms,
total_ms: total_started.elapsed().as_secs_f64() * 1000.0,
})
}
}
/// `MAKEPAD_GPU_PROF` line: live device memory + weight-cache/pool counters
/// (reset on each call) — the 32GB-card decode phase lives at the WDDM
/// residency cliff, so "where the bytes are" is the first question.
fn flux2_dev_prof_mem(label: &str) {
let stats = crate::backend::gpu_perf_stats(true);
eprintln!(
"flux2dev prof mem {label}: free={:.0}MB total={:.0}MB weight_stream={} ({:.0}MB) \
weight_evict_events={} pool_fresh_alloc={} ({:.0}MB) pool_oom_clears={} \
pool_overcap_free={:.0}MB",
stats.mem_free_bytes as f64 / (1024.0 * 1024.0),
stats.mem_total_bytes as f64 / (1024.0 * 1024.0),
stats.weight_stream_count,
stats.weight_stream_bytes as f64 / (1024.0 * 1024.0),
stats.weight_evict_events,
stats.pool_fresh_alloc_count,
stats.pool_fresh_alloc_bytes as f64 / (1024.0 * 1024.0),
stats.pool_oom_clears,
stats.pool_overcap_free_bytes as f64 / (1024.0 * 1024.0),
);
}
/// Resolve the dev weight layout under one root: the Comfy fp8mixed DiT
/// (`flux2_dev_fp8mixed.safetensors`) or, for a quantized tier, the single
/// `*.gguf` DiT in the root (city96 `flux2-dev-Q4_K_M.gguf` & co.), plus
/// the TE / VAE / tokenizer. Those three are identical across every dev tier
/// (the tiers differ only in the DiT), so a tier root that lacks them falls
/// back to the sibling canonical `flux2-dev/` dir — the registry lists them
/// for each tier with the same `cache_as`, so the 18 GB TE is downloaded
/// once and shared.
pub fn flux2_dev_paths_from_root(root: impl AsRef<Path>) -> Result<Flux2DevPaths> {
let root = root.as_ref();
let canonical = root
.parent()
.map(|parent| parent.join("flux2-dev"))
.filter(|dir| dir != root && dir.is_dir());
let shared = |name: &str| -> PathBuf {
let own = root.join(name);
if own.exists() {
return own;
}
match &canonical {
Some(dir) if dir.join(name).exists() => dir.join(name),
_ => own,
}
};
let fp8 = root.join("flux2_dev_fp8mixed.safetensors");
let transformer = if fp8.is_file() {
fp8
} else {
let mut ggufs: Vec<PathBuf> = std::fs::read_dir(root)
.map_err(|err| DiffusionError::io(root, err.to_string()))?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|p| {
p.is_file()
&& p.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
})
.collect();
ggufs.sort();
match ggufs.len() {
0 => {
return Err(DiffusionError::workflow(format!(
"flux2-dev transformer missing: neither {} nor a *.gguf under {}",
fp8.display(),
root.display()
)))
}
1 => ggufs.remove(0),
n => {
return Err(DiffusionError::workflow(format!(
"flux2-dev root {} holds {n} *.gguf files; one DiT per tier root",
root.display()
)))
}
}
};
let text_encoder = shared("mistral_3_small_flux2_fp8.safetensors");
let tokenizer = shared("tokenizer");
let vae = ["flux2-vae.safetensors", "vae/diffusion_pytorch_model.safetensors"]
.into_iter()
.map(shared)
.find(|p| p.exists())
.ok_or_else(|| {
DiffusionError::workflow(format!("flux2-dev vae not found under {}", root.display()))
})?;
if !text_encoder.is_file() {
return Err(DiffusionError::workflow(format!(
"flux2-dev text_encoder missing: {}",
text_encoder.display()
)));
}
if !tokenizer.is_dir() {
return Err(DiffusionError::workflow(format!(
"flux2-dev tokenizer dir missing: {}",
tokenizer.display()
)));
}
Ok(Flux2DevPaths {
transformer,
text_encoder,
tokenizer,
vae,
})
}
pub fn flux2_require_cuda() -> Result<()> {
if gpu_device_available() {
Ok(())
} else {
Err(DiffusionError::workflow(
"flux2-klein-4b is CUDA-only; refusing CPU/Metal fallback",
))
}
}