Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / core / utils / clip_grad.py: 63%

415 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"""Distributed-aware gradient clipping for parallel training. 

16 

17Communication is driven by each parameter's DTensorSpec (device_mesh + 

18placements) rather than any specific parallelism strategy, so a single 

19implementation covers FSDP, HSDP, TP+FSDP, and other DTensor-expressed 

20parallelisms. 

21 

22Collective safety aligned with FSDP1; numerical precision aligned with 

23FSDP2's ``_NormPartial`` norm computation path: 

24 

25* Gradient norms from sharded parameters are all-reduced across the 

26 corresponding shard process group. 

27* Non-sharded / replicated norms contribute locally without communication. 

28* **All ranks participate in the same collectives** regardless of local 

29 gradient availability, preventing collective-misalignment deadlocks. 

30 

31The finite p-norm is bit-exact with upstream only for the pure-FSDP, 

32single-dtype case; mixed sharded + replicated is mathematically correct 

33but intentionally diverges (see ``_total_norm_fsdp2_aligned``). 

34 

35Note: PP does not use DTensor layout for gradients today. Cross-stage 

36norm aggregation will require an additional manual all-reduce and is 

37left for future work. 

38""" 

39# pylint: disable=forbidden-backend-import,not-callable 

40import functools 

41import math 

42import warnings 

43from collections import defaultdict, namedtuple 

44from dataclasses import dataclass 

45from typing import Any, Dict, Iterable, List, Optional, Tuple, Union 

46 

47import torch 

48import torch.distributed as dist 

49 

50from hyper_parallel.core.dtensor.placement_types import Partial 

51 

52try: 

53 from torch.utils._foreach_utils import ( 

54 _device_has_foreach_support, 

55 _group_tensors_by_device_and_dtype, 

56 _has_foreach_support, 

57 ) 

58except ImportError: 

59 _device_has_foreach_support = None # type: ignore[assignment] 

60 _group_tensors_by_device_and_dtype = None # type: ignore[assignment] 

61 _has_foreach_support = None # type: ignore[assignment] 

62 

63__all__: list[str] = ["clip_grad_norm_"] 

64 

65 

66@dataclass(frozen=True) 

67class NormShardPlan: 

68 """One norm contributor and its replica ranks for each dense parameter.""" 

69 

70 representatives: dict[str, bool] 

71 replica_ranks: dict[str, tuple[int, ...]] 

72 

73 

74def _norm_coverage(param: torch.nn.Parameter) -> tuple[tuple[int, ...], tuple[tuple[int, int], ...]]: 

75 """Describe the exact global rectangle represented by a local parameter.""" 

76 global_shape = tuple(param.shape) 

77 layout = getattr(param, "layout", None) if _is_dtensor(param) else None 

78 if layout is None: 

79 area = tuple((0, size) for size in global_shape) 

80 else: 

81 from hyper_parallel.core.dtensor.layout import infer_slice_area_by_layout # pylint: disable=C0415 

82 

83 rank = dist.get_rank() if dist.is_initialized() else 0 

84 if rank not in layout.rank_list: 

85 raise ValueError("Norm layout does not contain the current global rank") 

86 area = infer_slice_area_by_layout( 

87 layout, layout.rank_list.index(rank), global_shape, 

88 ) 

89 local = param._local_tensor if _is_dtensor(param) else param # pylint: disable=W0212 

90 if tuple(end - start for start, end in area) != tuple(local.shape): 

91 raise ValueError("Norm layout cannot describe the exact local parameter coverage") 

92 return global_shape, tuple((int(start), int(end)) for start, end in area) 

93 

94 

95def _norm_representative(fqn: str, shape: tuple[int, ...], 

96 stage_data: dict[int, dict], rank: int, 

97 local_area: tuple) -> tuple[bool, tuple[int, ...]]: 

98 """Validate disjoint shard coverage and identify copies of this area.""" 

99 representatives = {} 

100 for member, data in stage_data.items(): 

101 if data[fqn][0] != shape: 

102 raise ValueError(f"Norm shard global shape mismatch: {fqn}") 

103 area = data[fqn][1] 

104 representatives.setdefault(area, []).append(member) 

105 nonempty = [area for area in representatives if all(end > start for start, end in area)] 

106 volume = sum(math.prod(end - start for start, end in area) for area in nonempty) 

107 if volume != math.prod(shape): 

108 raise ValueError(f"Norm shard coverage is incomplete or duplicated: {fqn}") 

109 for index, left in enumerate(nonempty): 

110 for right in nonempty[index + 1:]: 

111 if all(max(a[0], b[0]) < min(a[1], b[1]) for a, b in zip(left, right)): 

112 raise ValueError(f"Norm shard rectangles overlap: {fqn}") 

113 copies = tuple(representatives[local_area]) 

114 return rank == min(copies), copies 

115 

116 

117def build_norm_shard_plan(model: torch.nn.Module, excluded_params: Iterable[torch.nn.Parameter], 

118 mesh_context: Any) -> NormShardPlan: 

119 """Choose one rank per identical global shard and verify full coverage. 

120 

121 Args: 

122 model: Model with final FSDP parameter layouts. 

123 excluded_params: Parameters managed by an external optimizer. 

124 mesh_context: Stage-local mesh and topology metadata. 

125 """ 

126 excluded_ids = {id(param) for param in excluded_params} 

127 local = { 

128 fqn: _norm_coverage(param) 

129 for fqn, param in model.named_parameters() 

130 if param.requires_grad and id(param) not in excluded_ids 

131 } 

132 if dist.is_initialized(): 

133 gathered = [None] * dist.get_world_size() 

134 dist.all_gather_object(gathered, local) 

135 rank = dist.get_rank() 

136 else: 

137 gathered = [local] 

138 rank = 0 

139 mesh = getattr(mesh_context, "device_mesh", None) 

140 stage_ranks = (tuple(mesh.rank_list) if mesh is not None and hasattr(mesh, "rank_list") 

141 else tuple(range(len(gathered)))) 

