Skip to content

Commit cf7264e

Browse files
authored
feat(adapter/nemo): adapt to NeMo's new CheckpointIO interface and fix metadata persistence (#99)
This commit adapts ML Flashpoint to recent NeMo and Megatron-Core updates where `load_content_metadata` is now required during the checkpoint loading sequence, while also fixing several related persistence and tensor unwrapping issues.
1 parent 569d32c commit cf7264e

4 files changed

Lines changed: 178 additions & 7 deletions

File tree

src/ml_flashpoint/adapter/megatron/load_strategies.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,7 @@ def load(self, sharded_state_dict: ShardedStateDict, checkpoint_dir: Union[str,
9595
torch_dist_checkpoint.load(state_dict=pyt_state_dict, storage_reader=storage_reader, planner=planner)
9696

9797
mlf_state_dict: dict[str, Union[TorchShardedTensor, list[io.BytesIO]]] = {
98-
k: v if not isinstance(v, TorchShardedTensor) else _unwrap_pyt_sharded_tensor(v)
99-
for k, v in pyt_state_dict.items()
98+
k: _unwrap_pyt_sharded_tensor(v) for k, v in pyt_state_dict.items()
10099
}
101100
mlf_state_dict = _replace_sharded_keys_with_state_dict_keys(mlf_state_dict, flat_mapping, rename_mapping)
102101
# Need to restore dict key types to handle str<->int conversions for later merging/processing.

src/ml_flashpoint/adapter/nemo/checkpoint_io.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from megatron.core.dist_checkpointing.strategies.async_utils import (
2727
AsyncRequest as MegatronAsyncRequest,
2828
)
29-
from megatron.core.dist_checkpointing.strategies.common import TorchCommonLoadStrategy
29+
from megatron.core.dist_checkpointing.strategies.common import COMMON_STATE_FNAME, TorchCommonLoadStrategy
3030
from nemo.lightning.io.pl import MegatronCheckpointIO, TrainerContext, _fix_tensors_device
3131
from nemo.lightning.pytorch.trainer import Trainer
3232
from nemo.utils.callbacks.dist_ckpt_io import AsyncCompatibleCheckpointIO, AsyncFinalizableCheckpointIO
@@ -131,9 +131,13 @@ def save_checkpoint(
131131
"""
132132
if not _is_ml_flashpoint_checkpoint(self.flashpoint_base_dir, path):
133133
_LOGGER.info("Fallback to alternative checkpoint io.")
134-
return self.fallback_checkpoint_io.save_checkpoint(checkpoint, path)
134+
return self.fallback_checkpoint_io.save_checkpoint(checkpoint, path, storage_options=storage_options)
135135
_LOGGER.info("Use ML Flashpoint checkpoint io. Async_save: '%s'", self.async_save)
136136

137+
content_metadata = (storage_options or {}).get("content_metadata")
138+
if content_metadata is not None:
139+
checkpoint["content_metadata"] = content_metadata
140+
137141
# Use the helper for local-aware megatron save
138142
optional_async_request = save_local_aware_megatron_checkpoint(
139143
checkpoint=checkpoint,
@@ -265,6 +269,50 @@ def remove_checkpoint(self, path: _PATH) -> None:
265269
else:
266270
self.fallback_checkpoint_io.remove_checkpoint(path)
267271

272+
@override
273+
@log_execution_time(logger=_LOGGER, name="MLFlashpointCheckpointIO.load_content_metadata", level=logging.INFO)
274+
def load_content_metadata(self, path: Optional[_PATH] = None, preloaded_state_dict: Optional[dict] = None) -> dict:
275+
"""Loads checkpoint content metadata, handling ML Flashpoint checkpoints specifically.
276+
277+
This implementation is a specialized version of the standard logic found in NeMo's
278+
MegatronCheckpointIO (see: https://sourcegraph.com/r/github.com/NVIDIA-NeMo/NeMo@v2.5.0/-/blob/nemo/lightning/io/pl.py?L115),
279+
but is tailored to ML Flashpoint.
280+
281+
Standard helpers like `dist_checkpointing.load_content_metadata` are designed to read
282+
from the standard distributed metadata format (e.g., the .metadata file). However,
283+
ML Flashpoint checkpoints utilize a stub `metadata.json` containing only
284+
{"sharded_backend": ""} to satisfy Megatron's internal validation checks. Because this
285+
stub does not contain the actual training state, the standard helper would not be
286+
able to retrieve the correct metadata.
287+
288+
Therefore, this implementation persists the `content_metadata` inside the `common.pt`
289+
file and bypasses the stub to explicitly load the metadata from `common.pt`.
290+
291+
Args:
292+
path: The path to the checkpoint directory.
293+
preloaded_state_dict: Optional preloaded state dictionary.
294+
295+
Returns:
296+
A dictionary containing the metadata of the checkpoint.
297+
"""
298+
if not _is_ml_flashpoint_checkpoint(self.flashpoint_base_dir, path):
299+
_LOGGER.info("Fallback to alternative checkpoint io for load_content_metadata.")
300+
return self.fallback_checkpoint_io.load_content_metadata(path, preloaded_state_dict)
301+
302+
if preloaded_state_dict is not None:
303+
return preloaded_state_dict.get("content_metadata")
304+
305+
common_pt_path = os.path.join(path, COMMON_STATE_FNAME)
306+
if os.path.exists(common_pt_path):
307+
common_state_dict = torch.load(common_pt_path, map_location="cpu", weights_only=False)
308+
if "content_metadata" in common_state_dict:
309+
return common_state_dict["content_metadata"]
310+
311+
# Log a warning if the file exists but doesn't have the expected metadata key
312+
_LOGGER.warning("Checkpoint at %s exists but does not contain 'content_metadata'.", path)
313+
314+
return None
315+
268316

269317
class MLFlashpointAsyncFinalizableCheckpointIO(AsyncFinalizableCheckpointIO):
270318
"""CheckpointIO wrapper for async checkpoint saving and synchronous finalization

tests/adapter/megatron/test_load_strategies.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,14 @@ def _setup_load_mocks(mocker, global_rank):
435435
"ml_flashpoint.adapter.megatron.load_strategies.mcore_to_pyt_state_dict",
436436
return_value=mock_pyt_state_dict,
437437
)
438+
439+
def mock_unwrap(sh_ten):
440+
if isinstance(sh_ten, list):
441+
return sh_ten
442+
return [torch.empty(10, 20, dtype=torch.float32)]
443+
438444
mocker.patch(
439445
"ml_flashpoint.adapter.megatron.load_strategies._unwrap_pyt_sharded_tensor",
440-
return_value=[torch.empty(10, 20, dtype=torch.float32)],
446+
side_effect=mock_unwrap,
441447
)
442448
return load_patched_fn

tests/adapter/nemo/test_checkpoint_io.py

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,18 @@ def test_save_checkpoint_fallback(self, checkpoint_io_components, tmp_path, mock
170170
checkpoint = {"model": torch.nn.Linear(2, 2)}
171171
ckpt_version_path = str(tmp_path / "diff_path")
172172

173+
storage_options = {"content_metadata": {"version": 1}}
174+
173175
expected_return = mocker.MagicMock()
174176
alt_checkpoint_io.save_checkpoint.return_value = expected_return
175177

176178
# When
177-
result = checkpoint_io.save_checkpoint(checkpoint, ckpt_version_path)
179+
result = checkpoint_io.save_checkpoint(checkpoint, ckpt_version_path, storage_options=storage_options)
178180

179181
# Then
180-
alt_checkpoint_io.save_checkpoint.assert_called_once_with(checkpoint, ckpt_version_path)
182+
alt_checkpoint_io.save_checkpoint.assert_called_once_with(
183+
checkpoint, ckpt_version_path, storage_options=storage_options
184+
)
181185
assert result is expected_return
182186

183187
def test_save_ml_flashpoint_checkpoint_writes_common_state_dict(self, checkpoint_io_components, mocker):
@@ -222,6 +226,120 @@ def test_save_ml_flashpoint_checkpoint_writes_common_state_dict(self, checkpoint
222226
loaded_common_state_dict = torch.load(common_state_file_path)
223227
assert loaded_common_state_dict == common_state_dict
224228

229+
def test_save_ml_flashpoint_checkpoint_writes_metadata(self, checkpoint_io_components, mocker):
230+
"""Tests that content_metadata is injected into the checkpoint before saving."""
231+
# Given
232+
mocker.patch("ml_flashpoint.adapter.megatron.save_utils.torch.distributed.get_node_local_rank", return_value=0)
233+
checkpoint_io = checkpoint_io_components["checkpoint_io"]
234+
base_path = checkpoint_io_components["base_path"]
235+
ckpt_version_path = base_path + "/checkpoint1"
236+
237+
checkpoint = {"some_state": 123}
238+
storage_options = {"content_metadata": {"is_mlf": True}}
239+
240+
mock_save_preprocess = mocker.patch(
241+
"ml_flashpoint.adapter.megatron.save_utils.mcore_state_dict_utils.save_preprocess", return_value=({}, {})
242+
)
243+
mocker.patch("ml_flashpoint.adapter.megatron.save_utils.torch.save")
244+
mocker.patch.object(checkpoint_io, "_save_context")
245+
246+
# When
247+
checkpoint_io.save_checkpoint(checkpoint, ckpt_version_path, storage_options)
248+
249+
# Then
250+
mock_save_preprocess.assert_called_once()
251+
modified_checkpoint = mock_save_preprocess.call_args[0][0]
252+
assert "content_metadata" in modified_checkpoint
253+
assert modified_checkpoint["content_metadata"] == {"is_mlf": True}
254+
255+
def test_save_ml_flashpoint_checkpoint_does_not_overwrite_existing_metadata(self, checkpoint_io_components, mocker):
256+
"""Tests that existing content_metadata in the checkpoint is not overwritten
257+
if storage_options doesn't provide it."""
258+
# Given
259+
mocker.patch("ml_flashpoint.adapter.megatron.save_utils.torch.distributed.get_node_local_rank", return_value=0)
260+
checkpoint_io = checkpoint_io_components["checkpoint_io"]
261+
base_path = checkpoint_io_components["base_path"]
262+
ckpt_version_path = base_path + "/checkpoint_no_overwrite"
263+
264+
# Prepare a checkpoint that already contains metadata
265+
original_metadata = {"existing_key": "original_value"}
266+
checkpoint = {"model_state": [1, 2, 3], "content_metadata": original_metadata}
267+
268+
mocker.patch(
269+
"ml_flashpoint.adapter.megatron.save_utils.mcore_state_dict_utils.save_preprocess", return_value=({}, {})
270+
)
271+
272+
mocker.patch("ml_flashpoint.adapter.megatron.save_utils.torch.save")
273+
mocker.patch.object(checkpoint_io, "_save_context")
274+
275+
# Scenario 1: storage_options is None
276+
# When
277+
checkpoint_io.save_checkpoint(checkpoint, ckpt_version_path, storage_options=None)
278+
# Then: Verify metadata was not modified or removed
279+
assert checkpoint["content_metadata"] == original_metadata
280+
281+
# Scenario 2: storage_options is an empty dictionary {}
282+
# When
283+
checkpoint_io.save_checkpoint(checkpoint, ckpt_version_path, storage_options={})
284+
# Then: Verify metadata still remains unchanged
285+
assert checkpoint["content_metadata"] == original_metadata
286+
287+
def test_load_content_metadata_fallback(self, checkpoint_io_components, tmp_path):
288+
"""Tests load_content_metadata falls back to alternative IO for non-MLF paths."""
289+
# Given
290+
checkpoint_io = checkpoint_io_components["checkpoint_io"]
291+
alt_checkpoint_io = checkpoint_io_components["alt_checkpoint_io"]
292+
ckpt_version_path = str(tmp_path / "diff_path")
293+
294+
expected_metadata = {"meta": "fallback"}
295+
alt_checkpoint_io.load_content_metadata.return_value = expected_metadata
296+
297+
# When
298+
result = checkpoint_io.load_content_metadata(ckpt_version_path)
299+
300+
# Then
301+
alt_checkpoint_io.load_content_metadata.assert_called_once_with(ckpt_version_path, None)
302+
assert result == expected_metadata
303+
304+
def test_load_content_metadata_from_preloaded(self, checkpoint_io_components):
305+
"""Tests load_content_metadata prioritizes preloaded_state_dict."""
306+
# Given
307+
checkpoint_io = checkpoint_io_components["checkpoint_io"]
308+
ckpt_version_path = checkpoint_io.flashpoint_base_dir.data + "/checkpoint1"
309+
310+
expected_metadata = {"from_memory": True}
311+
preloaded = {"content_metadata": expected_metadata}
312+
313+
# When
314+
result = checkpoint_io.load_content_metadata(ckpt_version_path, preloaded_state_dict=preloaded)
315+
316+
# Then
317+
assert result == expected_metadata
318+
319+
def test_load_content_metadata_from_disk(self, checkpoint_io_components, mocker):
320+
"""Tests load_content_metadata loads from common.pt."""
321+
# Given
322+
checkpoint_io = checkpoint_io_components["checkpoint_io"]
323+
ckpt_version_path = checkpoint_io.flashpoint_base_dir.data + "/checkpoint1"
324+
325+
mocker.patch("ml_flashpoint.adapter.nemo.checkpoint_io.os.path.exists", return_value=True)
326+
327+
expected_metadata = {"from_disk": True}
328+
# Mock torch.load to return a dictionary containing our expected content_metadata
329+
mock_torch_load = mocker.patch(
330+
"ml_flashpoint.adapter.nemo.checkpoint_io.torch.load", return_value={"content_metadata": expected_metadata}
331+
)
332+
333+
# When
334+
result = checkpoint_io.load_content_metadata(ckpt_version_path)
335+
336+
# Then
337+
# It should load the common state dict from disk safely (CPU, weights_only=False) and extract the metadata
338+
mock_torch_load.assert_called_once()
339+
assert mock_torch_load.call_args[1]["map_location"] == "cpu"
340+
assert mock_torch_load.call_args[1]["weights_only"] is False
341+
assert result == expected_metadata
342+
225343
def test_save_ml_flashpoint_checkpoint_async_success(self, checkpoint_io_components, mocker):
226344
"""Tests a successful asynchronous MLF save."""
227345
# Given

0 commit comments

Comments
 (0)