Skip to content

Commit d557e88

Browse files
angel-coreGoogle-ML-Automation
authored andcommitted
Add Orbax v1 StatefulCheckpointable for MaxText grain iterators.
PiperOrigin-RevId: 951819898
1 parent 82a8e56 commit d557e88

2 files changed

Lines changed: 636 additions & 10 deletions

File tree

src/maxtext/common/grain_utility.py

Lines changed: 293 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,301 @@
2626
import jax
2727
from maxtext.input_pipeline import multihost_dataloading
2828
import numpy as np
29-
import orbax.checkpoint as ocp
30-
from orbax.checkpoint import v1 as ocp_v1
31-
29+
import orbax.checkpoint as ocp_v0
30+
from orbax.checkpoint import v1 as ocp
3231

3332
PyGrainCheckpointHandler = python.PyGrainCheckpointHandler
34-
Composite = ocp.args.Composite
33+
Composite = ocp_v0.args.Composite
3534
RemoteIteratorWrapper = multihost_dataloading.RemoteIteratorWrapper
3635

3736
ElasticIterator = grain_experimental.ElasticIterator
37+
_PROCESS_0_FILENAME = "process_0.json"
38+
39+
40+
def _process_filename(process_index: int, process_count: int) -> str:
41+
return f"process_{process_index}-of-{process_count}.json"
42+
43+
44+
def _state_to_text(iterator) -> str:
45+
"""Serializes an iterator's state (grain DatasetIterator -> json, else bytes)."""
46+
if isinstance(iterator, grain.DatasetIterator):
47+
return json.dumps(iterator.get_state(), indent=4)
48+
return iterator.get_state().decode()
49+
50+
51+
def _set_state_from_text(iterator, text: str) -> None:
52+
"""Inverse of :func:`_state_to_text`."""
53+
if isinstance(iterator, grain.DatasetIterator):
54+
iterator.set_state(json.loads(text))
55+
else:
56+
iterator.set_state(text.encode())
57+
58+
59+
async def no_op():
60+
return None
61+
62+
63+
class GrainCheckpointable_v1(ocp.StatefulCheckpointable):
64+
"""Orbax v1 `StatefulCheckpointable` for MaxText grain data iterators.
65+
66+
This is the v1 port of the `GrainCheckpointHandler`: a single object that
67+
dispatches on the held item, kept here (rather than in grain) only for the
68+
cases grain itself does not cover. The case-dispatch lives in this class —
69+
callers just wrap the item — matching the original handler.
70+
71+
`save` snapshots iterator state synchronously and returns a coroutine that
72+
does the file IO in the background (the Orbax two-phase contract); `load`
73+
returns a coroutine that reads and re-applies the state.
74+
75+
Cases:
76+
* **standard** grain iterator -> delegate to grain's own
77+
`StatefulCheckpointable` (`process_{index}-of-{count}.json`). This
78+
functionality is folded into grain.
79+
* **ElasticIterator** -> one shared `process_0.json` (state is a single
80+
global scalar; the fixed name survives a change in `jax.process_count()`).
81+
Lift target: grain, which owns ``ElasticIterator``.
82+
* **list** of `(iterator, process_index, process_count)` -> explicit
83+
per-file write/read for a host-count change (`expansion_factor_real_data`).
84+
The index/count arithmetic is computed by the caller.
85+
* **RemoteIteratorWrapper** (Pathways colocated-python) -> the wrapper
86+
persists/restores its own state keyed by `step`; identified structurally
87+
(it exposes `save_state`/`restore_state`) so this module stays
88+
independent of the input pipeline.
89+
"""
90+
91+
def __init__(self, item, *, restore_process_index=None, restore_process_count=None, step=None):
92+
"""Initializes a GrainCheckpointable_v1.
93+
94+
Args:
95+
item: a grain iterator, a grain `ElasticIterator`, a
96+
`RemoteIteratorWrapper`, or a list of `(iterator, process_index,
97+
process_count)` (scaled save).
98+
restore_process_index: restore-only; an int (scale-up) or list of ints
99+
(scale-down, paired with the items in `item`). `None` uses the
100+
current process index.
101+
restore_process_count: restore-only; the stored host count for the file name.
102+
`None` uses the current process count (the standard, grain-native
103+
path).
104+
step: required only for the `RemoteIteratorWrapper` case.
105+
"""
106+
self._item = item
107+
self._restore_process_index = restore_process_index
108+
self._restore_process_count = restore_process_count
109+
self._step = step
110+
111+
async def save(self, directory):
112+
"""Snapshots the wrapped iterator's state and returns a coroutine that writes it to ``directory``."""
113+
item = self._item
114+
115+
# RemoteIteratorWrapper handles checkpointing via colocated python
116+
if isinstance(item, RemoteIteratorWrapper):
117+
item.save_state(self._step)
118+
return no_op()
119+
120+
# ElasticIterator state is a single global scalar shared by all shards,
121+
# so we write one fixed `process_0.json` from process 0 only. This file
122+
# layout survives changes in `jax.process_count()`.
123+
if isinstance(item, ElasticIterator):
124+
state = item.get_state() # snapshot in the blocking phase
125+
126+
async def _write_elastic():
127+
path = await directory.await_creation()
128+
if jax.process_index() == 0: # one shared file written by process 0
129+
await asyncio.to_thread(
130+
(path / _PROCESS_0_FILENAME).write_text,
131+
json.dumps(state, indent=4),
132+
)
133+
134+
return _write_elastic()
135+
136+
if isinstance(item, list):
137+
snapshots = [(_state_to_text(it), idx, count) for it, idx, count in item]
138+
139+
async def _write_list():
140+
path = await directory.await_creation()
141+
for text, idx, count in snapshots:
142+
await asyncio.to_thread((path / _process_filename(idx, count)).write_text, text)
143+
144+
return _write_list()
145+
146+
if hasattr(item, "save"):
147+
# Standard: delegate to grain's own StatefulCheckpointable.
148+
return await item.save(directory)
149+
150+
# Custom fallback for iterators without .save()
151+
state_text = _state_to_text(item)
152+
153+
async def _write_single_fallback():
154+
path = await directory.await_creation()
155+
filename = path / _process_filename(jax.process_index(), jax.process_count())
156+
await asyncio.to_thread(filename.write_text, state_text)
157+
158+
return _write_single_fallback()
159+
160+
async def load(self, directory):
161+
"""Restores the wrapped iterator's state from ``directory`` (or returns a coroutine that does)."""
162+
item = self._item
163+
164+
# In Pathways + colocated_python environment, RemoteIteratorWrapper handles checkpointing
165+
if isinstance(item, RemoteIteratorWrapper):
166+
item.restore_state(self._step)
167+
return no_op()
168+
169+
# McJax and Pathways through controller cases
170+
# ElasticIterator: every process reads the same shared `process_0.json`.
171+
if isinstance(item, ElasticIterator):
172+
173+
async def _read_elastic():
174+
filename = directory / _PROCESS_0_FILENAME
175+
if not await asyncio.to_thread(filename.exists):
176+
raise ValueError(f"File {filename} does not exist.")
177+
item.set_state(json.loads(await asyncio.to_thread(filename.read_text)))
178+
179+
return _read_elastic()
180+
181+
if isinstance(item, list):
182+
# Scale-down: each held iterator reads its own stored shard file.
183+
specs = list(zip(item, self._restore_process_index)) # pyrefly: ignore[bad-argument-type]
184+
185+
async def _read_list():
186+
for iterator, idx in specs:
187+
filename = directory / _process_filename(idx, self._restore_process_count) # pyrefly: ignore[bad-argument-type]
188+
if not await asyncio.to_thread(filename.exists):
189+
raise ValueError(f"File {filename} does not exist.")
190+
_set_state_from_text(iterator, await asyncio.to_thread(filename.read_text))
191+
192+
return _read_list()
193+
194+
if self._restore_process_count is not None:
195+
# Single iterator with an explicit stored host count (scale-up).
196+
index = self._restore_process_index if self._restore_process_index is not None else jax.process_index()
197+
198+
async def _read_single():
199+
filename = directory / _process_filename(index, self._restore_process_count)
200+
if not await asyncio.to_thread(filename.exists):
201+
raise ValueError(f"File {filename} does not exist.")
202+
_set_state_from_text(item, await asyncio.to_thread(filename.read_text))
203+
204+
return _read_single()
205+
206+
if hasattr(item, "load"):
207+
# Standard: delegate to grain's own StatefulCheckpointable.
208+
return await item.load(directory)
209+
210+
# Custom fallback for iterators without .load()
211+
async def _read_single_fallback():
212+
filename = directory / _process_filename(jax.process_index(), jax.process_count())
213+
if not await asyncio.to_thread(filename.exists):
214+
raise ValueError(f"File {filename} does not exist.")
215+
_set_state_from_text(item, await asyncio.to_thread(filename.read_text))
216+
217+
return _read_single_fallback()
218+
219+
220+
def for_save(step: int, data_iterator: Any, expansion_factor_real_data: int) -> GrainCheckpointable_v1:
221+
"""Builds the v1 ``GrainCheckpointable_v1`` for saving the grain iterator."""
222+
if isinstance(data_iterator, RemoteIteratorWrapper):
223+
return GrainCheckpointable_v1(data_iterator, step=step)
224+
225+
if (
226+
not isinstance(data_iterator, list)
227+
and hasattr(data_iterator, "local_iterator")
228+
and isinstance(data_iterator.local_iterator, ElasticIterator)
229+
):
230+
return GrainCheckpointable_v1(data_iterator.local_iterator)
231+
232+
iterators = data_iterator if isinstance(data_iterator, list) else [data_iterator]
233+
process_count_total = jax.process_count() * len(iterators)
234+
if expansion_factor_real_data > 1:
235+
process_count_total = process_count_total // expansion_factor_real_data
236+
237+
if len(iterators) == 1 and process_count_total == jax.process_count():
238+
return GrainCheckpointable_v1(
239+
iterators[0].local_iterator if hasattr(iterators[0], "local_iterator") else iterators[0]
240+
)
241+
242+
specs = [
243+
(
244+
di.local_iterator if hasattr(di, "local_iterator") else di,
245+
jax.process_index() + i * jax.process_count(),
246+
process_count_total,
247+
)
248+
for i, di in enumerate(iterators)
249+
]
250+
return GrainCheckpointable_v1(specs)
251+
252+
253+
def for_restore(
254+
checkpoint_manager: Any, step: int, data_iterator: Any, expansion_factor_real_data: int
255+
) -> GrainCheckpointable_v1:
256+
"""Builds the v1 ``GrainCheckpointable_v1`` for restoring the grain iterator."""
257+
if isinstance(data_iterator, RemoteIteratorWrapper):
258+
return GrainCheckpointable_v1(data_iterator, step=step)
259+
260+
if (
261+
not isinstance(data_iterator, list)
262+
and hasattr(data_iterator, "local_iterator")
263+
and isinstance(data_iterator.local_iterator, ElasticIterator)
264+
):
265+
return GrainCheckpointable_v1(data_iterator.local_iterator)
266+
267+
directory = checkpoint_manager.directory / str(step) / "iter"
268+
process_count_jax = jax.process_count()
269+
process_count_stored = len(list(directory.glob("process_*-of-*.json")))
270+
271+
if process_count_stored > process_count_jax:
272+
assert isinstance(data_iterator, list), (
273+
f"{process_count_stored} processes found in Grain checkpoint directory {directory}, but only "
274+
f"{process_count_jax} jax processes in this run, please set expansion_factor_real_data accordingly."
275+
)
276+
scaling_factor = len(data_iterator)
277+
expected = process_count_stored / process_count_jax
278+
assert scaling_factor == expected, (
279+
f"Found {process_count_stored} processes in checkpoint and {process_count_jax} JAX processes, "
280+
f"implying a scaling factor of {expected}, but the data_iterator list has {scaling_factor} items."
281+
)
282+
local_iterators = [x.local_iterator if hasattr(x, "local_iterator") else x for x in data_iterator]
283+
restore_process_index = [jax.process_index() + i * process_count_jax for i in range(scaling_factor)]
284+
return GrainCheckpointable_v1(
285+
local_iterators, restore_process_index=restore_process_index, restore_process_count=process_count_stored
286+
)
287+
288+
if process_count_stored == process_count_jax:
289+
assert not isinstance(data_iterator, list), (
290+
f"{process_count_stored} processes found in Grain checkpoint directory {directory}, matching the number of "
291+
"jax processes, please do not set expansion_factor_real_data."
292+
)
293+
return GrainCheckpointable_v1(
294+
data_iterator.local_iterator if hasattr(data_iterator, "local_iterator") else data_iterator
295+
)
296+
297+
if expansion_factor_real_data > 1 and process_count_stored == process_count_jax // expansion_factor_real_data:
298+
assert not isinstance(
299+
data_iterator, list
300+
), "when expansion_factor_real_data > 1, the data iterator should not be a list."
301+
return GrainCheckpointable_v1(
302+
data_iterator.local_iterator if hasattr(data_iterator, "local_iterator") else data_iterator,
303+
restore_process_index=jax.process_index(),
304+
restore_process_count=process_count_stored,
305+
)
306+
307+
raise ValueError(
308+
f"Error restoring Grain checkpoint in {directory}: "
309+
f"The number of stored checkpoint files ({process_count_stored}) "
310+
f"is incompatible with the number of JAX processes ({process_count_jax}). "
311+
"If you are resuming training with a different number of chips, see instructions in "
312+
"https://github.com/AI-Hypercomputer/maxtext/blob/main/docs/guides/data_input_pipeline/"
313+
"data_input_grain.md#using-grain"
314+
)
315+
316+
317+
# ------------------------------------------------------------------------------
318+
# TODO(b/532274266): Remove everything below this line once distillation_utils
319+
# supports the new GrainCheckpointHandler.
320+
# ------------------------------------------------------------------------------
38321