142 stage_data = {member: gathered[member] for member in stage_ranks} 

143 reference_names = set(local) 

144 if any(set(data) != reference_names for data in stage_data.values()): 

145 raise ValueError("Norm shard plan has different dense FQNs within one PP stage") 

146 representatives = {} 

147 replica_ranks = {} 

148 for fqn in sorted(reference_names): 

149 representatives[fqn], replica_ranks[fqn] = _norm_representative( 

150 fqn, local[fqn][0], stage_data, rank, local[fqn][1], 

151 ) 

152 return NormShardPlan(representatives, replica_ranks) 

153 

154 

155def _effective_dense_grads(parameters: list[tuple[str, torch.nn.Parameter]]) -> tuple[list, dict]: 

156 """Collect local gradients and reduce partial placements once.""" 

157 infos = [] 

158 mesh_cache = {} 

159 for _, param in parameters: 

160 mesh, shard_dims, partial_info = _get_param_mesh_info(param) 

161 if mesh is not None: 

162 mesh_cache[id(mesh)] = mesh 

163 infos.append((param, _get_local_grad(param), mesh, partial_info, 

164 (id(mesh) if mesh is not None else None, shard_dims))) 

165 return infos, _coalesce_partial_reduce(infos, mesh_cache) 

166 

167 

168def _check_dense_grad_presence(present: dict[str, bool], replica_ranks: dict[str, tuple[int, ...]]) -> None: 

169 """Reject mismatched gradient presence on copies of the same shard.""" 

170 if not dist.is_initialized(): 

171 return 

172 gathered = [None] * dist.get_world_size() 

173 dist.all_gather_object(gathered, present) 

174 for fqn, ranks in replica_ranks.items(): 

175 copies = [gathered[member][fqn] for member in ranks] 

176 if any(value != copies[0] for value in copies[1:]): 

177 raise ValueError(f"Norm shard gradient presence differs across replicas: {fqn}") 

178 

179 

180def dense_grad_norm_sq_and_refs(model: torch.nn.Module, 

181 excluded_params: Iterable[torch.nn.Parameter], 

182 norm_shard_plan: NormShardPlan, 

183 mesh_context: Any) -> tuple[torch.Tensor, list[torch.Tensor]]: 

184 """Sum each dense global shard once and return mutable effective gradients. 

185 

186 Args: 

187 model: Model with final FSDP parameter layouts. 

188 excluded_params: Parameters managed by an external optimizer. 

189 norm_shard_plan: Validated coverage and one representative per shard. 

190 mesh_context: Stage-local mesh and topology metadata. 

191 """ 

192 excluded_ids = {id(param) for param in excluded_params} 

193 parameters = [ 

194 (fqn, param) for fqn, param in model.named_parameters() 

195 if param.requires_grad and id(param) not in excluded_ids 

196 ] 

197 device = next(((_param_device(param)) for _, param in parameters), None) 

198 if device is None: 

199 mesh = getattr(mesh_context, "device_mesh", None) 

200 device = torch.device(getattr(mesh, "device_type", "cpu")) 

201 infos, reduced = _effective_dense_grads(parameters) 

202 refs = [] 

203 local_sq = torch.zeros((), device=device, dtype=torch.float32) 

204 present = {} 

205 for index, (fqn, _) in enumerate(parameters): 

206 grad = infos[index][1] 

207 present[fqn] = grad is not None 

208 if grad is None: 

209 continue 

210 refs.append(grad) 

211 if norm_shard_plan.representatives[fqn]: 

212 view = reduced.get(index, grad) 

213 local_sq.add_(view.float().square().sum().to(device)) 

214 _check_dense_grad_presence(present, norm_shard_plan.replica_ranks) 

215 for name in getattr(getattr(mesh_context, "device_mesh", None), "mesh_dim_names", ()) or (): 

216 axis = mesh_context.device_mesh[name] 

217 if axis.size() > 1: 

218 dist.all_reduce(local_sq, op=dist.ReduceOp.SUM, group=axis.get_group()) 

219 return local_sq, refs 

220 

221 

222def scale_grad_refs_(refs: Iterable[torch.Tensor], coefficient: torch.Tensor) -> None: 

223 """Scale the exact dense gradients that the optimizer will consume. 

224 

225 Args: 

226 refs: Mutable dense effective gradients. 

227 coefficient: Shared clipping coefficient. 

228 """ 

229 for grad in refs: 

230 grad.mul_(coefficient) 

231 

232 

233# (id(mesh) or None, shard_dims) -> list of local grads for norm computation 

234_GradGroupKey = Tuple[Optional[int], Tuple[int, ...]] 

235 

236# (mesh_dim_index, dist.ReduceOp, needs_manual_avg) 

237_PartialReduceInfo = Tuple[int, "dist.ReduceOp", bool] 

238 

239# Result of _build_grad_groups; tuple-unpacking compatible with prior 7-tuple. 

240_GradGroups = namedtuple( 

241 "_GradGroups", 

242 "grad_groups all_grads norm_grads key_per_grad mesh_cache device " 

243 "has_dtensor_grad", 

244) 

245 

246 

247def _is_dtensor(value: object) -> bool: 

248 """Return whether *value* is a HyperParallel DTensor. 

249 

250 The DTensor module imports ``core.utils`` during initialization, so keep 

251 this import lazy to avoid a package initialization cycle. 

252 """ 

253 from hyper_parallel.core.dtensor.dtensor import DTensor # pylint: disable=C0415 

254 

255 return isinstance(value, DTensor) 

256 

257 

258# --------------------------------------------------------------------------- 

259# Reduce-op mapping 

260# --------------------------------------------------------------------------- 

261 

262_REDUCE_OP_AVG_SUPPORTED = hasattr(dist.ReduceOp, "AVG") 

263 

