Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / trainer / runtime / fsdp.py: 84%

215 statements  

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

1# Copyright 2025-2026 Bytedance Ltd. and/or its affiliates 

2# Copyright 2026 Huawei Technologies Co., Ltd 

3# 

4# Licensed under the Apache License, Version 2.0 (the "License"); 

5# you may not use this file except in compliance with the License. 

6# You may obtain a copy of the License at 

7# 

8# http://www.apache.org/licenses/LICENSE-2.0 

9# 

10# Unless required by applicable law or agreed to in writing, software 

11# distributed under the License is distributed on an "AS IS" BASIS, 

12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

13# See the License for the specific language governing permissions and 

14# limitations under the License. 

15 

16"""Trainer-side FSDP lifecycle policies and runtime diagnostics. 

17 

18Split out of the former ``auto_models/trainer/base.py`` in stage 7 

19(05 §15.11 step 3). ``BaseTrainer`` keeps the same-named methods as thin 

20delegating subclass hooks; the policy itself lives here. The FSDP config is 

21duck-typed (``reshard_after_backward`` / ``dp_shard_size`` / 

22``requires_grad_sync`` attributes) so this module does not import Trainer DTOs. 

23""" 

24# pylint: disable=forbidden-backend-import 

25 

26import logging 

27from collections import defaultdict 

28from dataclasses import dataclass, field 

29from typing import Any, List, Optional 

30 

31import torch 

32from torch.utils._pytree import tree_flatten 

33 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38@dataclass 

39class _FSDPMicroStepTrace: 

40 """Observed FSDP unit order for one forward/backward micro-step.""" 

41 

42 global_step: int 

43 micro_step: int 

44 forward: list[str] = field(default_factory=list) 

45 backward: list[str] = field(default_factory=list) 

46 

47 

48class FSDPRuntimeDiagnostics: 

49 """Validate Torch FSDP prefetch order at the optimizer-step boundary. 

50 

51 This observer does not launch, wait, release, or otherwise mutate FSDP 

52 communication. It records the actual module execution order and inspects 

53 outstanding all-gather state immediately before the optimizer may update 

54 parameter shards. 

55 """ 

56 

57 def __init__(self, hsdp_model_parts: List[Any]) -> None: 

58 """Attach diagnostic hooks to all HSDP units in the model parts.""" 

59 self._module_by_name: dict[str, Any] = {} 

60 self._name_by_module_id: dict[int, str] = {} 

61 self._scheduler_by_name: dict[str, Any] = {} 

62 self._hook_handles: list[Any] = [] 

63 self._traces: list[_FSDPMicroStepTrace] = [] 

64 self._active_trace: Optional[_FSDPMicroStepTrace] = None 

65 self._discover_units(hsdp_model_parts) 

66 self._register_hooks() 

67 

68 @property 

69 def unit_count(self) -> int: 

70 """Return the number of observed HSDP modules.""" 

71 return len(self._module_by_name) 

72 

73 def _discover_units(self, hsdp_model_parts: List[Any]) -> None: 

74 """Build stable names for HSDP modules without importing backend internals.""" 

75 multiple_parts = len(hsdp_model_parts) > 1 

76 for part_index, model_part in enumerate(hsdp_model_parts): 

77 part_prefix = f"part[{part_index}]" if multiple_parts else "" 

78 for module_fqn, module in model_part.named_modules(): 

79 scheduler = getattr(module, "hsdp_scheduler", None) 

80 if scheduler is None or id(module) in self._name_by_module_id: 

81 continue 

82 if part_prefix and module_fqn: 

83 module_name = f"{part_prefix}.{module_fqn}" 

84 elif part_prefix: 

85 module_name = part_prefix 

86 else: 

87 module_name = module_fqn or "<root>" 

88 self._module_by_name[module_name] = module 

89 self._name_by_module_id[id(module)] = module_name 

90 self._scheduler_by_name[module_name] = scheduler 

91 

92 def _register_hooks(self) -> None: 

93 """Register hooks on every unit for order and child-bypass diagnostics.""" 

94 for module_name, module in self._module_by_name.items(): 

95 self._hook_handles.append( 

96 module.register_forward_pre_hook( 

97 self._make_forward_pre_hook(module_name) 

98 ) 

99 ) 

