Skip to content

Commit b8a3fba

Browse files
committed
Fix optimizer state restoration and multi-host sharding in WAN trainer
- Fix no-op state.replace(...) when restoring opt_state and step by reassigning state. - Add restore_optimizer_state_by_path to normalize and align Orbax checkpoint paths with live Optax PyTree structure. - Update max_utils.device_put_replicated to convert to NumPy host arrays before callback slicing to avoid multi-host dispatch failures, and fast-path matching shardings.
1 parent a4ff3ae commit b8a3fba

4 files changed

Lines changed: 128 additions & 24 deletions

File tree

‎src/maxdiffusion/generate_ltx_video.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,7 @@ def run(config):
213213

214214
for prompt_idx, current_prompt in enumerate(prompts):
215215
prompt_word_count = len(current_prompt.split())
216-
enhance_prompt = (
217-
prompt_enhancement_words_threshold > 0 and prompt_word_count < prompt_enhancement_words_threshold
218-
)
216+
enhance_prompt = prompt_enhancement_words_threshold > 0 and prompt_word_count < prompt_enhancement_words_threshold
219217

220218
s0 = time.perf_counter()
221219
images = pipeline(

‎src/maxdiffusion/max_utils.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,9 +503,13 @@ def device_put_replicated(x, sharding):
503503
504504
to also shard an array based on sharding.
505505
"""
506+
if isinstance(x, jax.Array) and hasattr(x, "sharding") and x.sharding == sharding:
507+
return x
508+
506509
arr = getattr(x, "value", x)
510+
arr_np = np.asarray(arr)
507511
shd = getattr(sharding, "value", sharding)
508-
res = jax.make_array_from_callback(arr.shape, shd, lambda index: arr[index])
512+
res = jax.make_array_from_callback(arr_np.shape, shd, lambda index: arr_np[index])
509513
if hasattr(x, "set_value"):
510514
x.set_value(res)
511515
return x

‎src/maxdiffusion/tests/input_pipeline_interface_test.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import pathlib
2222
import shutil
2323
import subprocess
24+
import tempfile
2425
import unittest
2526
import pytest
2627
from absl.testing import absltest
@@ -75,7 +76,8 @@ def setUp(self):
7576
def test_make_dreambooth_train_iterator(self):
7677
instance_class_gcs_dir = "gs://maxdiffusion-github-runner-test-assets/datasets/dreambooth/instance_class"
7778
class_class_gcs_dir = "gs://maxdiffusion-github-runner-test-assets/datasets/dreambooth/class_class"
78-
local_dir = "/tmp/"
79+
self.temp_dir = tempfile.TemporaryDirectory()
80+
local_dir = self.temp_dir.name
7981
instance_class_local_dir = max_utils.download_blobs(instance_class_gcs_dir, local_dir)
8082
class_class_local_dir = max_utils.download_blobs(class_class_gcs_dir, local_dir)
8183

@@ -136,6 +138,7 @@ def test_make_dreambooth_train_iterator(self):
136138

137139
cleanup(instance_class_local_dir)
138140
cleanup(class_class_local_dir)
141+
self.temp_dir.cleanup()
139142

140143
@pytest.mark.skip(
141144
"This test is deprecated and will be removed in a future version. Reason: stable diffusion 2 base is no longer in HuggingFace"

‎src/maxdiffusion/trainers/base_wan_trainer.py‎

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import os
2121
import pprint
2222
import threading
23+
from typing import Any
2324
from flax import nnx
2425
from flax.linen import partitioning as nn_partitioning
2526
from flax.training import train_state
@@ -49,6 +50,54 @@ def _to_array(x):
4950
return x
5051

5152

53+
def _normalize_path(path: tuple[Any, ...]) -> tuple[Any, ...]:
54+
"""Normalizes a PyTree path tuple for optimizer state matching."""
55+
normalized = []
56+
for p in path:
57+
if hasattr(p, "key"):
58+
val = p.key
59+
elif hasattr(p, "idx"):
60+
val = p.idx
61+
elif hasattr(p, "name"):
62+
val = p.name
63+
else:
64+
val = p
65+
66+
if isinstance(val, str) and val.isdigit():
67+
val = int(val)
68+
normalized.append(val)
69+
70+
if normalized and normalized[-1] == "value":
71+
normalized = normalized[:-1]
72+
73+
return tuple(normalized)
74+
75+
76+
def restore_optimizer_state_by_path(
77+
target_opt_shardings: Any,
78+
restored_opt_state: Any,
79+
) -> Any:
80+
"""Restores optimizer state by matching exact parameter paths and applying sharding."""
81+
raw_restored_map, _ = jax.tree_util.tree_flatten_with_path(restored_opt_state)
82+
83+
normalized_restored_map = {}
84+
for raw_path, val in raw_restored_map:
85+
norm_path = _normalize_path(raw_path)
86+
normalized_restored_map[norm_path] = val
87+
88+
def _restore_leaf(path, target_sharding):
89+
norm_path = _normalize_path(path)
90+
if norm_path not in normalized_restored_map:
91+
raise KeyError(f"Optimizer checkpoint missing path: {norm_path}.")
92+
val = normalized_restored_map[norm_path]
93+
return max_utils.device_put_replicated(val, target_sharding)
94+
95+
return jax.tree_util.tree_map_with_path(
96+
_restore_leaf,
97+
target_opt_shardings,
98+
)
99+
100+
52101
def generate_sample(config, pipeline, filename_prefix):
53102
"""
54103
Generates a video to validate training did not corrupt the model
@@ -178,9 +227,11 @@ def start_training(self):
178227
with nn_partitioning.axis_rules(self.config.logical_axis_rules):
179228
pipeline, opt_state, step = self.checkpointer.load_checkpoint()
180229
restore_args = {}
181-
if opt_state and step:
182-
restore_args = {"opt_state": opt_state, "step": step}
230+
if opt_state is not None:
231+
restore_args["opt_state"] = opt_state
183232
del opt_state
233+
if step is not None:
234+
restore_args["step"] = step
184235
if self.config.enable_ssim:
185236
# Generate a sample before training to compare against generated sample after training.
186237
pretrained_video_path = generate_sample(self.config, pipeline, filename_prefix="pre-training-")
@@ -256,26 +307,76 @@ def eval(self, mesh, eval_rng_key, step, p_eval_step, state, scheduler_state, wr
256307
def training_loop(self, pipeline, optimizer, learning_rate_scheduler, train_data_iterator, restore_args: dict = {}):
257308
mesh = pipeline.mesh
258309
graphdef, params, rest_of_state = nnx.split(pipeline.transformer, nnx.Param, ...)
310+
start_step = 0
259311

260312
with mesh, nn_partitioning.axis_rules(self.config.logical_axis_rules):
261313
state = TrainState.create(
262314
apply_fn=graphdef.apply, params=params, tx=optimizer, graphdef=graphdef, rest_of_state=rest_of_state
263315
)
264-
if restore_args:
265-
step = restore_args.get("step", 0)
266-
max_logging.log(f"Restoring optimizer and resuming from step {step}")
267-
state.replace(opt_state=restore_args.get("opt_state"), step=restore_args.get("step", 0))
268-
del restore_args["opt_state"]
269-
del optimizer
270316
state = jax.tree.map(_to_array, state)
271317
state_spec = nnx.get_partition_spec(state)
272318
state = jax.lax.with_sharding_constraint(state, state_spec)
273319
state_shardings = nnx.get_named_sharding(state, mesh)
274-
if jax.process_index() == 0 and restore_args:
275-
max_logging.log("--- Optimizer State Sharding Spec (opt_state) ---")
276-
pretty_string = pprint.pformat(state_spec.opt_state, indent=4, width=60)
277-
max_logging.log(pretty_string)
278-
max_logging.log("------------------------------------------------")
320+
if restore_args.get("opt_state") is not None:
321+
max_logging.log("Restoring optimizer")
322+
resharded_opt_state = restore_optimizer_state_by_path(
323+
target_opt_shardings=state_shardings.opt_state,
324+
restored_opt_state=restore_args["opt_state"],
325+
)
326+
state = state.replace(opt_state=resharded_opt_state)
327+
del restore_args["opt_state"]
328+
if jax.process_index() == 0:
329+
max_logging.log("--- Optimizer State Sharding Spec (opt_state) ---")
330+
pretty_string = pprint.pformat(state_spec.opt_state, indent=4, width=60)
331+
max_logging.log(pretty_string)
332+
max_logging.log("------------------------------------------------")
333+
334+
if restore_args.get("step") is not None:
335+
step = restore_args["step"]
336+
# A checkpoint labelled N is written *after* iteration N completes,
337+
# and the loop starts at step 0, so it already reflects N+1 applied
338+
# updates. Resume at N+1 so that iteration N is not re-run, and so
339+
# that TrainState.step stays aligned with the optimizer's internal
340+
# step count, which actually drives the LR schedule.
341+
state = state.replace(step=step + 1)
342+
start_step = step + 1
343+
max_logging.log(f"Resuming training from step {start_step}")
344+
else:
345+
# Weights-only: no optimizer state means no schedule position.
346+
# Resetting to 0 keeps the applied LR and the logged LR consistent.
347+
if "step" in restore_args:
348+
max_logging.log(
349+
"Weights-only checkpoint detected (no opt_state); resetting step"
350+
" to 0. The LR schedule will restart from the beginning. If you"
351+
" intended to resume training, re-save with optimizer state."
352+
)
353+
weights_step = restore_args.pop("step")
354+
355+
# The loop restarts at 0 but checkpoint_dir still holds steps up to
356+
# weights_step, and Orbax will skip saving the checkpoints at those
357+
# steps. Therefore, we redirect the three output paths that
358+
# pyconfig derives from run_name, then rebuild the CheckpointManager.
359+
previous_run_name = self.config.run_name
360+
previous_checkpoint_dir = self.config.checkpoint_dir
361+
new_run_name = f"{previous_run_name}_from_weights_{weights_step}"
362+
keys = self.config.get_keys()
363+
keys["run_name"] = new_run_name
364+
for key, subdir in (
365+
("tensorboard_dir", "tensorboard"),
366+
("checkpoint_dir", "checkpoints"),
367+
):
368+
keys[key] = os.path.join(self.config.output_dir, new_run_name, subdir, "")
369+
max_logging.log(
370+
f"Redirecting outputs from run_name {previous_run_name} to"
371+
f" {new_run_name}: {previous_checkpoint_dir} already holds"
372+
f" checkpoints up to step {weights_step}, which this run would"
373+
" otherwise fail to save over."
374+
)
375+
max_logging.log(f" checkpoint_dir: {self.config.checkpoint_dir}")
376+
max_logging.log(f" tensorboard_dir: {self.config.tensorboard_dir}")
377+
self.checkpointer.checkpoint_manager.close()
378+
self.checkpointer = self._get_checkpointer()
379+
279380
if self.config.hardware != "gpu":
280381
max_utils.delete_pytree(params)
281382
data_shardings = self.get_data_shardings(mesh)
@@ -301,8 +402,6 @@ def training_loop(self, pipeline, optimizer, learning_rate_scheduler, train_data
301402

302403
rng = jax.random.key(self.config.seed)
303404
rng, eval_rng_key = jax.random.split(rng)
304-
start_step = 0
305-
last_step_completion = datetime.datetime.now()
306405
local_metrics_file = open(self.config.metrics_file, "a", encoding="utf8") if self.config.metrics_file else None
307406
running_gcs_metrics = [] if self.config.gcs_metrics else None
308407
first_profiling_step = self.config.skip_first_n_steps_for_profiler
@@ -311,9 +410,6 @@ def training_loop(self, pipeline, optimizer, learning_rate_scheduler, train_data
311410
last_profiling_step = np.clip(
312411
first_profiling_step + self.config.profiler_steps - 1, first_profiling_step, self.config.max_train_steps - 1
313412
)
314-
if restore_args.get("step", 0):
315-
max_logging.log(f"Resuming training from step {step}")
316-
start_step = restore_args.get("step", 0)
317413
per_device_tflops, _, _ = BaseWanTrainer.calculate_tflops(pipeline)
318414
scheduler_state = pipeline.scheduler_state
319415
example_batch = load_next_batch(train_data_iterator, None, self.config)
@@ -367,7 +463,10 @@ def training_loop(self, pipeline, optimizer, learning_rate_scheduler, train_data
367463
writer.flush()
368464
if self.config.save_final_checkpoint:
369465
max_logging.log(f"Saving final checkpoint for step {step}")
370-
self.checkpointer.save_checkpoint(self.config.max_train_steps - 1, pipeline, state.params)
466+
if self.config.save_optimizer:
467+
self.checkpointer.save_checkpoint(self.config.max_train_steps - 1, pipeline, state)
468+
else:
469+
self.checkpointer.save_checkpoint(self.config.max_train_steps - 1, pipeline, state.params)
371470
self.checkpointer.checkpoint_manager.wait_until_finished()
372471
# load new state for trained transformer
373472
pipeline.transformer = nnx.merge(state.graphdef, state.params, state.rest_of_state)

0 commit comments

Comments
 (0)