264_STR_TO_REDUCE_OP: Dict[str, "dist.ReduceOp"] = { 

265 "sum": dist.ReduceOp.SUM, 

266 "max": dist.ReduceOp.MAX, 

267 "min": dist.ReduceOp.MIN, 

268} 

269if _REDUCE_OP_AVG_SUPPORTED: 

270 _STR_TO_REDUCE_OP["avg"] = dist.ReduceOp.AVG 

271 

272 

273def _str_to_reduce_op(op_str: str) -> Tuple["dist.ReduceOp", bool]: 

274 """Map a ``Partial`` placement's *reduce_op* string to ``dist.ReduceOp``. 

275 

276 Returns ``(reduce_op, needs_manual_avg)`` where *needs_manual_avg* 

277 is ``True`` when ``"avg"`` is requested but the backend does not 

278 support ``dist.ReduceOp.AVG`` — the caller should use SUM and 

279 manually divide by the group size. 

280 """ 

281 lower = op_str.lower() 

282 if lower == "avg" and not _REDUCE_OP_AVG_SUPPORTED: 

283 return dist.ReduceOp.SUM, True 

284 op = _STR_TO_REDUCE_OP.get(lower) 

285 if op is None: 

286 raise ValueError( 

287 f"Unsupported Partial reduce_op: {op_str!r}. " 

288 f"Supported: {sorted(set(list(_STR_TO_REDUCE_OP) + ['avg']))}" 

289 ) 

290 return op, False 

291 

292 

293# --------------------------------------------------------------------------- 

294# Helpers 

295# --------------------------------------------------------------------------- 

296 

297def _normalize_parameters( 

298 parameters: Union["torch.nn.Module", torch.Tensor, Iterable[torch.Tensor]], 

299) -> List[torch.Tensor]: 

300 """Normalize *parameters* to a flat list of tensors. 

301 

302 * ``torch.nn.Module`` -> ``list(module.parameters())`` 

303 * single ``torch.Tensor`` -> ``[tensor]`` 

304 * iterable of tensors -> ``list(iterable)`` 

305 """ 

306 if isinstance(parameters, torch.nn.Module): 

307 return list(parameters.parameters()) 

308 if isinstance(parameters, torch.Tensor): 

309 return [parameters] 

310 return list(parameters) 

311 

312 

313def _param_device(param: torch.Tensor) -> torch.device: 

314 """Return the local device of *param* (unwrap DTensor if needed).""" 

315 if _is_dtensor(param): 

316 return param._local_tensor.device # pylint: disable=protected-access 

317 return param.device 

318 

319 

320def _get_grad_obj(param: torch.nn.Parameter) -> Optional[torch.Tensor]: 

321 """Return the gradient object for *param*. 

322 

323 Checks ``param.main_grad`` first (used when 

324 ``MixedPrecisionPolicy.apply_grad_on_fp32_main_grad=True``), 

325 falling back to ``param.grad``. 

326 """ 

327 grad = getattr(param, "main_grad", None) 

328 if grad is not None: 

329 return grad 

330 return param.grad 

331 

332 

333def _get_local_grad(param: torch.nn.Parameter) -> Optional[torch.Tensor]: 

334 """Return the local gradient tensor, or ``None`` if absent. 

335 

336 Supports ``main_grad`` for fp32 mixed-precision training. 

337 """ 

338 if not param.requires_grad: 

339 return None 

340 grad = _get_grad_obj(param) 

341 if grad is None: 

342 return None 

343 if _is_dtensor(grad): 

344 return grad._local_tensor # pylint: disable=protected-access 

345 return grad 

346 

347 

348def _get_param_mesh_info( 

349 param: torch.nn.Parameter, 

350) -> Tuple[ 

351 Optional[object], 

352 Tuple[int, ...], 

353 Tuple[_PartialReduceInfo, ...], 

354]: 

355 """Derive DeviceMesh, Shard dims and Partial info from DTensorSpec. 

356 

357 Checks the *gradient's* spec first; falls back to the *parameter's* 

358 spec when the gradient is a plain tensor on a DTensor parameter 

359 (common after FSDP/HSDP backward where ``param.grad`` is stored as 

360 the local shard tensor). 

361 

362 Returns ``(mesh, shard_dims, partial_info)`` where *partial_info* 

363 is a tuple of ``(mesh_dim, dist.ReduceOp, needs_manual_avg)`` 

364 triples that respect the ``Partial`` placement's ``reduce_op`` 

365 attribute. *needs_manual_avg* is ``True`` when ``"avg"`` was 

366 requested but the backend lacks ``dist.ReduceOp.AVG`` support. 

367 """ 

368 grad = _get_grad_obj(param) 

369 # Prefer grad's spec (most accurate); fall back to param's. 

370 spec_source = grad if _is_dtensor(grad) else param 

371 if not _is_dtensor(spec_source): 

372 return None, (), () 

373 

374 shard_dims = tuple( 

375 i for i, p in enumerate(spec_source.placements) 

376 if p.is_shard() 

377 ) 

378 partial_info = tuple( 

379 (i, *_str_to_reduce_op(p.reduce_op)) 

380 for i, p in enumerate(spec_source.placements) 

381 if isinstance(p, Partial) 

382 ) 

383 return spec_source.device_mesh, shard_dims, partial_info 

384 

385 

386def _sum_p_norms( 

387 dev_grads: List[torch.Tensor], 

388 norm_type: float, 

389 device: torch.device, 

390 total: torch.Tensor, 

391) -> None: 

392 """Accumulate sum-of-p-th-powers for *dev_grads* into *total*.""" 

393 for g in dev_grads: 

394 n = torch.linalg.vector_norm(g, norm_type) 

395 total.add_(n.to(device=device) ** norm_type) 

396 

397 

398def _foreach_p_norms( 

399 grads: List[torch.Tensor], 

400 norm_type: float, 

401 device: torch.device, 

402) -> torch.Tensor: 

