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

434 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-15 07:18 +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 collections.abc import Mapping 

45from dataclasses import dataclass 

46from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Union 

47 

48import torch 

49import torch.distributed as dist 

50 

51from hyper_parallel.core.dtensor.placement_types import Partial 

52 

53if TYPE_CHECKING: 

54 from hyper_parallel.core.dtensor.layout import Layout 

55 

56try: 

57 from torch.utils._foreach_utils import ( 

58 _device_has_foreach_support, 

59 _group_tensors_by_device_and_dtype, 

60 _has_foreach_support, 

61 ) 

62except ImportError: 

63 _device_has_foreach_support = None # type: ignore[assignment] 

64 _group_tensors_by_device_and_dtype = None # type: ignore[assignment] 

65 _has_foreach_support = None # type: ignore[assignment] 

66 

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

68 

69 

70@dataclass(frozen=True) 

71class NormShardPlan: 

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

73 

74 representatives: dict[str, bool] 

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

76 

77 

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

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

80 global_shape = tuple(param.shape) 

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

82 if layout is None: 

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

84 else: 

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

86 

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

88 if rank not in layout.rank_list: 

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

90 area = infer_slice_area_by_layout( 

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

92 ) 

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

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

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

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

97 

98 

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

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

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

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

103 representatives = {} 

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

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

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

107 area = data[fqn][1] 

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

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

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

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

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

113 for index, left in enumerate(nonempty): 

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

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

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

117 copies = tuple(representatives[local_area]) 

118 return rank == min(copies), copies 

119 

120 

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

122 mesh_context: Any) -> NormShardPlan: 

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

124 

125 Args: 

126 model: Model with final FSDP parameter layouts. 

127 excluded_params: Parameters managed by an external optimizer. 

128 mesh_context: Stage-local mesh and topology metadata. 

129 """ 

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

131 local = { 

132 fqn: _norm_coverage(param) 

133 for fqn, param in model.named_parameters() 

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

135 } 

136 if dist.is_initialized(): 

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

138 dist.all_gather_object(gathered, local) 

139 rank = dist.get_rank() 

140 else: 

141 gathered = [local] 

142 rank = 0 

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

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

145 else tuple(range(len(gathered)))) 

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

147 reference_names = set(local) 

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

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

150 representatives = {} 

151 replica_ranks = {} 

152 for fqn in sorted(reference_names): 

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

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

155 ) 

156 return NormShardPlan(representatives, replica_ranks) 

157 

158 

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

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

161 infos = [] 

162 mesh_cache = {} 

163 for _, param in parameters: 

164 mesh, shard_dims, partial_info = _get_param_mesh_info(param) 

165 if mesh is not None: 

166 mesh_cache[id(mesh)] = mesh 

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

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

169 return infos, _coalesce_partial_reduce(infos, mesh_cache) 

170 

171 

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

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

174 if not dist.is_initialized(): 

175 return 

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

177 dist.all_gather_object(gathered, present) 

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

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

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

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

182 

183 

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

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

186 norm_shard_plan: NormShardPlan, 

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

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

189 

190 Args: 

191 model: Model with final FSDP parameter layouts. 

192 excluded_params: Parameters managed by an external optimizer. 

193 norm_shard_plan: Validated coverage and one representative per shard. 

194 mesh_context: Stage-local mesh and topology metadata. 

195 """ 

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

197 parameters = [ 

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

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

200 ] 

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

202 if device is None: 

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

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

205 infos, reduced = _effective_dense_grads(parameters) 

206 refs = [] 

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

208 present = {} 

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

210 grad = infos[index][1] 

211 present[fqn] = grad is not None 

212 if grad is None: 

213 continue 

214 refs.append(grad) 

215 if norm_shard_plan.representatives[fqn]: 

216 view = reduced.get(index, grad) 

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

218 _check_dense_grad_presence(present, norm_shard_plan.replica_ranks) 

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

220 axis = mesh_context.device_mesh[name] 

221 if axis.size() > 1: 

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

223 return local_sq, refs 

224 

225 

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

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

228 

229 Args: 

230 refs: Mutable dense effective gradients. 

231 coefficient: Shared clipping coefficient. 

