Skip to content

Commit 0c384e2

Browse files
committed
Run the Z-Image Qwen3 text encoder in NNX
Split Qwen3 checkpoint conversion out of the model definition into models/qwen3_utils.py, mirroring transformer_z_image / z_image_utils. Fix two bugs that made NNXFlaxQwen3Attention unusable: it applied no causal mask, and it treated attention_mask as an additive mask rather than the 0/1 padding mask its callers pass. Against HF Transformers the port scored cosine 0.242; it now matches at 0.999996. Declare param_dtype on the Qwen3 Linear/Embed layers. Flax stores parameters in param_dtype -- float32 by default -- regardless of the compute dtype, so a bfloat16 run held the 4B encoder in float32: 16.1 GB per chip instead of 8.0 GB. Norm scales stay float32 deliberately, and now say so in the module rather than being retyped after loading. Z-Image builds its denoiser and text encoder through one NNX path in the checkpointer; only the Diffusers VAE is still Linen. flux2klein keeps the existing loader, so its numerics are unchanged. Verified on v6e-8 (Z-Image-Turbo, 1024x1024, 9 steps, 8 images): 0.31s per image, output within 0.024/255 of the previous Linen encoder.
1 parent 974b7ce commit 0c384e2

6 files changed

Lines changed: 359 additions & 272 deletions

File tree

src/maxdiffusion/checkpointing/z_image_checkpointer.py

Lines changed: 72 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
3. fill it, either from safetensors or from the Orbax cache, placing each
2929
parameter directly into its target sharding.
3030
31-
`_component` does 1 and 2 for the Flax modules; the denoiser needs its own
32-
version of the same because it is NNX rather than Linen.
31+
`_nnx_component` does 1 and 2 for the denoiser and the text encoder;
32+
`_linen_component` does the same for the Diffusers VAE, which is still Linen.
3333
"""
3434

3535
import inspect
@@ -50,7 +50,8 @@
5050

5151
from .. import max_logging, max_utils
5252
from ..models import FlaxAutoencoderKL
53-
from ..models.qwen3_flax import FlaxQwen3Config, FlaxQwen3Model, load_qwen3_weights
53+
from ..models.qwen3_flax import FlaxQwen3Config, NNXFlaxQwen3Model
54+
from ..models.qwen3_utils import load_qwen3_weights
5455
from ..models.z_image.transformer_z_image import ZImageTransformer2DModel
5556
from ..models.z_image.z_image_utils import load_z_image_transformer
5657
from ..pipelines.z_image import ZImagePipeline
@@ -91,12 +92,25 @@ def _abstract_tree(shapes, shardings):
9192
)
9293

9394

94-
def _component(module, init_args, mesh, axis_rules):
95-
"""Abstract params of a Linen module, plus their target shardings.
95+
def _nnx_component(factory, mesh, axis_rules, seed=0):
96+
"""Build an NNX module abstractly: its graph, params and target shardings.
9697
97-
Shardings come from the module's own logical annotations. A module with no
98-
annotations (`vae_flax.FlaxAutoencoderKL` has none) resolves to fully
99-
replicated, which is exactly what it used to be given explicitly.
98+
Shardings come from the module's own `nnx.with_partitioning` annotations,
99+
resolved against the mesh.
100+
"""
101+
graphdef, state, rest = nnx.split(nnx.eval_shape(factory, nnx.Rngs(jax.random.key(seed))), nnx.Param, ...)
102+
shardings = None
103+
if mesh is not None:
104+
sharding_tree = nn.logical_to_mesh_sharding(nnx.get_partition_spec(state), mesh, axis_rules)
105+
shardings = {path: variable.value for path, variable in nnx.to_flat_state(sharding_tree)}
106+
return graphdef, state, rest, shardings
107+
108+
109+
def _linen_component(module, init_args, mesh, axis_rules):
110+
"""The Linen equivalent of `_nnx_component`, for the Diffusers VAE.
111+
112+
A module with no logical annotations (`vae_flax.FlaxAutoencoderKL` has none)
113+
resolves to fully replicated.
100114
"""
101115
abstract = jax.eval_shape(lambda: module.init(jax.random.key(0), *init_args))
102116
shapes = _plain(abstract["params"])
@@ -106,9 +120,15 @@ def _component(module, init_args, mesh, axis_rules):
106120
return shapes, dict(flatten_dict(_plain(mesh_shardings["params"])))
107121