100 self._hook_handles.append( 

101 module.register_forward_hook( 

102 self._make_backward_registration_hook(module_name) 

103 ) 

104 ) 

105 

106 def _make_forward_pre_hook(self, module_name: str): 

107 """Create a hook recording non-recompute forward execution.""" 

108 

109 def record_forward(module: Any, inputs: Any) -> None: 

110 """Record one observed module forward entry.""" 

111 del inputs 

112 if self._active_trace is None: 

113 return 

114 scheduler = getattr(module, "hsdp_scheduler", None) 

115 scheduler_ctx = getattr(scheduler, "scheduler_ctx", None) 

116 if getattr(scheduler_ctx, "root_bp_state", False): 

117 return 

118 self._active_trace.forward.append(module_name) 

119 

120 return record_forward 

121 

122 def _make_backward_registration_hook(self, module_name: str): 

123 """Create a forward hook attaching a one-shot output-gradient observer.""" 

124 

125 def register_backward_observer(module: Any, inputs: Any, outputs: Any) -> None: 

126 """Attach a one-shot gradient observer to differentiable outputs.""" 

127 del inputs 

128 scheduler = getattr(module, "hsdp_scheduler", None) 

129 scheduler_ctx = getattr(scheduler, "scheduler_ctx", None) 

130 if getattr(scheduler_ctx, "root_bp_state", False): 

131 return 

132 output_tensors = [ 

133 output 

134 for output in tree_flatten(outputs)[0] 

135 if torch.is_tensor(output) and output.requires_grad 

136 ] 

137 if not output_tensors: 

138 return 

139 

140 fired = [False] 

141 hook_handles = [] 

142 

143 def record_backward(gradient: Any) -> Any: 

144 """Record the module on its first output-gradient callback.""" 

145 if not fired[0]: 

146 fired[0] = True 

147 if self._active_trace is not None: 

148 self._active_trace.backward.append(module_name) 

149 for hook_handle in hook_handles: 

150 hook_handle.remove() 

151 return gradient 

152 

153 for output_tensor in output_tensors: 

154 hook_handles.append(output_tensor.register_hook(record_backward)) 

155 

156 return register_backward_observer 

157 

158 def begin_micro_step(self, global_step: int, micro_step: int) -> None: 

159 """Start tracing one micro-step in the current optimizer step.""" 

160 if micro_step == 0: 

161 self._traces.clear() 

162 trace = _FSDPMicroStepTrace(global_step, micro_step) 

163 self._traces.append(trace) 

164 self._active_trace = trace 

165 

166 @staticmethod 

167 def _first_positions(execution_order: list[str]) -> dict[str, int]: 

168 """Return first-observed positions while preserving repeated calls in logs.""" 

169 positions = {} 

170 for position, module_name in enumerate(execution_order): 

171 positions.setdefault(module_name, position) 

172 return positions 

173 

174 def _prefetch_target_names(self, module_name: str, phase: str) -> list[str]: 

175 """Resolve configured prefetch targets to diagnostic module names.""" 

176 scheduler = self._scheduler_by_name[module_name] 

177 attribute = ( 

178 "forward_prefetch_cells" 

179 if phase == "forward" 

180 else "backward_prefetch_cells" 

181 ) 

182 target_names = [] 

183 for target in getattr(scheduler, attribute, ()): 

184 target_names.append( 

185 self._name_by_module_id.get( 

186 id(target), 

187 f"<untracked:{type(target).__name__}>", 

188 ) 

189 ) 

190 return target_names 

191 

192 def _find_order_violations(self) -> list[str]: 

193 """Compare configured prefetch edges with each observed execution trace.""" 

194 violations = [] 

195 for trace in self._traces: 

196 for phase in ("forward", "backward"): 

197 execution_order = getattr(trace, phase) 

198 positions = self._first_positions(execution_order) 

199 for source_name, source_position in positions.items(): 

200 for target_name in self._prefetch_target_names(source_name, phase): 

201 target_position = positions.get(target_name) 

202 if target_position is None: 

203 violations.append( 

204 f"step={trace.global_step} micro_step={trace.micro_step} " 

205 f"phase={phase}: {source_name} prefetched {target_name}, " 

206 "but the target did not execute" 

207 ) 

208 elif target_position <= source_position: 

209 violations.append( 

210 f"step={trace.global_step} micro_step={trace.micro_step} " 

211 f"phase={phase}: {source_name} prefetched {target_name} " 

212 f"at positions {source_position}->{target_position}" 

213 ) 