403 """Fast path: fuse per-tensor norms via ``_foreach_norm``. 

404 

405 Restricted to float32 tensors to preserve the same numerical 

406 precision as ``vector_norm(dtype=float32)``. Non-float32 tensors 

407 and backends that raise ``RuntimeError`` fall back to per-tensor 

408 ``vector_norm``. 

409 """ 

410 total = torch.tensor(0.0, device=device, dtype=torch.float32) 

411 grouped = _group_tensors_by_device_and_dtype( 

412 [[g.detach() for g in grads]], 

413 ) 

414 for (dev, _), ([dev_grads], _) in grouped.items(): 

415 if ( 

416 dev_grads[0].dtype == torch.float32 

417 and _has_foreach_support(dev_grads, dev) 

418 ): 

419 try: 

420 per_norms = torch._foreach_norm( # pylint: disable=W0212 

421 dev_grads, norm_type, 

422 ) 

423 except RuntimeError: 

424 per_norms = None 

425 if per_norms is not None: 

426 total.add_( 

427 torch.stack([ 

428 n.to(device=device) ** norm_type 

429 for n in per_norms 

430 ]).sum(), 

431 ) 

432 else: 

433 _sum_p_norms(dev_grads, norm_type, device, total) 

434 else: 

435 _sum_p_norms(dev_grads, norm_type, device, total) 

436 return total 

437 

438 

439def _per_tensor_norms( 

440 grads: List[torch.Tensor], 

441 norm_type: float, 

442 device: torch.device, 

443) -> List[torch.Tensor]: 

444 """Return per-tensor norms as a list of scalar tensors on *device*.""" 

445 if not grads: 

446 return [] 

447 

448 if _group_tensors_by_device_and_dtype is None or not hasattr(torch, "_foreach_norm"): 

449 return [ 

450 torch.linalg.vector_norm(g.detach(), norm_type).to(device=device) 

451 for g in grads 

452 ] 

453 

454 norms: List[torch.Tensor] = [] 

455 grouped = _group_tensors_by_device_and_dtype( 

456 [[g.detach() for g in grads]], 

457 ) 

458 for (dev, _), ([dev_grads], _) in grouped.items(): 

459 if dev_grads and _has_foreach_support(dev_grads, dev): 

460 try: 

461 per_norms = torch._foreach_norm( # pylint: disable=W0212 

462 dev_grads, norm_type, 

463 ) 

464 except RuntimeError: 

465 per_norms = None 

466 if per_norms is not None: 

467 norms.extend( 

468 [n.to(device=device) for n in per_norms], 

469 ) 

470 continue 

471 norms.extend([ 

472 torch.linalg.vector_norm(g, norm_type).to(device=device) 

473 for g in dev_grads 

474 ]) 

475 return norms 

476 

477 

478def _compute_local_norm( # pylint: disable=R0911 

479 grads: List[torch.Tensor], 

480 norm_type: float, 

481 device: torch.device, 

482) -> torch.Tensor: 

483 """Compute the combined norm of *grads* locally in FP32. 

484 

485 When *grads* is empty, returns the **identity element** for the 

486 subsequent all-reduce so that this rank contributes a neutral value 

487 (aligned with FSDP1's ``_zero_scalar`` approach): 

488 

489 * ``inf`` -> 0 (neutral for MAX; norms are non-negative) 

490 * ``-inf`` -> +inf (neutral for MIN) 

491 * ``0`` -> 0 (neutral for SUM) 

492 * finite -> 0 (neutral for SUM) 

493 """ 

494 if not grads: 

495 if norm_type == -math.inf: 

496 return torch.tensor( 

497 float("inf"), device=device, dtype=torch.float32, 

498 ) 

499 return torch.tensor(0.0, device=device, dtype=torch.float32) 

500 

501 if norm_type == math.inf: 

502 norms = [ 

503 torch.linalg.vector_norm(g.detach(), math.inf) 

504 for g in grads 

505 ] 

506 return torch.stack(norms).max().to(device) 

507 

508 if norm_type == -math.inf: 

509 norms = [ 

510 torch.linalg.vector_norm(g.detach(), -math.inf) 

511 for g in grads 

512 ] 

513 return torch.stack(norms).min().to(device) 

514 

515 if norm_type == 0: 

516 norms = [ 

517 torch.linalg.vector_norm(g.detach(), 0) 

518 for g in grads 

519 ] 

520 return torch.stack(norms).sum().to(device) 

521 

522 # Finite p-norm: return sum of p-th powers. 

523 if ( 

524 len(grads) > 1 

525 and _group_tensors_by_device_and_dtype is not None 

526 and hasattr(torch, "_foreach_norm") 

527 ): 

528 return _foreach_p_norms(grads, norm_type, device) 

529 

530 # Scalar fallback when foreach utilities are unavailable. 

531 norms = [ 

532 torch.linalg.vector_norm(g.detach(), norm_type) 

533 for g in grads 

534 ] 

535 norm_powers = [n.to(device=device) ** norm_type for n in norms] 

536 return torch.stack(norm_powers).sum() 

537 

538 

539# --------------------------------------------------------------------------- 

540# Total norm aggregation with collectives 

541# --------------------------------------------------------------------------- 

542 

543def _get_total_norm( 

544 grad_groups: Dict[_GradGroupKey, List[torch.Tensor]], 

545 norm_type: float, 

546 mesh_cache: Dict[int, object], 

547 device: torch.device, 

548 norm_grads: List[torch.Tensor], 

549 key_per_grad: List[_GradGroupKey], 

550) -> torch.Tensor: 

551 """Compute total gradient norm with per-group all-reduce. 

552 

553 ``norm_grads`` (parallel to ``key_per_grad``) holds the tensor whose 

554 norm to take per parameter; only the finite p-norm path consumes it. 

555 """ 

556 if norm_type == math.inf: 

557 return _total_norm_inf( 

558 grad_groups, norm_type, mesh_cache, device, 

559 dist.ReduceOp.MAX, 

560 ) 

561 

562 if norm_type == -math.inf: 

