Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / core / dtensor / random.py: 72%

209 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"""RNG state management for distributed tensor operations. 

16 

17Provides utilities for tracking and synchronizing random number generator states 

18across multiple devices in distributed training scenarios. 

19""" 

20# pylint: disable=C9006,C9007,W0603 

21 

22__all__ = [ 

23 "is_rng_supported_mesh", 

24 "manual_seed", 

25 "OffsetBasedRNGTracker", 

26] 

27 

28import contextlib 

29import warnings 

30from logging import getLogger 

31import typing 

32from typing import Optional 

33import functools 

34import operator 

35 

36import torch 

37import torch.distributed as dist 

38 

39from hyper_parallel.core.dtensor import _utils 

40from hyper_parallel.core.dtensor.placement_types import Shard 

41from hyper_parallel.core.dtensor.device_mesh import DeviceMesh 

42 

43Tensor = torch.tensor 

44 

45logger = getLogger(__name__) 

46 

47 

48def is_rng_supported_mesh(device_mesh: Optional[DeviceMesh] = None) -> bool: 

49 """Check if the device mesh supports DTensor random operations. 

50 

51 Currently, DTensor random operations are only supported on CUDA and CUDA-like 

52 devices. Users should call this function before using DTensor random APIs to 

53 verify compatibility. 

54 

55 Args: 

56 device_mesh: Optional :class:`DeviceMesh` to check (same semantics as PyTorch 

57 ``torch.distributed.tensor``). If omitted, only the active device handle 

58 is checked. 

59 

60 Returns: 

61 bool: ``True`` if the device mesh supports DTensor random operations, 

62 ``False`` otherwise. 

63 """ 

64 if device_mesh is not None and device_mesh.device_type == "cpu": 

65 warnings.warn( 

66 f"DTensor random operators may not have complete support on {device_mesh.device_type} device mesh", 

67 stacklevel=2, 

68 ) 

69 return False 

70 device_handle = _utils.get_device_handle() 

71 if device_handle and hasattr(device_handle, "set_rng_state"): 

72 return True 

73 if device_mesh is not None: 

74 warnings.warn( 

75 f"DTensor random operators may not have complete support on {device_mesh.device_type} device mesh", 

76 stacklevel=2, 

77 ) 

78 return False 

79 

80 

81class _PhiloxState: 

82 """ 

83 Convenience accessor for interpreting the packed bits of (seed: uint64, offset: uint64) in the philox state, 

84 which for some reason is actually exposed as a size-16 uint8 tensor. 

85 

86 The state is always moved to .cpu since it is necessary for it to be on CPU before applying it back to a generator. 

87 """ 

88 

89 def __init__(self, state: Tensor): 

90 self._state = state.to("cpu") 

91 

92 @property 

93 def state(self): 

94 """Return the underlying RNG state tensor (CPU uint8).""" 

95 return self._state 

96 

97 @property 

98 def offset(self) -> int: 

99 """Return the offset value (last 8 bytes) of the Philox RNG state.""" 

100 return int(self._state[8:].view(dtype=torch.int64).item()) 

101 

102 @offset.setter 

103 def offset(self, offset: int) -> None: 

104 """Set the offset value of the Philox RNG state.""" 

105 offset_tensor = Tensor([offset], dtype=torch.uint64).view( 

106 torch.uint8 

107 ) # device? 

108 self._state[8:] = offset_tensor 

109 

110 @property 

111 def seed(self) -> int: 

112 """Return the seed value (first 8 bytes) of the Philox RNG state.""" 

113 return int(self._state[:8].view(dtype=torch.uint64).item()) 

114 

115 @seed.setter 

116 def seed(self, seed: int) -> None: 

117 """Set the seed value of the Philox RNG state.""" 

118 seed_tensor = Tensor([seed], dtype=torch.uint64).view( 

119 torch.uint8 

120 )# device 

121 self._state[:8] = seed_tensor 

122 

123 

124class _RNGStateTracker: 