108122

109-
def _transformer_component(transformer_config, config, mesh):
110-
"""The NNX equivalent of `_component`, plus the graph needed to rebuild it."""
123+
def _fill(graphdef, state, rest, params):
124+
"""Put loaded params into an abstract NNX state and rebuild the module."""
125+
flat_state = dict(nnx.to_flat_state(state))
126+
for path, value in flatten_dict(params).items():
127+
flat_state[path].value = value
128+
return nnx.merge(graphdef, nnx.from_flat_state(flat_state), rest)
129+
111130

131+
def _transformer_factory(transformer_config, config, mesh):
112132
def factory(rngs):
113133
return ZImageTransformer2DModel(
114134
rngs=rngs,
@@ -120,26 +140,15 @@ def factory(rngs):
120140
**transformer_config,
121141
)
122142

123-
graphdef, state, rest = nnx.split(nnx.eval_shape(factory, nnx.Rngs(jax.random.key(config.seed))), nnx.Param, ...)
124-
shardings = None
125-
if mesh is not None:
126-
sharding_tree = nn.logical_to_mesh_sharding(nnx.get_partition_spec(state), mesh, config.logical_axis_rules)
127-
shardings = {path: variable.value for path, variable in nnx.to_flat_state(sharding_tree)}
128-
return graphdef, state, rest, shardings
129-
130-
131-
def _fill(graphdef, state, rest, params):
132-
"""Put loaded params into an abstract NNX state and rebuild the module."""
133-
flat_state = dict(nnx.to_flat_state(state))
134-
for path, value in flatten_dict(params).items():
135-
flat_state[path].value = value
136-
return nnx.merge(graphdef, nnx.from_flat_state(flat_state), rest)
143+
return factory
137144

138145

139146
def create_z_image_transformer(model_id: str, config, mesh: Optional[jax.sharding.Mesh] = None):
140147
"""Instantiate and stream a Diffusers Z-Image checkpoint into an NNX model."""
141148
transformer_config = ZImageTransformer2DModel.load_config(model_id, subfolder="transformer")
142-
graphdef, state, rest, shardings = _transformer_component(transformer_config, config, mesh)
149+
graphdef, state, rest, shardings = _nnx_component(
150+
_transformer_factory(transformer_config, config, mesh), mesh, config.logical_axis_rules, config.seed
151+
)
143152
params = load_z_image_transformer(model_id, state.to_pure_dict(), target_shardings=shardings)
144153
return _fill(graphdef, state, rest, params)
145154

@@ -196,27 +205,31 @@ def build_vae(self, vae_config: dict):
196205
"""VAE module, its abstract params and their target shardings."""
197206
vae = FlaxAutoencoderKL(**vae_config, dtype=self.config.activations_dtype, weights_dtype=self.config.weights_dtype)
198207
sample = jnp.ones((1, vae.config.in_channels, 64, 64), jnp.float32)
199-
return vae, *_component(vae, (sample,), self.mesh, self._vae_axis_rules())
208+
return vae, *_linen_component(vae, (sample,), self.mesh, self._vae_axis_rules())
200209

