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

162 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"""Dataset-agnostic DP index slicing and resumable micro-batch sampling.""" 

16 

17from __future__ import annotations 

18 

19import operator 

20import os 

21import random 

22from collections.abc import Iterator, Mapping, Sequence 

23from typing import Any, Literal, Union, cast 

24 

25import torch 

26 

27from hyper_parallel.data.dataset_logging import get_dataset_logger 

28 

29IndexMapping = Union[Mapping[int, int], Sequence[int]] 

30SamplerType = Literal["single", "cyclic"] 

31 

32logger = get_dataset_logger(__name__) 

33 

34 

35def _validate_positive_integer(value: int, name: str) -> None: 

36 """Validate an integer boundary shared by sampler options.""" 

37 if value <= 0: 

38 raise ValueError(f"{name} must be a positive integer") 

39 

40 

41class _DatasetBatchSampler: 

42 """Share validation, DP slicing, epoch, and checkpoint state. 

43 

44 BatchSampler splits one global batch across DP ranks 

45 → each rank receives its logical Dataset index 

46 → Dataset.__getitem__(index) 

47 → shuffle_index maps the logical index to a random packed sample 

48 """ 

49 

50 def __init__( 

51 self, 

52 *, 

53 total_samples: int, 

54 consumed_samples: int, 

55 micro_batch_size: int, 

56 global_batch_size: int, 

57 dp_rank: int, 

58 dp_world_size: int, 

59 drop_last: bool, 

60 index_mapping: IndexMapping | None, 

61 ) -> None: 

62 """Validate and store DP slicing and checkpoint state.""" 

63 _validate_positive_integer(total_samples, "total_samples") 

64 _validate_positive_integer(micro_batch_size, "micro_batch_size") 

65 _validate_positive_integer(dp_world_size, "dp_world_size") 

66 self._validate_consumed_samples(consumed_samples, total_samples) 

67 if not 0 <= dp_rank < dp_world_size: 

68 raise ValueError(f"dp_rank must be in [0, {dp_world_size}), but got {dp_rank!r}") 

69 

70 self.total_samples = total_samples 

71 self.consumed_samples = consumed_samples 

72 self.micro_batch_size = micro_batch_size 

73 self.dp_rank = dp_rank 

74 self.dp_world_size = dp_world_size 

75 self.drop_last = drop_last 

76 self.index_mapping = index_mapping 

77 self.global_micro_batch_size = micro_batch_size * dp_world_size 

78 _validate_positive_integer(global_batch_size, "global_batch_size") 

79 if global_batch_size % self.global_micro_batch_size != 0: 

80 raise ValueError("global_batch_size must be divisible by micro_batch_size * dp_world_size") 

81 

82 self.global_batch_size = global_batch_size 

83 self.epoch = 0 

84 self._resume_at_source_batch = False 

85 

86 @staticmethod 

87 def _validate_consumed_samples(consumed_samples: int, total_samples: int) -> None: 

88 """Allow sequential sampling to stop exactly at the epoch boundary.""" 

89 if not 0 <= consumed_samples <= total_samples: 

90 raise ValueError("consumed_samples must be in [0, total_samples]") 

91 

92 def __iter__(self) -> Iterator[list[int]]: 

93 """Yield sequential rank-local indices for ``single`` mode.""" 

94 while self.consumed_samples < self.total_samples: 

95 block_start = self.consumed_samples 

96 block_stop = min(block_start + self.global_micro_batch_size, self.total_samples) 

97 block_size = block_stop - block_start 

98 if self.drop_last and block_size < self.global_micro_batch_size: 

99 self.consumed_samples = self.total_samples 

100 break 

101 

102 local_start = block_start + self.dp_rank * self.micro_batch_size 

103 local_stop = min(local_start + self.micro_batch_size, block_stop) 

104 self.consumed_samples = block_stop 

105 if local_start >= block_stop: 

106 continue 