125 """ 

126 Tracks and manages RNG states for DTensor random operations. 

127 

128 Maintains a mapping from operation tags to RNG state tensors (ByteTensor), 

129 providing standardized interfaces for state access and modification. 

130 

131 The core method `_distribute_region` establishes the proper RNG context 

132 when DTensor executes random operators across distributed devices. 

133 """ 

134 

135 def __init__(self, device): 

136 self._device = device 

137 self._device_handle = _utils.get_device_handle() 

138 if not self._device_handle: 

139 raise RuntimeError( 

140 f"{self.__class__.__name__} instantiation requires the presence of " 

141 ) 

142 self._use_distribute_region = True 

143 

144 @property 

145 def distribute_region_enabled(self) -> bool: 

146 """Return whether the RNG distribute region is enabled for distributed random operations.""" 

147 return self._use_distribute_region 

148 

149 @distribute_region_enabled.setter 

150 def distribute_region_enabled(self, value) -> None: 

151 """Set whether the RNG distribute region is enabled.""" 

152 self._use_distribute_region = value 

153 

154 def _distribute_region( 

155 self, device_mesh, placements, global_shape, generator = None 

156 ): 

157 pass 

158 

159 def _manual_seed(self, parallel_seed: int) -> None: 

160 pass 

161 

162 

163class OffsetBasedRNGTracker(_RNGStateTracker): 

164 """ 

165 This subclass of ``_RNGStateTracker`` defines the default policy of how RNG states 

166 should be shared and synchronized among all ranks to respect the semantics of DTensor 

167 random operators. 

168 """ 

169 

170 def __init__( 

171 self, 

172 run_state_sync: bool = True, 

173 ): 

174 super().__init__(_resolve_device()) 

175 rng_state = self._get_device_state() 

176 if run_state_sync: 

177 # synchronize RNG state using rank 0's current one 

178 dist.broadcast(rng_state, 0) 

179 my_rng_state = self._get_device_state() 

180 if not all(my_rng_state == rng_state): 

181 logger.warning( 

182 "DTensor is synchronizing RNG states of every rank with the state from rank 0. " 

183 "This behavior is deprecated. " 

184 "Please call ``manual_seed(seed, device_mesh)`` from " 

185 "``hyper_parallel.core.dtensor.random`` on every rank that participates in SPMD DTensor " 

186 "operations with the same seed. If using Pipeline Parallelism, each pipelining state would use " 

187 "a different seed, but all ranks belonging to one pipeline stage would use the same seed." 

188 ) 

189 self._set_device_state(rng_state) 

190 

191 def _manual_seed(self, parallel_seed: int) -> None: 

192 """Set the default RNG seed (``torch.manual_seed``), as in PyTorch DTensor.""" 

193 torch.manual_seed(parallel_seed) 

194 

195 def _get_device_state(self): 

196 rng_state = self._device_handle.get_rng_state().to(self._device) 

197 return rng_state 

198 

199 def _set_device_state(self, state: Tensor): 

200 # It seems that the underlying generator wants a cpu tensor but the dtensor code expects `_get_device_state` 

201 # to convert to a 'device' tensor, probably because we may use it with our backend comms for sync/debug 

202 # for now, we just convert back to cpu here to make sure it always works. 

203 self._device_handle.set_rng_state(state.to("cpu")) 

204 

205 @contextlib.contextmanager 

206 def _distribute_region( 

207 self, device_mesh, placements, global_shape, generator = None 

208 ): 

209 

210 # regular (non-LocalTensor) mode 

211 if generator is not None: 

212 # This is a little hacky, but for any user-passed generator, we store its state under a unique key, 

213 # not because we need to keep a copy of it but because its the easiest way to make it work with the 

214 # existing set/get APIs. We also ensure we remove it from rng_states after each _distribute_region. 

215 state = _PhiloxState(generator.get_state()) 

216 else: 

217 state = _PhiloxState(self._get_device_state()) 

218 

219 if self.distribute_region_enabled: 

220 old_offset = state.offset 

221 self._set_pre_op_offset(state, device_mesh, placements, global_shape) 