201210
def build_text_encoder(self, text_encoder_config: dict):
202-
"""Qwen3 module, its abstract params and their target shardings."""
203-
model = FlaxQwen3Model(
204-
FlaxQwen3Config(
205-
vocab_size=text_encoder_config["vocab_size"],
206-
hidden_size=text_encoder_config["hidden_size"],
207-
intermediate_size=text_encoder_config["intermediate_size"],
208-
num_hidden_layers=text_encoder_config["num_hidden_layers"],
209-
num_attention_heads=text_encoder_config["num_attention_heads"],
210-
num_key_value_heads=text_encoder_config["num_key_value_heads"],
211-
head_dim=text_encoder_config["head_dim"],
212-
rms_norm_eps=text_encoder_config["rms_norm_eps"],
213-
rope_theta=text_encoder_config["rope_theta"],
214-
max_position_embeddings=text_encoder_config["max_position_embeddings"],
215-
dtype=self.config.weights_dtype,
216-
)
211+
"""Qwen3 graph, its abstract params and their target shardings."""
212+
qwen3_config = FlaxQwen3Config(
213+
vocab_size=text_encoder_config["vocab_size"],
214+
hidden_size=text_encoder_config["hidden_size"],
215+
intermediate_size=text_encoder_config["intermediate_size"],
216+
num_hidden_layers=text_encoder_config["num_hidden_layers"],
217+
num_attention_heads=text_encoder_config["num_attention_heads"],
218+
num_key_value_heads=text_encoder_config["num_key_value_heads"],
219+
head_dim=text_encoder_config["head_dim"],
220+
rms_norm_eps=text_encoder_config["rms_norm_eps"],
221+
rope_theta=text_encoder_config["rope_theta"],
222+
max_position_embeddings=text_encoder_config["max_position_embeddings"],
223+
dtype=self.config.weights_dtype,
224+
)
225+
# The norms declare float32 scales, so the abstract params already carry the
226+
# right dtypes for both the loader and the Orbax restore target.
227+
return _nnx_component(
228+
lambda rngs: NNXFlaxQwen3Model(rngs=rngs, config=qwen3_config),
229+
self.mesh,
230+
self._text_encoder_axis_rules(),
231+
self.config.seed,
217232
)
218-
ids = jnp.ones((1, 8), jnp.int32)
219-
return model, *_component(model, (ids, ids), self.mesh, self._text_encoder_axis_rules())
220233

221234
def _vae_axis_rules(self):
222235
"""`vae_logical_axis_rules` if the config declares them, as WAN does."""
@@ -248,18 +261,18 @@ def load_diffusers_pipeline(self) -> ZImagePipeline:
248261
)
249262
vae_params = self._place(vae_params, vae_shardings)
250263