107 

108 local_indices = [ 

109 self._resolve_index(index) 

110 for index in range(local_start, local_stop) 

111 ] 

112 yield local_indices 

113 

114 def __len__(self) -> int: 

115 """Return the remaining number of micro-batches for this DP rank.""" 

116 remaining_samples = self.total_samples - self.consumed_samples 

117 full_batches, partial_samples = divmod(remaining_samples, self.global_micro_batch_size) 

118 if self.drop_last or partial_samples <= self.dp_rank * self.micro_batch_size: 

119 return full_batches 

120 return full_batches + 1 

121 

122 def _resolve_index(self, logical_index: int) -> int: 

123 """Apply an optional logical-to-physical sample index mapping.""" 

124 if self.index_mapping is None: 

125 return logical_index 

126 

127 resolved_index = self.index_mapping[logical_index] 

128 if isinstance(resolved_index, bool): 

129 raise TypeError(f"index_mapping[{logical_index}] must be an integer") 

130 

131 physical_index = operator.index(resolved_index) 

132 return physical_index 

133 

134 def state_dict(self) -> dict[str, int]: 

135 """Return the Dataset epoch and position needed to resume iteration.""" 

136 sampler_state = { 

137 "consumed_samples": self.consumed_samples, 

138 "epoch": self.epoch, 

139 "global_batch_size": self.global_batch_size, 

140 "dp_world_size": self.dp_world_size, 

141 } 

142 return sampler_state 

143 

144 def enable_source_batch_resume(self) -> None: 

145 """Restore this sampler at a source-batch boundary for Online batching.""" 

146 self._resume_at_source_batch = True 

147 

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

149 """Restore the global sample position from a checkpoint. 

150 

151 Args: 

152 state_dict: Sampler state produced by :meth:`state_dict`. 

153 

154 Raises: 

155 ValueError: If the checkpoint does not contain a valid sample position. 

156 """ 

157 consumed_samples = state_dict["consumed_samples"] 

158 self._validate_consumed_samples(consumed_samples, self.total_samples) 

159 if state_dict["global_batch_size"] != self.global_batch_size: 

160 if self._resume_at_source_batch: 

161 raise ValueError("Online dataloader resume requires an unchanged global_batch_size") 

162 

163 raise ValueError("elastic DP resume requires an unchanged global_batch_size") 

164 

165 if self._resume_at_source_batch: 

166 if state_dict["dp_world_size"] != self.dp_world_size: 

167 raise ValueError("Online dataloader resume does not support DP world-size changes") 

168 

169 if ( 

170 consumed_samples != self.total_samples 

171 and consumed_samples % self.global_micro_batch_size != 0 

172 ): 

173 raise ValueError("Online source sampler state must align to a distributed micro-batch") 

174 

175 elif consumed_samples % self.global_batch_size != 0: 

176 raise ValueError("sampler state must align to a global optimizer batch") 

177 

178 self.consumed_samples = consumed_samples 

179 self.epoch = state_dict["epoch"] 

180 

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

182 """Start a new epoch while preserving progress in the restored epoch. 

183 

184 Args: 

185 epoch: Zero-based training epoch. 

186 

187 Raises: 

188 ValueError: If ``epoch`` is not a non-negative integer. 

189 """ 

190 if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 0: 

191 raise ValueError("epoch must be a non-negative integer") 

192 

193 if epoch != self.epoch: 

194 self.consumed_samples = 0 

195 self.epoch = epoch 

196 

197 

198class _CyclicDatasetBatchSampler(_DatasetBatchSampler): 

199 """Yield one deterministic shuffled epoch for ``cyclic`` mode.""" 

200 

201 def __init__(self, *, data_sharding: bool, seed: int, **sampler_options: Any) -> None: 

202 """Store the cyclic data-sharding policy.""" 

203 super().__init__(**sampler_options) 

204 if not self.drop_last: 

