Skip to content

Commit c666fb8

Browse files
Merge pull request AI-Hypercomputer#3807 from AI-Hypercomputer:aireen/shard_by_row2
PiperOrigin-RevId: 938837636
2 parents e6ce71b + 7a898f8 commit c666fb8

4 files changed

Lines changed: 237 additions & 37 deletions

File tree

src/maxtext/input_pipeline/grain_data_processing.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
"""Input pipeline using Grain."""
1616

17+
import math
1718
import glob
1819
from pathlib import Path
1920
import functools
@@ -179,35 +180,37 @@ def create_dataset_from_pattern(pattern):
179180
elastic=elastic,
180181
)
181182
return dataset
182-
elif data_file_type == "tfrecord":
183+
elif data_file_type in ("tfrecord", "parquet"):
183184
data_files = find_data_files(data_file_pattern)
185+
file_slice, files_per_host, row_shard = input_pipeline_utils.compute_file_sharding(
186+
len(data_files), dataloading_host_index, dataloading_host_count
187+
)
188+
if len(data_files) < dataloading_host_count:
189+
max_logging.warning(
190+
f"Data shard count ({len(data_files)}) < host count ({dataloading_host_count}). "
191+
f"Each shard will be read by at most {math.ceil(dataloading_host_count / len(data_files))} hosts. "
192+
f"Concurrent reading by multiple hosts may cause slow down."
193+
)
194+
min_files_per_host = len(data_files) // dataloading_host_count
195+
if grain_worker_count > min_files_per_host:
196+
raise ValueError(
197+
f"grain_worker_count ({grain_worker_count}) exceeds the minimum number of {data_file_type} files "
198+
f"per host ({min_files_per_host} = {len(data_files)} files / {dataloading_host_count} hosts). "
199+
f"Lower grain_worker_count to at most {min_files_per_host}."
200+
)
184201
dataset = grain.MapDataset.source(data_files)
185202
if shuffle:
186203
dataset = dataset.shuffle(seed=shuffle_seed)
187204
dataset = dataset.repeat(num_epoch)
188-
dataset = dataset[dataloading_host_index::dataloading_host_count] # sharding
189-
dataset = dataset.map(input_pipeline_utils.make_tfrecord_iter_dataset)
190-
files_per_host = max(len(data_files) // dataloading_host_count, 1)
205+
dataset = dataset[file_slice]
206+
if data_file_type == "tfrecord":
207+
dataset = dataset.map(input_pipeline_utils.make_tfrecord_iter_dataset)
208+
else:
209+
dataset = dataset.map(grain.experimental.ParquetIterDataset)
191210
cycle_length = min(files_per_host, grain_num_threads)
192211
dataset = grain.experimental.InterleaveIterDataset(dataset, cycle_length=cycle_length)
193-
if shuffle:
194-
dataset = grain.experimental.WindowShuffleIterDataset(dataset, window_size=shuffle_buffer_size, seed=shuffle_seed)
195-
return dataset
196-
elif data_file_type == "parquet":
197-
data_files = find_data_files(data_file_pattern)
198-
dataset = grain.MapDataset.source(data_files)
199-
if shuffle:
200-
dataset = dataset.shuffle(seed=shuffle_seed)
201-
dataset = dataset.repeat(num_epoch)
202-
dataset = dataset[dataloading_host_index::dataloading_host_count] # sharding
203-
assert grain_worker_count <= len(dataset), (
204-
f"grain worker count is currently {grain_worker_count}, exceeding the max allowable value {len(dataset)} "
205-
f"(file shard count of a data loading host) for your dataset. "
206-
f"Please lower grain_worker_count or increase file shard count."
207-
)
208-
dataset = dataset.map(grain.experimental.ParquetIterDataset)
209-
cycle_length = min(len(dataset) // num_epoch, grain_num_threads)
210-
dataset = grain.experimental.InterleaveIterDataset(dataset, cycle_length=cycle_length)
212+
if row_shard is not None:
213+
dataset = input_pipeline_utils.IndexShardIterDataset(dataset, host_index=row_shard[0], host_count=row_shard[1])
211214
if shuffle:
212215
dataset = grain.experimental.WindowShuffleIterDataset(dataset, window_size=shuffle_buffer_size, seed=shuffle_seed)
213216
return dataset
@@ -489,7 +492,7 @@ def make_grain_train_iterator(
489492
for x in train_dataloader_list
490493
]
491494

492-
# Default non-colocated, non-expansion path
495+
# Default non-colocated, expansion_factor_real_data >=1 path
493496
shard_index = process_indices.index(jax.process_index())
494497
shard_count = len(process_indices)
495498
train_ds = get_ds_fn(

src/maxtext/input_pipeline/input_pipeline_utils.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,79 @@ def make_tfrecord_iter_dataset(path: str):
496496
return TFRecordIterDataset(path)
497497

498498

499+
def compute_file_sharding(file_count, host_index, host_count):
500+
"""Compute per-host file slicing and optional row-shard parameters.
501+
502+
When file_count >= host_count, each host reads a disjoint subset of files via the
503+
standard `[host_index::host_count]` slice. When file_count < host_count, every file
504+
is replicated across `ceil(host_count/file_count)` hosts (or `floor` for files past
505+
the remainder), and those hosts shard records by index within the file. This bounds
506+
concurrent readers per file at `ceil(host_count/file_count)`
507+
508+
Returns:
509+
file_slice (slice): file slice this host should keep — also the (start, step)
510+
pair `(host_index, host_count)` to feed `tf.distribute.InputContext` when applicable.
511+
files_per_host (int): files this host's slice contains per epoch.
512+
row_shard (tuple|None): `(row_shard_index, row_shard_count)` when host_count > file_count
513+
and the file's group has >1 reader; otherwise None.
514+
"""
515+
if file_count >= host_count:
516+
return slice(host_index, None, host_count), max(file_count // host_count, 1), None
517+
file_idx = host_index % file_count
518+
row_shard_idx = host_index // file_count
519+
row_shard_count = (host_count // file_count) + (1 if file_idx < (host_count % file_count) else 0)
520+
row_shard = (row_shard_idx, row_shard_count) if row_shard_count > 1 else None
521+
return slice(file_idx, None, file_count), 1, row_shard
522+
523+
524+
class _IndexShardDatasetIterator(grain.DatasetIterator):
525+
"""Iterator that yields every nth element of its parent (round-robin by index)."""
526+
527+
def __init__(self, parent: grain.DatasetIterator, host_index: int, host_count: int):
528+
super().__init__(parent)
529+
self._host_index = host_index
530+
self._host_count = host_count
531+
self._next_index = 0
532+
533+
def __next__(self):
534+
while True:
535+
value = next(self._parent)
536+
current = self._next_index
537+
self._next_index += 1
538+
if current % self._host_count == self._host_index:
539+
return value
540+
541+
def get_state(self):
542+
return {
543+
"next_index": self._next_index,
544+
"parent": self._parent.get_state(),
545+
}
546+
547+
def set_state(self, state):
548+
self._next_index = state["next_index"]
549+
self._parent.set_state(state["parent"])
550+
551+
552+
class IndexShardIterDataset(grain.IterDataset):
553+
"""Shards an IterDataset across hosts by element index (host i keeps records where idx % N == i).
554+
555+
Use when the upstream `IterDataset` order is deterministic and identical on every host;
556+
this guarantees disjoint, balanced slices without per-file sharding.
557+
"""
558+
559+
def __init__(self, parent: grain.IterDataset, host_index: int, host_count: int):
560+
super().__init__(parent)
561+
self._host_index = host_index
562+
self._host_count = host_count
563+
564+
def __iter__(self) -> _IndexShardDatasetIterator:
565+
return _IndexShardDatasetIterator(
566+
self._parent.__iter__(),
567+
host_index=self._host_index,
568+
host_count=self._host_count,
569+
)
570+
571+
499572
@dataclasses.dataclass
500573
class ParseFeatures(grain.MapTransform):
501574
"""Parse serialized tf.train.Example protos for arrayrecord/tfrecord datasets.

src/maxtext/input_pipeline/tfds_data_processing.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414

1515
"""Input pipeline for a LM1B dataset."""
1616

17-
import warnings
1817
import functools
18+
import math
1919

2020
import ml_collections
2121

@@ -27,6 +27,7 @@
2727
from maxtext.input_pipeline import multihost_dataloading
2828
from maxtext.input_pipeline.packing import sequence_packing
2929
from maxtext.input_pipeline import input_pipeline_utils
30+
from maxtext.utils import max_logging
3031

3132
AUTOTUNE = tf.data.experimental.AUTOTUNE
3233

@@ -54,21 +55,31 @@ def get_datasets(
5455
else:
5556
read_config = tfds.ReadConfig()
5657

57-
if ds_builder.info.splits[data_split].num_shards >= dataloading_host_count:
58-
read_config.input_context = tf.distribute.InputContext(
59-
input_pipeline_id=dataloading_host_index,
60-
num_input_pipelines=dataloading_host_count,
61-
)
62-
ds = ds_builder.as_dataset(split=data_split, read_config=read_config, shuffle_files=shuffle_files)
58+
num_shards = ds_builder.info.splits[data_split].num_shards
59+
_, _, row_shard = input_pipeline_utils.compute_file_sharding(num_shards, dataloading_host_index, dataloading_host_count)
60+
if num_shards >= dataloading_host_count:
61+
input_pipeline_id = dataloading_host_index
62+
num_input_pipelines = dataloading_host_count
6363
else:
64-
warnings.warn(
65-
f"WARNING: Inefficient dataloading. Your {dataset_name} contains {ds_builder.info.splits[data_split].num_shards}"
66-
f"shards, smaller than {dataloading_host_count=}. This is known to lead to inefficient dataloading."
67-
"see https://github.com/google/maxtext/blob/main/getting_started/Data_Input_Pipeline.md"
68-
"#multihost-dataloading-best-practice"
64+
# When `num_shards < dataloading_host_count`, falls back to hybrid sharding: each TFDS
65+
# shard is read by a `ceil(host_count/num_shards)` group of hosts, and within that group
66+
# hosts split records via `Dataset.shard`. This bounds concurrent readers per shard,
67+
# avoiding the thundering-herd I/O pattern of a "every host reads all shards" fallback.
68+
input_pipeline_id = dataloading_host_index % num_shards
69+
num_input_pipelines = num_shards
70+
max_logging.warning(
71+
f"{dataset_name}: shard count ({num_shards}) < host count ({dataloading_host_count}). "
72+
f"Each shard will be read by at most {math.ceil(dataloading_host_count / num_shards)} hosts. "
73+
f"Concurrent reading by multiple hosts may cause slow down."
6974
)
70-
ds = ds_builder.as_dataset(split=data_split, read_config=read_config, shuffle_files=shuffle_files)
71-
ds = ds.shard(num_shards=dataloading_host_count, index=dataloading_host_index)
75+
76+
read_config.input_context = tf.distribute.InputContext(
77+
input_pipeline_id=input_pipeline_id,
78+
num_input_pipelines=num_input_pipelines,
79+
)
80+
ds = ds_builder.as_dataset(split=data_split, read_config=read_config, shuffle_files=shuffle_files)
81+
if row_shard is not None:
82+
ds = ds.shard(num_shards=row_shard[1], index=row_shard[0])
7283

7384
return ds
7485

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2023–2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for input_pipeline_utils."""
16+
17+
import pytest
18+
import unittest
19+
20+
from maxtext.input_pipeline.input_pipeline_utils import compute_file_sharding
21+
22+
23+
@pytest.mark.cpu_only
24+
class ComputeFileShardingNormalCaseTest(unittest.TestCase):
25+
"""file_count >= host_count: disjoint file subsets, no row sharding."""
26+
27+
def test_even_split(self):
28+
# 8 files, 4 hosts → interleaved assignment, 2 files each
29+
file_slice, files_per_host, _ = compute_file_sharding(8, host_index=0, host_count=4)
30+
self.assertEqual(list(range(8)[file_slice]), [0, 4])
31+
self.assertEqual(files_per_host, 2)
32+
33+
file_slice, files_per_host, _ = compute_file_sharding(8, host_index=1, host_count=4)
34+
self.assertEqual(list(range(8)[file_slice]), [1, 5])
35+
self.assertEqual(files_per_host, 2)
36+
37+
file_slice, files_per_host, _ = compute_file_sharding(8, host_index=2, host_count=4)
38+
self.assertEqual(list(range(8)[file_slice]), [2, 6])
39+
self.assertEqual(files_per_host, 2)
40+
41+
file_slice, files_per_host, _ = compute_file_sharding(8, host_index=3, host_count=4)
42+
self.assertEqual(list(range(8)[file_slice]), [3, 7])
43+
self.assertEqual(files_per_host, 2)
44+
45+
def test_uneven_split(self):
46+
# 5 files, 4 hosts → host 0 gets an extra file
47+
file_slice, _, _ = compute_file_sharding(5, host_index=0, host_count=4)
48+
self.assertEqual(list(range(5)[file_slice]), [0, 4])
49+
50+
file_slice, _, _ = compute_file_sharding(5, host_index=1, host_count=4)
51+
self.assertEqual(list(range(5)[file_slice]), [1])
52+
53+
file_slice, _, _ = compute_file_sharding(5, host_index=2, host_count=4)
54+
self.assertEqual(list(range(5)[file_slice]), [2])
55+
56+
file_slice, _, _ = compute_file_sharding(5, host_index=3, host_count=4)
57+
self.assertEqual(list(range(5)[file_slice]), [3])
58+
59+
def test_single_host_gets_all_files(self):
60+
file_slice, files_per_host, _ = compute_file_sharding(8, host_index=0, host_count=1)
61+
self.assertEqual(list(range(8)[file_slice]), [0, 1, 2, 3, 4, 5, 6, 7])
62+
self.assertEqual(files_per_host, 8)
63+
64+
def test_no_row_shard_in_normal_case(self):
65+
for host_index in range(4):
66+
_, _, row_shard = compute_file_sharding(8, host_index, host_count=4)
67+
self.assertIsNone(row_shard)
68+
69+
70+
class ComputeFileShardingUndersizedCaseTest(unittest.TestCase):
71+
"""file_count < host_count: multiple hosts share a file, split by row."""
72+
73+
def test_single_file_four_hosts(self):
74+
# All 4 hosts read the same file, each gets a quarter of the rows
75+
_, _, row_shard = compute_file_sharding(1, host_index=0, host_count=4)
76+
self.assertEqual(row_shard, (0, 4)) # row index 0 of 4
77+
78+
_, _, row_shard = compute_file_sharding(1, host_index=1, host_count=4)
79+
self.assertEqual(row_shard, (1, 4)) # row index 1 of 4
80+
81+
_, _, row_shard = compute_file_sharding(1, host_index=2, host_count=4)
82+
self.assertEqual(row_shard, (2, 4)) # row index 2 of 4
83+
84+
_, _, row_shard = compute_file_sharding(1, host_index=3, host_count=4)
85+
self.assertEqual(row_shard, (3, 4)) # row index 3 of 4
86+
87+
def test_three_files_eight_hosts(self):
88+
# 8 hosts round-robin across 3 files:
89+
# hosts 0,3,6 → file 0 (3 readers); hosts 1,4,7 → file 1 (3 readers); hosts 2,5 → file 2 (2 readers)
90+
expected = {
91+
# host_index: (file_indices, row_shard)
92+
0: ([0], (0, 3)),
93+
1: ([1], (0, 3)),
94+
2: ([2], (0, 2)),
95+
3: ([0], (1, 3)),
96+
4: ([1], (1, 3)),
97+
5: ([2], (1, 2)),
98+
6: ([0], (2, 3)),
99+
7: ([1], (2, 3)),
100+
}
101+
for host_index, (exp_files, exp_row_shard) in expected.items():
102+
file_slice, _, row_shard = compute_file_sharding(3, host_index, host_count=8)
103+
self.assertEqual(list(range(3)[file_slice]), exp_files, f"host {host_index} file assignment")
104+
self.assertEqual(row_shard, exp_row_shard, f"host {host_index} row shard")
105+
106+
def test_no_row_shard_when_only_one_reader(self):
107+
# 2 files, 3 hosts: file 1 has only one reader (host 1) → no row split needed
108+
_, _, row_shard = compute_file_sharding(2, host_index=1, host_count=3)
109+
self.assertIsNone(row_shard)
110+
111+
112+
if __name__ == "__main__":
113+
unittest.main()

0 commit comments

Comments
 (0)