Skip to content

Commit 6057403

Browse files
committed
refactor(lora): support JAX/NNX model wrappers directly in restore_lora_from_path
1 parent ecf666f commit 6057403

4 files changed

Lines changed: 87 additions & 48 deletions

File tree

src/maxtext/trainers/post_train/sft/train_sft.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,8 @@ def setup_trainer_state(mt_config, goodput_recorder=None):
262262
# Provide rules context so 'norm' is translated to mesh axes during maybe_restore
263263
with nn_partitioning.axis_rules(mt_config.logical_axis_rules):
264264
trainer = MaxTextPeftTrainer(model, optimizer, tunix_config)
265-
if mt_config.lora.lora_restore_path:
266-
trainer = lora_utils.restore_lora_from_path(trainer, mt_config)
265+
if mt_config.lora.lora_restore_path and trainer.train_steps == 0:
266+
lora_utils.restore_lora_from_path(trainer.model, mt_config)
267267
trainer.with_training_hooks(training_hooks)
268268
trainer.with_data_hooks(data_hooks)
269269
trainer = use_maxtext_loss_function(trainer, mt_config)

src/maxtext/utils/lora_utils.py

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import json
2020
import os
2121
import re
22-
from typing import Any, Optional
22+
from typing import Optional
2323

2424
from flax import nnx, linen as nn
2525
from flax.linen import partitioning as nn_partitioning
@@ -465,10 +465,9 @@ def _build_lora_provider(mt_config: pyconfig.HyperParameters) -> qwix.LoraProvid
465465
return qwix.LoraProvider(**lora_kwargs)
466466

467467

468-
def _prepare_dummy_inputs() -> tuple[jnp.ndarray, jnp.ndarray]:
468+
def _prepare_dummy_inputs(dummy_bs: int = 1) -> tuple[jnp.ndarray, jnp.ndarray]:
469469
"""Builds dummy decoder inputs used to materialize LoRA parameters."""
470470
# Keep LoRA warmup as small as possible to minimize compile/memory overhead.
471-
dummy_bs = 1
472471
seq_len = 1
473472
decoder_input_tokens = jnp.zeros((dummy_bs, seq_len), dtype=jnp.int32)
474473
decoder_positions = jnp.zeros((dummy_bs, seq_len), dtype=jnp.int32)
@@ -483,30 +482,50 @@ def is_lora_enabled(model: nnx.Module) -> bool:
483482
return False
484483

485484

486-
def _verify_lora_parameters(lora_model: nnx.Module, mt_config: pyconfig.HyperParameters):
485+
def _verify_lora_parameters(lora_model: nnx.Module, mt_config: pyconfig.HyperParameters) -> None:
487486
"""Validates that LoRA is active or that target modules were matched."""
488487

489488
if is_lora_enabled(lora_model):
489+
wrapped_modules = set()
490+
for path, value in nnx.iter_graph(lora_model):
491+
if isinstance(value, nnx.LoRAParam):
492+
if len(path) > 1:
493+
parent_path = "/".join(str(p) for p in path[:-1])
494+
wrapped_modules.add(parent_path)
495+
496+
if wrapped_modules:
497+
wrapped_modules = sorted(list(wrapped_modules))
498+
max_logging.log(
499+
f"LoRA configured: module_path='{_get_lora_module_path(mt_config)}' successfully matched "
500+
f"{len(wrapped_modules)} target submodules."
501+
)
502+
preview_limit = 20
503+
preview_modules = wrapped_modules[:preview_limit]
504+
max_logging.log(f"Sample matched submodules ({len(preview_modules)} of {len(wrapped_modules)}): {preview_modules}")
505+
else:
506+
max_logging.log("LoRA is enabled. (Detailed submodules match report skipped due to mock model or empty state)")
490507
return
491508

492509
lora_module_path = _get_lora_module_path(mt_config)
493510
compiled_module_path = re.compile(lora_module_path)
494-
matched_module_paths = []
495-
sample_module_paths = []
496511

512+
matched_module_paths = []
497513
for path, _ in nnx.iter_modules(lora_model):
498514
module_path = "/".join(str(p) for p in path)
499-
if len(sample_module_paths) < 100:
500-
sample_module_paths.append(module_path)
501-
if compiled_module_path.search(module_path):
515+
if module_path and compiled_module_path.search(module_path):
502516
matched_module_paths.append(module_path)
503517

504518
if not matched_module_paths:
505-
max_logging.log(
506-
f"LoRA module_path='{lora_module_path}' did not match any weights. " f"Sample module paths: {sample_module_paths}"
507-
)
519+
max_logging.log(f"Error: LoRA module_path='{lora_module_path}' did not match any weights.")
508520
raise ValueError("LoRA enabled but no LoRA parameters found in decoder/model state.")
509521