214 return violations 

215 

216 @staticmethod 

217 def _parameter_name(hsdp_param: Any, param_index: int) -> str: 

218 """Return a useful parameter name for diagnostics.""" 

219 return getattr(hsdp_param, "_param_fqn", None) or f"param[{param_index}]" 

220 

221 def _find_pending_all_gathers(self) -> list[str]: 

222 """Find unconsumed fused and non-fused all-gathers without mutating them.""" 

223 pending = [] 

224 seen_schedulers = set() 

225 for module_name, scheduler in self._scheduler_by_name.items(): 

226 if id(scheduler) in seen_schedulers: 

227 continue 

228 seen_schedulers.add(id(scheduler)) 

229 state = getattr(scheduler, "hsdp_state", None) 

230 if state is None: 

231 continue 

232 

233 param_group = getattr(state, "param_group", None) 

234 for bucket_index, bucket in enumerate( 

235 getattr(param_group, "all_gather_buckets", ()) 

236 ): 

237 result = getattr(bucket, "all_gather_result", None) 

238 if result is not None: 

239 handle = getattr(result, "handle", None) 

240 pending.append( 

241 f"{module_name}: fused_bucket[{bucket_index}] " 

242 f"handle={'set' if handle is not None else 'completed-not-consumed'}" 

243 ) 

244 

245 for param_index, hsdp_param in enumerate(getattr(state, "hsdp_params", ())): 

246 comm_ctx = getattr(hsdp_param, "allgather_comm_ctx", None) 

247 handle = getattr(comm_ctx, "allgather_handle", None) 

248 output = getattr(comm_ctx, "allgather_output", None) 

249 if handle is not None or output is not None: 

250 state_details = [] 

251 if handle is not None: 

252 state_details.append("handle=set") 

253 if output is not None: 

254 state_details.append("output=not-consumed") 

255 pending.append( 

256 f"{module_name}: " 

257 f"{self._parameter_name(hsdp_param, param_index)} " 

258 + ", ".join(state_details) 

259 ) 

260 return pending 

261 

262 @staticmethod 

263 def _hsdp_parameter_has_gradient(hsdp_param: Any) -> bool: 

264 """Return whether a managed parameter participated in backward.""" 

265 sharded_param = getattr(hsdp_param, "sharded_param", None) 

266 candidates = ( 

267 getattr(sharded_param, "grad", None), 

268 getattr(sharded_param, "main_grad", None), 

269 getattr(hsdp_param, "_grad", None), 

270 getattr(hsdp_param, "unsharded_accumulated_grad", None), 

271 getattr(hsdp_param, "unsharded_accumulated_grad_data", None), 

272 ) 

273 return any(value is not None for value in candidates) 

274 

275 def _find_bypassed_child_units(self) -> list[str]: 

276 """Find child FSDP parameters used without executing their owner module.""" 

277 if not self._traces: 

278 return [] 

279 forward_counts = defaultdict(int) 

280 for trace in self._traces: 

281 for module_name in trace.forward: 

282 forward_counts[module_name] += 1 

283 bypassed = [] 

284 root_names = {"<root>"} 

285 root_names.update( 

286 name for name in self._module_by_name 

287 if name.startswith("part[") and "." not in name 

288 ) 

289 for module_name, scheduler in self._scheduler_by_name.items(): 

290 if module_name in root_names or forward_counts[module_name] > 0: 

291 continue 

292 state = getattr(scheduler, "hsdp_state", None) 

293 gradient_parameters = [ 

294 self._parameter_name(hsdp_param, param_index) 

295 for param_index, hsdp_param in enumerate(getattr(state, "hsdp_params", ())) 

296 if self._hsdp_parameter_has_gradient(hsdp_param) 

297 ] 

298 if gradient_parameters: 

299 bypassed.append( 

300 f"{module_name}: forward_count=0, gradient_parameters={gradient_parameters}" 

301 ) 

302 return bypassed 

303 

304 def validate_before_optimizer_step(self) -> None: 

305 """Fail before parameter updates if the traced FSDP lifecycle is unsafe.""" 

306 self._active_trace = None 

307 order_violations = self._find_order_violations() 

308 pending_all_gathers = self._find_pending_all_gathers() 