563 return _total_norm_inf( 

564 grad_groups, norm_type, mesh_cache, device, 

565 dist.ReduceOp.MIN, 

566 ) 

567 

568 if norm_type == 0: 

569 return _total_norm_sum( 

570 grad_groups, norm_type, mesh_cache, device, 

571 ) 

572 

573 # Finite p-norm: FSDP2-aligned sequence. 

574 total_p = _total_norm_fsdp2_aligned( 

575 grad_groups, norm_type, mesh_cache, device, 

576 norm_grads, key_per_grad, 

577 ) 

578 return total_p ** (1.0 / norm_type) 

579 

580 

581def _total_norm_inf( # pylint: disable=R0913,R0917 

582 grad_groups, norm_type, mesh_cache, device, reduce_op, 

583): 

584 """Shared logic for inf / -inf norms.""" 

585 group_norms: List[torch.Tensor] = [] 

586 for (mesh_id, shard_dims), grads in grad_groups.items(): 

587 local_norm = _compute_local_norm(grads, norm_type, device) 

588 if mesh_id is not None: 

589 mesh = mesh_cache[mesh_id] 

590 for dim in shard_dims: 

591 dist.all_reduce( 

592 local_norm, op=reduce_op, 

593 group=mesh.get_group(dim), 

594 ) 

595 group_norms.append(local_norm) 

596 if not group_norms: 

597 if norm_type == -math.inf: 

598 return torch.tensor(float("inf"), device=device) 

599 return torch.tensor(0.0, device=device) 

600 stacked = torch.stack(group_norms) 

601 return stacked.max() if reduce_op == dist.ReduceOp.MAX else stacked.min() 

602 

603 

604def _total_norm_sum(grad_groups, norm_type, mesh_cache, device): 

605 """Shared logic for finite norms and L0 (all use SUM all-reduce).""" 

606 total = torch.tensor(0.0, device=device) 

607 for (mesh_id, shard_dims), grads in grad_groups.items(): 

608 local_val = _compute_local_norm(grads, norm_type, device) 

609 if mesh_id is not None: 

610 mesh = mesh_cache[mesh_id] 

611 for dim in shard_dims: 

612 dist.all_reduce( 

613 local_val, op=dist.ReduceOp.SUM, 

614 group=mesh.get_group(dim), 

615 ) 

616 total.add_(local_val) 

617 return total 

618 

619 

620def _reduction_signature(grad_groups, mesh_cache): 

621 """Bucket grad-group keys by the *process group(s)* they reduce over. 

622 

623 Two keys that reduce over the same set of process groups (same global 

624 ranks per shard dim) must be **pooled** so their norms accumulate in 

625 one stack -- matching FSDP2's single foreach-norm + single reduce and 

626 keeping the loss bit-exact even when params live on several distinct 

627 ``DeviceMesh`` objects that share the same DP process group (common 

628 for multi-component models). Keys that reduce over *different* 

629 process groups (TP+FSDP heterogeneous sharding, expert parallel) get 

630 separate buckets, each reduced over its own group. 

631 

632 Returns ``(key_to_sig, sig_groups, sig_order)``: 

633 

634 * ``key_to_sig`` -- ``key -> signature`` (hashable; ``()`` = replicate 

635 / no communication). 

636 * ``sig_groups`` -- ``signature -> list[ProcessGroup]`` to all-reduce. 

637 * ``sig_order`` -- signatures in first-seen (parameter) order, so all 

638 ranks issue the same collectives in the same order. 

639 """ 

640 key_to_sig: Dict[_GradGroupKey, Tuple] = {} 

641 sig_groups: Dict[Tuple, List[object]] = {} 

642 sig_order: List[Tuple] = [] 

643 for mesh_id, shard_dims in grad_groups: 

644 if shard_dims and mesh_id is not None: 

645 mesh = mesh_cache[mesh_id] 

646 groups = [mesh.get_group(dim) for dim in shard_dims] 

647 sig = tuple( 

648 tuple(dist.get_process_group_ranks(group)) for group in groups 

649 ) 

650 else: 

651 groups = [] 

652 sig = () 

653 key_to_sig[(mesh_id, shard_dims)] = sig 

654 if sig not in sig_groups: 

655 sig_groups[sig] = groups 

656 sig_order.append(sig) 

657 return key_to_sig, sig_groups, sig_order 

658 

659 

660def _total_norm_fsdp2_aligned(grad_groups, norm_type, mesh_cache, device, 

661 norm_grads, key_per_grad): 

662 """FSDP2-aligned norm for finite p-norms. 

663 

664 Grads are bucketed by the *process group* they reduce over (see 

665 :func:`_reduction_signature`); ``norm_grads`` supplies, in global 

666 parameter order, the Partial-reduced (already-global) view for Partial 

667 grads and the raw local grad otherwise. Each bucket does ONE ``stack`` 

668 → ONE ``vector_norm`` → ``^p`` → ONE ``all_reduce SUM`` per shard dim, 

669 and the buckets are summed locally. 

670 

671 Reduction rules: 

672 

673 * Sharded bucket -- reduce over its own group(s). Same-group params 

674 across distinct ``DeviceMesh`` objects pool into one reduce; distinct 

675 groups (TP+FSDP heterogeneous, expert parallel) stay separate -- the 

676 per-group convention also used by :func:`_total_norm_inf` / 

677 :func:`_total_norm_sum` and by VeOmni / torchtitan. 

678 * Replicate bucket (signature ``()``) -- contribute norm² locally with 

679 NO communication; reducing it over the shard group would over-count 

680 it ``shard_world_size`` times (the FSDP1 convention). 

681 

682 Empty-but-present buckets still issue their all_reduce (identity ``0`` 

683 for SUM) so all ranks run the same collectives. 

684 

685 Bit-exact with upstream ``torch.nn.utils.clip_grad_norm_`` only for the 

686 pure-FSDP, single-bucket, single-dtype case. Mixed shard + replicate is 

687 mathematically correct but intentionally diverges (upstream folds the 

688 replicate norm into one ``_NormPartial`` reduce and over-counts it); 

689 mixed-dtype follows upstream's :func:`_per_tensor_norms` device/dtype 

690 regrouping rather than strict global order. 

691 

692 Returns the global sum of p-th powers (caller takes p-th root). 

693 """ 