205 raise ValueError("cyclic sampling requires drop_last=True") 

206 

207 self.data_sharding = data_sharding 

208 self.seed = seed 

209 

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

211 """Restore a fixed-GBS cursor, repartitioning only global shuffle order.""" 

212 if self.data_sharding and state_dict["dp_world_size"] != self.dp_world_size: 

213 raise ValueError("elastic DP resume requires data_sharding=False") 

214 

215 super().load_state_dict(state_dict) 

216 

217 def __len__(self) -> int: 

218 """Return the number of local micro-batches remaining in this epoch.""" 

219 active_samples = self._active_samples() 

220 remaining_samples = active_samples - self.consumed_samples 

221 return remaining_samples // self.global_micro_batch_size 

222 

223 def __iter__(self) -> Iterator[list[int]]: 

224 """Build the current epoch permutation and DP slices.""" 

225 active_samples = self._active_samples() 

226 if self.consumed_samples % self.global_micro_batch_size != 0: 

227 raise ValueError("cyclic consumed_samples must align to a distributed micro-batch") 

228 

229 logger.debug( 

230 "Starting cyclic Dataset epoch=%d at consumed_samples=%d", 

231 self.epoch, 

232 self.consumed_samples, 

233 ) 

234 

235 if self.data_sharding: 

236 # Shuffle one contiguous bucket per DP rank. 

237 bucket_size = active_samples // self.dp_world_size 

238 bucket_offset = self.consumed_samples // self.dp_world_size 

239 bucket_start = self.dp_rank * bucket_size 

240 random_offsets = list(range(bucket_size)) 

241 random.Random(self.seed + self.epoch).shuffle(random_offsets) 

242 rank_indices = [bucket_start + offset for offset in random_offsets[bucket_offset:]] 

243 else: 

244 # Shuffle the complete global-batch region, then stride by DP rank. 

245 shuffled_indices = list(range(active_samples)) 

246 random.Random(self.seed + self.epoch).shuffle(shuffled_indices) 

247 active_indices = shuffled_indices[self.consumed_samples:] 

248 rank_indices = active_indices[self.dp_rank::self.dp_world_size] 

249 

250 local_batch = [] 

251 for index in rank_indices: 

252 local_batch.append(index) 

253 if len(local_batch) == self.micro_batch_size: 

254 self.consumed_samples += self.global_micro_batch_size 

255 yield local_batch 

256 local_batch = [] 

257 

258 def _active_samples(self) -> int: 

259 """Return the complete global micro-batch region reused every epoch.""" 

260 active_samples = self.total_samples - self.total_samples % self.global_micro_batch_size 

261 if active_samples <= 0: 

262 raise ValueError("cyclic Dataset must contain one complete global micro-batch") 

263 

264 return active_samples 

265 

266 

267def _resolve_index_mapping( 

268 index_mapping: IndexMapping | None, 

269 data_rearrange_map: IndexMapping | str | os.PathLike[str] | None, 

270) -> IndexMapping | None: 

271 """Resolve the optional rearrangement-map configuration.""" 

272 if index_mapping is not None and data_rearrange_map is not None: 

273 raise ValueError("configure only one of index_mapping and data_rearrange_map") 

274 

275 mapping_source = data_rearrange_map if data_rearrange_map is not None else index_mapping 

276 if isinstance(mapping_source, (str, os.PathLike)): 

277 mapping_path = os.fspath(mapping_source) 

278 loaded_mapping = torch.load(f=mapping_path) 

279 resolved_mapping = cast(IndexMapping, loaded_mapping) 

280 return resolved_mapping 

281 return mapping_source 

282 

283 