232 """ 

233 for grad in refs: 

234 grad.mul_(coefficient) 

235 

236 

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

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

239 

240# (mesh_dim_index, dist.ReduceOp, needs_manual_avg) 

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

242 

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

244_GradGroups = namedtuple( 

245 "_GradGroups", 

246 "grad_groups all_grads norm_grads key_per_grad mesh_cache device " 

247 "has_dtensor_grad", 

248) 

249 

250 

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

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

253 

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

255 this import lazy to avoid a package initialization cycle. 

256 """ 

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

258 

259 return isinstance(value, DTensor) 

260 

261 

262# --------------------------------------------------------------------------- 

263# Reduce-op mapping 

264# --------------------------------------------------------------------------- 

265 

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

267 

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

269 "sum": dist.ReduceOp.SUM, 

270 "max": dist.ReduceOp.MAX, 

271 "min": dist.ReduceOp.MIN, 

272} 

273if _REDUCE_OP_AVG_SUPPORTED: 

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

275 

276 

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

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

279 

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

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

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

283 manually divide by the group size. 

284 """ 

285 lower = op_str.lower() 

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

287 return dist.ReduceOp.SUM, True 

288 op = _STR_TO_REDUCE_OP.get(lower) 

289 if op is None: 

290 raise ValueError( 

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

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

293 ) 

294 return op, False 

295 

296 

297# --------------------------------------------------------------------------- 

298# Helpers 

299# --------------------------------------------------------------------------- 

300 

301def _normalize_parameters( 

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

303) -> List[torch.Tensor]: 

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

305 

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

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

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

309 """ 

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

311 return list(parameters.parameters()) 

312 if isinstance(parameters, torch.Tensor): 

313 return [parameters] 

314 return list(parameters) 

315 

316 

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

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

319 if _is_dtensor(param): 

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

321 return param.device 

322 

323 

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

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

326 

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

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

329 falling back to ``param.grad``. 

330 """ 

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

332 if grad is not None: 

333 return grad 

334 return param.grad 

335 

336 

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

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

339 

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

341 """ 

342 if not param.requires_grad: 

343 return None 

344 grad = _get_grad_obj(param) 

345 if grad is None: 

346 return None 

347 if _is_dtensor(grad): 

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

349 return grad 

350 

351 

352def _get_param_mesh_info( 

353 param: torch.nn.Parameter, 

354 parameter_layout: Optional["Layout"] = None, 

355) -> Tuple[ 

356 Optional[object], 

357 Tuple[int, ...], 

358 Tuple[_PartialReduceInfo, ...], 

359]: 

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

361 

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

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

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

365 the local shard tensor). 

366 

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

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

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

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

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

372 """ 

373 grad = _get_grad_obj(param) 

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

375 spec_source = grad if _is_dtensor(grad) else param 

376 if parameter_layout is None: 

377 if not _is_dtensor(spec_source): 

378 return None, (), () 

379 placements = spec_source.placements 

380 mesh = spec_source.device_mesh 

381 else: 

382 layout = spec_source.layout if _is_dtensor(spec_source) else None 

383 if layout is not None and ( 

384 layout.mesh_shape, layout.rank_list, layout.alias_name, layout.placements, layout.tensor_shape 

385 ) != ( 

386 parameter_layout.mesh_shape, parameter_layout.rank_list, parameter_layout.alias_name, 

387 parameter_layout.placements, parameter_layout.tensor_shape 

388 ): 

389 raise ValueError("Explicit parameter layout conflicts with its DTensor layout") 

390 layout = layout if layout is not None else parameter_layout 

391 placements = layout.placements 

392 mesh = layout.mesh 

393 

394 shard_dims = tuple( 

395 i for i, p in enumerate(placements) 

396 if p.is_shard() 

397 ) 

398 partial_info = tuple( 

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

400 for i, p in enumerate(placements) 

401 if isinstance(p, Partial) 

402 ) 

403 return mesh, shard_dims, partial_info 

404 

405 

406def _sum_p_norms( 

407 dev_grads: List[torch.Tensor], 

408 norm_type: float, 

409 device: torch.device, 

410 total: torch.Tensor, 

411) -> None: 

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

413 for g in dev_grads: 

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

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

416 

417 

418def _foreach_p_norms( 

419 grads: List[torch.Tensor], 

420 norm_type: float, 

421 device: torch.device, 

422) -> torch.Tensor: 

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

424 

425 Restricted to float32 tensors to preserve the same numerical 

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

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

428 ``vector_norm``. 

429 """ 

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

431 grouped = _group_tensors_by_device_and_dtype( 

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

433 ) 

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

