Skip to content

Commit bff4616

Browse files
angel-coreGoogle-ML-Automation
authored andcommitted
Refactor grain checkpoint handler functionality into grain_utility file.
PiperOrigin-RevId: 951631507
1 parent 0faa9be commit bff4616

4 files changed

Lines changed: 343 additions & 305 deletions

File tree

src/maxtext/common/checkpointing.py

Lines changed: 13 additions & 291 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@
1515

1616
"""Create an Orbax CheckpointManager with specified (Async or not) Checkpointer."""
1717

18-
import asyncio
1918
import time
20-
from typing import Any, Optional
19+
from typing import Any
2120

2221
from absl import flags
2322
import datetime
@@ -29,14 +28,14 @@
2928
from maxtext.input_pipeline.multihost_dataloading import MultiHostDataLoadIterator
3029
from maxtext.input_pipeline.multihost_dataloading import RemoteIteratorWrapper
3130
from maxtext.input_pipeline.synthetic_data_processing import PlaceHolderDataIterator
31+
from maxtext.common import grain_utility
3232
from maxtext.common import train_state_nnx
3333
from maxtext.utils import exceptions
3434
from maxtext.utils import max_logging
3535
from maxtext.utils import gcs_utils
3636
from maxtext.utils import elastic_utils
3737
from maxtext.checkpoint_conversion.utils.load_dynamic import load_safetensors_dynamic_state
3838

39-
import numpy as np
4039
import orbax.checkpoint as ocp
4140
from orbax.checkpoint import v1 as ocp_v1
4241
from orbax.checkpoint._src.arrays import sharding as sharding_utils
@@ -45,11 +44,7 @@
4544
import orbax.checkpoint.experimental.emergency.checkpoint_manager as emergency_checkpoint_manager
4645
import orbax.checkpoint.experimental.emergency.replicator_checkpoint_manager as emergency_replicator_checkpoint_manager
4746
# pylint: disable=too-many-positional-arguments
48-
import dataclasses
49-
import json
5047

51-
import grain
52-
from grain.python import PyGrainCheckpointHandler
5348
from grain.experimental import ElasticIterator
5449

5550
CheckpointManager = ocp.CheckpointManager
@@ -62,152 +57,6 @@
6257
EmergencyReplicatorCheckpointManager = emergency_replicator_checkpoint_manager.ReplicatorCheckpointManager
6358

6459