309 bypassed_child_units = self._find_bypassed_child_units() 

310 error_sections = [] 

311 if bypassed_child_units: 

312 error_sections.append( 

313 "child FSDP module was bypassed while its parameter " 

314 "participated in backward:\n - " + "\n - ".join(bypassed_child_units) 

315 ) 

316 if order_violations: 

317 error_sections.append( 

318 "configured prefetch targets do not follow " 

319 "the observed runtime order:\n - " + "\n - ".join(order_violations) 

320 ) 

321 if pending_all_gathers: 

322 error_sections.append( 

323 "unconsumed parameter all-gather exists at " 

324 "the optimizer boundary:\n - " + "\n - ".join(pending_all_gathers) 

325 ) 

326 if error_sections: 

327 raise RuntimeError( 

328 "\n".join(error_sections) 

329 + "\nThe optimizer was not executed. Configure FSDP prefetch from the " 

330 "model's actual forward/backward execution order; do not carry an " 

331 "all-gather across optimizer.step()." 

332 ) 

333 

334 def trace_evidence(self) -> dict[str, Any]: 

335 """Return stable execution-order evidence for structured reports.""" 

336 return { 

337 "units": sorted(self._module_by_name), 

338 "micro_steps": [ 

339 { 

340 "global_step": trace.global_step, 

341 "micro_step": trace.micro_step, 

342 "forward": list(trace.forward), 

343 "backward": list(trace.backward), 

344 } 

345 for trace in self._traces 

346 ], 

347 } 

348 

349 def close(self) -> None: 

350 """Remove diagnostic hooks when a caller no longer needs the observer.""" 

351 for hook_handle in self._hook_handles: 

352 hook_handle.remove() 

353 self._hook_handles.clear() 

354 

355 

356def build_fsdp_runtime_diagnostics( 

357 hsdp_model_parts: List[Any], 

358) -> Optional[FSDPRuntimeDiagnostics]: 

359 """Create diagnostics for Torch Trainer model parts, or return ``None``.""" 

360 if not hsdp_model_parts: 

361 return None 

362 diagnostics = FSDPRuntimeDiagnostics(hsdp_model_parts) 

363 if diagnostics.unit_count == 0: 

364 diagnostics.close() 

365 return None 

366 logger.info( 

367 "Enabled Trainer FSDP runtime diagnostics for %d modules", 

368 diagnostics.unit_count, 

369 ) 

370 return diagnostics 

371 

372 

373def model_reshard( 

374 hsdp_model_parts: List[Any], 

375 fsdp_config: Any, 

376 micro_step: int, 

377 num_micro_steps: int, 

378) -> None: 

379 """Reshard model after backward pass.""" 

380 if ( 

381 fsdp_config.reshard_after_backward is False 

382 and num_micro_steps > 1 

383 ): 

384 if micro_step == 0: 

385 for model_part in hsdp_model_parts: 

386 model_part.set_reshard_after_backward(False) 

387 elif micro_step == num_micro_steps - 1: 

388 for model_part in hsdp_model_parts: 

389 model_part.set_reshard_after_backward(True) 

390 

391 

392def configure_fsdp_gradient_sync( 

393 hsdp_model_parts: List[Any], 

394 fsdp_config: Any, 

395 dp_replicate_size: int, 

396 micro_step: int, 

397 num_micro_steps: int, 

398) -> None: 

399 """Configure FSDP gradient synchronization for one micro step.""" 

400 if ( 

401 fsdp_config.dp_shard_size > 1 

402 and num_micro_steps > 1 

403 ): 

404 is_last_micro_batch = micro_step == num_micro_steps - 1 

405 requires_gradient_sync = ( 

406 fsdp_config.requires_grad_sync 

407 or is_last_micro_batch 

408 ) 

409 is_hsdp = dp_replicate_size > 1 

410 for model_part in hsdp_model_parts: 

411 model_part.set_requires_gradient_sync(requires_gradient_sync) 

412 model_part.set_is_last_backward(is_last_micro_batch) 

413 if is_hsdp: 

414 model_part.set_requires_all_reduce(is_last_micro_batch) 

415 

416 

417__all__ = [ 

418 "FSDPRuntimeDiagnostics", 

419 "build_fsdp_runtime_diagnostics", 

420 "configure_fsdp_gradient_sync", 

421 "model_reshard", 

422]