Skip to content

Commit 5ebda76

Browse files
FLUX.2: wire e2e pipeline into benchmark CI + 4-chip nightly
Mirrors the image-gen e2e pattern (#5044 Playground, #5244 SDXL-Lightning, #5291 Janus-Pro), extended for the first multichip (tensor-parallel) image-gen model. FLUX.2 runs on 4 Blackhole chips: ~24B Mistral3 text encoder + ~32B Flux2 transformer SPMD-sharded (model-parallel degree 4), VAE replicated. Benchmark CI: - imagegen_benchmark.py: make per-component perf reporting component-set agnostic (a model reports only the components it runs; FLUX.2 has a single text encoder, so te2 is omitted), with an optional step label and mesh_shape reporting. SDXL/Playground output is unchanged (verified on hardware). - benchmarks/flux2_pipeline.py: new self-contained multichip pipeline. Sets CONVERT_SHLO_TO_SHARDY=1 before use_spmd() (required so tt-mlir gets shardy annotations), mesh from MESH_SHAPES[num_devices]. - test_imagegen.py: add test_flux2. - perf-bench-matrix.json: flux2 entry on qb2-blackhole (4-chip). Nightly test (tests/torch/models/flux2/test_flux2_pipeline.py): - Add @pytest.mark.qb2_blackhole and make the mesh adaptive via MESH_SHAPES (no-op at 4 chips). - Add a qb2-blackhole standalone-models job (scoped to the flux2 dir) to model-test-lb-blackhole-nightly.json so the test is collected on 4 chips. Validated on a 4-chip qb2 Blackhole: FLUX.2 benchmark passes with a coherent 1024x1024 image; SDXL single-chip benchmark passes unchanged.
1 parent 697c97d commit 5ebda76

6 files changed

Lines changed: 325 additions & 21 deletions

File tree

.github/workflows/perf-bench-matrix.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@
7070
"name": "sdxl_lightning",
7171
"pytest": "tests/benchmark/test_imagegen.py::test_sdxl_lightning"
7272
},
73+
{
74+
"name": "flux2",
75+
"pytest": "tests/benchmark/test_imagegen.py::test_flux2",
76+
"runs-on": "qb2-blackhole"
77+
},
7378
{
7479
"name": "bge_m3_encode",
7580
"pyreq": "transformers==4.57.1 FlagEmbedding",

.github/workflows/test-matrix-presets/model-test-lb-blackhole-nightly.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
{"runs-on": "lb-blackhole", "name": "run_forge_models_lb_blackhole_multichip", "dir": "./tests/runner/test_models.py::test_all_models_torch", "test-mark": "lb-blackhole and tensor_parallel and expected_passing", "forge-models": true},
33
{"runs-on": "lb-blackhole", "name": "run_forge_models_lb_blackhole_multichip", "dir": "./tests/runner/test_models.py::test_all_models_jax", "test-mark": "lb-blackhole and tensor_parallel and expected_passing", "forge-models": true},
44
{"runs-on": "lb-blackhole", "name": "run_forge_models_lb_blackhole_llm_multichip", "dir": "./tests/runner/test_models.py::test_llms_torch", "test-mark": "lb-blackhole and tensor_parallel and expected_passing", "forge-models": true},
5-
{ "runs-on": "lb-blackhole", "name": "run_standalone_models_lb_blackhole_multichip", "dir": "./tests/torch/models", "test-mark": "lb_blackhole" }
5+
{ "runs-on": "lb-blackhole", "name": "run_standalone_models_lb_blackhole_multichip", "dir": "./tests/torch/models", "test-mark": "lb_blackhole" },
6+
{ "runs-on": "qb2-blackhole", "name": "run_standalone_models_qb2_blackhole_flux2", "dir": "./tests/torch/models/flux2", "test-mark": "qb2_blackhole" }
67
]
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# SPDX-FileCopyrightText: (c) 2026 Tenstorrent AI ULC
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""FLUX.2-dev — multichip benchmark-side pipeline for the imagegen harness.
6+
7+
Mirrors the nightly e2e test (tests/torch/models/flux2/test_flux2_pipeline.py):
8+
the diffusers Flux2Pipeline orchestrates, the Mistral3 text encoder and Flux2
9+
transformer are SPMD-sharded (model axis), the VAE is replicated. The text
10+
encoder is evicted before the transformer is placed and the VAE is placed
11+
lazily, so peak DRAM ≈ max(component). Per-component times go into ``self._perf``.
12+
"""
13+
14+
import os
15+
import time
16+
from typing import Optional
17+
18+
import numpy as np
19+
import torch
20+
import torch_xla
21+
import torch_xla.distributed.spmd as xs
22+
import torch_xla.runtime as xr
23+
from diffusers import Flux2Pipeline
24+
from loguru import logger
25+
from torch_xla.distributed.spmd import Mesh
26+
27+
from third_party.tt_forge_models.flux2.pytorch.src.model_utils import (
28+
DTYPE,
29+
GUIDANCE_SCALE,
30+
HEIGHT,
31+
MESH_NAMES,
32+
MESH_SHAPES,
33+
PROMPT,
34+
REPO_ID,
35+
SEED,
36+
WIDTH,
37+
Mistral3TextEncoderWrapper,
38+
shard_text_encoder_specs,
39+
shard_transformer_specs,
40+
tokenize_prompt,
41+
)
42+
43+
44+
class _DeviceDenoiser:
45+
"""Routes the transformer to the TP-sharded model on TT; each call is one
46+
denoise step, timed into ``perf["unet_steps"]``."""
47+
48+
def __init__(self, transformer, mesh, perf):
49+
self._dev = torch_xla.device()
50+
self._perf = perf
51+
self.config = transformer.config
52+
self.dtype = next(transformer.parameters()).dtype
53+
54+
transformer = transformer.to(self._dev)
55+
if hasattr(transformer, "tie_weights"):
56+
transformer.tie_weights()
57+
for tensor, spec in shard_transformer_specs(transformer).items():
58+
xs.mark_sharding(tensor, mesh, spec)
59+
self._compiled = torch.compile(transformer, backend="tt")
60+
61+
def __call__(self, **kwargs):
62+
moved = {
63+
k: (v.to(self._dev) if torch.is_tensor(v) else v) for k, v in kwargs.items()
64+
}
65+
t0 = time.perf_counter()
66+
out = self._compiled(**moved)
67+
torch_xla.sync()
68+
# cpu cast forces a sync — the timer ends after the result lands on host.
69+
if isinstance(out, (tuple, list)):
70+
result = type(out)(o.cpu() if torch.is_tensor(o) else o for o in out)
71+
else:
72+
result = out.cpu()
73+
self._perf["unet_steps"].append(time.perf_counter() - t0)
74+
return result
75+
76+
77+
class _DeviceVAEDecoder:
78+
"""Routes vae.decode() to TT (replicated), placed lazily. Decode time goes
79+
into ``perf["vae"]``; raw pixels ([-1, 1]) are stashed on ``last_pixels`` so
80+
the harness can save them without the pipeline's PIL postprocess."""
81+
82+
def __init__(self, vae, mesh, perf):
83+
self._dev = torch_xla.device()
84+
self._perf = perf
85+
self.config = vae.config
86+
self.dtype = next(vae.parameters()).dtype
87+
self.bn = vae.bn # stays on CPU; pipeline reads it host-side for denorm
88+
self._vae = vae
89+
self._compiled = None
90+
self.last_pixels = None
91+
92+
def decode(self, latents, return_dict=False):
93+
# Lazy device placement: keep the VAE off-device during the denoise loop
94+
# so it does not inflate the denoiser's peak DRAM; place it only now.
95+
if self._compiled is None:
96+
vae = self._vae.to(self._dev)
97+
self._compiled = torch.compile(
98+
lambda z: vae.decode(z, return_dict=False)[0], backend="tt"
99+
)
100+
t0 = time.perf_counter()
101+
out = self._compiled(latents.to(self._dev))
102+
torch_xla.sync()
103+
image = out.cpu() if torch.is_tensor(out) else out
104+
self._perf["vae"] = time.perf_counter() - t0
105+
self.last_pixels = image
106+
return (image,)
107+
108+
109+
class Flux2Config:
110+
def __init__(
111+
self,
112+
height: int = HEIGHT,
113+
width: int = WIDTH,
114+
compile_options: Optional[dict] = None,
115+
):
116+
self.height = height
117+
self.width = width
118+
# Forwarded for parity with the other imagegen pipelines; unused inline.
119+
self.compile_options = compile_options or {}
120+
121+
122+
class Flux2Pipeline_TT:
123+
"""Flux2Pipeline with every module on TT, tensor-parallel sharded. Built
124+
once; generate() runs twice (warmup + steady). Raw transformer/VAE are kept
125+
so the TT wrappers can be rebuilt fresh each call."""
126+
127+
def __init__(self, config: Flux2Config):
128+
self.config = config
129+
self._perf = {}
130+
131+
def setup(self):
132+
# CONVERT_SHLO_TO_SHARDY=1 + use_spmd() (mirrors infra.enable_spmd):
133+
# required so tt-mlir gets shardy annotations, else presharded args lose
134+
# their @Sharding custom call and compilation fails.
135+
os.environ["CONVERT_SHLO_TO_SHARDY"] = "1"
136+
xr.use_spmd()
137+
self.num_devices = xr.global_runtime_device_count()
138+
self.mesh_shape = MESH_SHAPES[self.num_devices]
139+
device_ids = np.array(range(self.num_devices))
140+
self.mesh = Mesh(device_ids, self.mesh_shape, MESH_NAMES)
141+
logger.info(
142+
f"FLUX.2 mesh {self.mesh_shape} (names={MESH_NAMES}) "
143+
f"over {self.num_devices} devices"
144+
)
145+
self.pipe = Flux2Pipeline.from_pretrained(REPO_ID, torch_dtype=DTYPE)
146+
# Keep the raw modules so wrappers can be rebuilt on each generate().
147+
self._raw_transformer = self.pipe.transformer
148+
self._raw_vae = self.pipe.vae
149+
150+
def generate(
151+
self,
152+
prompt: str,
153+
num_inference_steps: int,
154+
seed: Optional[int] = SEED,
155+
):
156+
dev = torch_xla.device()
157+
self._perf = {
158+
"te1": None,
159+
"unet_steps": [],
160+
"vae": None,
161+
"total": None,
162+
"step_label": "Transformer",
163+
}
164+
t_total_start = time.perf_counter()
165+
166+
# ── Stage 1: text encoder (sharded, compiled) → prompt embeds, evict ──
167+
logger.info("[STAGE] Text encoder: start")
168+
text_encoder = self.pipe.text_encoder
169+
encoder_wrapper = Mistral3TextEncoderWrapper(text_encoder).eval()
170+
input_ids, attention_mask = tokenize_prompt(prompt)
171+
172+
text_encoder = text_encoder.to(dev)
173+
if hasattr(text_encoder, "tie_weights"):
174+
text_encoder.tie_weights()
175+
te_specs = shard_text_encoder_specs(text_encoder)
176+
assert te_specs, "text-encoder shard spec is empty — descent failed (would OOM)"
177+
for tensor, spec in te_specs.items():
178+
xs.mark_sharding(tensor, self.mesh, spec)
179+
te_compiled = torch.compile(encoder_wrapper, backend="tt")
180+
181+
t0 = time.perf_counter()
182+
with torch.no_grad():
183+
prompt_embeds = te_compiled(input_ids.to(dev), attention_mask.to(dev))
184+
torch_xla.sync()
185+
prompt_embeds = prompt_embeds.cpu()
186+
self._perf["te1"] = time.perf_counter() - t0
187+
188+
# Free the 24B encoder from device before placing the 32B denoiser.
189+
self.pipe.text_encoder = text_encoder.to("cpu")
190+
del te_compiled, encoder_wrapper
191+
import gc
192+
193+
gc.collect()
194+
torch_xla.sync()
195+
logger.info("[STAGE] Text encoder: done")
196+
197+
# ── Stage 2: denoiser (sharded) + VAE (replicated, lazy) → image ─────
198+
logger.info("[STAGE] Transformer + VAE: start")
199+
self.pipe.transformer = _DeviceDenoiser(
200+
self._raw_transformer, self.mesh, self._perf
201+
)
202+
vae_wrapper = _DeviceVAEDecoder(self._raw_vae, self.mesh, self._perf)
203+
self.pipe.vae = vae_wrapper
204+
205+
generator = torch.Generator().manual_seed(seed) if seed is not None else None
206+
self.pipe(
207+
prompt=None,
208+
prompt_embeds=prompt_embeds,
209+
height=self.config.height,
210+
width=self.config.width,
211+
num_inference_steps=num_inference_steps,
212+
guidance_scale=GUIDANCE_SCALE,
213+
generator=generator,
214+
)
215+
logger.info("[STAGE] Transformer + VAE: done")
216+
217+
self._perf["total"] = time.perf_counter() - t_total_start
218+
# Raw VAE pixels in [-1, 1], shape (1, 3, H, W) — the harness's
219+
# save_image() expects this range, so return it instead of the PIL.
220+
return vae_wrapper.last_pixels