222 with fork_rng( 

223 devices=[self._device], device_type=_utils.device_type() 

224 ): 

225 self._device_handle.set_rng_state(state.state) 

226 try: 

227 yield # execute the region code 

228 finally: 

229 # update offset to synchronize among ranks 

230 self._set_post_op_offset(state, global_shape, old_offset) 

231 

232 else: 

233 yield 

234 

235 if generator is not None: 

236 # ensure we (a) propagate the state advancement back to the user's RNG so its visible and impacts any future 

237 # usage of that RNG (dtensor or non-dtensor), (b) drop it from our own cache so that if the user updates 

238 # the seed value in their rng and uses it with DTensor again, we always use the latest value 

239 generator.set_state(state.state) 

240 else: 

241 self._set_device_state(state.state) 

242 

243 def compute_offset_incr(self, device_mesh, placements, global_shape) -> int: 

244 """Compute the per-shard RNG offset increment for the current rank. 

245 

246 Based on the shard linear index and local shard size, computes how much to 

247 advance the offset so that each shard gets a unique portion of the random stream. 

248 

249 Args: 

250 device_mesh (DeviceMesh): The device mesh describing the device topology. 

251 placements (Sequence[Placement]): The placement strategy for each mesh dimension. 

252 global_shape: input global shape 

253 

254 Returns: 

255 int: The offset increment, 4-byte aligned. 

256 """ 

257 mesh_coordinate = device_mesh.get_coordinate() 

258 shard_idx_by_dim, total_num_shards_by_dim = _calc_shard_info( 

259 mesh_coordinate, 

260 device_mesh, 

261 placements, 

262 global_shape, 

263 reverse_repeated_shards=True, 

264 ) 

265 shard_linear_idx = self._calc_shard_linear_idx( 

266 shard_idx_by_dim, total_num_shards_by_dim 

267 ) 

268 local_size_on_rank_0 = _calc_first_shard_size(device_mesh, placements, global_shape) 

269 local_size = functools.reduce(operator.mul, local_size_on_rank_0, 1) 

270 return (shard_linear_idx * local_size + 3) // 4 * 4 

271 

272 def _set_pre_op_offset(self, state: _PhiloxState, device_mesh, placements, global_shape) -> None: 

273 """Set the starting random number generator (RNG) offset for the local shard 

274 on the current process before operation execution.The offset value begins from 

275 the current accumulated position and increments by the local shard size until 

276 covering the total elements of the global distributed tensor. Multiple processes 

277 holding replicas of the same shard will share identical starting offset values. 

278 

279 Args: 

280 state (`Tensor`): The generator state to modify 

281 device_mesh (DeviceMesh): The device mesh describing the device topology. 

282 placements (Sequence[Placement]): The placement strategy for each mesh dimension. 

283 Each element should be a Placement object (Shard, Replicate, Partial, etc.). 

284 global_shape: input global shape 

285 

286 Returns: 

287 None 

288 

289 .. warning:: 

290 The current implementation does not consider memory layout contiguity. 

291 

292 Example: 

293 take a DTensor of shape [8, 16] as an example. Assume that the DTensor 

294 is placed on a device mesh with placements ([Shard(1), Replicate(), Shard(0)]), 

295 and the mesh is: 

296 [[[0, 1], [2, 3]], [[4, 5], [6, 7]]] 

297 ``mesh.get_coordinate()`` provides the coordinate of the current rank 

298 in the mesh. For example, the coordinate of rank 5 is (1, 0, 1). 

299 

300 Another concept to introduce besides rank coordinate is shard coordinate. 

301 Each rank holds a local shard of the DTensor. In the example, the DTensor 

302 is partitioned into 4 [4, 8] shards. The first shard has 2 replicas and 

303 rank 0 (coord (0, 0, 0)) and rank 2 (coord (0, 1, 0)) have 1 replica each. 

304 That being said, the local shard on rank 0 and rank 2 correspond to the same 

305 shard of the DTensor. To denote each DTensor shard, we use a shard coordinate 

306 (in the example, it will be a tuple (i, j) where shard (i, j) has the slice 

307 DTensor[4 * i : 4 * (i + 1), 8 * j : 8 * (j + 1)], 0 <= i < 2, 0 <= j < 2). 

308 

309 Once we have rank coordinate and shard coordinate, we can calculate on each rank 

310 what shard of the DTensor the rank holds, with the help of dim_map. The dim_map 

311 of the above DTensor is [2, 0] so the shard coordinate of a rank with rank coord 

312 (x, y, z) is simply (z, x) by taking(rank_coord[dim_map[0]],rank_coord[dim_map[1]]). 

313 Following this calculation, 

314 rank 0 and rank 2 holds the shard of coord (0, 0); 

315 rank 1 and rank 3 holds the shard of coord (0, 1); 

316 rank 4 and rank 6 holds the shard of coord (1, 0); 

317 rank 5 and rank 7 holds the shard of coord (1, 1); 

318 

319 The last value to calculate before obtaining the starting offset is the shard linear index. 

320 The starting offset for each rank will be its shard_linear_index * local_tensor_numel. 

321 """ 