694 key_to_sig, sig_groups, sig_order = _reduction_signature( 

695 grad_groups, mesh_cache, 

696 ) 

697 

698 # Bucket norms by reduction signature, preserving global parameter order. 

699 sig_grads: Dict[Tuple, List[torch.Tensor]] = {sig: [] for sig in sig_order} 

700 for grad, key in zip(norm_grads, key_per_grad): 

701 sig_grads[key_to_sig[key]].append(grad) 

702 

703 total_p = torch.tensor(0.0, device=device, dtype=torch.float32) 

704 for sig in sig_order: 

705 grads = sig_grads[sig] 

706 if grads: 

707 norms = _per_tensor_norms(grads, norm_type, device) 

708 local_p = torch.linalg.vector_norm( 

709 torch.stack(norms).to(torch.float32), norm_type, 

710 ) ** norm_type 

711 else: 

712 local_p = torch.tensor(0.0, device=device, dtype=torch.float32) 

713 

714 for group in sig_groups[sig]: 

715 dist.all_reduce(local_p, op=dist.ReduceOp.SUM, group=group) 

716 

717 total_p = total_p + local_p 

718 

719 return total_p 

720 

721 

722def _build_coalesce_buffer( 

723 param_infos: List[Tuple], 

724 indices: List[int], 

725) -> Tuple[List[torch.Tensor], List[int], List[bool], List[int]]: 

726 """Build flat fp32 chunks for one coalesce group. 

727 

728 Returns ``(chunks, chunk_sizes, has_grad, active_indices)``. 

729 Frozen params are skipped; trainable grad-free params contribute 

730 zeros so the collective matches ranks that have a grad. 

731 """ 

732 chunks: List[torch.Tensor] = [] 

733 chunk_sizes: List[int] = [] 

734 has_grad: List[bool] = [] 

735 active_indices: List[int] = [] 

736 

737 for idx in indices: 

738 param = param_infos[idx][0] 

739 local_grad = param_infos[idx][1] 

740 if local_grad is not None: 

741 chunks.append( 

742 local_grad.detach().reshape(-1).to(torch.float32), 

743 ) 

744 chunk_sizes.append(local_grad.numel()) 

745 has_grad.append(True) 

746 active_indices.append(idx) 

747 elif param.requires_grad: 

748 local_p = ( 

749 param._local_tensor # pylint: disable=W0212 

750 if _is_dtensor(param) else param.data 

751 ) 

752 numel = local_p.numel() 

753 chunks.append( 

754 torch.zeros( 

755 numel, device=local_p.device, 

756 dtype=torch.float32, 

757 ), 

758 ) 

759 chunk_sizes.append(numel) 

760 has_grad.append(False) 

761 active_indices.append(idx) 

762 

763 return chunks, chunk_sizes, has_grad, active_indices 

764 

765 

766def _coalesce_partial_reduce( # pylint: disable=R0914 

767 param_infos: List[Tuple], 

768 mesh_cache: Dict[int, object], 

769) -> Dict[int, torch.Tensor]: 

770 """Coalesce Partial all-reduces: O(N) collectives → O(G). 

771 

772 Groups parameters sharing the same ``(mesh, partial_info)`` and 

773 flattens their gradients (or zeros for trainable grad-free params) 

774 into a single fp32 buffer. **One** ``all_reduce`` per buffer 

775 replaces the previous per-parameter collective calls. 

776 

777 For TP+FSDP (all params share the same mesh / placements), this 

778 turns ~200 individual all-reduces into 1 — saving 10-20 ms per 

779 training step at typical HCCS/NCCL latencies. 

780 

781 Frozen params (``requires_grad=False``) are consistently grad-free 

782 across all ranks and are excluded from the buffer to avoid wasting 

783 bandwidth. 

784 

785 All buffers use float32 to guarantee dtype consistency across ranks 

786 in mixed-precision training (grad may be fp16/bf16 while param is 

787 fp32). 

788 

789 Returns a dict mapping *param_infos* index → reduced gradient view 

790 (1-D fp32 slice of the coalesced buffer). Only entries for params 

791 with actual gradients are included. 

792 """ 

793 # Group by Partial coalesce key: (mesh_id, partial_info) 

794 coalesce_groups: Dict[ 

795 Tuple, List[int], 

796 ] = defaultdict(list) 

797 for idx, info in enumerate(param_infos): 

798 mesh, partial_info = info[2], info[3] 

799 if partial_info: 

800 if mesh is None: 

801 raise RuntimeError( 

802 "clip_grad_norm_: parameter has Partial placements " 

803 "but no DeviceMesh. This is a DTensor invariant " 

804 "violation." 

805 ) 

806 pck = (id(mesh), partial_info) 

807 coalesce_groups[pck].append(idx) 

808 

809 reduced: Dict[int, torch.Tensor] = {} 

810 

811 for (mesh_id, partial_info), indices in coalesce_groups.items(): 

812 mesh = mesh_cache[mesh_id] 

813 chunks, chunk_sizes, has_grad, active_indices = ( 

814 _build_coalesce_buffer(param_infos, indices) 

815 ) 

816 

817 if not chunks: 

818 continue # all params frozen, no collective needed 

819 

820 # Sanity check: same mesh → same device. Fail fast on 

821 # misconfigured inputs rather than silent NCCL errors. 

822 buf_device = chunks[0].device 

823 for chunk in chunks[1:]: 

824 if chunk.device != buf_device: 

825 raise RuntimeError( 

826 f"clip_grad_norm_: parameters in the same Partial " 

827 f"coalesce group are on different devices " 

828 f"({buf_device} vs {chunk.device}). All parameters " 

829 f"sharing the same DeviceMesh must reside on the " 

830 f"same local device." 

831 ) 

