Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / data / online / iterable.py: 19%

235 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

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# http://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"""Build streaming Online sources and their local/global blend policy.""" 

16 

17from __future__ import annotations 

18 

19import random 

20from collections.abc import Callable, Mapping, Sequence 

21from dataclasses import replace 

22from typing import Any 

23 

24from torch.utils.data import IterableDataset # pylint: disable=forbidden-backend-import 

25 

26from hyper_parallel.data.constants import ONLINE_BLEND_STRATEGIES 

27from hyper_parallel.data.online.source import OnlineDataPath, _OnlineSourceBuilder 

28from hyper_parallel.data.parallel import ( 

29 DataLoaderParallelContext, 

30 build_dataset_for_dataloader, 

31 split_iterable_dataset_by_dp, 

32) 

33 

34SampleTransform = Callable[[Any], list[Any]] 

35 

36 

37def build_online_iterable_source( 

38 *, 

39 data_config: Mapping[str, Any], 

40 data_path: OnlineDataPath | None = None, 

41 dataloader_context: DataLoaderParallelContext | None = None, 

42 sample_filter: Callable[[Mapping[str, Any]], bool] | None = None, 

43) -> Any: 

44 """Build a locally shuffled and optionally DP-sharded Online stream. 

45 

46 Args: 

47 data_config: Online streaming source options. 

48 data_path: Local path or Hub dataset ID. Use ``data_config.sources`` 

49 instead for multiple sources. 

50 dataloader_context: Optional DataLoader ownership and DP policy. 

51 sample_filter: Optional raw-sample predicate applied before shuffling. 

52 

53 Returns: 

54 One Iterable Dataset on each DataLoader-owning rank. 

55 """ 

56 normalized_context = dataloader_context or DataLoaderParallelContext() 

57 if normalized_context.data_index_cache: 

58 normalized_context = replace(normalized_context, data_index_cache=False) 

59 

60 dataset_builder = _OnlineIterableSourceBuilder(data_path, data_config, normalized_context) 

61 

62 def dataset_factory() -> Any: 

63 """Build the configured single-source or multi-source stream.""" 

64 iterable_dataset = dataset_builder.build_source(sample_filter=sample_filter) 

65 return iterable_dataset 

66 

67 iterable_source = build_dataset_for_dataloader( 

68 dataset_factory, 

69 normalized_context, 

70 barrier_needed=False, 

71 ) 

72 return iterable_source 

73 

74 

75def _supports_output_index_for_resume(dataset: Any) -> bool: 

76 """Return whether a Dataset can emit and rebuild stable output indices.""" 

77 get_item = getattr(dataset, "get_item", None) 

78 supports_output_index = callable(get_item) and hasattr(dataset, "output_index_for_resume") 

79 return supports_output_index 

80 

81 

82class IterableTransformDataset(IterableDataset): 

83 """Apply a sample transform lazily while preserving streaming source state.""" 

84 

85 @classmethod 

86 def apply(cls, source_dataset: Any, transform: SampleTransform) -> Any: 

87 """Transform one Iterable Dataset built on a DataLoader-owning rank. 

88 

89 Args: 

90 source_dataset: Iterable Dataset, or ``None`` on a non-owning rank. 

91 transform: Transform from one RawSample to zero, one, or many ModelSamples. 

92 

93 Returns: 

94 A transformed Iterable Dataset, or ``None`` on a non-owning rank. 

95 """ 

96 if source_dataset is None: 

97 return None 

98 

99 iterable_dataset = cls(source_dataset, transform) 

100 return iterable_dataset 

101 

102 def __init__(self, source_dataset: Any, transform: SampleTransform) -> None: 

103 """Store the streaming source and its lazy sample transform.""" 

104 self.source_dataset = source_dataset 

105 self.transform = transform 

106 

107 @property 

108 def output_index_for_resume(self) -> bool: 

109 """Return whether the upstream source emits stable output indices.""" 

110 if not _supports_output_index_for_resume(self.source_dataset): 

111 raise AttributeError("The upstream iterable does not support output-index resume") 

112 

113 output_index_enabled = bool(self.source_dataset.output_index_for_resume) 

114 return output_index_enabled 

115 

116 @output_index_for_resume.setter 

117 def output_index_for_resume(self, value: bool) -> None: 

118 """Forward output-index mode to a replayable upstream source.""" 