522+
# Simplify matched paths by replacing numeric layer indices with "*" to avoid redundant output
523+
simplified_matches = sorted(
524+
{"/".join("*" if p.isdigit() else p for p in path.split("/")) for path in matched_module_paths}
525+
)
526+
max_logging.log(f"LoRA target verification: successfully matched {len(matched_module_paths)} modules.")
527+
max_logging.log(f"Matched submodule patterns: {simplified_matches}")
528+
510529
raise ValueError(
511530
"LoRA module path matched target modules, but nnx.LoRAParam is still "
512531
"missing. For Tunix PeftTrainer, LoRA params must be materialized before "
@@ -572,8 +591,12 @@ def apply_lora_to_model(
572591

573592
lora_provider = _build_lora_provider(mt_config)
574593

594+
dp_size = 1
595+
if mesh is not None and "data" in mesh.shape:
596+
dp_size = mesh.shape["data"]
597+
575598
model_rngs = getattr(model.decoder, "rngs", None)
576-
decoder_input_tokens, decoder_positions = _prepare_dummy_inputs()
599+
decoder_input_tokens, decoder_positions = _prepare_dummy_inputs(dummy_bs=dp_size)
577600

578601
lora_model = qwix.apply_lora_to_model(
579602
model,
@@ -621,20 +644,27 @@ def _safe_reshard(var, sharding_spec):
621644
return lora_model
622645

623646

624-
def restore_lora_from_path(trainer: Any, mt_config: pyconfig.HyperParameters) -> Any:
625-
"""Restores LoRA parameter weights from an external Orbax checkpoint for a fresh run."""
647+
def restore_lora_from_path(model: nnx.Module, mt_config: pyconfig.HyperParameters) -> nnx.Module:
648+
"""Restores LoRA parameter weights from an external Orbax checkpoint.
649+
650+
This function performs the restore in-place on the model's parameters and
651+
returns the model with the restored weights applied.
652+
653+
Args:
654+
model: The JAX/Flax NNX model (nnx.Module).
655+
mt_config: The HyperParameters config containing the lora configuration.
656+
657+
Returns:
658+
The model with the restored LoRA weights applied in-place.
659+
660+
Raises:
661+
ValueError: If LoRA is not enabled on the model, but a restore path is set.
662+
"""
626663
lora_restore_path = mt_config.lora.lora_restore_path
627664
if not lora_restore_path:
628-
return trainer
629-
630-
train_steps = getattr(trainer, "train_steps", 0)
631-
if train_steps > 0:
632-
max_logging.log(
633-
f"PeftTrainer restored current run at step {train_steps}; " f"ignoring lora_restore_path '{lora_restore_path}'."
634-
)
635-
return trainer
665+
return model
636666

637-
if not is_lora_enabled(trainer.model):
667+
if not is_lora_enabled(model):
638668
lora_module_path = _get_lora_module_path(mt_config)
639669
if not mt_config.lora.enable_lora:
640670
raise ValueError(
@@ -644,7 +674,7 @@ def restore_lora_from_path(trainer: Any, mt_config: pyconfig.HyperParameters) ->
644674

645675
sync_lora_metadata(mt_config)
646676

647-
abstract_lora_params = nnx.state(trainer.model, nnx.LoRAParam)
677+
abstract_lora_params = nnx.state(model, nnx.LoRAParam)
648678

649679
target_for_restore = jax.tree.map(
650680
lambda v: {"value": v.value},
@@ -700,9 +730,9 @@ def _map_to_state(path, variable):
700730
is_leaf=lambda n: isinstance(n, nnx.Variable),
701731
)
702732

703-
nnx.update(trainer.model, abstract_lora_params)
733+
nnx.update(model, abstract_lora_params)
704734
max_logging.log(f"LoRA restore complete from '{lora_restore_path}'.")
705-
return trainer
735+
return model
706736

707737

708738
# NNX-shaped LoRA helpers.

tests/post_training/unit/lora_utils_test.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -141,26 +141,41 @@ def test_prepare_dummy_inputs(self):
141141
self.assertEqual(tokens.shape, (1, 1))
142142
self.assertEqual(positions.shape, (1, 1))
143143

144-
def test_verify_lora_parameters_enabled(self):
145-
"""Test verification of LoRA parameters when enabled."""
144+
def test_verify_lora_parameters_success(self):
145+
"""Test verification of LoRA parameters with matches and enabled LoRA."""
146146
mock_model = mock.MagicMock()
147147
mock_config = mock.MagicMock(spec=pyconfig.HyperParameters)
148+
mock_config.lora = mock.MagicMock()
149+
mock_config.lora.lora_module_path = ".*mlp/wi_0.*"
150+
151+
mock_param = nnx.LoRAParam(0.0)
152+
mock_graph_entries = [
153+
(("decoder", "layers", 0, "mlp", "wi_0", "lora_a"), mock_param),
154+
]
148155

149-
# Note: we use our local is_lora_enabled now
150-
with mock.patch("maxtext.utils.lora_utils.is_lora_enabled", return_value=True):
151-
# Should not raise
156+
with (
157+
mock.patch("maxtext.utils.lora_utils.nnx.iter_graph", return_value=mock_graph_entries),
158+
mock.patch("maxtext.utils.lora_utils.is_lora_enabled", return_value=True),
159+
mock.patch("maxtext.utils.max_logging.log") as mock_log,
160+
):
152161
lora_utils._verify_lora_parameters(mock_model, mock_config)
153162

163+
# Should log the successful match pattern summary
164+
log_calls = [call[0][0] for call in mock_log.call_args_list]
165+
self.assertTrue(any("successfully matched" in msg for msg in log_calls))
166+
self.assertTrue(any("Sample matched submodules" in msg for msg in log_calls))
167+
154168
def test_verify_lora_parameters_not_enabled_no_match(self):
155-
"""Test verification fails when LoRA parameters are expected but not found."""
169+
"""Test verification fails with ValueError when no modules match at all."""
156170
mock_model = mock.MagicMock()
157171
mock_config = mock.MagicMock(spec=pyconfig.HyperParameters)
158172
mock_config.lora = mock.MagicMock()
159-
mock_config.model_name = "llama"
160173
mock_config.lora.lora_module_path = "non_existent"
161174

162-
with mock.patch("maxtext.utils.lora_utils.is_lora_enabled", return_value=False):
163-
mock_model.iter_modules.return_value = []
175+
with (
176+
mock.patch("flax.nnx.iter_modules", return_value=[]),
177+
mock.patch("maxtext.utils.lora_utils.is_lora_enabled", return_value=False),
178+
):
164179
with self.assertRaisesRegex(ValueError, "no LoRA parameters found"):
165180
lora_utils._verify_lora_parameters(mock_model, mock_config)
166181

@@ -274,15 +289,11 @@ def test_restore_lora_from_path(self):
274289
model, _ = model_creation_utils.from_pretrained(cfg, mesh=None, model_mode=model_creation_utils.MODEL_MODE_TRAIN)
275290
model = lora_utils.apply_lora_to_model(model, None, cfg)
276291

277-
trainer = mock.MagicMock()
278-
trainer.model = model
279-
trainer.train_steps = 0
280-
281292
restored_state = nnx.state(model, nnx.LoRAParam)
282293

283294
with mock.patch("orbax.checkpoint.PyTreeCheckpointer.restore", return_value=restored_state) as mock_restore:
284295
with mock.patch("flax.nnx.update") as mock_update:
285-
lora_utils.restore_lora_from_path(trainer, cfg)
296+
lora_utils.restore_lora_from_path(model, cfg)
286297
mock_restore.assert_called_once()
287298
args, kwargs = mock_restore.call_args
288299
self.assertEqual(args[0], "some/path")

tests/utils/forward_pass_logit_checker.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,7 @@ def main(config, test_args): # pylint: disable=W0621
261261
if config.lora.enable_lora:
262262
model = lora_utils.apply_lora_to_model(model, mesh, config)
263263
if config.lora.lora_restore_path:
264-
mock_trainer = type("MockTrainer", (), {"model": model, "train_steps": 0})
265-
lora_utils.restore_lora_from_path(mock_trainer, config)
264+
lora_utils.restore_lora_from_path(model, config)
266265
state = None
267266
else:
268267
model = models.transformer_as_linen(config, mesh=mesh, quant=quant, model_mode=MODEL_MODE_TRAIN)
@@ -432,7 +431,7 @@ def main(config, test_args): # pylint: disable=W0621
432431
max_logging.log(f"Loading HF model with dtype: {torch_dtype} (derived from config.dtype: {config.dtype})")
433432

434433
hf_model = AutoModelForCausalLM.from_pretrained(
435-
test_args.hf_model_path, dtype=torch_dtype, token=hf_token, trust_remote_code=test_args.trust_remote_code
434+
test_args.hf_model_path, torch_dtype=torch_dtype, token=hf_token, trust_remote_code=test_args.trust_remote_code
436435
)
437436
hf_lora_path = config.hf_lora_adapter_path
438437
if hf_lora_path:
@@ -469,8 +468,7 @@ def main(config, test_args): # pylint: disable=W0621
469468
if config.lora.enable_lora:
470469
maxtext_model = lora_utils.apply_lora_to_model(maxtext_model, mesh, config)
471470
if config.lora.lora_restore_path:
472-
mock_trainer = type("MockTrainer", (), {"model": maxtext_model, "train_steps": 0})
473-
lora_utils.restore_lora_from_path(mock_trainer, config)
471+
lora_utils.restore_lora_from_path(maxtext_model, config)
474472
maxtext_state = None
475473
else:
476474
maxtext_model = models.transformer_as_linen(config, mesh, quant=quant, model_mode=MODEL_MODE_TRAIN)

0 commit comments

Comments
 (0)