435 if ( 

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

437 and _has_foreach_support(dev_grads, dev) 

438 ): 

439 try: 

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

441 dev_grads, norm_type, 

442 ) 

443 except RuntimeError: 

444 per_norms = None 

445 if per_norms is not None: 

446 total.add_( 

447 torch.stack([ 

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

449 for n in per_norms 

450 ]).sum(), 

451 ) 

452 else: 

453 _sum_p_norms(dev_grads, norm_type, device, total) 

454 else: 

455 _sum_p_norms(dev_grads, norm_type, device, total) 

456 return total 

457 

458 

459def _per_tensor_norms( 

460 grads: List[torch.Tensor], 

461 norm_type: float, 

462 device: torch.device, 

463) -> List[torch.Tensor]: 

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

465 if not grads: 

466 return [] 

467 

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

469 return [ 

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

471 for g in grads 

472 ] 

473 

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

475 grouped = _group_tensors_by_device_and_dtype( 

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

477 ) 

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

479 if dev_grads and _has_foreach_support(dev_grads, dev): 

480 try: 

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

482 dev_grads, norm_type, 

483 ) 

484 except RuntimeError: 

485 per_norms = None 

486 if per_norms is not None: 

487 norms.extend( 

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

489 ) 

490 continue 

491 norms.extend([ 

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

493 for g in dev_grads 

494 ]) 

495 return norms 

496 

497 

498def _compute_local_norm( # pylint: disable=R0911 

499 grads: List[torch.Tensor], 

500 norm_type: float, 

501 device: torch.device, 

502) -> torch.Tensor: 

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

504 

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

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

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

508 

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

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

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

512 * finite -> 0 (neutral for SUM) 

513 """ 

514 if not grads: 

515 if norm_type == -math.inf: 

516 return torch.tensor( 

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

518 ) 

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

520 

521 if norm_type == math.inf: 

522 norms = [ 

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

524 for g in grads 

525 ] 

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

527 

528 if norm_type == -math.inf: 

529 norms = [ 

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

531 for g in grads 

532 ] 

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

534 

535 if norm_type == 0: 

536 norms = [ 

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

538 for g in grads 

539 ] 

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

541 

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

543 if ( 

544 len(grads) > 1 

545 and _group_tensors_by_device_and_dtype is not None 

546 and hasattr(torch, "_foreach_norm") 

547 ): 

548 return _foreach_p_norms(grads, norm_type, device) 

549 

550 # Scalar fallback when foreach utilities are unavailable. 

551 norms = [ 

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

553 for g in grads 

554 ] 

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

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

557 

558 

559# --------------------------------------------------------------------------- 

560# Total norm aggregation with collectives 

561# --------------------------------------------------------------------------- 

562 

563def _get_total_norm( 

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

565 norm_type: float, 

566 mesh_cache: Dict[int, object], 

567 device: torch.device, 

568 norm_grads: List[torch.Tensor], 

569 key_per_grad: List[_GradGroupKey], 

570) -> torch.Tensor: 

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

572 

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

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

575 """ 

576 if norm_type == math.inf: 

577 return _total_norm_inf( 

578 grad_groups, norm_type, mesh_cache, device, 

579 dist.ReduceOp.MAX, 

580 ) 

581 

582 if norm_type == -math.inf: 

583 return _total_norm_inf( 

584 grad_groups, norm_type, mesh_cache, device, 

585 dist.ReduceOp.MIN, 

586 ) 

587 

588 if norm_type == 0: 

589 return _total_norm_sum( 

590 grad_groups, norm_type, mesh_cache, device, 

591 ) 

592 

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

594 total_p = _total_norm_fsdp2_aligned( 

595 grad_groups, norm_type, mesh_cache, device, 

596 norm_grads, key_per_grad, 

597 ) 

598 return total_p ** (1.0 / norm_type) 

599 

600 

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

602 grad_groups, norm_type, mesh_cache, device, reduce_op, 

603): 

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

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

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

607 local_norm = _compute_local_norm(grads, norm_type, device) 

608 if mesh_id is not None: 

609 mesh = mesh_cache[mesh_id] 

610 for dim in shard_dims: 

611 dist.all_reduce( 

612 local_norm, op=reduce_op, 

613 group=mesh.get_group(dim), 

614 ) 

615 group_norms.append(local_norm) 

616 if not group_norms: 