119 if not _supports_output_index_for_resume(self.source_dataset): 

120 raise ValueError("The upstream iterable does not support output-index resume") 

121 

122 self.source_dataset.output_index_for_resume = value 

123 

124 def get_item(self, output_index: Any) -> list[Any]: 

125 """Rebuild and transform one upstream item from its stable output index.""" 

126 if not _supports_output_index_for_resume(self.source_dataset): 

127 raise AttributeError("The upstream iterable does not support get_item") 

128 

129 raw_sample = self.source_dataset.get_item(output_index) 

130 transformed_samples = self._transform_sample(raw_sample) 

131 return transformed_samples 

132 

133 def __iter__(self) -> Any: 

134 """Transform source records lazily while preserving source order.""" 

135 output_index_enabled = ( 

136 _supports_output_index_for_resume(self.source_dataset) 

137 and bool(self.source_dataset.output_index_for_resume) 

138 ) 

139 for source_item in self.source_dataset: 

140 if output_index_enabled: 

141 raw_sample, output_index = source_item 

142 else: 

143 raw_sample = source_item 

144 

145 transformed_samples = self._transform_sample(raw_sample) 

146 if not transformed_samples: 

147 continue 

148 if output_index_enabled: 

149 yield transformed_samples, output_index 

150 elif len(transformed_samples) == 1: 

151 yield transformed_samples[0] 

152 else: 

153 yield transformed_samples 

154 

155 def state_dict(self) -> dict[str, Any]: 

156 """Forward checkpoint state to a stateful upstream stream.""" 

157 state_builder = getattr(self.source_dataset, "state_dict", None) 

158 source_state = {} 

159 if callable(state_builder): 

160 source_state = state_builder() 

161 transform_state = {"source_dataset": source_state} 

162 return transform_state 

163 

164 def load_state_dict(self, state_dict: Mapping[str, Any]) -> None: 

165 """Restore checkpoint state through the upstream stream interface.""" 

166 state_loader = getattr(self.source_dataset, "load_state_dict", None) 

167 if not callable(state_loader): 

168 raise ValueError("Online iterable source does not support load_state_dict") 

169 

170 state_loader(state_dict["source_dataset"]) 

171 

172 def set_epoch(self, epoch: int) -> None: 

173 """Forward deterministic epoch state when supported upstream.""" 

174 epoch_setter = getattr(self.source_dataset, "set_epoch", None) 

175 if callable(epoch_setter): 

176 epoch_setter(epoch) 

177 

178 def _transform_sample(self, raw_sample: Any) -> list[Any]: 

179 """Transform one RawSample and enforce the zero/one/many contract.""" 

180 transformed_samples = self.transform(raw_sample) 

181 if not isinstance(transformed_samples, list): 

182 raise TypeError("Online Iterable sample transform must return a list") 

183 

184 return transformed_samples 

185 

186 

187class _LocalWeightedIterableDataset(IterableDataset): 

188 """Choose among already-sharded Iterable sources on the local rank.""" 

189 

190 def __init__( 

191 self, 

192 source_datasets: Sequence[Any], 

193 weights: Sequence[float], 

194 *, 

195 random_seed: int, 

196 stopping_strategy: str, 

197 vary_by_epoch: bool, 

198 ) -> None: 

199 """Store the rank-local sources and weighted selection policy.""" 

200 self.source_datasets = tuple(source_datasets) 

201 self.weights = tuple(weights) 

202 self.random_seed = random_seed 

203 self.stopping_strategy = stopping_strategy 

204 self.vary_by_epoch = vary_by_epoch 

205 self.epoch = 0 

206 self._random_state = None 

207 self._active_indices = None 

208 self._exhausted_once = None 

209 

210 def __iter__(self) -> Any: 

211 """Yield samples according to the configured local source weights.""" 

212 source_epoch = getattr(self.source_datasets[0], "epoch", self.epoch) 

213 epoch = 0 

214 if self.vary_by_epoch: 

215 epoch = int(source_epoch) 

216 

217 random_generator = random.Random(self.random_seed + epoch) 

218 if self._random_state is not None: 

219 random_generator.setstate(self._random_state) 

220 

221 source_iterators = [] 

222 for source_dataset in self.source_datasets: 

223 source_iterators.append(iter(source_dataset)) 

224 