284def build_dataset_batch_sampler( 

285 *, 

286 total_samples: int, 

287 micro_batch_size: int, 

288 global_batch_size: int, 

289 dp_rank: int, 

290 dp_world_size: int, 

291 consumed_samples: int = 0, 

292 drop_last: bool = True, 

293 index_mapping: IndexMapping | None = None, 

294 data_rearrange_map: IndexMapping | str | os.PathLike[str] | None = None, 

295 sampler_type: SamplerType = "single", 

296 data_sharding: bool = False, 

297 seed: int = 0, 

298) -> _DatasetBatchSampler: 

299 """Build a Dataset batch sampler. 

300 

301 The sampler groups logical indices into global DP blocks, gives each DP rank 

302 one contiguous micro-batch, and checkpoints the next global sample position. 

303 It does not know how samples are stored or transformed. 

304 

305 Supported scenarios: 

306 - ``single``: Sample sequential Dataset indices. 

307 - ``single`` with ``data_rearrange_map``: Resolve indices through an 

308 in-memory mapping or a mapping checkpoint path. 

309 - ``cyclic``: Use epoch-based ``randperm`` order, with 

310 ``data_sharding=True/False`` and resumable sampler state. 

311 

312 Args: 

313 total_samples: Number of logical samples in the Dataset. 

314 micro_batch_size: Samples consumed by one DP rank per micro-step. 

315 global_batch_size: Fixed samples consumed globally by one optimizer step. 

316 dp_rank: Rank inside the data-parallel group. 

317 dp_world_size: Number of ranks in the data-parallel group. 

318 consumed_samples: Global logical sample position restored from a checkpoint. 

319 drop_last: Whether to omit an incomplete global DP block. 

320 index_mapping: Optional logical-to-physical sample index mapping. 

321 data_rearrange_map: Mapping object or checkpoint path. 

322 sampler_type: ``single`` for sequential sampling or ``cyclic`` for a 

323 deterministic shuffled epoch. 

324 data_sharding: Whether cyclic sampling shuffles an independent 

325 contiguous bucket on each DP rank. 

326 seed: Base seed used by cyclic epoch shuffling. 

327 

328 Returns: 

329 A resumable iterable of rank-local index lists. 

330 """ 

331 resolved_mapping = _resolve_index_mapping(index_mapping, data_rearrange_map) 

332 logger.debug( 

333 "Building Dataset sampler: type=%s, total_samples=%d, consumed_samples=%d, micro_batch_size=%d, " 

334 "dp_rank=%d, dp_world_size=%d, drop_last=%s, data_sharding=%s, mapped=%s", 

335 sampler_type, total_samples, consumed_samples, micro_batch_size, dp_rank, dp_world_size, drop_last, 

336 data_sharding, resolved_mapping is not None, 

337 ) 

338 sampler_options = { 

339 "total_samples": total_samples, 

340 "consumed_samples": consumed_samples, 

341 "micro_batch_size": micro_batch_size, 

342 "global_batch_size": global_batch_size, 

343 "dp_rank": dp_rank, 

344 "dp_world_size": dp_world_size, 

345 "drop_last": drop_last, 

346 "index_mapping": resolved_mapping, 

347 } 

348 

349 if sampler_type == "single": 

350 # Scenario 1 — single without a mapping: sequential Dataset indices. 

351 # Scenario 2 — single + data_rearrange_map: the same logical sequence 

352 # is resolved through an in-memory mapping or mapping checkpoint. 

353 batch_sampler = _DatasetBatchSampler(**sampler_options) 

354 return batch_sampler 

355 

356 if sampler_type == "cyclic": 

357 # Scenario 3 — cyclic: use epoch-based randperm order, 

358 # support data_sharding=True/False, and checkpoint consumed_samples. 

359 if resolved_mapping is not None: 

360 raise ValueError("cyclic sampling does not support a rearrangement map") 

361 

362 batch_sampler = _CyclicDatasetBatchSampler( 

363 data_sharding=data_sharding, 

364 seed=seed, 

365 **sampler_options, 

366 ) 

367 return batch_sampler 

368 

369 raise ValueError("sampler_type must be one of: single, cyclic")