617 if norm_type == -math.inf: 

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

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

620 stacked = torch.stack(group_norms) 

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

622 

623 

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

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

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

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

628 local_val = _compute_local_norm(grads, norm_type, device) 

629 if mesh_id is not None: 

630 mesh = mesh_cache[mesh_id] 

631 for dim in shard_dims: 

632 dist.all_reduce( 

633 local_val, op=dist.ReduceOp.SUM, 

634 group=mesh.get_group(dim), 

635 ) 

636 total.add_(local_val) 

637 return total 

638 

639 

640def _reduction_signature(grad_groups, mesh_cache): 

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

642 

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

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

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

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

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

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

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

650 separate buckets, each reduced over its own group. 

651 

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

653 

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

655 / no communication). 

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

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

658 ranks issue the same collectives in the same order. 

659 """ 

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

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

662 sig_order: List[Tuple] = [] 

663 for mesh_id, shard_dims in grad_groups: 

664 if shard_dims and mesh_id is not None: 

665 mesh = mesh_cache[mesh_id] 

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

667 sig = tuple( 

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

669 ) 

670 else: 

671 groups = [] 

672 sig = () 

673 key_to_sig[(mesh_id, shard_dims)] = sig 

674 if sig not in sig_groups: 

675 sig_groups[sig] = groups 

676 sig_order.append(sig) 

677 return key_to_sig, sig_groups, sig_order 

678 

679 

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

681 norm_grads, key_per_grad): 

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

683 

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

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

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

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

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

689 and the buckets are summed locally. 

690 

691 Reduction rules: 

692 

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

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

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

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

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

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

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

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

701 

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

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

704 

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

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

707 mathematically correct but intentionally diverges (upstream folds the 

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

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

710 regrouping rather than strict global order. 

711 

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

713 """ 

714 key_to_sig, sig_groups, sig_order = _reduction_signature( 

715 grad_groups, mesh_cache, 

716 ) 

717 

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

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

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

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

722 

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

724 for sig in sig_order: 

725 grads = sig_grads[sig] 

726 if grads: 

727 norms = _per_tensor_norms(grads, norm_type, device) 

728 local_p = torch.linalg.vector_norm( 

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

730 ) ** norm_type 

731 else: 

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

733 

734 for group in sig_groups[sig]: 

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

736 

737 total_p = total_p + local_p 

738 

739 return total_p 

740 

741 

