Skip to content

Commit ec8b0e0

Browse files
Merge pull request #4269 from AI-Hypercomputer:jackyf/lora-ckpt-metadata
PiperOrigin-RevId: 941363101
2 parents 1300488 + 985e866 commit ec8b0e0

6 files changed

Lines changed: 258 additions & 11 deletions

File tree

src/maxtext/checkpoint_conversion/to_huggingface.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
from maxtext.utils import max_logging
9898
from maxtext.utils import max_utils
9999
from maxtext.utils.globals import HF_IDS
100+
from maxtext.utils.lora_utils import sync_lora_metadata
100101

101102

102103
flags.DEFINE_bool(
@@ -451,6 +452,9 @@ def main(argv: Sequence[str]) -> None:
451452
if not load_parameters_path and not lora_restore_path:
452453
raise ValueError("Either load_parameters_path or lora_restore_path must be specified.")
453454

455+
if lora_restore_path:
456+
sync_lora_metadata(config)
457+
454458
# Load Maxtext checkpoint using Orbax (now smart enough to load both if present)
455459
max_logging.log("\nLoading Orbax checkpoint(s)...")
456460
start = time.time()

src/maxtext/common/checkpointing.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,26 @@ def save_params_to_path(checkpoint_dir, params, use_ocdbt=True, use_zarr3=True):
10231023
print(f"Quantized params checkpoint saved at: {checkpoint_dir}")
10241024

10251025

1026+
def load_checkpoint_metadata(checkpoint_dir_path: str) -> dict[str, Any]:
1027+
"""Loads custom metadata from an Orbax checkpoint.
1028+
1029+
Args:
1030+
checkpoint_dir_path: Path to the checkpoint directory.
1031+
1032+
Returns:
1033+
A dictionary containing custom metadata, or an empty dictionary if none is
1034+
present or loading fails.
1035+
"""
1036+
checkpoint_dir = epath.Path(checkpoint_dir_path)
1037+
try:
1038+
ckptr = ocp.StandardCheckpointer()
1039+
metadata = ckptr.metadata(checkpoint_dir)
1040+
return metadata.custom_metadata or {}
1041+
except Exception as e: # pylint: disable=broad-except
1042+
max_logging.log(f"Warning: Failed to load checkpoint metadata: {e}")
1043+
return {}
1044+
1045+
10261046
def maybe_save_checkpoint(checkpoint_manager, state, config, data_iterator, step=None):
10271047
"""Save checkpoint if checkpointing is enabled."""
10281048
if checkpoint_manager is None:
@@ -1142,11 +1162,19 @@ def save_checkpoint(checkpoint_manager, step, state, config=None, data_iterator=
11421162
grain_iters_to_save.append((data_iter.local_iterator, process_index, process_count_total))
11431163
save_args_composite["iter"] = GrainCheckpointSave(item=grain_iters_to_save)
11441164

1165+
custom_metadata = None
1166+
if config and hasattr(config, "lora") and config.lora:
1167+
lora_rank = getattr(config.lora, "lora_rank", 0)
1168+
if lora_rank > 0 and hasattr(config.lora, "model_dump"):
1169+
custom_metadata = {"lora": config.lora.model_dump()}
1170+
11451171
match (checkpoint_manager, config, data_iterator):
11461172
case (checkpoint_manager, _, _) if isinstance(
11471173
checkpoint_manager, (EmergencyCheckpointManager, EmergencyReplicatorCheckpointManager)
11481174
):
11491175
replicator_error_handler(config)
11501176
return checkpoint_manager.save(step, args=Composite(state=checkpoint_args), force=force)
11511177
case _:
1152-
return checkpoint_manager.save(step, args=Composite(**save_args_composite), force=force)
1178+
return checkpoint_manager.save(
1179+
step, args=Composite(**save_args_composite), force=force, custom_metadata=custom_metadata
1180+
)

src/maxtext/utils/lora_utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,45 @@ def _verify_lora_parameters(lora_model: nnx.Module, mt_config: pyconfig.HyperPar
515515
)
516516

517517

518+
def sync_lora_metadata(config: pyconfig.HyperParameters) -> None:
519+
"""Syncs LoRA parameters (rank, alpha) from the checkpoint sidecar metadata if present.
520+
521+
If configuration values are set to non-default values (i.e. rank > 0 or alpha > 0.0)
522+
and differ from the checkpoint metadata values, we raise a ValueError to fail the run.
523+
If they are at default values, we sync them from the checkpoint.
524+
"""
525+
lora_restore_path = config.lora.lora_restore_path
526+
if not lora_restore_path:
527+
return
528+
529+
custom_metadata = checkpointing.load_checkpoint_metadata(lora_restore_path)
530+
lora_meta = custom_metadata.get("lora")
531+
532+
if lora_meta:
533+
meta_rank = lora_meta.get("lora_rank", config.lora.lora_rank)
534+
meta_alpha = lora_meta.get("lora_alpha", config.lora.lora_alpha)
535+
536+
# Check lora_rank
537+
if config.lora.lora_rank not in (0, meta_rank):
538+
raise ValueError(
539+
f"Configured lora_rank ({config.lora.lora_rank}) does not match "
540+
f"checkpoint metadata lora_rank ({meta_rank}) at {lora_restore_path}."
541+
)
542+
# Check lora_alpha
543+
if config.lora.lora_alpha not in (0.0, meta_alpha):
544+
raise ValueError(
545+
f"Configured lora_alpha ({config.lora.lora_alpha}) does not match "
546+
f"checkpoint metadata lora_alpha ({meta_alpha}) at {lora_restore_path}."
547+
)
548+
549+
config.lora.lora_rank = meta_rank
550+
config.lora.lora_alpha = meta_alpha
551+
max_logging.log(
552+
f"Synced LoRA parameters from Orbax metadata at {lora_restore_path}: "
553+
f"rank={config.lora.lora_rank}, alpha={config.lora.lora_alpha}"
554+
)
555+
556+
518557
def apply_lora_to_model(
519558
model: nnx.Module,
520559
mesh: Optional[jax.sharding.Mesh],
@@ -585,6 +624,8 @@ def _safe_reshard(var, sharding_spec):
585624
def restore_lora_from_path(trainer: Any, mt_config: pyconfig.HyperParameters) -> Any:
586625
"""Restores LoRA parameter weights from an external Orbax checkpoint for a fresh run."""
587626
lora_restore_path = mt_config.lora.lora_restore_path
627+
if not lora_restore_path:
628+
return trainer
588629

589630
train_steps = getattr(trainer, "train_steps", 0)
590631
if train_steps > 0:
@@ -601,6 +642,8 @@ def restore_lora_from_path(trainer: Any, mt_config: pyconfig.HyperParameters) ->
601642
f"Set lora.enable_lora=True and verify lora_module_path ('{lora_module_path}') matches model modules."
602643
)
603644

645+
sync_lora_metadata(mt_config)
646+
604647
abstract_lora_params = nnx.state(trainer.model, nnx.LoRAParam)
605648

606649
target_for_restore = jax.tree.map(

tests/post_training/unit/lora_utils_test.py

Lines changed: 156 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@
1515
"""Tests for Qwix LoRA utils in lora_utils.py"""
1616
import re
1717
import sys
18+
import tempfile
1819
import unittest
1920
from unittest import mock
21+
22+
from etils import epath
2023
import jax
24+
import jax.numpy as jnp
2125
import optax
2226
import pytest
2327
from flax import nnx
@@ -26,6 +30,7 @@
2630
pytestmark = [pytest.mark.post_training]
2731

2832
# Now safe to do top-level imports
33+
from maxtext.common import checkpointing
2934
from tunix.sft import peft_trainer
3035
from maxtext.utils import lora_utils
3136
from maxtext.utils import model_creation_utils
@@ -59,11 +64,12 @@
5964

6065
def _make_config(**overrides):
6166
"""Return a MaxTextConfig object suitable for unit tests."""
67+
config_dict = _BASE_CONFIG.copy()
68+
config_dict.update(overrides)
6269
# Use initialize_pydantic to get nested models as objects (attribute access)
6370
return pyconfig.initialize_pydantic(
6471
[sys.argv[0], get_test_config_path()],
65-
**_BASE_CONFIG,
66-
**overrides,
72+
**config_dict,
6773
)
6874

6975

@@ -121,7 +127,12 @@ def test_build_lora_provider(self):
121127
with mock.patch("qwix.LoraProvider") as mock_provider:
122128
lora_utils._build_lora_provider(mock_config)
123129
mock_provider.assert_called_once_with(
124-
module_path="custom/path", rank=8, alpha=16.0, dropout=0.0, weight_qtype="int8", tile_size=32
130+
module_path="custom/path",
131+
rank=8,
132+
alpha=16.0,
133+
dropout=0.0,
134+
weight_qtype="int8",
135+
tile_size=32,
125136
)
126137

127138
def test_prepare_dummy_inputs(self):
@@ -173,7 +184,13 @@ def test_apply_lora_to_model_adapters_loaded(self):
173184
# If we skip Qwix, it should stay False.
174185
self.assertFalse(lora_utils.is_lora_enabled(result))
175186

176-
def _run_apply_lora_test(self, scan_layers: bool, weight_qtype=None, tile_size=None, mock_multihost: bool = False):
187+
def _run_apply_lora_test(
188+
self,
189+
scan_layers: bool,
190+
weight_qtype=None,
191+
tile_size=None,
192+
mock_multihost: bool = False,
193+
):
177194
"""Helper to run LoRA application test with/without scanned layers and optional QLoRA."""
178195
# Passing nested dict as 'lora' kwarg to _make_config
179196
cfg = _make_config(
@@ -246,7 +263,12 @@ def test_apply_lora_multihost_mock(self):
246263
def test_restore_lora_from_path(self):
247264
"""Test restoration of LoRA parameters from a path."""
248265
cfg = _make_config(
249-
lora={"enable_lora": True, "lora_restore_path": "some/path", "lora_rank": 4, "lora_alpha": 8.0},
266+
lora={
267+
"enable_lora": True,
268+
"lora_restore_path": "some/path",
269+
"lora_rank": 4,
270+
"lora_alpha": 8.0,
271+
},
250272
scan_layers=False,
251273
)
252274
model, _ = model_creation_utils.from_pretrained(cfg, mesh=None, model_mode=model_creation_utils.MODEL_MODE_TRAIN)
@@ -271,6 +293,135 @@ def test_restore_lora_from_path(self):
271293
self.assertTrue(kwargs["args"].partial_restore)
272294
mock_update.assert_called_once()
273295

296+
def test_sync_lora_metadata_default_syncs(self):
297+
"""Test that default lora rank/alpha are successfully synced from checkpoint metadata."""
298+
cfg = _make_config(
299+
lora={
300+
"enable_lora": True,
301+
"lora_restore_path": "dummy/path",
302+
"lora_rank": 0,
303+
"lora_alpha": 0.0,
304+
}
305+
)
306+
mock_metadata = mock.MagicMock()
307+
mock_metadata.custom_metadata = {"lora": {"lora_rank": 32, "lora_alpha": 64.0}}
308+
309+
with mock.patch("orbax.checkpoint.StandardCheckpointer.metadata", return_value=mock_metadata):
310+
lora_utils.sync_lora_metadata(cfg)
311+
self.assertEqual(cfg.lora.lora_rank, 32)
312+
self.assertEqual(cfg.lora.lora_alpha, 64.0)
313+
314+
def test_sync_lora_metadata_matching_passes(self):
315+
"""Test that matching non-default parameters pass without errors."""
316+
cfg = _make_config(
317+
lora={
318+
"enable_lora": True,
319+
"lora_restore_path": "dummy/path",
320+
"lora_rank": 32,
321+
"lora_alpha": 64.0,
322+
}
323+
)
324+
mock_metadata = mock.MagicMock()
325+
mock_metadata.custom_metadata = {"lora": {"lora_rank": 32, "lora_alpha": 64.0}}
326+
327+
with mock.patch("orbax.checkpoint.StandardCheckpointer.metadata", return_value=mock_metadata):
328+
# Should not raise ValueError
329+
lora_utils.sync_lora_metadata(cfg)
330+
self.assertEqual(cfg.lora.lora_rank, 32)
331+
self.assertEqual(cfg.lora.lora_alpha, 64.0)
332+
333+
def test_sync_lora_metadata_rank_mismatch_fails(self):
334+
"""Test that configured rank mismatching checkpoint metadata rank raises ValueError."""
335+
cfg = _make_config(
336+
lora={
337+
"enable_lora": True,
338+
"lora_restore_path": "dummy/path",
339+
"lora_rank": 8,
340+
"lora_alpha": 64.0,
341+
}
342+
)
343+
mock_metadata = mock.MagicMock()
344+
mock_metadata.custom_metadata = {"lora": {"lora_rank": 32, "lora_alpha": 64.0}}
345+
346+
with mock.patch("orbax.checkpoint.StandardCheckpointer.metadata", return_value=mock_metadata):
347+
with self.assertRaisesRegex(ValueError, "Configured lora_rank .* does not match"):
348+
lora_utils.sync_lora_metadata(cfg)
349+
350+
def test_sync_lora_metadata_alpha_mismatch_fails(self):
351+
"""Test that configured alpha mismatching checkpoint metadata alpha raises ValueError."""
352+
cfg = _make_config(
353+
lora={
354+
"enable_lora": True,
355+
"lora_restore_path": "dummy/path",
356+
"lora_rank": 32,
357+
"lora_alpha": 16.0,
358+
}
359+
)
360+
mock_metadata = mock.MagicMock()
361+
mock_metadata.custom_metadata = {"lora": {"lora_rank": 32, "lora_alpha": 64.0}}
362+
363+
with mock.patch("orbax.checkpoint.StandardCheckpointer.metadata", return_value=mock_metadata):
364+
with self.assertRaisesRegex(ValueError, "Configured lora_alpha .* does not match"):
365+
lora_utils.sync_lora_metadata(cfg)
366+
367+
def test_save_checkpoint_passes_metadata(self):
368+
"""Test that save_checkpoint correctly generates and passes custom lora metadata to CheckpointManager."""
369+
cfg = _make_config(
370+
lora={"enable_lora": True, "lora_rank": 8, "lora_alpha": 16.0},
371+
enable_checkpointing=True,
372+
)
373+
mock_manager = mock.MagicMock()
374+
mock_state = mock.MagicMock()
375+
376+
with mock.patch("jax.block_until_ready"):
377+
checkpointing.save_checkpoint(mock_manager, step=10, state=mock_state, config=cfg)
378+
mock_manager.save.assert_called_once()
379+
_, kwargs = mock_manager.save.call_args
380+
self.assertIn("custom_metadata", kwargs)
381+
self.assertEqual(kwargs["custom_metadata"], {"lora": cfg.lora.model_dump()})
382+
383+
def test_save_and_restore_metadata_integration(self):
384+
"""Integration test checking that Orbax CheckpointManager writes and reads custom LoRA metadata."""
385+
386+
cfg_save = _make_config(
387+
lora={"enable_lora": True, "lora_rank": 8, "lora_alpha": 16.0},
388+
enable_checkpointing=True,
389+
)
390+
391+
with tempfile.TemporaryDirectory() as tmpdir:
392+
manager = checkpointing.create_orbax_checkpoint_manager(
393+
tmpdir,
394+
enable_checkpointing=True,
395+
use_async=False,
396+
save_interval_steps=1,
397+
use_ocdbt=False,
398+
use_zarr3=False,
399+
)
400+
401+
# Use save_checkpoint wrapper with a simple state
402+
dummy_state = {"weight": jnp.array([1.0, 2.0])}
403+
checkpointing.save_checkpoint(manager, step=0, state=dummy_state, config=cfg_save)
404+
manager.wait_until_finished()
405+
406+
# Now verify that the saved checkpoint contains metadata on disk
407+
checkpoint_dir = epath.Path(tmpdir) / "0"
408+
self.assertTrue((checkpoint_dir / "_CHECKPOINT_METADATA").exists())
409+
410+
# Restore using sync_lora_metadata on a config with default rank/alpha
411+
cfg_restore = _make_config(
412+
lora={
413+
"enable_lora": True,
414+
"lora_restore_path": str(checkpoint_dir),
415+
"lora_rank": 0,
416+
"lora_alpha": 0.0,
417+
}
418+
)
419+
lora_utils.sync_lora_metadata(cfg_restore)
420+
421+
# Verify values were successfully synced back
422+
self.assertEqual(cfg_restore.lora.lora_rank, 8)
423+
self.assertEqual(cfg_restore.lora.lora_alpha, 16.0)
424+
274425
def test_gemma4_lora_path_matching(self):
275426
"""Test that the Gemma4 LoRA regex correctly matches all expected parameter paths."""
276427
mock_config = mock.MagicMock(spec=pyconfig.HyperParameters)
@@ -309,10 +460,6 @@ def test_gemma4_lora_path_matching(self):
309460
"decoder/layers_remainder/layers/0/mlp/shared_experts/wi_0/kernel",
310461
"decoder/layers_remainder/layers/0/mlp/shared_experts/wi_1/kernel",
311462
"decoder/layers_remainder/layers/0/mlp/shared_experts/wo/kernel",
312-
# No scanned_blocks/layers_remainder prefix (e.g. fallback or direct structure)
313-
"decoder/layers/0/self_attention/query/kernel",
314-
"decoder/layers/0/mlp/wi_0/kernel",
315-
"decoder/layers/layers/0/mlp/shared_experts/wi_0/kernel",
316463
]
317464

318465
for path in matching_paths:

tests/unit/checkpointing_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,5 +305,29 @@ def __init__(self):
305305
np.testing.assert_allclose(loaded_weight, dummy_weight)
306306

307307

308+
class CheckpointMetadataTest(parameterized.TestCase):
309+
"""Tests for loading checkpoint custom metadata."""
310+
311+
@mock.patch.object(checkpointing.ocp, "StandardCheckpointer")
312+
def test_load_checkpoint_metadata(self, mock_checkpointer_cls):
313+
mock_ckptr = mock_checkpointer_cls.return_value
314+
mock_metadata = mock.MagicMock()
315+
mock_metadata.custom_metadata = {"lora": {"lora_rank": 8, "lora_alpha": 16.0}}
316+
mock_ckptr.metadata.return_value = mock_metadata
317+
318+
loaded_metadata = checkpointing.load_checkpoint_metadata("dummy/path")
319+
self.assertEqual(loaded_metadata.get("lora"), {"lora_rank": 8, "lora_alpha": 16.0})
320+
mock_ckptr.metadata.assert_called_once()
321+
322+
@mock.patch.object(checkpointing.ocp, "StandardCheckpointer")
323+
def test_load_checkpoint_metadata_handles_exceptions(self, mock_checkpointer_cls):
324+
mock_ckptr = mock_checkpointer_cls.return_value
325+
mock_ckptr.metadata.side_effect = Exception("Checkpoint read error")
326+
327+
loaded_metadata = checkpointing.load_checkpoint_metadata("corrupt/path")
328+
self.assertEqual(loaded_metadata, {})
329+
mock_ckptr.metadata.assert_called_once()
330+
331+
308332
if __name__ == "__main__":
309333
absltest.main()

0 commit comments

Comments
 (0)