65-
class GrainCheckpointHandler(PyGrainCheckpointHandler, ocp.CheckpointHandler):
66-
"""A CheckpointHandler that allows specifying process_index and process_count."""
67-
68-
def save(
69-
self,
70-
directory: epath.Path,
71-
# `item` is for backwards compatibility with older Orbax API, see
72-
# https://orbax.readthedocs.io/en/latest/guides/checkpoint/api_refactor.html.
73-
item: Optional[Any] = None,
74-
args: Any = None,
75-
):
76-
"""Saves the given iterator to the checkpoint in `directory`."""
77-
item = item or args.item # pytype:disable=attribute-error
78-
79-
# RemoteIteratorWrapper handles checkpointing via colocated python
80-
if isinstance(item, RemoteIteratorWrapper):
81-
step = int(directory.parent.name)
82-
item.save_state(step)
83-
return
84-
85-
# ElasticIterator state is a single global scalar shared by all shards,
86-
# so we write one fixed `process_0.json` from process 0 only. This file
87-
# layout survives changes in `jax.process_count()`.
88-
if isinstance(item, ElasticIterator):
89-
if jax.process_index() == 0:
90-
directory.mkdir(parents=True, exist_ok=True)
91-
filename = directory / "process_0.json"
92-
filename.write_text(json.dumps(item.get_state(), indent=4))
93-
return
94-
95-
def save_single_process(item, process_index, process_count):
96-
filename = directory / f"process_{process_index}-of-{process_count}.json"
97-
if isinstance(item, grain.DatasetIterator):
98-
state = json.dumps(item.get_state(), indent=4)
99-
else:
100-
state = item.get_state().decode()
101-
filename.write_text(state)
102-
103-
if isinstance(item, list):
104-
for local_iterator, process_index, process_count in item:
105-
save_single_process(local_iterator, process_index, process_count)
106-
else:
107-
process_index, process_count = jax.process_index(), jax.process_count()
108-
save_single_process(item, process_index, process_count)
109-
110-
def restore(
111-
self,
112-
directory: epath.Path,
113-
item: Optional[Any] = None,
114-
args: Any = None,
115-
) -> Any:
116-
"""Restores the given iterator from the checkpoint in `directory`."""
117-
item = item or args.item
118-
process_index = getattr(args, "process_index", None)
119-
process_count = getattr(args, "process_count", None)
120-
121-
# In Pathways + colocated_python environment, RemoteIteratorWrapper handles checkpointing
122-
if isinstance(item, RemoteIteratorWrapper):
123-
step = int(directory.parent.name)
124-
item.restore_state(step)
125-
return item
126-
127-
# McJax and Pathways through controller cases
128-
# ElasticIterator: every process reads the same shared `process_0.json`.
129-
if isinstance(item, ElasticIterator):
130-
filename = directory / "process_0.json"
131-
if not filename.exists():
132-
raise ValueError(f"File {filename} does not exist.")
133-
item.set_state(json.loads(filename.read_text()))
134-
return item
135-
136-
def restore_single_process(item, process_index, process_count):
137-
filename = directory / f"process_{process_index}-of-{process_count}.json"
138-
if not filename.exists():
139-
raise ValueError(f"File {filename} does not exist.")
140-
state = filename.read_text()
141-
if isinstance(item, grain.DatasetIterator):
142-
state = json.loads(state)
143-
else:
144-
state = state.encode()
145-
item.set_state(state)
146-
return item
147-
148-
if isinstance(item, list):
149-
restored_items = []
150-
for data_iter, process_idx in zip(item, process_index): # pyrefly: ignore[bad-argument-type]
151-
restored_items.append(restore_single_process(data_iter, process_idx, process_count))
152-
return restored_items
153-
else:
154-
if process_index is None or process_count is None:
155-
process_index, process_count = jax.process_index(), jax.process_count()
156-
return restore_single_process(item, process_index, process_count)
157-
158-
159-
@ocp.args.register_with_handler(GrainCheckpointHandler, for_save=True)
160-
@dataclasses.dataclass
161-
class GrainCheckpointSave(ocp.args.CheckpointArgs):
162-
item: Any
163-
164-
165-
@ocp.args.register_with_handler(GrainCheckpointHandler, for_restore=True)
166-
@dataclasses.dataclass
167-
class GrainCheckpointRestore(ocp.args.CheckpointArgs):
168-
item: Any
169-
process_index: Optional[int | list[int]] = None
170-
process_count: Optional[int] = None
171-
172-
173-
class GrainCheckpointable(ocp_v1.StatefulCheckpointable):
174-
"""Adapts `GrainCheckpointHandler` to Orbax v1's `StatefulCheckpointable`."""
175-
176-
def __init__(
177-
self,
178-
*,
179-
save_args: GrainCheckpointSave | None = None,
180-
restore_args: GrainCheckpointRestore | None = None,
181-
):
182-
self._handler = GrainCheckpointHandler()
183-
self._save_args = save_args
184-
self._restore_args = restore_args
185-
186-
async def save(self, directory):
187-
"""Saves the Grain iterator state to the given directory."""
188-
# `GrainCheckpointHandler.save` snapshots iterator state (`get_state`) AND
189-
# writes it; both must happen in this (blocking) save phase, NOT in the
190-
# returned background coroutine.
191-
path = await directory.await_creation()
192-
self._handler.save(path, args=self._save_args)
193-
194-
async def _committed(): # nothing left for the background commit phase
195-
return None
196-
197-
return _committed()
198-
199-
async def load(self, directory: epath.Path):
200-
"""Loads the Grain iterator state from the given directory."""
201-
handler, args = self._handler, self._restore_args
202-
203-
# This will be ran to completion so asynchronous portion is just for
204-
# compatibility with Orbax v1 API.
205-
async def _background_load():
206-
await asyncio.to_thread(handler.restore, directory, args=args)
207-
208-
return _background_load()
209-
210-
21160
def _weight_mismatches(want, have, path=()):
21261
"""Returns `(path, problem)` for each weight in `want` that `have` didn't restore faithfully.
21362
@@ -462,7 +311,7 @@ def create_orbax_checkpoint_manager(
462311

463312
if dataset_type is not None and dataset_type == "grain":
464313
item_names += ("iter",)
465-
item_handlers["iter"] = GrainCheckpointHandler() # pyrefly: ignore[bad-assignment]
314+
item_handlers["iter"] = grain_utility.GrainCheckpointHandler() # pyrefly: ignore[bad-assignment]
466315

467316
# local storage checkpoint needs parent directory created
468317
p = gcs_utils.mkdir_and_check_permissions(checkpoint_dir)
@@ -623,137 +472,6 @@ def print_save_message(step, async_checkpointing):
623472
max_logging.log(f"Saved a checkpoint at step {step}.")
624473

625474

626-
def _find_idx(array: np.ndarray, replica_axis_idx: int):
627-
"""Returns the index along given dimension that the current host belongs to."""
628-
idx = None
629-
for idx, val in np.ndenumerate(array):
630-
if val.process_index == jax.process_index():
631-
break
632-
return idx[replica_axis_idx]
633-
634-
635-
def _replica_devices(device_array: np.ndarray, replica_axis_idx: int):
636-
"""Returns the devices from the replica that current host belongs to.
637-
638-
Replicas are assumed to be restricted to the first axis.
639-
640-
Args:
641-
device_array: devices of the mesh that can be obtained by mesh.devices()
642-
replica_axis_idx: axis dimension along which replica is taken
643-
644-
Returns:
645-
devices inside the replica that current host is in
646-
"""
647-
idx = _find_idx(device_array, replica_axis_idx)
648-
replica_result = np.take(device_array, idx, axis=replica_axis_idx)
649-
return np.expand_dims(replica_result, axis=replica_axis_idx)
650-
651-
652-
def _prepare_scaled_down_grain_restore_args(
653-
data_iterator: list, process_count_jax: int, process_count_stored: int, directory: epath.Path
654-
) -> GrainCheckpointRestore:
655-
"""
656-
Prepares the restore arguments for a scaled-up (list) data iterator.
657-
658-
This is used when restoring a checkpoint saved with more processes than
659-
the current run (e.g., 64 files onto 32 JAX processes).
660-
"""
661-
# 1. Validation Assertions
662-
assert isinstance(data_iterator, list), (
663-
f"{process_count_stored} processes found in Grain checkpoint directory {directory}, but only "
664-
f"{process_count_jax} jax processes in this run, please set expansion_factor_real_data accordingly."
665-
)
666-
667-
scaling_factor = len(data_iterator)
668-
expected_process_count = process_count_stored / process_count_jax
669-
assert scaling_factor == expected_process_count, (
670-
f"Found {process_count_stored} processes in checkpoint and {process_count_jax} "
671-
f"JAX processes, implying a scaling factor of {expected_process_count}. "
672-
f"However, the data_iterator list has {scaling_factor} items."
673-
)
674-
675-
# 2. Prepare Arguments
676-
local_iterator_list = [x.local_iterator for x in data_iterator]
677-
# Each JAX process calculates the global indices it's responsible for.
678-
# e.g., process 0 with scaling_factor=2 handles checkpoints from processes [0, 32]
679-
# e.g., process 1 with scaling_factor=2 handles checkpoints from processes [1, 33]
680-
process_index_list = [jax.process_index() + i * process_count_jax for i in range(scaling_factor)]
681-
682-
return GrainCheckpointRestore(local_iterator_list, process_index=process_index_list, process_count=process_count_stored)
683-
684-
685-
def _restore_grain_iterator(
686-
checkpoint_manager,
687-
step: int,
688-
data_iterator,
689-
checkpoint_args,
690-
expansion_factor_real_data: int, # This must be defined in the outer scope
691-
) -> tuple[Any, None]:
692-
"""
693-
Handles the complex logic for restoring a Grain data iterator checkpoint.
694-
This function dispatches to the correct restore strategy based on
695-
the number of stored checkpoint files vs. current JAX processes.
696-
"""
697-
if isinstance(data_iterator, RemoteIteratorWrapper):
698-
grain_restore_args = GrainCheckpointRestore(item=data_iterator)
699-
restored_state = checkpoint_manager.restore(step, args=Composite(items=checkpoint_args, iter=grain_restore_args))
700-
return (restored_state, None)
701-
702-
# ElasticIterator: one shared `process_0.json` regardless of shard count.
703-
if not isinstance(data_iterator, list) and isinstance(data_iterator.local_iterator, ElasticIterator):
704-
grain_restore_args = GrainCheckpointRestore(item=data_iterator.local_iterator)
705-
restored_state = checkpoint_manager.restore(step, args=Composite(items=checkpoint_args, iter=grain_restore_args))
706-
return (restored_state, None)
707-
708-
directory = checkpoint_manager.directory / str(step) / "iter"
709-
process_count_jax = jax.process_count()
710-
711-
# Count the number of checkpoint files
712-
process_count_stored = len(list(directory.glob("process_*-of-*.json")))
713-
714-
grain_restore_args = None
715-
716-
if process_count_stored > process_count_jax:
717-
# Scaling down from a larger number of hosts. (e.g., 128 files -> 64 processes)
718-
# In this case, each host restores a list of data iterators.
719-
grain_restore_args = _prepare_scaled_down_grain_restore_args(
720-
data_iterator, process_count_jax, process_count_stored, directory
721-
)
722-
723-
elif process_count_stored == process_count_jax:
724-
# Normal case: number of hosts is the same. (e.g., 64 files -> 64 processes)
725-
assert not isinstance(data_iterator, list), (
726-
f"{process_count_stored} processes found in Grain checkpoint directory {directory}, matching the number of "
727-
"jax process, please do not set expansion_factor_real_data."
728-
)
729-
grain_restore_args = GrainCheckpointRestore(data_iterator.local_iterator)
730-
731-
elif expansion_factor_real_data > 1 and process_count_stored == process_count_jax // expansion_factor_real_data:
732-
# Scaling up to a larger number of hosts.(e.g., 32 files -> 64 processes)
733-
# In this case, a subset of hosts restore the data iterator.
734-
assert not isinstance(
735-
data_iterator, list
736-
), "when expansion_factor_real_data > 1, the data iterator should not be a list."
737-
grain_restore_args = GrainCheckpointRestore(
738-
data_iterator.local_iterator, process_index=jax.process_index(), process_count=process_count_stored
739-
)
740-
741-
else:
742-
# Case 4: Mismatch
743-
raise ValueError(
744-
f"Error restoring Grain checkpoint in {directory}: "
745-
f"The number of stored checkpoint files ({process_count_stored}) "
746-
f"is incompatible with the number of JAX processes ({process_count_jax}). "
747-
"If you are resuming training with a different number of chips, see instructions in "
748-
"https://github.com/AI-Hypercomputer/maxtext/blob/main/docs/guides/data_input_pipeline/"
749-
"data_input_grain.md#using-grain"
750-
)
751-
752-
# Call restore once with the composed arguments
753-
restored_state = checkpoint_manager.restore(step, args=Composite(items=checkpoint_args, iter=grain_restore_args))
754-
return (restored_state, None)
755-
756-
757475
def load_state_if_possible(
758476
checkpoint_manager: CheckpointManager | None,
759477
data_iterator: MultiHostDataLoadIterator | list[MultiHostDataLoadIterator] | None,
@@ -811,7 +529,7 @@ def map_to_pspec(data):
811529
pspec = data.sharding.spec
812530
mesh = data.sharding.mesh
813531
replica_axis_index = 0
814-
replica_devices = _replica_devices(mesh.devices, replica_axis_index)
532+
replica_devices = grain_utility.replica_devices(mesh.devices, replica_axis_index)
815533
replica_mesh = jax.sharding.Mesh(replica_devices, mesh.axis_names)
816534
single_replica_sharding = jax.sharding.NamedSharding(replica_mesh, pspec)
817535

@@ -846,7 +564,7 @@ def map_to_pspec(data):
846564
and not isinstance(data_iterator, PlaceHolderDataIterator)
847565
and (checkpoint_manager.directory / str(step) / "iter").exists()
848566
):
849-
restored, _ = _restore_grain_iterator(
567+
restored, _ = grain_utility.restore_grain_iterator(
850568
checkpoint_manager, step, data_iterator, checkpoint_args, expansion_factor_real_data
851569
)
852570
else:
@@ -907,7 +625,7 @@ def map_to_pspec(data):
907625
and not isinstance(data_iterator, PlaceHolderDataIterator)
908626
and (checkpoint_manager.directory / str(step) / "iter").exists()
909627
):
910-
return _restore_grain_iterator(
628+
return grain_utility.restore_grain_iterator(
911629
checkpoint_manager,
912630
step,
913631
data_iterator,
@@ -1189,12 +907,14 @@ def save_checkpoint(checkpoint_manager, step, state, config=None, data_iterator=
1189907
if config and config.dataset_type == "grain" and not isinstance(data_iterator, PlaceHolderDataIterator):
1190908
if isinstance(data_iterator, RemoteIteratorWrapper):
1191909
# Pass the wrapper directly; GrainCheckpointHandler will call save_state with the step
1192-
save_args_composite["iter"] = GrainCheckpointSave(item=data_iterator) # pyrefly: ignore[bad-assignment]
910+
save_args_composite["iter"] = grain_utility.GrainCheckpointSave(
911+
item=data_iterator
912+
) # pyrefly: ignore[bad-assignment]
1193913
elif not isinstance(data_iterator, list) and isinstance(
1194914
data_iterator.local_iterator, ElasticIterator
1195915
): # pyrefly: ignore[missing-attribute]
1196916
# ElasticIterator checkpoints a single global scalar shared by all shards.
1197-
save_args_composite["iter"] = GrainCheckpointSave(
917+
save_args_composite["iter"] = grain_utility.GrainCheckpointSave(
1198918
item=data_iterator.local_iterator
1199919
) # pyrefly: ignore[bad-assignment]
1200920
else:
@@ -1209,7 +929,9 @@ def save_checkpoint(checkpoint_manager, step, state, config=None, data_iterator=
1209929
grain_iters_to_save.append(
1210930
(data_iter.local_iterator, process_index, process_count_total)
1211931
) # pyrefly: ignore[missing-attribute]
1212-
save_args_composite["iter"] = GrainCheckpointSave(item=grain_iters_to_save) # pyrefly: ignore[bad-assignment]
932+
save_args_composite["iter"] = grain_utility.GrainCheckpointSave(
933+
item=grain_iters_to_save
934+
) # pyrefly: ignore[bad-assignment]
1213935

1214936
custom_metadata = {}
1215937
if config:

0 commit comments

Comments
 (0)