322 current_offset = state.offset 

323 offset_incr = self.compute_offset_incr(device_mesh, placements, global_shape) 

324 state.offset = current_offset + offset_incr 

325 

326 def _set_post_op_offset( 

327 self, state: _PhiloxState, global_shape, old_offset: int 

328 ) -> None: 

329 """Sets the RNG to a synchronized state after running the local random op. 

330 Restores the random number generator to a globally consistent state following 

331 local shard execution. Each process must advance its offset by the total element 

332 count of the distributed tensor, measured from the offset value recorded before 

333 the operation began. 

334 

335 Args: 

336 state (`Tensor`): The generator state to modify. 

337 global_shape: The global shape of the distributed tensor. 

338 old_offset (int): The RNG offset before the operation. 

339 

340 Returns: 

341 None 

342 """ 

343 numel = functools.reduce(operator.mul, global_shape, 1) 

344 numel = (numel + 3) // 4 * 4 

345 state.offset = old_offset + numel 

346 

347 def _calc_shard_linear_idx( 

348 self, shard_coord: list[int], shard_size: list[int] 

349 ) -> int: 

350 return _calc_shard_linear_idx(shard_coord, shard_size) 

351 

352 

353def _calc_first_shard_size(device_mesh, placements, global_shape) -> list[int]: 

354 """Calculate the size of the first shard on rank 0. 

355 

356 Args: 

357 device_mesh: The device mesh describing the device topology. 

358 placements: Sequence of Placement objects (Shard, Replicate, etc.). 

359 global_shape: input global shape 

360 

361 Returns: 

362 list[int]: Shape of rank 0's local shard. 

363 """ 

364 local_size_on_rank_0 = list(global_shape) 

365 for idx, placement in enumerate(placements): 

366 if isinstance(placement, Shard): 

367 mesh_dim_size = device_mesh.size(idx) 

368 shard_dim = placement.dim 

369 current_dim_size = local_size_on_rank_0[shard_dim] 

370 local_size_on_rank_0[shard_dim], _ = local_shard_size_and_offset( 

371 current_dim_size, 

372 mesh_dim_size, 

373 0, 

374 ) 

375 return local_size_on_rank_0 

376 

377 

378def _calc_shard_info( 

379 mesh_coordinate, 

380 device_mesh, 

381 placements, 

382 global_shape, 

383 reverse_repeated_shards=False, 

384): 

385 """Calculate shard information for a specific rank.""" 

386 mesh_size = device_mesh.mesh_shape 

387 # note: dim_map does not allow double sharding which is the FSDP(fully_shard)+TP 

388 # case. Replace the custom logic with dim_map once we support it. 

389 dim_map = [-1] * len(global_shape) 

390 for i, placement in enumerate(placements): 

391 if isinstance(placement, Shard): 

392 shard_dim = placement.dim 

393 if dim_map[shard_dim] == -1: 

394 dim_map[shard_dim] = [i] 

395 else: 

396 mesh_dim_list = dim_map[shard_dim] 