225 if self._active_indices is None: 

226 active_indices = list(range(len(source_iterators))) 

227 else: 

228 active_indices = list(self._active_indices) 

229 

230 if self._exhausted_once is None: 

231 exhausted_once = set() 

232 else: 

233 exhausted_once = set(self._exhausted_once) 

234 

235 while active_indices: 

236 active_weights = [] 

237 for active_index in active_indices: 

238 active_weights.append(self.weights[active_index]) 

239 

240 source_index = random_generator.choices(active_indices, weights=active_weights, k=1)[0] 

241 try: 

242 source_sample = next(source_iterators[source_index]) 

243 except StopIteration: 

244 if self.stopping_strategy == "first_exhausted": 

245 self._clear_iteration_state() 

246 return 

247 

248 exhausted_once.add(source_index) 

249 if self.stopping_strategy == "all_exhausted_without_replacement": 

250 active_indices.remove(source_index) 

251 self._save_iteration_state(random_generator, active_indices, exhausted_once) 

252 continue 

253 

254 if len(exhausted_once) == len(source_iterators): 

255 self._clear_iteration_state() 

256 return 

257 

258 source_iterators[source_index] = iter(self.source_datasets[source_index]) 

259 self._save_iteration_state(random_generator, active_indices, exhausted_once) 

260 continue 

261 

262 self._save_iteration_state(random_generator, active_indices, exhausted_once) 

263 yield source_sample 

264 

265 self._clear_iteration_state() 

266 

267 def set_epoch(self, epoch: int) -> None: 

268 """Set the local blend epoch and forward it to every source.""" 

269 self.epoch = epoch 

270 self._clear_iteration_state() 

271 for source_dataset in self.source_datasets: 

272 epoch_setter = getattr(source_dataset, "set_epoch", None) 

273 if callable(epoch_setter): 

274 epoch_setter(epoch) 

275 

276 def state_dict(self) -> dict[str, Any]: 

277 """Save source cursors and the local weighted-selection cursor.""" 

278 source_states = [] 

279 for source_dataset in self.source_datasets: 

280 state_builder = getattr(source_dataset, "state_dict", None) 

281 source_state = None 

282 if callable(state_builder): 

283 source_state = state_builder() 

284 source_states.append(source_state) 

285 

286 source_epoch = getattr(self.source_datasets[0], "epoch", self.epoch) 

287 dataset_state = { 

288 "epoch": int(source_epoch), 

289 "random_state": self._random_state, 

290 "active_indices": self._active_indices, 

291 "exhausted_once": self._exhausted_once, 

292 "sources": source_states, 

293 } 

294 return dataset_state 

295 

296 def load_state_dict(self, state_dict: Mapping[str, Any]) -> None: 

297 """Restore source cursors and the local weighted-selection cursor.""" 

298 source_states = state_dict["sources"] 

299 if len(source_states) != len(self.source_datasets): 

300 raise ValueError("Online source count does not match the saved local blend state") 

301 

302 for source_dataset, source_state in zip(self.source_datasets, source_states): 

303 if source_state is None: 

304 continue 

305 state_loader = getattr(source_dataset, "load_state_dict", None) 

306 if not callable(state_loader): 

307 raise ValueError("Online iterable source does not support load_state_dict") 

308 state_loader(source_state) 

309 

310 self.epoch = int(state_dict["epoch"]) 

311 self._random_state = state_dict["random_state"] 

312 self._active_indices = state_dict["active_indices"] 

313 self._exhausted_once = state_dict["exhausted_once"] 

314 

315 def _save_iteration_state( 

316 self, 

317 random_generator: random.Random, 

318 active_indices: Sequence[int], 

319 exhausted_once: set[int], 

320 ) -> None: 

321 """Record the local source-selection cursor after one decision.""" 

322 self._random_state = random_generator.getstate() 

323 self._active_indices = tuple(active_indices) 

324 self._exhausted_once = tuple(sorted(exhausted_once)) 

325 

326 def _clear_iteration_state(self) -> None: 

327 """Reset the local source-selection cursor for a new epoch.""" 

328 self._random_state = None 

329 self._active_indices = None 

330 self._exhausted_once = None 

331 

332 

333class _OnlineIterableSourceBuilder(_OnlineSourceBuilder): 

334 """Build the rank-local or globally interleaved streaming source graph.""" 