832 

833 buf = torch.cat(chunks) 

834 

835 for pdim, reduce_op, needs_avg in partial_info: 

836 group = mesh.get_group(pdim) 

837 dist.all_reduce(buf, op=reduce_op, group=group) 

838 if needs_avg: 

839 buf /= dist.get_world_size(group=group) 

840 

841 # Extract views for params with actual gradients. 

842 offset = 0 

843 for i, idx in enumerate(active_indices): 

844 numel = chunk_sizes[i] 

845 if has_grad[i]: 

846 reduced[idx] = buf[offset:offset + numel] 

847 offset += numel 

848 

849 return reduced 

850 

851 

852def _build_grad_groups( # pylint: disable=R0914 

853 params: List[torch.Tensor], 

854) -> Tuple[ 

855 Dict[_GradGroupKey, List[torch.Tensor]], 

856 List[torch.Tensor], 

857 List[torch.Tensor], 

858 List[_GradGroupKey], 

859 Dict[int, object], 

860 torch.device, 

861 bool, 

862]: 

863 """Classify parameters into grad groups and pre-reduce Partial grads. 

864 

865 Group structure is derived from *parameter* DTensorSpecs (always 

866 present on every rank) rather than gradients (which may be ``None`` 

867 on some ranks). This ensures every rank enters the same set of 

868 collectives, preventing deadlocks (aligned with FSDP1 where all 

869 ranks unconditionally execute the same all-reduce path). 

870 

871 Partial gradients are reduced via a **coalesced** all-reduce 

872 (see ``_coalesce_partial_reduce``), turning O(N) per-parameter 

873 collectives into O(G) where G is the number of distinct 

874 ``(mesh, partial_info)`` groups (typically 1 for TP+FSDP). 

875 

876 Returns 

877 ``(grad_groups, all_grads, norm_grads, key_per_grad, mesh_cache, 

878 device, has_dtensor_grad)``. ``grad_groups`` maps each 

879 ``(mesh_id, shard_dims)`` key to its grads; ``all_grads`` is the 

880 flat list of raw local grads (global parameter order) scaled 

881 in-place by the clip step; ``norm_grads`` is parallel to 

882 ``all_grads`` but holds the Partial-reduced view for Partial grads 

883 (the value whose norm is taken on the finite-p path); 

884 ``key_per_grad`` is parallel to both and maps each grad back to its 

885 group key. 

886 """ 

887 # --- Phase 1: classify all parameters --- 

888 param_infos: List[Tuple] = [] 

889 mesh_cache: Dict[int, object] = {} 

890 device: Optional[torch.device] = None 

891 

892 for param in params: 

893 mesh, shard_dims, partial_info = _get_param_mesh_info(param) 

894 key: _GradGroupKey = ( 

895 id(mesh) if mesh is not None else None, shard_dims, 

896 ) 

897 if mesh is not None: 

898 mesh_cache[id(mesh)] = mesh 

899 if device is None: 

900 device = _param_device(param) 

901 local_grad = _get_local_grad(param) 

902 param_infos.append( 

903 (param, local_grad, mesh, partial_info, key), 

904 ) 

905 

906 if device is None: 

907 device = torch.device("cpu") 

908 

909 # --- Phase 2: coalesced Partial reduction (O(N) → O(G)) --- 

910 reduced = _coalesce_partial_reduce(param_infos, mesh_cache) 

911 

912 # --- Phase 3: build grad_groups --- 

913 grad_groups: Dict[_GradGroupKey, List[torch.Tensor]] = defaultdict( 

914 list, 

915 ) 

916 all_grads: List[torch.Tensor] = [] 

917 # norm_grads / key_per_grad are parallel to all_grads (see Returns): the 

918 # norm-input view (Partial-reduced where coalesced, else raw local) and 

919 # the group key per grad, kept in global parameter order. 

920 norm_grads: List[torch.Tensor] = [] 

921 key_per_grad: List[_GradGroupKey] = [] 

922 has_dtensor_grad = False 

923 

924 for idx, info in enumerate(param_infos): 

925 param, local_grad, key = info[0], info[1], info[4] 

926 if local_grad is None: 

927 # Ensure the key exists so the Shard norm all-reduce is 

928 # entered even when this rank has no grads for the group. 

929 if key not in grad_groups: 

930 grad_groups[key] = [] 

931 continue 

932 

933 grad_obj = _get_grad_obj(param) 

934 if _is_dtensor(grad_obj): 

935 has_dtensor_grad = True 

936 all_grads.append(local_grad) 

937 key_per_grad.append(key) 

938 if idx in reduced: 

939 grad_groups[key].append(reduced[idx]) 

940 norm_grads.append(reduced[idx]) 

941 else: 

942 grad_groups[key].append(local_grad) 

943 norm_grads.append(local_grad) 

944 

945 return _GradGroups( 

946 grad_groups, all_grads, norm_grads, key_per_grad, 

947 mesh_cache, device, has_dtensor_grad, 

948 ) 

949 

950 

951def _clip_grads_with_norm_( 

952 all_grads: List[torch.Tensor], 

953 max_norm: float, 

954 total_norm: torch.Tensor, 

955 foreach: Optional[bool] = None, 

956) -> None: 

957 """Scale gradients in-place so the total norm <= *max_norm*.""" 

958 clip_coef = max_norm / (total_norm + 1e-6) 

959 clip_coef_clamped = torch.clamp(clip_coef, max=1.0) 

960 

961 if _group_tensors_by_device_and_dtype is not None: 

962 grouped_grads = _group_tensors_by_device_and_dtype( 

963 [all_grads], 

964 ) 

965 for (device, dtype), ([device_grads], _) in grouped_grads.items(): 

966 use_foreach = ( 

967 foreach is None and _has_foreach_support(device_grads, device) 

968 ) or (foreach and _device_has_foreach_support(device)) 

