Skip to content

Commit 4f33eba

Browse files
lukebaumannGoogle-ML-Automation
authored andcommitted
# Description
This PR fixes a `ValueError: can only convert an array of size 1 to a Python scalar` that occurs in `RemoteIteratorWrapper` during state save/restore on multi-device topologies (size > 1). It also adds validation to ensure colocated Python data input is only used with Pathways (single controller) enabled, and replaces incorrect usages of `jax.local_devices()` with `global_mesh.devices`. # Root Cause 1. **ValueError in save/restore**: `RemoteIteratorWrapper.save_state` and `restore_state` were attempting to shape the step value array using `self.dummy_array.shape` and shard it across devices. On topologies with more than 1 device, this resulted in a partitioned array. When this partitioned array was passed to the local iterator, attempting to unpack it to a Python scalar (e.g. via `.item()` or direct conversion) failed because JAX does not allow converting partitioned arrays of size > 1 to Python scalars. 2. **Incorrect Device Resolution**: `RemoteIteratorWrapper` was using `jax.local_devices()` to determine CPU/TPU devices. Under Pathways (single-controller), all devices in the cluster are virtualized as local to the JAX client, meaning `jax.local_devices()` returns all devices (including inactive ones during elastic scale-down), which is incorrect for sharding and shape calculations. 3. **Missing Validation**: `colocated_python_data_input` relies on Pathways single-controller mode, but there was no validation enforcing this constraint, which could lead to cryptic failures if misconfigured. # Solution 1. **Replicated Scalar for Step**: Modified `RemoteIteratorWrapper.save_state` and `restore_state` in `multihost_dataloading.py` to pass the training step as a replicated 0D JAX scalar array (global shape `()`) with replicated sharding (`NamedSharding` with `PartitionSpec()`). This ensures the array has size 1 on all devices and can be safely converted to a Python scalar by the local iterator. 2. **Use Global Mesh Devices**: Replaced `jax.local_devices()` with `global_mesh.devices` (via `tuple(global_mesh.devices.flat)`) in `RemoteIteratorWrapper.__init__` to ensure it only uses the active devices defined by the global mesh, handling elastic scaling correctly. 3. **Config Validation**: Added a check in `types.py` to raise a `ValueError` if `colocated_python_data_input` is enabled but `enable_single_controller` is false. # Tests Added new unit tests in `third_party/py/maxtext/tests/unit/multihost_dataloading_test.py` to verify the fixes: 1. `test_remote_iterator_wrapper_save_state`: Parameterized over different mesh shapes (1, 2, and 4 devices). Instantiates `RemoteIteratorWrapper` and verifies that calling `save_state` successfully writes the state to a JSON file without raising `ValueError`. 2. `test_remote_iterator_wrapper_restore_state`: Parameterized over different mesh shapes. Verifies that `restore_state` successfully restores the state from a JSON file and resumes iteration correctly. These tests are configured to run with `XLA_FLAGS="--xla_force_host_platform_device_count=4"` via the `BUILD` target to simulate multi-device environments. # Checklist Before submitting this PR, please make sure (put X in square brackets): - [X] I have performed a self-review of my code. For an optional AI review, add the `gemini-review` label. - [X] I have necessary comments in my code, particularly in hard-to-understand areas. - [X] I have run end-to-end tests tests and provided workload links above if applicable. - [X] I have made or will make corresponding changes to the doc if needed. PiperOrigin-RevId: 936996365
1 parent fd5bf7c commit 4f33eba

4 files changed

Lines changed: 183 additions & 23 deletions

File tree