335 

336 def __init__( 

337 self, 

338 data_path: OnlineDataPath | None, 

339 data_config: Mapping[str, Any], 

340 dataloader_context: DataLoaderParallelContext, 

341 ) -> None: 

342 """Store source configuration and DataLoader ownership.""" 

343 super().__init__(data_path, data_config) 

344 self.dataloader_context = dataloader_context 

345 

346 def build_source( 

347 self, 

348 *, 

349 sample_filter: Callable[[Mapping[str, Any]], bool] | None = None, 

350 ) -> Any: 

351 """Load Iterable sources and apply their shard, shuffle, and blend policy.""" 

352 source_loaders, weights = self._create_source_loaders() 

353 source_datasets = [] 

354 for source_loader in source_loaders: 

355 source_dataset = source_loader.load(streaming=True, sample_filter=sample_filter) 

356 if isinstance(source_dataset, tuple): 

357 raise ValueError("Online Iterable Dataset requires one train source per entry") 

358 source_datasets.append(source_dataset) 

359 

360 if len(source_datasets) == 1: 

361 iterable_dataset = self._prepare_local_source(source_datasets[0]) 

362 return iterable_dataset 

363 

364 blend_strategy = str(self.data_config.get("blend_strategy", "local_weighted")) 

365 if blend_strategy not in ONLINE_BLEND_STRATEGIES: 

366 raise ValueError( 

367 f"Unsupported Online blend_strategy {blend_strategy!r}; " 

368 f"expected one of {ONLINE_BLEND_STRATEGIES!r}" 

369 ) 

370 

371 if blend_strategy == "local_weighted": 

372 local_sources = [] 

373 for source_dataset in source_datasets: 

374 local_source = self._prepare_local_source(source_dataset) 

375 local_sources.append(local_source) 

376 

377 iterable_dataset = _LocalWeightedIterableDataset( 

378 local_sources, 

379 weights, 

380 random_seed=self._random_seed, 

381 stopping_strategy=self._stopping_strategy, 

382 vary_by_epoch=self._shuffle_enabled, 

383 ) 

384 return iterable_dataset 

385 

386 shuffled_sources = [] 

387 for source_dataset in source_datasets: 

388 shuffled_source = self._shuffle(source_dataset) 

389 shuffled_sources.append(shuffled_source) 

390 

391 iterable_dataset = self._interleave_sources(shuffled_sources, weights) 

392 if self._shuffle_enabled: 

393 # buffer_size=1 preserves output order while making set_epoch reseed the full source graph. 

394 iterable_dataset = iterable_dataset.shuffle(seed=self._random_seed, buffer_size=1) 

395 if self._split_by_data_parallel: 

396 iterable_dataset = split_iterable_dataset_by_dp(iterable_dataset, self.dataloader_context) 

397 return iterable_dataset 

398 

399 @property 

400 def _shuffle_enabled(self) -> bool: 

401 """Return whether Iterable sources use local buffer shuffle.""" 

402 return bool(self.data_config.get("shuffle", True)) 

403 

404 @property 

405 def _split_by_data_parallel(self) -> bool: 

406 """Return whether Iterable sources own DP sharding.""" 

407 return bool(self.data_config.get("split_by_data_parallel", True)) 

408 

409 def _prepare_local_source(self, source_dataset: Any) -> Any: 

410 """Apply per-source DP sharding before local buffer shuffle.""" 

411 if self._split_by_data_parallel: 

412 source_dataset = split_iterable_dataset_by_dp(source_dataset, self.dataloader_context) 

413 local_dataset = self._shuffle(source_dataset) 

414 return local_dataset 

415 

416 def _shuffle(self, source_dataset: Any) -> Any: 

417 """Apply deterministic buffer shuffle when configured.""" 

418 if not self._shuffle_enabled: 

419 return source_dataset 

420 

421 buffer_size = int(self.data_config.get("shuffle_buffer_size", 10_000)) 

422 if buffer_size <= 0: 

423 raise ValueError("Online shuffle_buffer_size must be positive") 

424 

425 shuffled_dataset = source_dataset.shuffle(seed=self._random_seed, buffer_size=buffer_size) 

426 return shuffled_dataset 

427 

428 

429__all__ = [ 

430 "IterableTransformDataset", 

431 "build_online_iterable_source", 

432]