39322

40-
class GrainCheckpointHandler(PyGrainCheckpointHandler, ocp.CheckpointHandler):
323+
class GrainCheckpointHandler(PyGrainCheckpointHandler, ocp_v0.CheckpointHandler):
41324
"""A CheckpointHandler that allows specifying process_index and process_count."""
42325

43326
def save(
@@ -131,21 +414,21 @@ def restore_single_process(item, process_index, process_count):
131414
return restore_single_process(item, process_index, process_count)
132415

133416

134-
@ocp.args.register_with_handler(GrainCheckpointHandler, for_save=True)
417+
@ocp_v0.args.register_with_handler(GrainCheckpointHandler, for_save=True)
135418
@dataclasses.dataclass
136-
class GrainCheckpointSave(ocp.args.CheckpointArgs):
419+
class GrainCheckpointSave(ocp_v0.args.CheckpointArgs):
137420
item: Any
138421

139422

140-
@ocp.args.register_with_handler(GrainCheckpointHandler, for_restore=True)
423+
@ocp_v0.args.register_with_handler(GrainCheckpointHandler, for_restore=True)
141424
@dataclasses.dataclass
142-
class GrainCheckpointRestore(ocp.args.CheckpointArgs):
425+
class GrainCheckpointRestore(ocp_v0.args.CheckpointArgs):
143426
item: Any
144427
process_index: Optional[int | list[int]] = None
145428
process_count: Optional[int] = None
146429

147430

148-
class GrainCheckpointable(ocp_v1.StatefulCheckpointable):
431+
class GrainCheckpointable(ocp.StatefulCheckpointable):
149432
"""Adapts `GrainCheckpointHandler` to Orbax v1's `StatefulCheckpointable`."""
150433

151434
def __init__(

0 commit comments

Comments
 (0)