tests/benchmark/benchmarks/imagegen_benchmark.py

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,24 @@ def benchmark_imagegen_torch_xla(
120120
total_samples = 1
121121
samples_per_sec = total_samples / steady_state_time
122122

123-
# Per-component forward+sync times from the pipeline's own instrumentation
124-
# (steady-state pass).
123+
# Per-component steady-state times. A model reports only the components it
124+
# runs (e.g. FLUX.2 has a single text encoder, so te2 is absent); missing
125+
# ones are treated as 0 s and dropped from the breakdown + measurements.
125126
perf = pipeline._perf
126-
unet_steps = perf["unet_steps"]
127-
unet_step_mean_s = sum(unet_steps) / len(unet_steps)
128-
tt_components_total = perf["te1"] + perf["te2"] + sum(unet_steps) + perf["vae"]
127+
unet_steps = perf.get("unet_steps") or []
128+
unet_step_mean_s = sum(unet_steps) / len(unet_steps) if unet_steps else 0.0
129+
130+
def _component_s(key):
131+
value = perf.get(key)
132+
return value if value is not None else 0.0
133+
134+
te1_s = _component_s("te1")
135+
te2_s = _component_s("te2")
136+
vae_s = _component_s("vae")
137+
# Per-step label (UNet / Transformer); measurement name stays unet_step_mean_s.
138+
step_label = perf.get("step_label", "UNet")
139+
140+
tt_components_total = te1_s + te2_s + sum(unet_steps) + vae_s
129141
cpu_overhead = max(0.0, perf["total"] - tt_components_total)
130142

131143
metadata = get_benchmark_metadata()
@@ -149,25 +161,40 @@ def benchmark_imagegen_torch_xla(
149161
data_format="bfloat16",
150162
input_size=input_size,
151163
)
152-
print(
153-
f"| Num inference steps: {num_inference_steps}\n"
154-
f"| Steady-state:\n"
155-
f"| Text encoder 1 (s): {perf['te1']:.3f}\n"
156-
f"| Text encoder 2 (s): {perf['te2']:.3f}\n"
157-
f"| UNet step mean (s): {unet_step_mean_s:.3f}\n"
158-
f"| VAE (s): {perf['vae']:.3f}\n"
159-
f"| CPU overhead (s): {cpu_overhead:.3f}"
160-
)
164+
breakdown_lines = [
165+
f"| Num inference steps: {num_inference_steps}",
166+
"| Steady-state:",
167+
]
168+
if perf.get("te1") is not None:
169+
breakdown_lines.append(f"| Text encoder 1 (s): {te1_s:.3f}")
170+
if perf.get("te2") is not None:
171+
breakdown_lines.append(f"| Text encoder 2 (s): {te2_s:.3f}")
172+
breakdown_lines.append(f"| {step_label} step mean (s): {unet_step_mean_s:.3f}")
173+
if perf.get("vae") is not None:
174+
breakdown_lines.append(f"| VAE (s): {vae_s:.3f}")
175+
breakdown_lines.append(f"| CPU overhead (s): {cpu_overhead:.3f}")
176+
print("\n".join(breakdown_lines))
161177

162178
custom_measurements = [
163179
{"measurement_name": "images_per_second", "value": samples_per_sec},
164180
{"measurement_name": "e2e_latency", "value": steady_state_time},
165-
{"measurement_name": "text_encoder_1_s", "value": perf["te1"]},
166-
{"measurement_name": "text_encoder_2_s", "value": perf["te2"]},
167-
{"measurement_name": "unet_step_mean_s", "value": unet_step_mean_s},
168-
{"measurement_name": "vae_s", "value": perf["vae"]},
169-
{"measurement_name": "cpu_overhead_s", "value": cpu_overhead},
170181
]
182+
if perf.get("te1") is not None:
183+
custom_measurements.append(
184+
{"measurement_name": "text_encoder_1_s", "value": te1_s}
185+
)
186+
if perf.get("te2") is not None:
187+
custom_measurements.append(
188+
{"measurement_name": "text_encoder_2_s", "value": te2_s}
189+
)
190+
custom_measurements.append(
191+
{"measurement_name": "unet_step_mean_s", "value": unet_step_mean_s}
192+
)
193+
if perf.get("vae") is not None:
194+
custom_measurements.append({"measurement_name": "vae_s", "value": vae_s})
195+
custom_measurements.append(
196+
{"measurement_name": "cpu_overhead_s", "value": cpu_overhead}
197+
)
171198

172199
result = create_benchmark_result(
173200
full_model_name=full_model_name,
@@ -192,6 +219,7 @@ def benchmark_imagegen_torch_xla(
192219
device_name=socket.gethostname(),
193220
arch=get_xla_device_arch(),
194221
device_count=xr.global_runtime_device_count(),
222+
mesh_shape=getattr(pipeline, "mesh_shape", None),
195223
input_is_image=True,
196224
)
197225

tests/benchmark/test_imagegen.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,49 @@ def generate_fn(prompt, steps):
191191
optimization_level=0,
192192
output_image_path="test_sdxl_lightning_output.png",
193193
)
194+
195+
196+
def test_flux2(output_file, request):
197+
from benchmarks.flux2_pipeline import Flux2Config, Flux2Pipeline_TT
198+
from third_party.tt_forge_models.flux2.pytorch.src.model_utils import (
199+
HEIGHT,
200+
NUM_INFERENCE_STEPS,
201+
PROMPT,
202+
WIDTH,
203+
)
204+
205+
# FLUX.2-dev: ~24B Mistral3 text encoder + ~32B Flux2 transformer (both
206+
# tensor-parallel sharded across the mesh's model axis) + replicated VAE.
207+
# Multichip — wired to the 4-chip blackhole (qb2) in perf-bench-matrix.json.
208+
prompt = PROMPT
209+
num_inference_steps = NUM_INFERENCE_STEPS
210+
height = HEIGHT
211+
width = WIDTH
212+
213+
def build_pipeline_fn(compile_options):
214+
pipeline = Flux2Pipeline_TT(
215+
config=Flux2Config(compile_options=compile_options)
216+
)
217+
pipeline.setup()
218+
219+
def generate_fn(prompt, steps):
220+
return pipeline.generate(
221+
prompt=prompt,
222+
num_inference_steps=steps,
223+
seed=DEFAULT_SEED,
224+
)
225+
226+
return pipeline, generate_fn
227+
228+
test_imagegen(
229+
build_pipeline_fn=build_pipeline_fn,
230+
model_info_name="flux2",
231+
output_file=output_file,
232+
request=request,
233+
prompt=prompt,
234+
num_inference_steps=num_inference_steps,
235+
height=height,
236+
width=width,
237+
optimization_level=0,
238+
output_image_path="test_flux2_output.png",
239+
)

0 commit comments

Comments
 (0)