src/maxtext/configs/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2931,6 +2931,11 @@ def calculate_global_batch_sizes(per_device_batch_size, expansion_factor, num_de
29312931
raise ValueError("At most one of `load_parameters_path` or `load_full_state_path` should be set.")
29322932
if self.elastic_enabled and not self.enable_single_controller:
29332933
raise ValueError("Elastic training is only supported with Pathways (`enable_single_controller=True`).")
2934+
if self.colocated_python_data_input and not self.enable_single_controller:
2935+
raise ValueError(
2936+
"Colocated python data input is only supported with Pathways (single"
2937+
" controller) enabled (`enable_single_controller=True`)."
2938+
)
29342939
if self.grain_use_elastic_iterator and self.grain_file_type != "arrayrecord":
29352940
raise ValueError(
29362941
"`grain_use_elastic_iterator=True` only supports `grain_file_type=arrayrecord`. "

src/maxtext/input_pipeline/multihost_dataloading.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ def _colocated_cpu_mesh(mesh: Mesh) -> Mesh:
179179
return colocated_python.colocated_cpu_devices(mesh)
180180

181181

182-
@colocated_python.colocated_python_class
183182
class RemoteIterator:
184183
"iterator class for using colocated python class"
185184

@@ -264,16 +263,22 @@ class RemoteIteratorWrapper:
264263
"""Wrapper for RemoteIterator that handles device placement."""
265264

266265
def __init__(self, get_ds_fn, preprocessing_fn, global_mesh, global_shape, checkpoint_path="", elastic=False):
267-
self.cpu_devices = _colocated_cpu_devices(jax.local_devices())
268-
self.tpu_devices = jax.local_devices()
266+
self.cpu_devices = _colocated_cpu_devices(tuple(global_mesh.devices.flat))
269267
self.cpu_mesh = _colocated_cpu_mesh(global_mesh)
270268
self.tpu_sharding = jax.sharding.NamedSharding(global_mesh, PartitionSpec(global_mesh.axis_names))
271269
self.cpu_sharding = jax.sharding.NamedSharding(self.cpu_mesh, PartitionSpec(self.cpu_mesh.axis_names))
272270
self.dummy_array = jnp.zeros((len(self.cpu_devices)))
273271
self.dummy_array = jax.device_put(self.dummy_array, self.cpu_sharding)
274272
# This is a proxy to a RemoteIterator running in a colocated process,
275273
# named "local_iterator" to match MultiHostDataLoadIterator's interface.
276-
self.local_iterator = RemoteIterator(get_ds_fn, preprocessing_fn, global_shape, checkpoint_path, elastic=elastic)
274+
remote_iterator_cls = colocated_python.colocated_python_class(RemoteIterator)
275+
self.local_iterator = remote_iterator_cls(
276+
get_ds_fn,
277+
preprocessing_fn,
278+
global_shape,
279+
checkpoint_path,
280+
elastic=elastic,
281+
)
277282
max_logging.log("RemoteIteratorWrapper initiated")
278283

279284
def __iter__(self):
@@ -288,11 +293,13 @@ def __next__(self):
288293
return jax.device_put(out, self.tpu_sharding)
289294

290295
def save_state(self, step):
291-
step_array = jnp.full(self.dummy_array.shape, step, dtype=jnp.int32)
292-
step_array = jax.device_put(step_array, self.cpu_sharding)
296+
replicated_cpu_sharding = NamedSharding(self.cpu_mesh, PartitionSpec())
297+
step_array = jnp.array(step, dtype=jnp.int32)
298+
step_array = jax.device_put(step_array, replicated_cpu_sharding)
293299
self.local_iterator.save_state(step_array)
294300

295301
def restore_state(self, step):
296-
step_array = jnp.full(self.dummy_array.shape, step, dtype=jnp.int32)
297-
step_array = jax.device_put(step_array, self.cpu_sharding)
302+
replicated_cpu_sharding = NamedSharding(self.cpu_mesh, PartitionSpec())
303+
step_array = jnp.array(step, dtype=jnp.int32)
304+
step_array = jax.device_put(step_array, replicated_cpu_sharding)
298305
self.local_iterator.restore_state(step_array)

src/maxtext/layers/quantizations.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,17 +1014,18 @@ def _wrap(self, f, name=None):
10141014
import transformer_engine.jax # pylint: disable=import-outside-toplevel # pytype: disable=import-error
10151015
from transformer_engine.common import recipe # pylint: disable=import-outside-toplevel # pytype: disable=import-error
10161016

1017-
fp8_recipe = self._recipe
1018-
10191017
class TEWrapper(transformer_engine.jax.flax.module.TransformerEngineBase):
10201018
"""Wrapper module for TransformerEngine quantization."""
10211019

1022-
def generate_quantizer_set(self, postfix: str = "",
1020+
def generate_quantizer_set(
1021+
self,
1022+
postfix: str = "",
10231023
variable_collection: str | None = None,
10241024
quantization_checkpoint_name: str | None = None,
10251025
fp8_recipe: recipe.Recipe | None = None,
10261026
n_groups: int | None = None,
10271027
):
1028+
"""Generates a set of quantizers for TransformerEngine."""
10281029

10291030
OVERWRITE_WITH_GRADIENT = "_overwrite_with_gradient"
10301031
return super().generate_quantizer_set( # pytype: disable=wrong-keyword-args

tests/unit/multihost_dataloading_test.py

Lines changed: 159 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2023–2025 Google LLC
1+
# Copyright 2023–2026 Google LLC
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -12,29 +12,86 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
# pylint: disable=missing-module-docstring, missing-function-docstring
15+
# pylint: disable=missing-module-docstring, missing-function-docstring, line-too-long, g-generic-assert
1616
import itertools
17+
import json
18+
19+
import pathlib
1720
import sys
18-
import unittest
21+
import tempfile
1922

20-
import pytest
2123

22-
import numpy as np
24+
from unittest import mock
2325

26+
from absl.testing import absltest
27+
from absl.testing import parameterized
2428
import jax
25-
from jax.sharding import Mesh
2629
from jax.experimental import mesh_utils
27-
30+
import jax.experimental.colocated_python
31+
from jax.sharding import Mesh
2832
from maxtext.configs import pyconfig
2933
from maxtext.input_pipeline import multihost_dataloading
30-
from tests.utils.test_helpers import get_test_config_path, get_test_dataset_path, get_test_base_output_directory
34+
from tests.utils.test_helpers import get_test_base_output_directory
35+
from tests.utils.test_helpers import get_test_config_path
36+
from tests.utils.test_helpers import get_test_dataset_path
37+
import numpy as np
38+
import pytest
39+
40+
# Mock jax.experimental.colocated_python before it is imported by
41+
# multihost_dataloading
42+
mock.patch.object(
43+
jax.experimental.colocated_python,
44+
"colocated_python_class",
45+
lambda cls: cls,
46+
).start()
47+
mock.patch.object(
48+
jax.experimental.colocated_python,
49+
"colocated_cpu_devices",
50+
lambda x: x,
51+
).start()
52+
53+
54+
class MockIterator:
55+
"""Mock iterator for testing dataloading state saving/restoring."""
56+
57+
def __init__(self, mesh_size):
58+
self.state = 0
59+
self.mesh_size = mesh_size
60+
61+
def __next__(self):
62+
self.state += 1
63+
return np.full((self.mesh_size, 1), self.state, dtype=np.int32)
64+
65+
def get_state(self) -> dict[str, int]:
66+
return {"state": self.state}
67+
68+
def set_state(self, state: dict[str, int]):
69+
self.state = state["state"]
70+
71+
72+
class MockDataloader:
73+
"""Mock dataloader for testing."""
74+
75+
def __init__(self, mesh_size):
76+
self.mesh_size = mesh_size
77+
78+
def __iter__(self) -> MockIterator:
79+
return MockIterator(self.mesh_size)
3180

3281

33-
class MultihostDataloadingTest(unittest.TestCase):
82+
def _get_test_mesh_shapes_named():
83+
return [
84+
("1_device", (1, 1)),
85+
("2_devices", (2, 1)),
86+
("4_devices", (2, 2)),
87+
]
88+
89+
90+
class MultihostDataloadingTest(parameterized.TestCase):
3491

3592
def setUp(self):
3693
super().setUp()
37-
# Note: this test uses gs://max-experiments/ (not gs://runner-maxtext-logs) in cloud mode
94+
# Note: this test uses gs://max-experiments/ (not runner logs) in cloud mode
3895
base_output_directory = get_test_base_output_directory(cloud_path="gs://max-experiments/")
3996
dataset_path = get_test_dataset_path(cloud_path="gs://maxtext-dataset/")
4097
batch_size = len(jax.devices())
@@ -62,8 +119,98 @@ def setUp(self):
62119
def test_batch_sharded_data_pipeline(self):
63120
first_batch = next(self.multihost_gen)
64121
sec_batch = next(self.multihost_gen)
65-
self.assertTrue(not np.array_equal(first_batch, sec_batch, equal_nan=True))
122+
self.assertFalse(np.array_equal(first_batch, sec_batch, equal_nan=True))
123+
124+
@parameterized.named_parameters(*_get_test_mesh_shapes_named())
125+
def test_remote_iterator_wrapper_save_state(self, mesh_shape):
126+
if jax.default_backend() == "gpu":
127+
self.skipTest("Skipping colocated python tests on GPU")
128+
mesh_size = mesh_shape[0] * mesh_shape[1]
129+
if mesh_size > len(jax.devices()):
130+
self.skipTest(
131+
f"Skipping test because available devices ({len(jax.devices())}) is"
132+
f" less than required mesh size ({mesh_size}) for shape {mesh_shape}."
133+
)
134+
135+
devs = jax.devices()[:mesh_size]
136+
devices = mesh_utils.create_device_mesh(mesh_shape, devs)
137+
mesh = Mesh(devices, ("x", "y"))
138+
139+
def get_ds_fn(dataloading_host_index, dataloading_host_count):
140+
del dataloading_host_index, dataloading_host_count
141+
return MockDataloader(mesh_size)
142+
143+
def preprocessing_fn(dataset):
144+
return dataset
145+
146+
global_shape = (mesh_size, 1)
147+
148+
with tempfile.TemporaryDirectory() as tmpdir:
149+
wrapper = multihost_dataloading.RemoteIteratorWrapper(
150+
get_ds_fn=get_ds_fn,
151+
preprocessing_fn=preprocessing_fn,
152+
global_mesh=mesh,
153+
global_shape=global_shape,
154+
checkpoint_path=tmpdir,
155+
elastic=False,
156+
)
157+
# Advance state once so the value is 1
158+
next(wrapper)
159+
160+
wrapper.save_state(step=5)
161+
162+
# Verify that a file was written in the tempdir containing {"state": 1}
163+
json_files = list(pathlib.Path(tmpdir).glob("**/*.json"))
164+
self.assertEqual(len(json_files), 1, f"Expected 1 JSON file, found: {json_files}")
165+
written_content = json_files[0].read_text()
166+
self.assertEqual(json.loads(written_content), {"state": 1})
167+
168+
@parameterized.named_parameters(*_get_test_mesh_shapes_named())
169+
def test_remote_iterator_wrapper_restore_state(self, mesh_shape):
170+
if jax.default_backend() == "gpu":
171+
self.skipTest("Skipping colocated python tests on GPU")
172+
mesh_size = mesh_shape[0] * mesh_shape[1]
173+
if mesh_size > len(jax.devices()):
174+
self.skipTest(
175+
f"Skipping test because available devices ({len(jax.devices())}) is"
176+
f" less than required mesh size ({mesh_size}) for shape {mesh_shape}."
177+
)
178+
179+
devs = jax.devices()[:mesh_size]
180+
devices = mesh_utils.create_device_mesh(mesh_shape, devs)
181+
mesh = Mesh(devices, ("x", "y"))
182+
183+
def get_ds_fn(dataloading_host_index, dataloading_host_count):
184+
del dataloading_host_index, dataloading_host_count
185+
return MockDataloader(mesh_size)
186+
187+
def preprocessing_fn(dataset):
188+
return dataset
189+
190+
global_shape = (mesh_size, 1)
191+
192+
with tempfile.TemporaryDirectory() as tmpdir:
193+
step = 5
194+
state_dir = pathlib.Path(tmpdir) / str(step) / "iter"
195+
state_dir.mkdir(parents=True, exist_ok=True)
196+
state_file = state_dir / "process_0-of-1.json"
197+
state_file.write_text('{"state": 10}')
198+
199+
wrapper = multihost_dataloading.RemoteIteratorWrapper(
200+
get_ds_fn=get_ds_fn,
201+
preprocessing_fn=preprocessing_fn,
202+
global_mesh=mesh,
203+
global_shape=global_shape,
204+
checkpoint_path=tmpdir,
205+
elastic=False,
206+
)
207+
208+
wrapper.restore_state(step=5)
209+
val = next(wrapper)
210+
211+
# Next value should be 11 (state 10 + 1)
212+
self.assertEqual(val.addressable_data(0)[0], 11)
66213

67214

68215
if __name__ == "__main__":
69-
unittest.main()
216+
absltest.main()

0 commit comments

Comments
 (0)