Skip to content

Commit 09c4c59

Browse files
committed
Onboard Flux.2-klein-4B multi-host TPU pod support (TP=4, DP=2, B=16)
1 parent ea2603b commit 09c4c59

5 files changed

Lines changed: 886 additions & 68 deletions

File tree

src/maxdiffusion/configs/base_flux2klein.yml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,6 @@ width: 1024
270270
batch_size: 4
271271
interactive: False
272272

273-
# 4B Architecture Dimensions
274-
depth: 20 # num_single_layers
275-
num_double_layers: 5
276-
hidden_size: 3072
277-
num_attention_heads: 24
273+
# Note: Architecture dimensions (depth, num_double_layers, num_attention_heads) are
274+
# automatically inferred from pretrained_model_name_or_path (transformer/config.json).
278275

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import gc
16+
import os
17+
import time
18+
from typing import List, Union
19+
from PIL import Image
20+
21+
import jax
22+
import jax.numpy as jnp
23+
import numpy as np
24+
from flax.linen import partitioning as nn_partitioning
25+
26+
from ..pipeline_flax_utils import FlaxDiffusionPipeline
27+
from .. import max_logging
28+
from ...models.flux.transformers.transformer_flux_flax import FluxTransformer2DModel
29+
from ...models.vae_flax import FlaxAutoencoderKL
30+
from ...models.qwen3_flax import FlaxQwen3Model
31+
from ...schedulers.scheduling_flow_match_flax import FlaxFlowMatchScheduler, compute_empirical_mu
32+
33+
from ...models.flux.util import (
34+
pack_latents,
35+
unpack_latents,
36+
prepare_latent_image_ids,
37+
prepare_text_ids,
38+
)
39+
40+
41+
class FlaxFlux2KleinPipeline(FlaxDiffusionPipeline):
42+
"""
43+
Unified end-to-end inference pipeline for Flux.2-klein-4B and 9B models on JAX+TPU.
44+
Supports dynamic parameter offloading to Host CPU to optimize HBM footprint.
45+
"""
46+
47+
def __init__(
48+
self,
49+
transformer: FluxTransformer2DModel,
50+
vae: FlaxAutoencoderKL,
51+
text_encoder: FlaxQwen3Model,
52+
tokenizer,
53+
scheduler: FlaxFlowMatchScheduler,
54+
config,
55+
mesh,
56+
**kwargs,
57+
):
58+
super().__init__()
59+
self.register_modules(
60+
transformer=transformer,
61+
vae=vae,
62+
text_encoder=text_encoder,
63+
tokenizer=tokenizer,
64+
scheduler=scheduler,
65+
)
66+
self._config = config
67+
self.mesh = mesh
68+
69+
# JIT compilation cache
70+
self._jitted_qwen3_forward = None
71+
self._jitted_transformer_step = None
72+
self._jitted_vae_decode = None
73+
74+
def _setup_jit_functions(self):
75+
if self._jitted_qwen3_forward is not None:
76+
return
77+
78+
@jax.jit
79+
def qwen3_forward(q_params, ids, mask):
80+
return self.text_encoder.apply({"params": q_params}, input_ids=ids, attention_mask=mask)
81+
82+
@jax.jit
83+
def transformer_step(t_params, latents, img_ids, prompt_embeds, txt_ids, vec, timestep, guidance):
84+
return self.transformer.apply(
85+
{"params": t_params},
86+
hidden_states=latents,
87+
img_ids=img_ids,
88+
encoder_hidden_states=prompt_embeds,
89+
txt_ids=txt_ids,
90+
pooled_projections=vec,
91+
timestep=timestep,
92+
guidance=guidance,
93+
)
94+
95+
@jax.jit
96+
def vae_decode(v_params, latents_unpatched):
97+
return self.vae.apply({"params": v_params}, latents=latents_unpatched, method=self.vae.decode)
98+
99+
self._jitted_qwen3_forward = qwen3_forward
100+
self._jitted_transformer_step = transformer_step
101+
self._jitted_vae_decode = vae_decode
102+
103+
def _prepare_latents(self, config, batch_size, height, width):
104+
num_channels_latents = 32
105+
latent_height = height // 8
106+
latent_width = width // 8
107+
latent_shape = (batch_size, num_channels_latents, latent_height, latent_width)
108+
109+
if config.use_latents:
110+
max_logging.log("use_latents is True. Loading latents from disk...")
111+
bundle_path = "src/maxdiffusion/tests/flux2_klein_complete_diagnostic_bundle.npz"
112+
if not os.path.exists(bundle_path):
113+
raise FileNotFoundError(f"Expected to find {bundle_path} but it was not found.")
114+
115+
bundle = np.load(bundle_path)
116+
if "initial_pipeline_latents" in bundle:
117+
packed_latents = bundle["initial_pipeline_latents"]
118+
elif "step_0_cond_transformer_input_latents" in bundle:
119+
packed_latents = bundle["step_0_cond_transformer_input_latents"]
120+
else:
121+
raise KeyError(
122+
f"Neither 'initial_pipeline_latents' nor 'step_0_cond_transformer_input_latents' was found in {bundle_path}"
123+
)
124+
125+
max_logging.log(f"Successfully loaded initial latents with shape: {packed_latents.shape}")
126+
if packed_latents.shape[0] != batch_size:
127+
packed_latents = np.repeat(packed_latents, batch_size, axis=0)
128+
latents = unpack_latents(packed_latents, batch_size, num_channels_latents, height, width)
129+
else:
130+
max_logging.log(f"use_latents is False. Generating random gaussian noise with shape: {latent_shape}...")
131+
np.random.seed(42) # Fixed seed for parity consistency
132+
latents = np.random.randn(*latent_shape).astype(np.float32)
133+
134+
return latents
135+
136+
def __call__(
137+
self,
138+
prompt: Union[str, List[str]],
139+
params,
140+
vae_params,
141+
qwen3_params,
142+
vae_bn_mean,
143+
vae_bn_std,
144+
transformer_shardings,
145+
vae_shardings,
146+
qwen3_shardings,
147+
height: int = 1024,
148+
width: int = 1024,
149+
num_inference_steps: int = 4,
150+
batch_size: int = 1,
151+
use_latents: bool = False,
152+
offload_encoders: bool = False,
153+
measure_time: bool = False,
154+
output_dir: str = "output/",
155+
output_name: str = "flux2klein_generated_image.png",
156+
):
157+
# 1. Setup JIT functions
158+
self._setup_jit_functions()
159+
160+
# 2. Setup prompts and inputs
161+
if isinstance(prompt, str):
162+
prompts = [prompt] * batch_size
163+
else:
164+
prompts = prompt
165+
166+
seq_len_img = (height // 16) * (width // 16)
167+
seq_len_txt = self._config.max_sequence_length
168+
169+
# Load or generate latents
170+
latents_numpy = self._prepare_latents(self._config, batch_size, height, width)
171+
latents_jax = jnp.array(latents_numpy)
172+
latents_jax = pack_latents(latents_jax)
173+
174+
# RoPE position IDs
175+
txt_ids_val = prepare_text_ids(batch_size, seq_len_txt)
176+
img_ids_val = prepare_latent_image_ids(batch_size, height // 16, width // 16)
177+
178+
# Scheduler
179+
mu = compute_empirical_mu(seq_len_img, num_inference_steps)
180+
scheduler_state = self.scheduler.create_state()
181+
explicit_sigmas = jnp.linspace(1.0, 0.25, num_inference_steps)
182+
scheduler_state = self.scheduler.set_timesteps_ltx2(
183+
state=scheduler_state,
184+
num_inference_steps=num_inference_steps,
185+
shift=mu,
186+
sigmas=explicit_sigmas,
187+
)
188+
189+
trace = {}
190+
191+
# ---------------------------------------------------------------------
192+
# PHASE A: Encode Prompt (Qwen3)
193+
# ---------------------------------------------------------------------
194+
max_logging.log(f"[PHASE A] Encoding {len(prompts)} prompt(s) using JAX Qwen3 on TPU...")
195+
t0 = time.perf_counter()
196+
197+
from huggingface_hub import snapshot_download
198+
199+
tokenizer_repo = getattr(self._config, "tokenizer_model_name_or_path", None)
200+
if not tokenizer_repo:
201+
tokenizer_repo = self._config.pretrained_model_name_or_path
202+
203+
tokenizer_path = snapshot_download(repo_id=tokenizer_repo)
204+
205+
from transformers import Qwen2TokenizerFast
206+
207+
try:
208+
tokenizer = Qwen2TokenizerFast.from_pretrained(tokenizer_path)
209+
except Exception:
210+
tokenizer = Qwen2TokenizerFast.from_pretrained(tokenizer_path, subfolder="tokenizer")
211+
212+
# Tokenize
213+
messages = [{"role": "user", "content": p} for p in prompts]
214+
# In batch execution, we format and tokenize prompts
215+
templated_texts = [
216+
tokenizer.apply_chat_template([msg], tokenize=False, add_generation_prompt=True, enable_thinking=False)
217+
for msg in messages
218+
]
219+
inputs = tokenizer(templated_texts, return_tensors="np", padding="max_length", truncation=True, max_length=seq_len_txt)
220+
prompt_ids = jnp.array(inputs["input_ids"])
221+
prompt_mask = jnp.array(inputs["attention_mask"])
222+
223+
# Dynamically move Qwen3 parameters to TPU
224+
if offload_encoders:
225+
max_logging.log(" Moving Qwen3 parameters to TPU HBM...")
226+
qwen3_params_tpu = jax.device_put(qwen3_params, qwen3_shardings)
227+
else:
228+
qwen3_params_tpu = qwen3_params
229+
230+
# Run Text Encoding
231+
hidden_states, all_hidden_states = self._jitted_qwen3_forward(qwen3_params_tpu, prompt_ids, prompt_mask)
232+
233+
# Stack layers 9, 18, 27 to form prompt embeddings
234+
h_9 = all_hidden_states[9]
235+
h_18 = all_hidden_states[18]
236+
h_27 = all_hidden_states[27]
237+
out = jnp.stack([h_9, h_18, h_27], axis=1)
238+
# Transpose shape to [B, seq_len, 3*hidden_size]
239+
prompt_embeds_jax = jnp.transpose(out, (0, 2, 1, 3)).reshape((batch_size, seq_len_txt, -1))
240+
prompt_embeds_jax.block_until_ready()
241+
242+
trace["prompt_encoding"] = time.perf_counter() - t0
243+
max_logging.log(f" -> [TIMING] Prompt Encoding (Qwen3): {trace['prompt_encoding']:.4f} seconds ⏱️")
244+
245+
if offload_encoders:
246+
max_logging.log(" Releasing Qwen3 parameters from TPU HBM...")
247+
del qwen3_params_tpu
248+
gc.collect()
249+
250+
# ---------------------------------------------------------------------
251+
# PHASE B: Denoising Loop (Flux Transformer)
252+
# ---------------------------------------------------------------------
253+
max_logging.log(f"[PHASE B] Running {num_inference_steps}-step E2E Denoising Loop on a batch of {batch_size} images...")
254+
t0 = time.perf_counter()
255+
256+
# Dynamically move Flux Transformer parameters to TPU
257+
if offload_encoders:
258+
max_logging.log(" Moving Flux Transformer parameters to TPU HBM...")
259+
params_tpu = jax.device_put(params, transformer_shardings)
260+
else:
261+
params_tpu = params
262+
263+
guidance_vec_val = jnp.array([self._config.guidance_scale] * batch_size)
264+
vec_val = jnp.zeros((batch_size, 768))
265+
266+
with self.mesh, nn_partitioning.axis_rules(self._config.logical_axis_rules):
267+
for step_idx in range(num_inference_steps):
268+
timestep = scheduler_state.timesteps[step_idx]
269+
t_vec = jnp.array([timestep] * batch_size)
270+
271+
# Execute transformer forward pass step
272+
model_output = self._jitted_transformer_step(
273+
params_tpu, latents_jax, img_ids_val, prompt_embeds_jax, txt_ids_val, vec_val, t_vec, guidance_vec_val
274+
)
275+
276+
# Update latents using FlowMatch step
277+
latents_jax = self.scheduler.step(
278+
state=scheduler_state,
279+
model_output=model_output.sample,
280+
timestep=scheduler_state.timesteps[step_idx],
281+
sample=latents_jax,
282+
).prev_sample
283+
284+
# Print progress
285+
sigma_val = scheduler_state.sigmas[step_idx]
286+
max_logging.log(f" -> Step {step_idx}: Timestep = {scheduler_state.timesteps[step_idx]:.4f}, Sigma = {sigma_val:.4f}")
287+
288+
latents_jax.block_until_ready()
289+
290+
trace["denoise_loop"] = time.perf_counter() - t0
291+
max_logging.log(f" -> [TIMING] Denoising Loop (Flux): {trace['denoise_loop']:.4f} seconds ⏱️")
292+
293+
if offload_encoders:
294+
max_logging.log(" Releasing Flux Transformer parameters from TPU HBM...")
295+
del params_tpu
296+
gc.collect()
297+
298+
# ---------------------------------------------------------------------
299+
# PHASE C: Decode Latents (VAE Decoder)
300+
# ---------------------------------------------------------------------
301+
max_logging.log("[PHASE C] Decoding final latents to RGB image using JAX VAE decoder on TPU...")
302+
t0 = time.perf_counter()
303+
304+
# Apply Channel-wise Batch Normalization Scaling in packed sequence format (denormalize)
305+
vae_bn_mean_seq = vae_bn_mean.reshape(1, 1, 128)
306+
vae_bn_std_seq = vae_bn_std.reshape(1, 1, 128)
307+
latents_bn = latents_jax * vae_bn_std_seq + vae_bn_mean_seq
308+
309+
# Unpack packed latents back to spatial grid
310+
latents_unpacked = unpack_latents(latents_bn, batch_size, 32, height, width)
311+
312+
# Dynamically move VAE parameters to TPU
313+
if offload_encoders:
314+
max_logging.log(" Moving VAE parameters to TPU HBM...")
315+
vae_params_tpu = jax.device_put(vae_params, vae_shardings)
316+
else:
317+
vae_params_tpu = vae_params
318+
319+
# Decode VAE latents to RGB pixels
320+
decoded_out = self._jitted_vae_decode(vae_params_tpu, latents_unpacked)
321+
# VAE output is in decoded_out.sample
322+
images_rgb = decoded_out.sample
323+
images_rgb.block_until_ready()
324+
325+
trace["vae_decode"] = time.perf_counter() - t0
326+
max_logging.log(f" -> [TIMING] VAE Decoding: {trace['vae_decode']:.4f} seconds ⏱️")
327+
328+
if offload_encoders:
329+
max_logging.log(" Releasing VAE parameters from TPU HBM...")
330+
del vae_params_tpu
331+
gc.collect()
332+
333+
# ---------------------------------------------------------------------
334+
# POST-PROCESS: Format and Save Outputs
335+
# ---------------------------------------------------------------------
336+
max_logging.log("Postprocessing and saving generated images...")
337+
saved_paths = []
338+
# Clamp pixels and scale to [0, 255]
339+
images_rgb = jnp.clip((images_rgb + 1.0) / 2.0, 0.0, 1.0)
340+
images_numpy = np.array(images_rgb)
341+
342+
for b_idx in range(batch_size):
343+
image_np = np.array(images_numpy[b_idx] * 255.0, dtype=np.uint8)
344+
# Transpose channel dimension if shape is (C, H, W) instead of (H, W, C)
345+
if image_np.shape[0] == 3:
346+
image_np = image_np.transpose(1, 2, 0)
347+
348+
img = Image.fromarray(image_np)
349+
350+
# Formulate output filename for this batch index
351+
if batch_size > 1:
352+
batch_output_name = output_name.replace(".png", f"_b{b_idx}.png")
353+
else:
354+
batch_output_name = output_name
355+
356+
output_png_path = os.path.join(output_dir, batch_output_name)
357+
img.save(output_png_path)
358+
max_logging.log(f" -> Saved image: {output_png_path} | Prompt: '{prompts[b_idx]}'")
359+
saved_paths.append(output_png_path)
360+
361+
return saved_paths, trace

0 commit comments

Comments
 (0)