397 if not isinstance(mesh_dim_list, list): 

398 raise TypeError(f"Expected mesh_dim_list to be a list, got {type(mesh_dim_list)}") 

399 mesh_dim_list.append(i) 

400 

401 # Compute shard coordinate: 

402 # The coordinate on each tensor dim is a tuple (idx, range) 

403 # If a DTensor is partitioned on its dim i into n shards, and the current rank 

404 # holds the j-th, then its shard coordinate will be (idx=j, range=n) on dim i 

405 if mesh_coordinate is None: 

406 raise ValueError("mesh_coordinate must not be None") 

407 shard_idx_by_dim = [] 

408 total_num_shards_by_dim = [] # total number of shards on each tensor dim 

409 for mesh_dim in dim_map: 

410 shard_idx = 0 

411 total_num_shards = 1 

412 # the tensor dim is sharded on more than 1 mesh dim 

413 if isinstance(mesh_dim, list): 

414 repeated_mesh_dims = mesh_dim 

415 if reverse_repeated_shards: 

416 # RNG follows the sequential placement application: the last 

417 # placement subdivides each earlier shard and becomes major. 

418 repeated_mesh_dims = list(reversed(repeated_mesh_dims)) 

419 rank_coord = [mesh_coordinate[d] for d in repeated_mesh_dims] 

420 num_shards = [mesh_size[d] for d in repeated_mesh_dims] 

421 # compute the shard idx and total number of shards 

422 for idx, size in zip(rank_coord, num_shards): 

423 shard_idx = shard_idx * size + idx 

424 total_num_shards *= size 

425 

426 shard_idx_by_dim.append(shard_idx) 

427 total_num_shards_by_dim.append(total_num_shards) 

428 return shard_idx_by_dim, total_num_shards_by_dim 

429 

430 

431def _calc_shard_linear_idx(shard_coord: list[int], shard_size: list[int]) -> int: 

432 # compute shard linear index 

433 shard_linear_idx = 0 

434 shard_coord_stride = 1 

435 for idx, size in zip(reversed(shard_coord), reversed(shard_size)): 

436 shard_linear_idx += idx * shard_coord_stride 

437 shard_coord_stride *= size 

438 

439 return shard_linear_idx 

440 

441 

442def _resolve_device(): 

443 device_handle = _utils.get_device_handle() 

444 device_idx = dist.get_rank() % _utils.device_count(device_handle) 

445 

446 def get_device(device_idx): 

447 return _utils.device(device_idx) 

448 

449 return get_device(device_idx) 

450 

451 

452def manual_seed(seed: int, device_mesh: DeviceMesh) -> None: 

453 """Set the seed for generating random numbers on the calling rank (PyTorch DTensor parity). 

454 

455 Ensures the global RNG used by DTensor random ops is initialized consistently. Lazily 

456 creates the :class:`OffsetBasedRNGTracker` used by shard dispatch with 

457 ``run_state_sync=False`` so ranks are not synchronized from rank 0's prior RNG state. 

458 

459 Args: 

460 seed: Desired RNG seed (must be agreed across ranks in the mesh for SPMD). 

461 device_mesh: Mesh that must include the current process rank. 

462 

463 Raises: 

464 RuntimeError: If the current rank is not part of ``device_mesh`` (undefined DTensor 

465 RNG behavior in that case). 

466 

467 Warning: 

468 Does not validate that ``seed`` matches across ranks; callers must ensure SPMD 

469 consistency. Pipeline parallel: use one seed per pipeline stage group as in PyTorch. 

470 """ 

471 if not is_rng_supported_mesh(device_mesh): 

472 warnings.warn( 

473 "DTensor manual_seed() may not have complete support " 

474 f"on {device_mesh.device_type} device mesh", 

475 stacklevel=2, 

476 ) 

477 return 

478 

479 # Local import avoids import cycle: _op_dispatch imports this module at load time. 

480 from hyper_parallel.core.shard._op_dispatch import _OP_DISPATCHER # pylint: disable=C0415 

481 