969 if use_foreach: 

970 torch._foreach_mul_( # pylint: disable=W0212 

971 device_grads, 

972 clip_coef_clamped.to(device=device, dtype=dtype), 

973 ) 

974 elif foreach: 

975 raise RuntimeError( 

976 f"foreach=True was passed, but can't use the " 

977 f"foreach API on {device.type} tensors" 

978 ) 

979 else: 

980 clip_coef_clamped_cast = clip_coef_clamped.to(device=device, dtype=dtype) 

981 for g in device_grads: 

982 g.mul_(clip_coef_clamped_cast) 

983 else: 

984 # Fallback when _foreach_utils is unavailable. 

985 if foreach: 

986 raise RuntimeError( 

987 "foreach=True was passed, but " 

988 "torch.utils._foreach_utils is not available" 

989 ) 

990 for grad in all_grads: 

991 grad.mul_(clip_coef_clamped.to(grad.device, grad.dtype)) 

992 

993 

994# --------------------------------------------------------------------------- 

995# Public API 

996# --------------------------------------------------------------------------- 

997 

998@torch.no_grad() 

999def clip_grad_norm_( 

1000 parameters: Union[ 

1001 "torch.nn.Module", torch.Tensor, Iterable[torch.Tensor], 

1002 ], 

1003 max_norm: float, 

1004 norm_type: float = 2.0, 

1005 error_if_nonfinite: bool = False, 

1006 foreach: Optional[bool] = None, 

1007) -> torch.Tensor: 

1008 """Compute and clip gradient norm for distributed models. 

1009 

1010 Drop-in replacement for the standard ``clip_grad_norm_`` that 

1011 correctly handles DTensor-sharded parameters by deriving 

1012 communication from each parameter's DTensorSpec. 

1013 

1014 .. warning:: This function uses collective communications. It 

1015 **must be called on all ranks** to avoid deadlocks. Aligned 

1016 with FSDP1: every rank participates in the same collectives 

1017 regardless of local gradient availability. 

1018 

1019 Communication is derived from each parameter's DTensorSpec: 

1020 

1021 * ``Shard`` on mesh dim *d* -- all-reduce norm statistics 

1022 across ``device_mesh.get_group(d)`` 

1023 * ``Partial`` on mesh dim *d* -- all-reduce gradient values 

1024 using the placement's ``reduce_op`` before norm computation 

1025 * ``Replicate`` / plain tensor -- no communication 

1026 

1027 This covers FSDP, HSDP, TP+FSDP, and any combination expressible 

1028 via DTensor placements. PP cross-stage norm aggregation is not 

1029 yet handled (requires manual all-reduce across stages). 

1030 

1031 Args: 

1032 parameters: An ``nn.Module``, a single ``Tensor``, or an 

1033 iterable of ``Tensor`` s whose gradients to clip. 

1034 max_norm: Maximum allowed gradient norm. 

1035 norm_type: Type of the norm (default ``2.0``). 

1036 error_if_nonfinite: If ``True``, raise a ``RuntimeError`` 

1037 when the total norm is non-finite. Default ``False``. 

1038 foreach: Use the faster foreach-based implementation for the 

1039 gradient clipping step. If ``None``, use the foreach 

1040 implementation for devices that support it and silently 

1041 fall back to the per-tensor implementation for others. 

1042 Default ``None``. 

1043 

1044 Returns: 

1045 The total (unclipped) gradient norm as a scalar tensor, 

1046 cast to the promoted dtype of all gradient tensors. 

1047 """ 

1048 max_norm = float(max_norm) 

1049 norm_type = float(norm_type) 

1050 

1051 params = _normalize_parameters(parameters) 

1052 

1053 ( 

1054 grad_groups, all_grads, norm_grads, key_per_grad, 

1055 mesh_cache, device, has_dtensor_grad, 

1056 ) = _build_grad_groups(params) 

1057 

1058 # -- Norm + clip (all ranks participate) -------------------------------- 

1059 # _compute_local_norm returns identity elements for empty groups, 

1060 # so the subsequent all-reduce is safe and semantically neutral. 

1061 total_norm = _get_total_norm( 

1062 grad_groups, norm_type, mesh_cache, device, 

1063 norm_grads, key_per_grad, 

1064 ) 

1065 

1066 if error_if_nonfinite and torch.logical_or( 

1067 total_norm.isnan(), total_norm.isinf() 

1068 ): 

1069 raise RuntimeError( 

1070 f"The total norm of order {norm_type} for gradients from " 

1071 "`parameters` is non-finite, so it cannot be clipped. To " 

1072 "disable this error and scale the gradients by the " 

1073 "non-finite norm anyway, set " 

1074 "`error_if_nonfinite=False`" 

1075 ) 

1076 

1077 if all_grads: 

1078 # Disable foreach for dtensor-backed grads to avoid dispatch issues. 

1079 effective_foreach = False if has_dtensor_grad and foreach is None else foreach 

1080 _clip_grads_with_norm_( 

1081 all_grads, max_norm, total_norm, effective_foreach, 

1082 ) 

1083 

1084 # Promote return dtype to match gradient dtypes (FSDP1 convention). 

1085 # When this rank has no gradients, return in the default FP32 dtype 

1086 # (same as FSDP1's behavior to avoid extra communication). 

1087 if not all_grads: 

1088 warnings.warn( 

1089 "clip_grad_norm_ called on this rank with no gradients -- " 

1090 "returning the local norm in the default dtype " 

1091 f"{total_norm.dtype}", 

1092 stacklevel=2, 

1093 ) 

1094 return total_norm 

1095 

1096 total_norm_dtype = functools.reduce( 

1097 torch.promote_types, 

1098 [g.dtype for g in all_grads], 

1099 ) 

1100 # Return global all-reduced norm, consistent with torchtitan's 

1101 # full_tensor() approach — .item() returns the correct global value. 

1102 return total_norm.to(total_norm_dtype)