251-
text_encoder, shapes, text_encoder_shardings = self.build_text_encoder(
264+
graphdef, state, rest, shardings = self.build_text_encoder(
252265
AutoConfig.from_pretrained(model_id, subfolder="text_encoder").to_dict()
253266
)
254-
text_encoder_params = load_qwen3_weights(_text_encoder_dir(model_id), shapes, target_shardings=text_encoder_shardings)
267+
params = load_qwen3_weights(_text_encoder_dir(model_id), state.to_pure_dict(), target_shardings=shardings)
268+
text_encoder = _fill(graphdef, state, rest, params)
255269

256270
return self._assemble(
257271
transformer,
258272
vae,
259273
vae_params,
260274
AutoTokenizer.from_pretrained(model_id, subfolder="tokenizer", use_fast=True),
261275
text_encoder,
262-
text_encoder_params,
263276
raw_vae_config.get("shift_factor", _DEFAULT_VAE_SHIFT_FACTOR),
264277
)
265278

@@ -278,9 +291,14 @@ def _restore_pipeline(self, orbax_dir: str) -> Optional[ZImagePipeline]:
278291
return None
279292

280293
max_logging.log(f"Loading Z-Image pipeline from orbax checkpoint step {step} in {orbax_dir}")
281-
graphdef, state, rest, shardings = _transformer_component(metadata["transformer_config"], self.config, self.mesh)
294+
graphdef, state, rest, shardings = _nnx_component(
295+
_transformer_factory(metadata["transformer_config"], self.config, self.mesh),
296+
self.mesh,
297+
self.config.logical_axis_rules,
298+
self.config.seed,
299+
)
282300
vae, vae_shapes, vae_shardings = self.build_vae(metadata["vae_config"])
283-
text_encoder, text_shapes, text_shardings = self.build_text_encoder(metadata["text_encoder_config"])
301+
text_graphdef, text_state, text_rest, text_shardings = self.build_text_encoder(metadata["text_encoder_config"])
284302

285303
# Shapes come from the models themselves, so every item lands directly in
286304
# the sharding its component was built for -- no host staging.
@@ -290,7 +308,7 @@ def _restore_pipeline(self, orbax_dir: str) -> Optional[ZImagePipeline]:
290308
**{
291309
"transformer_state": ocp.args.StandardRestore(_abstract_tree(state.to_pure_dict(), shardings)),
292310
"vae_state": ocp.args.StandardRestore(_abstract_tree(vae_shapes, vae_shardings)),
293-
"text_encoder_state": ocp.args.StandardRestore(_abstract_tree(text_shapes, text_shardings)),
311+
"text_encoder_state": ocp.args.StandardRestore(_abstract_tree(text_state.to_pure_dict(), text_shardings)),
294312
}
295313
),
296314
)
@@ -300,22 +318,20 @@ def _restore_pipeline(self, orbax_dir: str) -> Optional[ZImagePipeline]:
300318
vae,
301319
restored["vae_state"],
302320
self._load_tokenizer(orbax_dir),
303-
text_encoder,
304-
restored["text_encoder_state"],
321+
_fill(text_graphdef, text_state, text_rest, restored["text_encoder_state"]),
305322
metadata["vae_shift_factor"],
306323
)
307324
except Exception as e: # pylint: disable=broad-except
308325
max_logging.log(f"Failed to load orbax checkpoint from {orbax_dir}, falling back to Diffusers: {e}")
309326
return None
310327

311-
def _assemble(self, transformer, vae, vae_params, tokenizer, text_encoder, text_encoder_params, vae_shift_factor):
328+
def _assemble(self, transformer, vae, vae_params, tokenizer, text_encoder, vae_shift_factor):
312329
return ZImagePipeline(
313330
transformer,
314331
vae,
315332
vae_params,
316333
tokenizer,
317334
text_encoder,
318-
text_encoder_params,
319335
dtype=self.config.activations_dtype,
320336
mesh=self.mesh,
321337
logical_axis_rules=self.config.logical_axis_rules,
@@ -370,7 +386,9 @@ def _save_pipeline(self, orbax_dir: str, pipeline: ZImagePipeline):
370386
**{
371387
"transformer_state": ocp.args.StandardSave(transformer_state.to_pure_dict()),
372388
"vae_state": ocp.args.StandardSave(_plain(pipeline.vae_params)),
373-
"text_encoder_state": ocp.args.StandardSave(_plain(pipeline.text_encoder_params)),
389+
"text_encoder_state": ocp.args.StandardSave(
390+
nnx.split(pipeline.text_encoder, nnx.Param, ...)[1].to_pure_dict()
391+
),
374392
CONFIG_ITEM: ocp.args.JsonSave(json.loads(json.dumps(metadata, default=str))),
375393
}
376394
),

src/maxdiffusion/generate_flux2klein.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737

3838
from maxdiffusion.models.flux.transformers.transformer_flux_flax import Flux2KleinTransformer2DModel
3939
from maxdiffusion.models.vae_flax import FlaxAutoencoderKL
40-
from maxdiffusion.models.qwen3_flax import FlaxQwen3Config, FlaxQwen3Model, load_and_convert_qwen3_weights
40+
from maxdiffusion.models.qwen3_flax import FlaxQwen3Config, FlaxQwen3Model
41+
from maxdiffusion.models.qwen3_utils import load_and_convert_qwen3_weights
4142
from maxdiffusion.schedulers.scheduling_flow_match_flax import FlaxFlowMatchScheduler
4243

4344

0 commit comments

Comments
 (0)