482 if _OP_DISPATCHER._rng_tracker is None: 

483 _OP_DISPATCHER._rng_tracker = OffsetBasedRNGTracker(run_state_sync=False) 

484 

485 if device_mesh.get_coordinate() is None: 

486 raise RuntimeError( 

487 "manual_seed requires the current rank to be a part of the device mesh " 

488 "otherwise DTensor RNG state on the rank will not be initialized and " 

489 "the behavior of DTensor random ops is undefined." 

490 ) 

491 

492 torch.manual_seed(seed) 

493 

494 

495def local_shard_size_and_offset( 

496 curr_local_size: int, 

497 num_chunks: int, 

498 rank, 

499): 

500 """ 

501 Given the size of the current local tensor (which may already be sharded on some dimensions), 

502 computes the new local shard size and offset given the desired number of chunks 

503 (num_chunks is generally equal to the size of the current sharding dim). 

504 

505 Note: new local shard offset is relative to the current sharded tensor, not the global tensor. 

506 See `_utils.compute_local_shape_and_global_offset` for computing global offset. 

507 

508 Returns (new local shard size, offset) 

509 

510 """ 

511 # Compute the chunk size inline 

512 if curr_local_size % num_chunks == 0: 

513 full_chunk_size = curr_local_size // num_chunks 

514 shard_starting_idx = full_chunk_size * rank 

515 return full_chunk_size, shard_starting_idx 

516 

517 # uneven sharding case 

518 full_chunk_size = (curr_local_size + num_chunks - 1) // num_chunks 

519 shard_starting_idx = full_chunk_size * rank 

520 

521 if curr_local_size < shard_starting_idx: 

522 return 0, typing.cast(int, curr_local_size) 

523 local_shard_size = ( 

524 min(curr_local_size, shard_starting_idx + full_chunk_size) 

525 - shard_starting_idx 

526 ) 

527 return local_shard_size, shard_starting_idx 

528 

529 

530_fork_rng_warned_already = False 

531 

532 

533@contextlib.contextmanager 

534def fork_rng( 

535 devices=None, 

536 enabled=True, 

537 device_type="npu", 

538): 

539 """ 

540 Forks the RNG, so that when you return, the RNG is reset 

541 to the state that it was previously in. 

542 

543 Args: 

544 devices (iterable of Device IDs): devices for which to fork 

545 the RNG. CPU RNG state is always forked. By default, :meth:`fork_rng` operates 

546 on all devices, but will emit a warning if your machine has a lot 

547 of devices, since this function will run very slowly in that case. 

548 If you explicitly specify devices, this warning will be suppressed 

549 enabled (bool): if ``False``, the RNG is not forked. This is a convenience 

550 argument for easily disabling the context manager without having 

551 to delete it and unindent your Python code under it. 

552 device_type (str): device type str, default is `npu`. As for supported device, 

553 see details in :ref:`accelerator<accelerators>` 

554 """ 

555 

556 device_mod = _utils.get_device_handle() 

557 if device_mod is None: 

558 raise RuntimeError( 

559 f"torch has no module of `{device_type}`, you should register it first." 

560 ) 

561 global _fork_rng_warned_already 

562 

563 if not enabled: 

564 yield 

565 return 

566 

567 if devices is None: 

568 num_devices = _utils.device_count(device_mod) 

569 if num_devices > 1 and not _fork_rng_warned_already: 

570 _fork_rng_warned_already = True 

571 devices = list(range(num_devices)) 

572 else: 

573 # Protect against user passing us a generator; we need to traverse this 

574 # multiple times but a generator will be exhausted upon first traversal 

575 devices = list(devices) 

576 

577 cpu_rng_state = _utils.get_rng_state() 

578 device_rng_states = [_utils.get_rng_state(device, device_mod) for device in devices] 

579 

580 try: 

581 yield 

582 finally: 

583 _utils.set_rng_state(cpu_rng_state) 

584 for device, device_rng_state in zip(devices, device_rng_states): 

585 _utils.set_rng_state(device_rng_state, device, device_mod)