742def _build_coalesce_buffer( 

743 param_infos: List[Tuple], 

744 indices: List[int], 

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

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

747 

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

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

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

751 """ 

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

753 chunk_sizes: List[int] = [] 

754 has_grad: List[bool] = [] 

755 active_indices: List[int] = [] 

756 

757 for idx in indices: 

758 param = param_infos[idx][0] 

759 local_grad = param_infos[idx][1] 

760 if local_grad is not None: 

761 chunks.append( 

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

763 ) 

764 chunk_sizes.append(local_grad.numel()) 

765 has_grad.append(True) 

766 active_indices.append(idx) 

767 elif param.requires_grad: 

768 local_p = ( 

769 param._local_tensor # pylint: disable=W0212 

770 if _is_dtensor(param) else param.data 

771 ) 

772 numel = local_p.numel() 

773 chunks.append( 

774 torch.zeros( 

775 numel, device=local_p.device, 

776 dtype=torch.float32, 

777 ), 

778 ) 

779 chunk_sizes.append(numel) 

780 has_grad.append(False) 

781 active_indices.append(idx) 

782 

783 return chunks, chunk_sizes, has_grad, active_indices 

784 

785 

786def _coalesce_partial_reduce( # pylint: disable=R0914 

787 param_infos: List[Tuple], 

788 mesh_cache: Dict[int, object], 

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

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

791 

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

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

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

795 replaces the previous per-parameter collective calls. 

796 

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

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

799 training step at typical HCCS/NCCL latencies. 

800 

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

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

803 bandwidth. 

804 

805 All buffers use float32 to guarantee dtype consistency across ranks 

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

807 fp32). 

808 

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

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

811 with actual gradients are included. 

812 """ 

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

814 coalesce_groups: Dict[ 

815 Tuple, List[int], 

816 ] = defaultdict(list) 

817 for idx, info in enumerate(param_infos): 

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

819 if partial_info: 

820 if mesh is None: 

821 raise RuntimeError( 

822 "clip_grad_norm_: parameter has Partial placements " 

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

824 "violation." 

825 ) 

826 pck = (id(mesh), partial_info) 

827 coalesce_groups[pck].append(idx) 

828 

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

830 

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

832 mesh = mesh_cache[mesh_id] 

833 chunks, chunk_sizes, has_grad, active_indices = ( 

834 _build_coalesce_buffer(param_infos, indices) 

835 ) 

836 

837 if not chunks: 

838 continue # all params frozen, no collective needed 

839 

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

841 # misconfigured inputs rather than silent NCCL errors. 

842 buf_device = chunks[0].device 

843 for chunk in chunks[1:]: 

844 if chunk.device != buf_device: 

845 raise RuntimeError( 

846 f"clip_grad_norm_: parameters in the same Partial " 

847 f"coalesce group are on different devices " 

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

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

850 f"same local device." 

851 ) 

852 

853 buf = torch.cat(chunks) 

854 

855 for pdim, reduce_op, needs_avg in partial_info: 

856 group = mesh.get_group(pdim) 

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

858 if needs_avg: 

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

860 

861 # Extract views for params with actual gradients. 

862 offset = 0 

863 for i, idx in enumerate(active_indices): 

864 numel = chunk_sizes[i] 

865 if has_grad[i]: 

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

867 offset += numel 

868 

869 return reduced 

870 

871 

872def _build_grad_groups( # pylint: disable=R0914 

873 params: List[torch.Tensor], 

874 parameter_layouts: Optional[Mapping[torch.nn.Parameter, "Layout"]] = None, 

875) -> Tuple[ 

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

877 List[torch.Tensor], 

878 List[torch.Tensor], 

879 List[_GradGroupKey], 

880 Dict[int, object], 

881 torch.device, 

882 bool, 

883]: 

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

885 

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

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

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

889 collectives, preventing deadlocks (aligned with FSDP1 where all 

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

891 

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

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

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

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

896 

897 Returns 

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

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

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

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

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

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

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

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

906 group key. 

907 """ 

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

909 param_infos: List[Tuple] = [] 

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

911 device: Optional[torch.device] = None 

912 

913 for param in params: 

914 if parameter_layouts is None: 

915 mesh, shard_dims, partial_info = _get_param_mesh_info(param) 

916 else: 

917 layout = parameter_layouts.get(param) 

918 if layout is None and not _is_dtensor(param) and not _is_dtensor(_get_grad_obj(param)): 

919 raise ValueError("Explicit layouts must cover every local parameter participating in clipping") 

920 mesh, shard_dims, partial_info = _get_param_mesh_info(param, layout) 

921 key: _GradGroupKey = ( 

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

923 ) 

924 if mesh is not None: 

925 mesh_cache[id(mesh)] = mesh 

926 if device is None: 

927 device = _param_device(param) 

928 local_grad = _get_local_grad(param) 

929 param_infos.append( 

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

931 ) 

932 

933 if device is None: 

934 device = torch.device("cpu") 

935 

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

937 reduced = _coalesce_partial_reduce(param_infos, mesh_cache) 

938 

939 # --- Phase 3: build grad_groups --- 

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

941 list, 

942 ) 

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

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

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

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

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

948 key_per_grad: List[_GradGroupKey] = [] 

949 has_dtensor_grad = False 

950 

951 for idx, info in enumerate(param_infos): 

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

953 if local_grad is None: 

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

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

956 if key not in grad_groups: 

957 grad_groups[key] = [] 

958 continue 

959 

960 grad_obj = _get_grad_obj(param) 

961 if _is_dtensor(grad_obj): 

962 has_dtensor_grad = True 

963 all_grads.append(local_grad) 

964 key_per_grad.append(key) 

965 if idx in reduced: 

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

967 norm_grads.append(reduced[idx]) 

968 else: 

969 grad_groups[key].append(local_grad) 

970 norm_grads.append(local_grad) 

971 

972 return _GradGroups( 

973 grad_groups, all_grads, norm_grads, key_per_grad, 

974 mesh_cache, device, has_dtensor_grad, 

975 ) 

976 

977 

978def _clip_grads_with_norm_( 

979 all_grads: List[torch.Tensor], 

980 max_norm: float, 

981 total_norm: torch.Tensor, 

982 foreach: Optional[bool] = None, 

983) -> None: 

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

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

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

987 

988 if _group_tensors_by_device_and_dtype is not None: 

989 grouped_grads = _group_tensors_by_device_and_dtype( 

990 [all_grads], 

991 ) 

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

993 use_foreach = ( 

994 foreach is None and _has_foreach_support(device_grads, device) 

995 ) or (foreach and _device_has_foreach_support(device)) 

996 if use_foreach: 

997 torch._foreach_mul_( # pylint: disable=W0212 

998 device_grads, 

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

1000 ) 

1001 elif foreach: 

1002 raise RuntimeError( 

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

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

1005 ) 

1006 else: 

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

1008 for g in device_grads: 

1009 g.mul_(clip_coef_clamped_cast) 

1010 else: 

1011 # Fallback when _foreach_utils is unavailable. 

1012 if foreach: 

1013 raise RuntimeError( 

1014 "foreach=True was passed, but " 

1015 "torch.utils._foreach_utils is not available" 

1016 ) 

1017 for grad in all_grads: 

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

1019 

1020 

1021# --------------------------------------------------------------------------- 

1022# Public API 

1023# --------------------------------------------------------------------------- 

1024 

1025@torch.no_grad() 

1026def clip_grad_norm_( 

1027 parameters: Union[ 

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

1029 ], 

1030 max_norm: float, 

1031 norm_type: float = 2.0, 

1032 error_if_nonfinite: bool = False, 

1033 foreach: Optional[bool] = None, 

1034 *, 

1035 parameter_layouts: Optional[Mapping[torch.nn.Parameter, "Layout"]] = None, 

1036) -> torch.Tensor: 

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

1038 

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

1040 correctly handles DTensor-sharded parameters by deriving 

1041 communication from each parameter's DTensorSpec. 

1042 

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

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

1045 with FSDP1: every rank participates in the same collectives 

1046 regardless of local gradient availability. 

1047 

1048 Communication is derived from each parameter's DTensorSpec: 

1049 

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

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

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

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

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

1055 

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

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

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

1059 

1060 Args: 

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

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

1063 max_norm: Maximum allowed gradient norm. 

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

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

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

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

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

1069 implementation for devices that support it and silently 

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

1071 Default ``None``. 

1072 parameter_layouts: Optional parameter-identity mapping to final HyperParallel 

1073 Layouts for local tensors. DTensor gradient/parameter layouts take 

1074 precedence and must agree with explicit metadata. Local trainable 

1075 parameters must be fully covered; frozen parameters are ignored. 

1076 

1077 Returns: 

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

1079 cast to the promoted dtype of all gradient tensors. 

1080 """ 

1081 max_norm = float(max_norm) 

1082 norm_type = float(norm_type) 

1083 

1084 params = _normalize_parameters(parameters) 

1085 if parameter_layouts is not None: 

1086 params = [param for param in dict.fromkeys(params) if param.requires_grad] 

1087 if foreach is None: 

1088 foreach = False 

1089 

1090 ( 

1091 grad_groups, all_grads, norm_grads, key_per_grad, 

1092 mesh_cache, device, has_dtensor_grad, 

1093 ) = (_build_grad_groups(params) if parameter_layouts is None 

1094 else _build_grad_groups(params, parameter_layouts)) 

1095 

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

1097 # _compute_local_norm returns identity elements for empty groups, 

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

1099 total_norm = _get_total_norm( 

1100 grad_groups, norm_type, mesh_cache, device, 

1101 norm_grads, key_per_grad, 

1102 ) 

1103 

1104 if error_if_nonfinite and torch.logical_or( 

1105 total_norm.isnan(), total_norm.isinf() 

1106 ): 

1107 raise RuntimeError( 

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

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

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

1111 "non-finite norm anyway, set " 

1112 "`error_if_nonfinite=False`" 

1113 ) 

1114 

1115 if all_grads: 

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

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

1118 _clip_grads_with_norm_( 

1119 all_grads, max_norm, total_norm, effective_foreach, 

1120 ) 

1121 

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

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

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

1125 if not all_grads: 

1126 warnings.warn( 

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

1128 "returning the local norm in the default dtype " 

1129 f"{total_norm.dtype}", 

1130 stacklevel=2, 

1131 ) 

1132 return total_norm 

1133 

1134 total_norm_dtype = functools.reduce( 

1135 torch.promote_types, 

1136 [g.dtype for g in all_grads], 

1137 ) 

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

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

1140 return total_norm.to(total_norm_dtype)