Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / compile / passes / parallel / pp_pass.py: 93%

715 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""" 

16PP Pass - Pipeline-Parallel Graph Split Pass 

17 

18Splits the (already FSDP-transformed) joint fwd+bwd FX graph into this 

19rank's pipeline stage, entirely at graph level — no model deepcopy, no 

20``torch.distributed.pipelining`` dependency. Where torchtitan splits at the 

21*module* level (``_split_module`` deepcopy + layer deletion, then wraps each 

22chunk in a ``PipelineStage``), this pass: 

23 

241. Attributes every FX node to a stage via ``nn_module_stack`` metadata 

25 (parameters are static placeholders, attributed by FQN ancestor walk). 

26 Root-level nodes the stack cannot resolve (residual adds written in the 

27 parent's ``forward``, loss glue) are attributed by their CONSUMERS, so a 

28 residual add sandwiched between two stages rides with the stage that 

29 consumes it. 

302. Detects the stage boundary as the set of values crossing each cut — 

31 activations forward, gradients backward. Real ATen graphs cross with 

32 more than the trunk tensor: dynamic-shape ``sym_size`` scalars and 

33 parent-level views all cross, so every crossing value is shipped (int 

34 scalars are packed as 0-d int64 tensors by the schedule and unwrapped 

35 with ``item()`` on arrival). 

363. Slices stage ``k`` into a forward subgraph and a backward subgraph. The 

37 forward outputs (all crossing activations plus every intermediate the 

38 backward references) become backward placeholders, so the GPipe schedule 

39 can run all forwards before all backwards. 

404. Prunes foreign-stage submodules from the live model (torch's 

41 ``named_parameters`` then yields only this stage's entries, keeping the 

42 trainer / optimizer FSDP- and PP-agnostic). 

435. Installs a self-contained pipeline schedule as a ``call_module`` node in 

44 a stub graph — the trainer's ``graph_module(*flat_inputs)`` dispatches to 

45 it unchanged, and the stub survives any later ``recompile()`` because the 

46 schedule lives in the graph itself, not in an overwritten ``forward``. 

47 ``ScheduleGPipe`` / ``Schedule1F1B`` drive one stage per rank; 

48 ``ScheduleInterleaved1F1B`` drives ``PassConfig.pp_chunks`` virtual stages 

49 per rank (VPP) using the loop mapping ``rank, rank + pp_degree, ...``. 

50 

51Run order in ``PassPipeline``: AFTER ``FSDPPass``. The FSDP collectives are 

52value-level FX nodes that follow the data flow: this stage's all_gather / 

53reduce_scatter nodes land inside its slices untouched, while foreign 

54stages' collectives are simply never copied. Splitting first (PP before 

55FSDP) would instead hand ``FSDPPass`` two subgraphs and a loss-less output 

56list, breaking its grad-index contract. This ordering is also what makes 

57the PP+DP hybrid work with no extra machinery: with the 2-D ``(pp, DP)`` 

58mesh (``GraphCompiler._init_pp_dp_mesh``), ``FSDPPass`` shards parameters 

59over the intra-stage DP group and ``PpPass`` pipelines each DP replica with 

60the same DP replica of the neighbouring stages over the cross-stage 

61``"pp"`` group. FSDP's backward re-gather / remat nodes are tagged 

62``autograd_backward`` by ``FSDPPass`` precisely so this pass classifies 

63them into the backward slice (lazy re-gather), and forward-phase reshard 

64nodes stay in the forward slice. 

65""" 

66 

67__all__ = ["PpPass", "_auto_stage_split"] 

68 

69import logging 

70from dataclasses import dataclass 

71from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple 

72 

73import torch 

74import torch.distributed as dist 

75from torch import fx, nn 

76from torch.distributed.distributed_c10d import _resolve_process_group 

77 

78from ...pass_config import PassConfig, normalize_dp_mode 

79from ..base import GraphPass 

80from ...graph_parallel_plan import GraphParallelPlan 

81from .pp_schedule import ( 

82 PipelineScheduleBase, 

83 ScheduleInterleaved1F1B, 

84 get_schedule_class, 

85) 

86 

87_LOG = logging.getLogger(__name__) 

88 

89_FWD = "fwd" 

90_BWD = "bwd" 

91 

92 

93def _sanitize(name: str) -> str: 

94 """Turn a dotted FQN into a codegen-safe placeholder name.""" 

95 return name.replace(".", "_") 

96 

97 

98def _iter_node_args(node: fx.Node) -> Iterator[fx.Node]: 

99 """Yield every ``fx.Node`` reachable in ``node.args`` / ``node.kwargs``.""" 

100 stack: List[Any] = list(node.args) + list(node.kwargs.values()) 

101 while stack: 

102 item = stack.pop() 

103 if isinstance(item, fx.Node): 

104 yield item 

105 elif isinstance(item, (tuple, list)): 

106 stack.extend(item) 

107 elif isinstance(item, dict): 

108 stack.extend(item.values()) 

109 

110 

111def _tensor_arg_stages(node: fx.Node, node_stage: Dict[fx.Node, int]) -> List[int]: 

112 """Stages of ``node``'s TENSOR-valued resolved args. 

113 

114 Shape-metadata args (dynamic-shape ``sym_size`` scalars) are excluded: 

115 they are born wherever shapes are known, carry no data dependency, and 

116 would otherwise drag a node's placement against its dataflow (e.g. a 

117 boundary-gradient view whose ``sym_size`` args are stage-0 born while 

118 the gradient itself is born downstream). 

119 """ 

120 stages = [] 

121 for arg in _iter_node_args(node): 

122 stage = node_stage.get(arg) 

123 if stage is None: 

124 continue 

125 val = arg.meta.get("val") 

126 if val is not None and not isinstance(val, torch.Tensor): 

127 continue 

128 stages.append(stage) 

129 return stages 

130 

131 

132def _innermost_fqns(node: fx.Node) -> List[str]: 

133 """Module FQNs on ``node``'s ``nn_module_stack``, innermost first.""" 

134 stack = node.meta.get("nn_module_stack") or {} 

135 return [fqn for fqn, _ in reversed(list(stack.values()))] 

136 

137 

138def _even_split(names: List[str], pp_degree: int, prefix: str = "") -> List[List[str]]: 

139 """Distribute ``names`` across ``pp_degree`` stages as evenly as possible. 

140 

141 The first ``len(names) % pp_degree`` stages get one extra entry; each 

142 entry is prefixed (``prefix``) so bare child names become FQNs. 

143 """ 

144 base, rem = divmod(len(names), pp_degree) 

145 stages: List[List[str]] = [[] for _ in range(pp_degree)] 

146 cursor = 0 

147 for s in range(pp_degree): 

148 size = base + (1 if s < rem else 0) 

149 stages[s] = [f"{prefix}{name}" for name in names[cursor : cursor + size]] 

150 cursor += size 

151 return stages 

152 

153 

154def _auto_stage_split(model: nn.Module, pp_degree: int) -> List[List[str]]: 

155 """Default PP split: even distribution of the model's layer container. 

156 

157 Mirrors torchtitan's ``_generate_llm_fqn_per_model_part`` weighting 

158 without hardcoding LLM module names: 

159 

160 - The first top-level child that is an ``nn.ModuleList`` with at least 

161 ``pp_degree`` entries is the *layer container* (typically ``layers``). 

162 - Top-level children registered before it go to stage 0 (input side, 

163 e.g. embeddings); children after it go to the last stage (output 

164 side, e.g. norm / lm_head). 

165 - Container elements are distributed as evenly as possible (see 

166 ``_even_split``). 

167 

168 Without a qualifying container, all top-level children are split evenly 

169 in registration order. 

170 

171 Returns: 

172 ``pp_degree`` lists of exact module FQNs, one per stage. 

173 """ 

174 if pp_degree < 2: 

175 raise ValueError(f"pp_degree must be >= 2 for a PP split, got {pp_degree}") 

176 

177 children = [name for name, _ in model.named_children()] 

178 

179 container: Optional[str] = None 

180 for name, child in model.named_children(): 

181 if isinstance(child, nn.ModuleList) and len(child) >= pp_degree: 

182 container = name 

183 break 

184 

185 if container is None: 

186 if len(children) < pp_degree: 

187 raise ValueError( 

188 f"Cannot split {len(children)} top-level children across " 

189 f"{pp_degree} PP stages; provide a manual stage plan via " 

190 f"GraphParallelPlan.pp_stage()" 

191 ) 

192 return _even_split(children, pp_degree) 

193 

194 container_mod = getattr(model, container) 

195 if isinstance(container_mod, nn.ModuleDict): 

196 elem_keys = list(container_mod.keys()) 

197 else: 

198 elem_keys = [str(i) for i in range(len(container_mod))] 

199 

200 idx = children.index(container) 

201 before, after = children[:idx], children[idx + 1 :] 

202 

203 stages = _even_split(elem_keys, pp_degree, prefix=f"{container}.") 

204 stages[0] = before + stages[0] 

205 stages[-1] = stages[-1] + after 

206 return stages 

207 

208 

209@dataclass 

210class _RunContext: 

211 """This rank's PP execution context, resolved and guarded for ``run``. 

212 

213 ``pp_degree`` is the number of PHYSICAL ranks in the PP group, while 

214 ``num_stages`` is the number of VIRTUAL stages (``pp_degree * chunks``). 

215 Rank ``stage_idx`` owns the virtual stages in ``stage_indices`` using the 

216 interleaved (loop) assignment ``stage_idx, stage_idx + pp_degree, ...``. 

217 With the default ``chunks == 1`` the two coincide. 

218 """ 

219 

220 model: nn.Module 

221 pp_degree: int 

222 group: Any 

223 stage_idx: int 

224 chunks: int 

225 num_stages: int 

226 stage_indices: List[int] 

227 

228 

229@dataclass 

230class _StageGraphs: 

231 """One virtual stage's sliced graphs and boundary contract.""" 

232 

233 stage_idx: int 

234 fwd_gm: fx.GraphModule 

235 bwd_gm: fx.GraphModule 

236 boundaries: "_Boundaries" 

237 state_indices: List[int] 

238 num_trainable: int 

239 

240 

241@dataclass 

242class _StageSplit: 

243 """Stage plan and per-node classification produced by ``_classify_stage``. 

244 

245 Bundles what the boundary-detection and subgraph-construction phases 

246 need, so ``run`` orchestrates phases instead of threading a dozen 

247 parallel locals through every call. 

248 """ 

249 

250 state_fqns: List[str] 

251 state_is_param: List[bool] 

252 stage_plan: List[List[str]] 

253 node_stage: Dict[fx.Node, int] 

254 node_phase: Dict[fx.Node, str] 

255 user_input_phs: List[fx.Node] 

256 user_input_stages: List[int] 

257 user_input_stage_sets: List[Tuple[int, ...]] 

258 state_phs: List[fx.Node] 

259 stage_state_indices: List[int] 

260 stage_state_fqns: List[str] 

261 

262 

263@dataclass 

264class _GraphClassification: 

265 """Per-node stage/phase classification produced by ``_classify_graph``.""" 

266 

267 stage_state_indices: List[int] 

268 node_stage: Dict[fx.Node, int] 

269 node_phase: Dict[fx.Node, str] 

270 user_input_phs: List[fx.Node] 

271 user_input_stages: List[int] 

272 user_input_stage_sets: List[Tuple[int, ...]] 

273 state_phs: List[fx.Node] 

274 

275 

276@dataclass 

277class _Boundaries: 

278 """Boundary value lists crossing this stage's cuts. 

279 

280 All lists are in joint-graph topological order so the producer and 

281 consumer of a cut agree on P2P ordering: 

282 

283 - ``act_in``: stage-(k-1) values consumed by stage k (tensors, 

284 dynamic-shape scalars, parent-level views — everything a real ATen 

285 graph pushes across a cut). 

286 - ``act_out``: stage-k values consumed by stage k+1. 

287 - ``grad_in``: stage-(k+1) backward values consumed by stage k. 

288 - ``grad_out``: stage-k backward values consumed by stage k-1. 

289 - ``saved``: forward-phase values referenced by this stage's backward 

290 nodes — exported as forward outputs and replayed as backward inputs. 

291 """ 

292 

293 act_in: List[fx.Node] 

294 act_out: List[fx.Node] 

295 grad_in: List[fx.Node] 

296 grad_out: List[fx.Node] 

297 saved: List[fx.Node] 

298 

299 

300@dataclass 

301class _SubgraphBuilder: 

302 """Accumulator for assembling one stage subgraph. 

303 

304 ``env`` maps already-copied same-slice nodes; ``foreign`` maps 

305 boundary values to their placeholder stand-ins; ``used`` carries the 

306 taken placeholder names for ``_unique``. 

307 """ 

308 

309 g: fx.Graph 

310 used: Set[str] 

311 env: Dict[fx.Node, fx.Node] 

312 foreign: Dict[fx.Node, fx.Node] 

313 

314 

315class PpPass(GraphPass): 

316 """Pipeline-parallel partitioning pass (graph-level stage split).""" 

317 

318 name = "pp_parallel" 

319 

320 def __init__(self, parallel_plan: Optional[GraphParallelPlan] = None) -> None: 

321 """Initialize PP pass state. 

322 

323 Args: 

324 parallel_plan: Declarative plan; ``pp_module_fqns_per_stage`` (from 

325 ``pp_stage()``) overrides the automatic even-by-layers 

326 split. 

327 """ 

328 super().__init__() 

329 self._parallel_plan = parallel_plan 

330 

331 def run( 

332 self, 

333 graph_module: fx.GraphModule, 

334 pass_config: PassConfig, 

335 **kwargs: Any, 

336 ) -> fx.GraphModule: 

337 """Split the joint graph to this rank's stage and install the schedule. 

338 

339 Args: 

340 graph_module: Joint fwd+bwd FX graph (post-``FSDPPass``). 

341 pass_config: Parallel configuration; ``pp_degree`` (``None`` 

342 resolves to ``world_size``) and ``pp_microbatch_size`` are 

343 read directly. 

344 **kwargs: Must include ``model`` (the live ``nn.Module``); may 

345 include ``parallel_plan``. 

346 

347 Returns: 

348 The same graph module, rewritten into a stage stub whose 

349 ``call_module`` node invokes the installed pipeline schedule 

350 (``ScheduleGPipe`` / ``Schedule1F1B`` / 

351 ``ScheduleInterleaved1F1B`` per ``pass_config.pp_schedule``). 

352 """ 

353 run_ctx = self._resolve_run_context(pass_config, kwargs) 

354 if run_ctx is None: 

355 return graph_module 

356 

357 split = self._classify_stage(graph_module, run_ctx) 

358 self._build_and_install_stage(graph_module, pass_config, run_ctx, split) 

359 return graph_module 

360 

361 def _resolve_run_context( 

362 self, 

363 pass_config: PassConfig, 

364 kwargs: Dict[str, Any], 

365 ) -> Optional[_RunContext]: 

366 """Guard and resolve the model, PP group, and this rank's stage. 

367 

368 Returns: 

369 The run context, or ``None`` when the pass is skipped 

370 (distributed not initialized, ``world_size == 1``, or 

371 ``pp_degree < 2``). 

372 """ 

373 if not dist.is_initialized() or dist.get_world_size() == 1: 

374 _LOG.info("Skipped: distributed not initialized or world_size=1") 

375 return None 

376 

377 model = kwargs.get("model") 

378 if model is None: 

379 raise ValueError( 

380 "PpPass requires the live model via kwargs (model=...) so it " 

381 "can resolve the stage plan and prune foreign-stage modules" 

382 ) 

383 self._parallel_plan = kwargs.get("parallel_plan", self._parallel_plan) 

384 

385 # Skip BEFORE _resolve_group_and_stage so a disabled pass does not 

386 # execute the collective dist.new_group. 

387 pp_degree = pass_config.pp_degree or dist.get_world_size() 

388 if pp_degree < 2: 

389 _LOG.info("Skipped: pp_degree=%s < 2", pp_degree) 

390 return None 

391 pp_degree, group, stage_idx = self._resolve_group_and_stage(pass_config, kwargs) 

392 chunks = pass_config.pp_chunks 

393 num_stages = pp_degree * chunks 

394 stage_indices = list(range(stage_idx, num_stages, pp_degree)) 

395 _LOG.info( 

396 "Running: rank %s/%s owns virtual stages %s/%s, world_size=%s", 

397 stage_idx, 

398 pp_degree, 

399 stage_indices, 

400 num_stages, 

401 dist.get_world_size(), 

402 ) 

403 return _RunContext( 

404 model=model, 

405 pp_degree=pp_degree, 

406 group=group, 

407 stage_idx=stage_idx, 

408 chunks=chunks, 

409 num_stages=num_stages, 

410 stage_indices=stage_indices, 

411 ) 

412 

413 def _classify_stage( 

414 self, graph_module: fx.GraphModule, run_ctx: _RunContext 

415 ) -> _StageSplit: 

416 """Resolve the stage plan and classify every node's stage + phase. 

417 

418 Chains the state-list contract checks, the stage-plan resolution, 

419 node classification, the gradient-output anchoring with its stage 

420 propagation, and the adjacent-dataflow validation. 

421 

422 Args: 

423 graph_module: Joint fwd+bwd FX graph (post-``FSDPPass``). 

424 run_ctx: This rank's PP execution context. 

425 

426 Returns: 

427 The ``_StageSplit`` bundling the plan and per-node 

428 classification for the construction phase. 

429 """ 

430 state_fqns = self._require_state_list(graph_module) 

431 state_is_param = self._require_is_param(graph_module, state_fqns) 

432 num_state_inputs = getattr(graph_module, "num_state_inputs", len(state_fqns)) 

433 

434 stage_plan = self._resolve_stage_plan(run_ctx.model, run_ctx.num_stages) 

435 stage_of_fqn = {fqn: s for s, fqns in enumerate(stage_plan) for fqn in fqns} 

436 

437 graph_cls = self._classify_graph( 

438 graph_module, 

439 run_ctx, 

440 stage_of_fqn, 

441 state_fqns, 

442 num_state_inputs, 

443 ) 

444 

445 self._anchor_grad_outputs( 

446 graph_module, 

447 graph_cls.node_stage, 

448 stage_of_fqn, 

449 state_fqns, 

450 run_ctx.model, 

451 ) 

452 self._propagate_anchor_stages( 

453 graph_module, graph_cls.node_stage, graph_cls.node_phase 

454 ) 

455 self._validate_adjacent_dataflow(graph_module, graph_cls.node_stage) 

456 

457 return _StageSplit( 

458 state_fqns=state_fqns, 

459 state_is_param=state_is_param, 

460 stage_plan=stage_plan, 

461 node_stage=graph_cls.node_stage, 

462 node_phase=graph_cls.node_phase, 

463 user_input_phs=graph_cls.user_input_phs, 

464 user_input_stages=graph_cls.user_input_stages, 

465 user_input_stage_sets=graph_cls.user_input_stage_sets, 

466 state_phs=graph_cls.state_phs, 

467 stage_state_indices=graph_cls.stage_state_indices, 

468 stage_state_fqns=[state_fqns[i] for i in graph_cls.stage_state_indices], 

469 ) 

470 

471 def _build_and_install_stage( 

472 self, 

473 graph_module: fx.GraphModule, 

474 pass_config: PassConfig, 

475 run_ctx: _RunContext, 

476 split: _StageSplit, 

477 ) -> None: 

478 """Build the owned virtual-stage subgraphs, prune, and install the stub. 

479 

480 Trainable-state resolution must precede ``_prune_live_model``: the 

481 gradient-output nodes map positionally onto the FULL trainable list, 

482 so pruning foreign-stage parameters first would shift the positions. 

483 

484 Args: 

485 graph_module: Joint fwd+bwd FX graph, rewritten in place. 

486 pass_config: Parallel configuration (microbatch size read). 

487 run_ctx: This rank's PP execution context. 

488 split: Stage plan and per-node classification. 

489 """ 

490 trainable_indices = self._trainable_state_indices( 

491 split.state_fqns, split.state_is_param, run_ctx.model 

492 ) 

493 state_positions = { 

494 idx: pos for pos, idx in enumerate(split.stage_state_indices) 

495 } 

496 

497 stage_graphs: List[_StageGraphs] = [] 

498 for stage_idx in run_ctx.stage_indices: 

499 boundaries = self._find_boundaries( 

500 graph_module, 

501 split.node_stage, 

502 split.node_phase, 

503 run_ctx.num_stages, 

504 stage_idx, 

505 split.user_input_phs, 

506 split.user_input_stage_sets, 

507 ) 

508 stage_state_indices = [ 

509 idx 

510 for idx in split.stage_state_indices 

511 if split.node_stage[split.state_phs[idx]] == stage_idx 

512 ] 

513 stage_state_set = set(stage_state_indices) 

514 stage_trainable = [i for i in trainable_indices if i in stage_state_set] 

515 fwd_gm, bwd_gm = self._build_stage_graphs( 

516 graph_module, 

517 split, 

518 boundaries, 

519 stage_idx, 

520 run_ctx.num_stages, 

521 stage_state_indices, 

522 trainable_indices, 

523 ) 

524 stage_graphs.append( 

525 _StageGraphs( 

526 stage_idx=stage_idx, 

527 fwd_gm=fwd_gm, 

528 bwd_gm=bwd_gm, 

529 boundaries=boundaries, 

530 state_indices=stage_state_indices, 

531 num_trainable=len(stage_trainable), 

532 ) 

533 ) 

534 

535 keep_fqns: List[str] = [] 

536 for stage_idx in run_ctx.stage_indices: 

537 keep_fqns.extend(split.stage_plan[stage_idx]) 

538 self._prune_live_model(run_ctx.model, keep_fqns) 

539 

540 sched = self._build_schedule( 

541 stage_graphs, 

542 pass_config, 

543 run_ctx, 

544 split, 

545 trainable_indices, 

546 state_positions, 

547 ) 

548 self._install_stub( 

549 graph_module, 

550 sched, 

551 split.stage_state_fqns, 

552 split.state_is_param, 

553 split.stage_state_indices, 

554 len(split.user_input_phs), 

555 ) 

556 

557 _LOG.info( 

558 "Completed: rank %s owns virtual stages %s, %d state entries, " 

559 "%d trainable params", 

560 run_ctx.stage_idx, 

561 run_ctx.stage_indices, 

562 len(split.stage_state_fqns), 

563 sum(stage.num_trainable for stage in stage_graphs), 

564 ) 

565 

566 def _build_stage_graphs( 

567 self, 

568 graph_module: fx.GraphModule, 

569 split: _StageSplit, 

570 boundaries: _Boundaries, 

571 stage_idx: int, 

572 num_stages: int, 

573 stage_state_indices: List[int], 

574 trainable_indices: List[int], 

575 ) -> Tuple[fx.GraphModule, fx.GraphModule]: 

576 """Build one virtual stage's forward and backward subgraphs.""" 

577 stage_state_fqns = [split.state_fqns[i] for i in stage_state_indices] 

578 fwd_gm, fwd_out_orig = self._build_fwd_graph( 

579 graph_module, 

580 split, 

581 boundaries, 

582 stage_idx, 

583 num_stages, 

584 stage_state_indices, 

585 stage_state_fqns, 

586 ) 

587 bwd_gm = self._build_bwd_graph( 

588 graph_module, 

589 split, 

590 boundaries, 

591 stage_idx, 

592 num_stages, 

593 stage_state_indices, 

594 stage_state_fqns, 

595 fwd_out_orig, 

596 trainable_indices, 

597 ) 

598 return fwd_gm, bwd_gm 

599 

600 # ------------------------------------------------------------------ 

601 # Tracer contract helpers 

602 # ------------------------------------------------------------------ 

603 

604 @staticmethod 

605 def _require_state_list(graph_module: fx.GraphModule) -> List[str]: 

606 """Return the graph's live ``state_fqns`` list (shared with JointGraph). 

607 

608 The tracer attaches the SAME list object to the GraphModule and the 

609 ``JointGraph``; mutating it in place (done in ``_install_stub``) 

610 keeps ``run_traced_graph``'s validation consistent with the pruned 

611 model. Copying here would desynchronize the two. 

612 """ 

613 state_fqns = getattr(graph_module, "state_fqns", None) 

614 if state_fqns is None: 

615 raise ValueError( 

616 "PpPass requires the tracer's state_fqns attribute on the " 

617 "joint graph (attach it via trace_model_graph)" 

618 ) 

619 return state_fqns 

620 

621 @staticmethod 

622 def _require_is_param( 

623 graph_module: fx.GraphModule, state_fqns: List[str] 

624 ) -> List[bool]: 

625 """Return the graph's live ``state_is_param`` list, defaulting to 

626 all-params (matching the tracer's fallback for older traces).""" 

627 state_is_param = getattr(graph_module, "state_is_param", None) 

628 if state_is_param is None: 

629 state_is_param = [True] * len(state_fqns) 

630 graph_module.state_is_param = state_is_param 

631 return state_is_param 

632 

633 @staticmethod 

634 def _loss_node(graph_module: fx.GraphModule) -> Optional[fx.Node]: 

635 """The original joint graph's loss node (output index 0).""" 

636 output = next((n for n in graph_module.graph.nodes if n.op == "output"), None) 

637 if output is None: 

638 return None 

639 returned = output.args[0] 

640 if isinstance(returned, (list, tuple)) and returned: 

641 first = returned[0] 

642 if isinstance(first, fx.Node): 

643 return first 

644 return None 

645 

646 # ------------------------------------------------------------------ 

647 # Group / plan resolution 

648 # ------------------------------------------------------------------ 

649 

650 def _resolve_group_and_stage( 

651 self, pass_config: PassConfig, kwargs: Dict[str, Any] 

652 ) -> Tuple[int, Any, int]: 

653 """Resolve PP degree, process group, and this rank's stage index. 

654 

655 Two layouts are supported: 

656 

657 - **PP+DP hybrid** (``pp_group_name`` kwarg, registered by 

658 ``GraphCompiler._init_pp_dp_mesh``): the group spans one rank per 

659 stage at this rank's DP position, so the stage index is simply this 

660 rank's position within the group and every DP replica of a stage 

661 pipelines with the same DP replica of its neighbours. Requires 

662 ``pp_degree * dp_degree == world_size`` (validated here). 

663 - **Pure PP** (no ``pp_group_name``): the group is the whole world and 

664 each rank owns one or more virtual stages. ``fsdp_enabled`` is 

665 rejected here — a 1-D fallback FSDP mesh spans the whole world and 

666 would shard parameters across stage boundaries; the hybrid must go 

667 through the 2-D mesh path. 

668 

669 A single PP process group carries both forward activations and 

670 backward gradients; the schedule keeps the two ends of every 

671 neighbour pair's FIFO complementary (see ``Schedule1F1B`` and 

672 ``ScheduleInterleaved1F1B``). 

673 """ 

674 pp_group_name = kwargs.get("pp_group_name") 

675 if pp_group_name is not None: 

676 return self._resolve_from_pp_group(pass_config, pp_group_name) 

677 

678 world_size = dist.get_world_size() 

679 rank = dist.get_rank() 

680 pp_degree = pass_config.pp_degree or world_size 

681 

682 if pp_degree not in (1, world_size): 

683 raise ValueError( 

684 f"Without a pp process group only pure-PP is supported: " 

685 f"pp_degree={pp_degree} must equal world_size={world_size} " 

686 f"(or be 1 to disable PP). For the PP+DP hybrid pass " 

687 f"pp_group_name (registered by GraphCompiler's 2-D pp mesh)." 

688 ) 

689 if pass_config.fsdp_enabled: 

690 raise ValueError( 

691 "PP+FSDP hybrid requires a 2-D (pp, DP) mesh expressing both " 

692 "dimensions: let GraphCompiler build it (fsdp_enabled=True + " 

693 "pp_enabled=True, which registers the cross-stage 'pp' group " 

694 "and passes pp_group_name to this pass). The 1-D fallback " 

695 "FSDP mesh spans the whole world and cannot host PP — FSDP " 

696 "would shard parameters across stage boundaries. Run pure-PP " 

697 "(fsdp_enabled=False, pp_degree == world_size) or the mesh " 

698 "path." 

699 ) 

700 

701 group = dist.new_group(list(range(world_size))) 

702 # Pure-PP (pp_degree == world_size, checked above), so each rank's 

703 # physical stage index is its rank. Keep the general formula so a 

704 # future DP-replication layout (pp_degree < world_size) maps 

705 # rank->stage contiguously rather than silently assuming 1:1. 

706 stage_idx = rank * pp_degree // world_size 

707 return pp_degree, group, stage_idx 

708 

709 def _resolve_from_pp_group( 

710 self, pass_config: PassConfig, group_name: str 

711 ) -> Tuple[int, Any, int]: 

712 """Resolve stage/pp-degree from the registered ``"pp"`` group. 

713 

714 Validates the hybrid topology: the configured ``pp_degree`` (when 

715 set) must match the group size, and with the DP axis enabled 

716 ``pp_degree * dp_degree`` must tile the world. HSDP needs a 3-D mesh 

717 and is rejected outright. 

718 """ 

719 group = _resolve_process_group(group_name) 

720 pp_degree = dist.get_world_size(group) 

721 stage_idx = dist.get_rank(group) 

722 

723 configured = pass_config.pp_degree 

724 if configured is not None and configured != pp_degree: 

725 raise ValueError( 

726 f"pass_config.pp_degree={configured} does not match the " 

727 f"registered pp group size ({pp_degree})" 

728 ) 

729 

730 if pass_config.fsdp_enabled: 

731 dp_mode = normalize_dp_mode(pass_config.dp_mode) 

732 if dp_mode == "hsdp": 

733 raise ValueError( 

734 "PP + HSDP requires a 3-D (pp, dp_replicate, fsdp_shard) " 

735 "mesh, which is not supported yet; run PP with " 

736 "dp_mode='fsdp' or 'ddp'" 

737 ) 

738 dp_degree = ( 

739 pass_config.fsdp_degree 

740 if dp_mode == "fsdp" 

741 else pass_config.dp_replicate_degree 

742 ) 

743 world_size = dist.get_world_size() 

744 if dp_degree is None: 

745 if world_size % pp_degree != 0: 

746 raise ValueError( 

747 f"world_size ({world_size}) not divisible by the pp " 

748 f"group size ({pp_degree}); cannot derive the DP " 

749 f"degree" 

750 ) 

751 dp_degree = world_size // pp_degree 

752 if pp_degree * dp_degree != world_size: 

753 raise ValueError( 

754 f"PP+DP degrees must tile the world: pp_degree " 

755 f"({pp_degree}) x dp_degree ({dp_degree}) != world_size " 

756 f"({world_size})" 

757 ) 

758 

759 _LOG.info( 

760 "Resolved stage %s/%s from pp group '%s'", stage_idx, pp_degree, group_name 

761 ) 

762 return pp_degree, group, stage_idx 

763 

764 def _resolve_stage_plan(self, model: nn.Module, num_stages: int) -> List[List[str]]: 

765 """Resolve the per-virtual-stage module FQN lists (manual or auto).""" 

766 manual = ( 

767 self._parallel_plan.pp_module_fqns_per_stage 

768 if self._parallel_plan is not None 

769 else None 

770 ) 

771 if manual is not None: 

772 if len(manual) != num_stages: 

773 raise ValueError( 

774 f"Manual PP plan declares {len(manual)} stages but the " 

775 f"pipeline has {num_stages} virtual stages " 

776 f"(pp_degree * pp_chunks)" 

777 ) 

778 seen: Set[str] = set() 

779 for s, fqns in enumerate(manual): 

780 for fqn in fqns: 

781 if fqn in seen: 

782 raise ValueError( 

783 f"Module '{fqn}' is assigned to multiple stages " 

784 f"(stage {s} repeats it)" 

785 ) 

786 seen.add(fqn) 

787 stage_plan = [list(fqns) for fqns in manual] 

788 else: 

789 stage_plan = _auto_stage_split(model, num_stages) 

790 

791 known = dict(model.named_modules()) 

792 for s, fqns in enumerate(stage_plan): 

793 for fqn in fqns: 

794 if fqn not in known: 

795 raise ValueError(f"Stage {s} declares unknown module '{fqn}'") 

796 self._warn_unassigned_modules(model, stage_plan) 

797 return stage_plan 

798 

799 @staticmethod 

800 def _warn_unassigned_modules(model: nn.Module, stage_plan: List[List[str]]) -> None: 

801 """Warn about live modules no stage declares. 

802 

803 A module that is neither declared itself, nor lives inside a 

804 declared one, nor is an ancestor of a declared one (a container 

805 whose elements are declared) has its graph nodes attributed by 

806 the dataflow fallbacks (consumer stage, then last stage) — rarely 

807 what a manual plan intends. Auto splits declare every top-level 

808 child, so they never warn. 

809 """ 

810 declared = [fqn for fqns in stage_plan for fqn in fqns] 

811 unassigned: List[str] = [] 

812 for fqn, _ in model.named_modules(): 

813 if not fqn: 

814 # Root: parent-level glue is attributed by dataflow. 

815 continue 

816 covered = any( 

817 fqn == d or fqn.startswith(d + ".") or d.startswith(fqn + ".") 

818 for d in declared 

819 ) 

820 if not covered: 

821 unassigned.append(fqn) 

822 if not unassigned: 

823 return 

824 shown = unassigned[:10] 

825 if len(unassigned) > len(shown): 

826 shown.append(f"... {len(unassigned) - len(shown)} more") 

827 _LOG.warning( 

828 "No PP stage declares modules %s — their nodes ride with the " 

829 "dataflow attribution (consumer stage, then last stage). " 

830 "Declare them via GraphParallelPlan.pp_stage() if that is not intended.", 

831 shown, 

832 ) 

833 

834 # ------------------------------------------------------------------ 

835 # Node classification (stage + phase, topo order) 

836 # ------------------------------------------------------------------ 

837 

838 def _classify_graph( # pylint: disable=too-many-locals 

839 self, 

840 graph_module: fx.GraphModule, 

841 run_ctx: _RunContext, 

842 stage_of_fqn: Dict[str, int], 

843 state_fqns: Sequence[str], 

844 num_state_inputs: int, 

845 ) -> _GraphClassification: 

846 """Resolve every node's stage and fwd/bwd phase in topological order. 

847 

848 Phase: explicit ``autograd_backward`` tag, else inherited from args 

849 (post-trace inserts such as FSDP's reduce_scatter/wait inherit the 

850 bwd phase through their grad arguments). 

851 

852 Stage resolution, in priority order: 

853 

854 1. ``nn_module_stack`` walk (traced nodes, innermost module first) 

855 2. argument inheritance (post-trace inserts — state placeholders 

856 win, since backward compute nodes reference their params) 

857 3. consumer attribution for stack-less root-level glue (residual 

858 adds written in the parent's forward): the stage of their 

859 consumers, minimum when several — resolved in reverse topo 

860 4. last stage as the final fallback (loss chain, dead nodes) 

861 

862 User-input placeholders are NOT anchors: they are routed AFTER the 

863 fwd/bwd classification by dataflow (``_route_user_inputs``) — each 

864 rides with the single stage that consumes it, so kwargs-general 

865 inputs (multiple features on stage 0, several labels on the last 

866 stage, an auxiliary input on a middle stage) all work without 

867 positional heuristics. 

868 

869 ``stage_state_indices`` lists the state entries owned by the virtual 

870 stages THIS rank owns (``run_ctx.stage_indices``); every state 

871 placeholder still gets a stage so foreign consumers can be 

872 attributed. 

873 

874 Root-level state (no stage-owning module ancestor) is assigned to 

875 the stage of its (single) consumer; multi-stage consumption is the 

876 RoPE-cache-shaped error case and fails with an actionable message. 

877 """ 

878 graph = graph_module.graph 

879 placeholders = [n for n in graph.nodes if n.op == "placeholder"] 

880 state_ph_set = set(placeholders[:num_state_inputs]) 

881 num_stages = run_ctx.num_stages 

882 

883 node_stage: Dict[fx.Node, int] = {} 

884 node_phase: Dict[fx.Node, str] = {} 

885 

886 ( 

887 state_phs, 

888 deferred_state, 

889 stage_state_indices, 

890 user_input_phs, 

891 ) = self._classify_placeholders( 

892 placeholders, 

893 node_stage, 

894 node_phase, 

895 state_fqns, 

896 stage_of_fqn, 

897 num_state_inputs, 

898 run_ctx, 

899 ) 

900 self._classify_forward_nodes( 

901 graph, state_ph_set, stage_of_fqn, num_stages, node_stage, node_phase 

902 ) 

903 self._classify_backward_nodes( 

904 graph, state_ph_set, num_stages, node_stage, node_phase 

905 ) 

906 user_input_stages, user_input_stage_sets = self._route_user_inputs( 

907 user_input_phs, node_stage, num_stages 

908 ) 

909 self._assign_deferred_state( 

910 deferred_state, 

911 state_phs, 

912 state_fqns, 

913 node_stage, 

914 run_ctx.stage_indices, 

915 stage_state_indices, 

916 ) 

917 

918 return _GraphClassification( 

919 stage_state_indices=sorted(stage_state_indices), 

920 node_stage=node_stage, 

921 node_phase=node_phase, 

922 user_input_phs=user_input_phs, 

923 user_input_stages=user_input_stages, 

924 user_input_stage_sets=user_input_stage_sets, 

925 state_phs=state_phs, 

926 ) 

927 

928 def _classify_placeholders( # pylint: disable=too-many-locals 

929 self, 

930 placeholders: List[fx.Node], 

931 node_stage: Dict[fx.Node, int], 

932 node_phase: Dict[fx.Node, str], 

933 state_fqns: Sequence[str], 

934 stage_of_fqn: Dict[str, int], 

935 num_state_inputs: int, 

936 run_ctx: _RunContext, 

937 ) -> Tuple[List[fx.Node], List[int], List[int], List[fx.Node]]: 

938 """Classify placeholder nodes and collect this stage's state entries. 

939 

940 Only STATE placeholders get a stage here (via their FQN's module 

941 ancestor); user-input placeholders are routed later by dataflow 

942 (``_route_user_inputs``), once their consumers' stages are known. 

943 

944 Args: 

945 placeholders: All placeholder nodes in graph order. 

946 node_stage: Stage assignment accumulator to fill. 

947 node_phase: Phase assignment accumulator to fill. 

948 state_fqns: FQNs of the traced state entries (params/buffers). 

949 stage_of_fqn: Module FQN -> owning stage map. 

950 num_state_inputs: Number of leading state placeholders. 

951 run_ctx: This rank's PP execution context. 

952 

953 Returns: 

954 ``(state_phs, deferred_state, stage_state_indices, 

955 user_input_phs)``. ``deferred_state`` lists root-level state 

956 indices whose stage is resolved later from their consumers. 

957 """ 

958 state_phs: List[fx.Node] = [] 

959 deferred_state: List[int] = [] 

960 stage_state_indices: List[int] = [] 

961 for idx, ph in enumerate(placeholders): 

962 node_phase[ph] = _FWD 

963 if idx < num_state_inputs: 

964 stage = self._state_stage(state_fqns[idx], stage_of_fqn) 

965 state_phs.append(ph) 

966 if stage is None: 

967 deferred_state.append(idx) 

968 else: 

969 node_stage[ph] = stage 

970 if stage in run_ctx.stage_indices: 

971 stage_state_indices.append(idx) 

972 user_input_phs = list(placeholders[num_state_inputs:]) 

973 return state_phs, deferred_state, stage_state_indices, user_input_phs 

974 

975 @staticmethod 

976 def _route_user_inputs( 

977 user_input_phs: List[fx.Node], 

978 node_stage: Dict[fx.Node, int], 

979 num_stages: int, 

980 ) -> Tuple[List[int], List[Tuple[int, ...]]]: 

981 """Assign each user-input placeholder to the stages consuming it. 

982 

983 Runs after the fwd/bwd classification, so every consumer node 

984 already carries its final stage (user inputs are deliberately not 

985 anchors — see ``_classify_graph``). Every rank receives the full 

986 flattened input list, so an input consumed on several stages (an 

987 embedding's token ids reused by its backward, a mask/position ids 

988 shared by many layers) is replicated directly to each consumer with 

989 no P2P. An input consumed by NO node (an ignored kwarg — the 

990 hand-built test fixtures' ``y``, an ``epoch`` scalar train_fn never 

991 reads) is warned about and routed to the last stage, where unused 

992 inputs have always ridden. 

993 

994 Args: 

995 user_input_phs: User-input placeholders in stub arg order. 

996 node_stage: Final per-node stage assignment (fwd+bwd done). 

997 num_stages: Total PP stage count. 

998 

999 Returns: 

1000 ``(primary_stages, stage_sets)`` in stub arg order. 

1001 ``primary_stages[i]`` is the first consumer stage (kept for 

1002 diagnostics and backward compatibility); ``stage_sets[i]`` is 

1003 every stage that consumes the input. 

1004 """ 

1005 primary: List[int] = [] 

1006 stage_sets: List[Tuple[int, ...]] = [] 

1007 for ph in user_input_phs: 

1008 consumer_stages = { 

1009 node_stage[u] 

1010 for u in ph.users 

1011 if u.op != "output" and node_stage.get(u) is not None 

1012 } 

1013 if not consumer_stages: 

1014 _LOG.warning( 

1015 "Model input '%s' is not consumed by the traced graph — " 

1016 "routing it to the last stage with the other unused " 

1017 "inputs (a misspelled keyword argument would leave its " 

1018 "tensor unused too)", 

1019 ph.name, 

1020 ) 

1021 consumers = (num_stages - 1,) 

1022 else: 

1023 consumers = tuple(sorted(consumer_stages)) 

1024 node_stage[ph] = consumers[0] 

1025 primary.append(consumers[0]) 

1026 stage_sets.append(consumers) 

1027 _LOG.info( 

1028 "User input '%s' routed to stages %s of %s", 

1029 ph.name, 

1030 consumers, 

1031 num_stages, 

1032 ) 

1033 return primary, stage_sets 

1034 

1035 def _classify_forward_nodes( 

1036 self, 

1037 graph: fx.Graph, 

1038 state_ph_set: Set[fx.Node], 

1039 stage_of_fqn: Dict[str, int], 

1040 num_stages: int, 

1041 node_stage: Dict[fx.Node, int], 

1042 node_phase: Dict[fx.Node, str], 

1043 ) -> None: 

1044 """Assign stages to forward nodes in topological order. 

1045 

1046 Anchors: module stack, then state-ph args, then max-args over 

1047 TENSOR args (data flows forward; shape-metadata scalars carry no 

1048 data dependency). Leftover root-level glue rides with its 

1049 consumers (reverse topo), else the last stage. 

1050 """ 

1051 floating: List[fx.Node] = [] 

1052 for node in graph.nodes: 

1053 if node.op in ("placeholder", "output"): 

1054 continue 

1055 phase = _BWD if node.meta.get("autograd_backward", False) else _FWD 

1056 if phase == _FWD: 

1057 for arg in _iter_node_args(node): 

1058 if node_phase.get(arg) == _BWD: 

1059 phase = _BWD 

1060 break 

1061 node_phase[node] = phase 

1062 if phase == _FWD: 

1063 stage = self._resolve_fwd_stage( 

1064 node, node_stage, state_ph_set, stage_of_fqn 

1065 ) 

1066 if stage is None: 

1067 floating.append(node) 

1068 else: 

1069 node_stage[node] = stage 

1070 

1071 self._assign_floating_fwd_nodes( 

1072 graph, floating, num_stages, node_stage, node_phase 

1073 ) 

1074 

1075 def _resolve_fwd_stage( 

1076 self, 

1077 node: fx.Node, 

1078 node_stage: Dict[fx.Node, int], 

1079 state_ph_set: Set[fx.Node], 

1080 stage_of_fqn: Dict[str, int], 

1081 ) -> Optional[int]: 

1082 """Resolve a forward node's stage anchor. 

1083 

1084 Priority: module stack, then state-ph args, then max-args over 

1085 TENSOR args (data flows forward; shape-metadata scalars carry no 

1086 data dependency). ``None`` means root-level glue to be resolved 

1087 from its consumers (see ``_assign_floating_fwd_nodes``). 

1088 """ 

1089 stage = self._stage_from_stack(node, stage_of_fqn) 

1090 if stage is None: 

1091 stage = self._stage_from_state_arg(node, node_stage, state_ph_set) 

1092 if stage is None: 

1093 arg_stages = _tensor_arg_stages(node, node_stage) 

1094 if arg_stages: 

1095 stage = max(arg_stages) 

1096 return stage 

1097 

1098 @staticmethod 

1099 def _assign_floating_fwd_nodes( 

1100 graph: fx.Graph, 

1101 floating: List[fx.Node], 

1102 num_stages: int, 

1103 node_stage: Dict[fx.Node, int], 

1104 node_phase: Dict[fx.Node, str], 

1105 ) -> None: 

1106 """Leftover fwd glue: consumers first (reverse topo), else last.""" 

1107 for node in reversed(list(graph.nodes)): 

1108 if node not in floating or node_phase[node] != _FWD: 

1109 continue 

1110 consumer_stages = { 

1111 node_stage[u] 

1112 for u in node.users 

1113 if u.op != "output" and node_stage.get(u) is not None 

1114 } 

1115 node_stage[node] = ( 

1116 min(consumer_stages) if consumer_stages else num_stages - 1 

1117 ) 

1118 

1119 def _classify_backward_nodes( 

1120 self, 

1121 graph: fx.Graph, 

1122 state_ph_set: Set[fx.Node], 

1123 num_stages: int, 

1124 node_stage: Dict[fx.Node, int], 

1125 node_phase: Dict[fx.Node, str], 

1126 ) -> None: 

1127 """Assign stages to backward nodes in topological order. 

1128 

1129 A bwd node runs where its earliest TENSOR input lives: gradients 

1130 born downstream are shipped down during the backward sweep, and 

1131 upstream activations arrive as replayed forward outputs. Weight- 

1132 gradient nodes that consume early activations are corrected by 

1133 the positional anchor (the param's stage owns its grad). 

1134 """ 

1135 for node in graph.nodes: 

1136 if node.op in ("placeholder", "output"): 

1137 continue 

1138 if node_phase[node] != _BWD: 

1139 continue 

1140 stage = self._stage_from_state_arg(node, node_stage, state_ph_set) 

1141 if stage is None: 

1142 arg_stages = _tensor_arg_stages(node, node_stage) 

1143 if arg_stages: 

1144 stage = min(arg_stages) 

1145 if stage is None: 

1146 # Dead-end bwd glue: ride with consumers if any, else last. 

1147 consumer_stages = { 

1148 node_stage[u] 

1149 for u in node.users 

1150 if u.op != "output" and node_stage.get(u) is not None 

1151 } 

1152 stage = min(consumer_stages) if consumer_stages else num_stages - 1 

1153 node_stage[node] = stage 

1154 

1155 def _assign_deferred_state( 

1156 self, 

1157 deferred_state: List[int], 

1158 state_phs: List[fx.Node], 

1159 state_fqns: Sequence[str], 

1160 node_stage: Dict[fx.Node, int], 

1161 owned_stages: Sequence[int], 

1162 stage_state_indices: List[int], 

1163 ) -> None: 

1164 """Assign root-level state to the stage of its (single) consumer. 

1165 

1166 Multi-stage consumption is the RoPE-cache-shaped error case and 

1167 fails with an actionable message. 

1168 """ 

1169 owned = set(owned_stages) 

1170 for idx in deferred_state: 

1171 ph = state_phs[idx] 

1172 consumer_stages = {node_stage[u] for u in ph.users if u.op != "output"} 

1173 if len(consumer_stages) > 1: 

1174 raise ValueError( 

1175 f"Root-level state '{state_fqns[idx]}' is consumed by " 

1176 f"stages {sorted(consumer_stages)} — v1 requires state " 

1177 f"to live under a single stage's module (e.g. move a " 

1178 f"shared RoPE cache under a per-stage module or split " 

1179 f"it per stage)" 

1180 ) 

1181 stage = next(iter(consumer_stages), 0) 

1182 node_stage[ph] = stage 

1183 if stage in owned: 

1184 stage_state_indices.append(idx) 

1185 _LOG.info( 

1186 "Root-level state '%s' assigned to stage %s by its consumer", 

1187 state_fqns[idx], 

1188 stage, 

1189 ) 

1190 

1191 def _state_stage(self, fqn: str, stage_of_fqn: Dict[str, int]) -> Optional[int]: 

1192 """Stage of a state entry via longest-ancestor walk over its FQN.""" 

1193 parts = fqn.split(".") 

1194 for i in range(len(parts) - 1, 0, -1): 

1195 stage = stage_of_fqn.get(".".join(parts[:i])) 

1196 if stage is not None: 

1197 return stage 

1198 return stage_of_fqn.get(fqn) 

1199 

1200 def _stage_from_stack( 

1201 self, node: fx.Node, stage_of_fqn: Dict[str, int] 

1202 ) -> Optional[int]: 

1203 """Stage from ``nn_module_stack``, innermost module first.""" 

1204 for fqn in _innermost_fqns(node): 

1205 stage = stage_of_fqn.get(fqn) 

1206 if stage is not None: 

1207 return stage 

1208 return None 

1209 

1210 def _stage_from_state_arg( 

1211 self, 

1212 node: fx.Node, 

1213 node_stage: Dict[fx.Node, int], 

1214 state_ph_set: Set[fx.Node], 

1215 ) -> Optional[int]: 

1216 """Stage from a state-placeholder argument, if any. 

1217 

1218 Post-trace inserts (FSDP collectives, weight-grad compute) reference 

1219 their owning stage's parameters — a state placeholder arg pins the 

1220 node to that stage. Generic argument inheritance is deliberately 

1221 NOT done here: it would let an early-stage argument pull a node 

1222 against the dataflow (see the max/min-args rules in 

1223 ``_classify_graph``). 

1224 """ 

1225 for arg in _iter_node_args(node): 

1226 if arg in state_ph_set: 

1227 return node_stage.get(arg) 

1228 return None 

1229 

1230 @staticmethod 

1231 def _propagate_anchor_stages( 

1232 graph_module: fx.GraphModule, 

1233 node_stage: Dict[fx.Node, int], 

1234 node_phase: Dict[fx.Node, str], 

1235 ) -> None: 

1236 """Pull bwd args up to anchored consumers (one reverse-topo pass). 

1237 

1238 A bwd value consumed by a DOWNSTREAM-stage bwd node must be 

1239 recomputed there: gradients flow downstream -> upstream, so a 

1240 downstream consumer cannot receive an upstream-born bwd value in 

1241 time (its own bwd sweep runs first). Fwd-phase args are untouched — 

1242 they arrive as replayed forward outputs; downstream-born args are 

1243 untouched — they arrive via the grad crossings. 

1244 

1245 A single REVERSE topological sweep reaches the fixpoint: a node is 

1246 visited only after every user of it has been visited and pulled it 

1247 to its final stage, so the node propagates its final value to its 

1248 own args. The previous iterate-to-fixpoint loop was quadratic in 

1249 the worst case on large joint graphs. 

1250 """ 

1251 for node in reversed(list(graph_module.graph.nodes)): 

1252 if node.op in ("placeholder", "output"): 

1253 continue 

1254 if node_phase.get(node) != _BWD: 

1255 continue 

1256 stage = node_stage.get(node) 

1257 if stage is None: 

1258 continue 

1259 for arg in _iter_node_args(node): 

1260 if node_phase.get(arg) != _BWD or node_stage.get(arg) is None: 

1261 continue 

1262 if node_stage[arg] < stage: 

1263 node_stage[arg] = stage 

1264 

1265 # ------------------------------------------------------------------ 

1266 # Boundary detection 

1267 # ------------------------------------------------------------------ 

1268 

1269 @staticmethod 

1270 def _validate_adjacent_dataflow( 

1271 graph_module: fx.GraphModule, 

1272 node_stage: Dict[fx.Node, int], 

1273 ) -> None: 

1274 """Reject skip-stage dataflow before it reaches the slice copy. 

1275 

1276 v1 ships boundary values between NEIGHBOURING stages only: a value 

1277 produced on stage ``j`` and consumed on stage ``k`` with 

1278 ``abs(j - k) > 1`` has no route (it would have to transit 

1279 intermediate stages untouched). Without this check such graphs die 

1280 later inside ``_copy_node`` / the fwd-output assembly with a 

1281 generic "outside this stage's slice" error that hides the root 

1282 cause. 

1283 

1284 Raises: 

1285 ValueError: When any producer-consumer pair spans more than 

1286 one stage cut (e.g. a dense skip connection across two 

1287 stages, or a stage plan that leaves a module's nodes to 

1288 the dataflow fallbacks). 

1289 """ 

1290 for node in graph_module.graph.nodes: 

1291 if node.op == "output": 

1292 continue 

1293 producer = node_stage.get(node) 

1294 if producer is None: 

1295 continue 

1296 for user in node.users: 

1297 if user.op == "output": 

1298 continue 

1299 consumer = node_stage.get(user) 

1300 if consumer is None or abs(consumer - producer) <= 1: 

1301 continue 

1302 raise ValueError( 

1303 f"Value '{node.name}' is produced on stage {producer} " 

1304 f"but consumed on stage {consumer}: v1 ships boundary " 

1305 f"values between neighbouring stages only. A skip " 

1306 f"connection spanning 2+ stages or a stage plan that " 

1307 f"leaves modules unattributed produces this — adjust " 

1308 f"GraphParallelPlan.pp_stage() so the dataflow crosses one cut " 

1309 f"at a time." 

1310 ) 

1311 

1312 def _anchor_grad_outputs( 

1313 self, 

1314 graph_module: fx.GraphModule, 

1315 node_stage: Dict[fx.Node, int], 

1316 stage_of_fqn: Dict[str, int], 

1317 state_fqns: Sequence[str], 

1318 model: Optional[nn.Module], 

1319 ) -> None: 

1320 """Pin each gradient-output node to its parameter's stage. 

1321 

1322 Gradient-output node i differentiates the i-th trainable parameter 

1323 (positional mapping, mirroring the tracer); it runs on the stage 

1324 owning that parameter even when min-args would place it upstream 

1325 (a later stage's weight-grad consumes an earlier stage's 

1326 activation, replayed from the forward outputs). 

1327 """ 

1328 output = next((n for n in graph_module.graph.nodes if n.op == "output"), None) 

1329 if output is None or model is None: 

1330 return 

1331 returned = output.args[0] 

1332 if not isinstance(returned, (list, tuple)): 

1333 return 

1334 state_is_param = getattr( 

1335 graph_module, "state_is_param", [True] * len(state_fqns) 

1336 ) 

1337 trainable = self._trainable_state_indices(state_fqns, state_is_param, model) 

1338 for i, grad_node in enumerate(returned[1:]): 

1339 if i >= len(trainable) or not isinstance(grad_node, fx.Node): 

1340 continue 

1341 pstage = self._state_stage(state_fqns[trainable[i]], stage_of_fqn) 

1342 if pstage is not None: 

1343 node_stage[grad_node] = pstage 

1344 

1345 def _find_boundaries( # pylint: disable=too-many-locals 

1346 self, 

1347 graph_module: fx.GraphModule, 

1348 node_stage: Dict[fx.Node, int], 

1349 node_phase: Dict[fx.Node, str], 

1350 num_stages: int, 

1351 stage_idx: int, 

1352 user_input_phs: Sequence[fx.Node], 

1353 user_input_stage_sets: Sequence[Sequence[int]], 

1354 ) -> _Boundaries: 

1355 """Locate the value lists crossing this virtual stage's cuts. 

1356 

1357 Returns: 

1358 The ``_Boundaries`` lists, all in joint-graph topological order 

1359 so the producer and consumer of a cut agree on P2P ordering. 

1360 See ``_Boundaries`` for the per-field semantics. 

1361 """ 

1362 k = stage_idx 

1363 is_last = k == num_stages - 1 

1364 nodes = [ 

1365 n for n in graph_module.graph.nodes if n.op not in ("placeholder", "output") 

1366 ] 

1367 

1368 def crossing( 

1369 producer: int, consumer: int, phase: Optional[str] 

1370 ) -> List[fx.Node]: 

1371 """Values produced by one stage and consumed by another. 

1372 

1373 ``phase=None`` matches any phase (backward crossings may carry 

1374 gradients as well as functionalized fwd views). 

1375 """ 

1376 found = [] 

1377 for v in nodes: 

1378 if node_stage[v] != producer: 

1379 continue 

1380 if phase is not None and node_phase[v] != phase: 

1381 continue 

1382 if any(node_stage.get(u) == consumer for u in v.users): 

1383 found.append(v) 

1384 return found 

1385 

1386 # FWD-phase only: values born in this stage's forward sweep and 

1387 # consumed downstream. Bwd-phase values cannot ship forward (they 

1388 # do not exist yet during the forward sweep); downstream-born 

1389 # gradients flow back via grad crossings below. 

1390 act_in_list = crossing(k - 1, k, _FWD) if k > 0 else [] 

1391 act_out_list = [] if is_last else crossing(k, k + 1, _FWD) 

1392 # Backward crossings are phase-AGNOSTIC: besides plain gradients, 

1393 # functionalized views (fwd nodes) can be consumed by an earlier 

1394 # stage's backward (weight-grad compute) — those values flow back 

1395 # during the backward sweep just the same. 

1396 grad_in_list = [] if is_last else crossing(k + 1, k, None) 

1397 grad_out_list = crossing(k, k - 1, None) if k > 0 else [] 

1398 

1399 # Forward-phase values the backward references. Stage-owned 

1400 # placeholders (state / routed user inputs of this stage) may appear 

1401 # and round-trip through the forward outputs; foreign placeholders 

1402 # cannot be shipped and fail loudly during the slice copy. 

1403 stage_owned_phs = { 

1404 p 

1405 for p in graph_module.graph.nodes 

1406 if p.op == "placeholder" and node_stage.get(p) == k 

1407 } 

1408 stage_owned_phs.update( 

1409 ph 

1410 for ph, stages in zip(user_input_phs, user_input_stage_sets) 

1411 if k in stages 

1412 ) 

1413 saved = self._collect_saved_values( 

1414 nodes, 

1415 node_stage, 

1416 node_phase, 

1417 k, 

1418 stage_owned_phs, 

1419 set(act_in_list), 

1420 grad_out_list, 

1421 ) 

1422 return _Boundaries( 

1423 act_in=act_in_list, 

1424 act_out=act_out_list, 

1425 grad_in=grad_in_list, 

1426 grad_out=grad_out_list, 

1427 saved=saved, 

1428 ) 

1429 

1430 @staticmethod 

1431 def _collect_saved_values( 

1432 nodes: List[fx.Node], 

1433 node_stage: Dict[fx.Node, int], 

1434 node_phase: Dict[fx.Node, str], 

1435 stage_idx: int, 

1436 stage_owned_phs: Set[fx.Node], 

1437 act_in_set: Set[fx.Node], 

1438 grad_out_list: List[fx.Node], 

1439 ) -> List[fx.Node]: 

1440 """Forward-phase values this stage's backward references. 

1441 

1442 Stage-owned placeholders (state / routed user inputs of this stage) 

1443 may appear and round-trip through the forward outputs; foreign 

1444 placeholders cannot be shipped and fail loudly during the slice 

1445 copy. Fwd-phase values this stage must ship BACKWARD (consumed by 

1446 the previous stage's backward) ride the fwd outputs as 

1447 pass-throughs: computed once in the fwd subgraph, replayed into 

1448 the bwd subgraph, and appended to its outputs. 

1449 """ 

1450 saved: List[fx.Node] = [] 

1451 seen: Set[fx.Node] = set() 

1452 for node in nodes: 

1453 if node_phase[node] != _BWD or node_stage[node] != stage_idx: 

1454 continue 

1455 for arg in _iter_node_args(node): 

1456 if not isinstance(arg, fx.Node) or arg in seen: 

1457 continue 

1458 # Fwd-phase values replay from the fwd outputs; bwd-phase 

1459 # values are only shippable when they arrived as boundary 

1460 # inputs (e.g. gradient-shape sym_size scalars). 

1461 if node_phase.get(arg) != _FWD and arg not in act_in_set: 

1462 continue 

1463 if arg.op == "placeholder" and arg not in stage_owned_phs: 

1464 continue 

1465 seen.add(arg) 

1466 saved.append(arg) 

1467 for v in grad_out_list: 

1468 if node_phase[v] == _FWD and v not in seen: 

1469 seen.add(v) 

1470 saved.append(v) 

1471 return saved 

1472 

1473 # ------------------------------------------------------------------ 

1474 # Subgraph construction 

1475 # ------------------------------------------------------------------ 

1476 

1477 @staticmethod 

1478 def _unique(name: str, used: Set[str]) -> str: 

1479 """Return ``name`` (or a suffixed variant) not yet in ``used``.""" 

1480 candidate = name 

1481 suffix = 1 

1482 while candidate in used: 

1483 candidate = f"{name}_{suffix}" 

1484 suffix += 1 

1485 used.add(candidate) 

1486 return candidate 

1487 

1488 def _copy_node( 

1489 self, 

1490 builder: _SubgraphBuilder, 

1491 node: fx.Node, 

1492 ) -> None: 

1493 """Copy ``node`` into ``builder.g``, remapping args through the 

1494 builder's env/foreign maps. 

1495 

1496 ``env`` maps already-copied same-slice nodes; ``foreign`` maps 

1497 boundary values to their placeholder stand-ins. The copy is 

1498 registered under ``builder.env[node]``; nothing is returned — 

1499 callers consume the copy through the builder's maps. 

1500 """ 

1501 if node.op == "get_attr": 

1502 raise ValueError( 

1503 f"Unexpected get_attr node '{node.name}' — the tracer " 

1504 f"contracts parameters/buffers to be static placeholders" 

1505 ) 

1506 

1507 def map_fn(a: Any) -> Any: 

1508 """Remap a single arg: env first, then foreign placeholders.""" 

1509 if isinstance(a, fx.Node): 

1510 if a in builder.env: 

1511 return builder.env[a] 

1512 if a in builder.foreign: 

1513 return builder.foreign[a] 

1514 raise ValueError( 

1515 f"Node '{node.name}' references '{a.name}' which lies " 

1516 f"outside this stage's slice — boundary detection or " 

1517 f"the stage plan is inconsistent" 

1518 ) 

1519 return a 

1520 

1521 new_args = fx.map_arg(node.args, map_fn) 

1522 new_kwargs = fx.map_arg(node.kwargs, map_fn) 

1523 new_node = builder.g.create_node(node.op, node.target, new_args, new_kwargs) 

1524 new_node.meta = dict(node.meta) 

1525 builder.env[node] = new_node 

1526 

1527 def _build_fwd_graph( 

1528 self, 

1529 graph_module: fx.GraphModule, 

1530 split: _StageSplit, 

1531 boundaries: _Boundaries, 

1532 stage_idx: int, 

1533 num_stages: int, 

1534 state_indices: Sequence[int], 

1535 state_fqns: Sequence[str], 

1536 ) -> Tuple[fx.GraphModule, List[fx.Node]]: 

1537 """Build one virtual stage's forward subgraph. 

1538 

1539 Outputs: ``(*act_out, *saved)`` for non-last stages, ``(loss, 

1540 *saved)`` for the last stage — where ``saved`` contains every 

1541 forward-phase value the backward references that is not already 

1542 crossing. The schedule ships the crossing prefix and replays the 

1543 full output tuple into the backward subgraph. 

1544 

1545 Returns: 

1546 ``(fwd_gm, fwd_out_orig)`` — the subgraph module together with 

1547 the ORIGINAL nodes at each output position, so the backward 

1548 builder can key its placeholder mapping on them. 

1549 """ 

1550 is_last = stage_idx == num_stages - 1 

1551 builder = _SubgraphBuilder(g=fx.Graph(), used=set(), env={}, foreign={}) 

1552 

1553 self._create_fwd_placeholders( 

1554 builder, split, boundaries, stage_idx, state_indices, state_fqns 

1555 ) 

1556 

1557 for node in graph_module.graph.nodes: 

1558 if node.op in ("placeholder", "output"): 

1559 continue 

1560 if split.node_phase[node] != _FWD or split.node_stage[node] != stage_idx: 

1561 continue 

1562 self._copy_node(builder, node) 

1563 

1564 loss_node = self._loss_node(graph_module) 

1565 out_orig = self._resolve_fwd_outputs( 

1566 boundaries.act_out, boundaries.saved, loss_node, is_last 

1567 ) 

1568 out_nodes = [ 

1569 builder.env[n] if n in builder.env else builder.foreign[n] for n in out_orig 

1570 ] 

1571 builder.g.output(tuple(out_nodes)) 

1572 return fx.GraphModule(nn.Module(), builder.g), out_orig 

1573 

1574 def _create_fwd_placeholders( 

1575 self, 

1576 builder: _SubgraphBuilder, 

1577 split: _StageSplit, 

1578 boundaries: _Boundaries, 

1579 stage_idx: int, 

1580 state_indices: Sequence[int], 

1581 state_fqns: Sequence[str], 

1582 ) -> None: 

1583 """Create the fwd subgraph's placeholders (state / boundary / inputs). 

1584 

1585 Incoming boundary values (virtual stage > 0) become placeholders; 

1586 each stage additionally takes the user inputs routed to it (stage 0 

1587 typically the feature batch, the last stage the labels; see 

1588 ``_route_user_inputs``). 

1589 """ 

1590 for i, orig_idx in enumerate(state_indices): 

1591 ph = builder.g.placeholder( 

1592 self._unique(_sanitize(state_fqns[i]), builder.used) 

1593 ) 

1594 ph.meta = dict(split.state_phs[orig_idx].meta) 

1595 builder.env[split.state_phs[orig_idx]] = ph 

1596 

1597 for i, act in enumerate(boundaries.act_in): 

1598 ph = builder.g.placeholder(self._unique(f"pp_act_in_{i}", builder.used)) 

1599 ph.meta = dict(act.meta) 

1600 builder.foreign[act] = ph 

1601 

1602 for i, (ph, stages) in enumerate( 

1603 zip(split.user_input_phs, split.user_input_stage_sets) 

1604 ): 

1605 if stage_idx not in stages: 

1606 continue 

1607 in_ph = builder.g.placeholder(self._unique(f"pp_input_{i}", builder.used)) 

1608 in_ph.meta = dict(ph.meta) 

1609 builder.env[ph] = in_ph 

1610 

1611 @staticmethod 

1612 def _resolve_fwd_outputs( 

1613 act_out_list: List[fx.Node], 

1614 saved: List[fx.Node], 

1615 loss_node: Optional[fx.Node], 

1616 is_last: bool, 

1617 ) -> List[fx.Node]: 

1618 """Order the fwd subgraph's outputs: boundary values first, then saved.""" 

1619 if is_last: 

1620 if loss_node is None: 

1621 raise ValueError("Last PP stage requires a loss node in the output") 

1622 out_orig: List[fx.Node] = [loss_node] 

1623 else: 

1624 if not act_out_list: 

1625 raise ValueError( 

1626 "Stage ships no boundary values but is not the last " 

1627 "stage — the stage plan does not cut the graph" 

1628 ) 

1629 out_orig = list(act_out_list) 

1630 

1631 crossing_ids = {id(v) for v in act_out_list} 

1632 for node in saved: 

1633 if id(node) in crossing_ids or (is_last and node is loss_node): 

1634 continue 

1635 out_orig.append(node) 

1636 return out_orig 

1637 

1638 def _build_bwd_graph( 

1639 self, 

1640 graph_module: fx.GraphModule, 

1641 split: _StageSplit, 

1642 boundaries: _Boundaries, 

1643 stage_idx: int, 

1644 num_stages: int, 

1645 state_indices: Sequence[int], 

1646 state_fqns: Sequence[str], 

1647 fwd_out_orig: List[fx.Node], 

1648 trainable_indices: List[int], 

1649 ) -> fx.GraphModule: 

1650 """Build one virtual stage's backward subgraph. 

1651 

1652 Placeholders: ``(*state, *grad_in, *fwd_outputs)`` — the schedule 

1653 passes the received boundary gradients and then the forward 

1654 outputs verbatim (on the last stage a single ``ones_like(loss)`` 

1655 takes the gradient slots' place). Outputs: ``(*param_grads,)`` 

1656 plus the boundary gradients for every stage except stage 0. 

1657 """ 

1658 builder = _SubgraphBuilder(g=fx.Graph(), used=set(), env={}, foreign={}) 

1659 self._create_bwd_placeholders( 

1660 builder, 

1661 graph_module, 

1662 split, 

1663 boundaries, 

1664 stage_idx, 

1665 num_stages, 

1666 state_indices, 

1667 state_fqns, 

1668 fwd_out_orig, 

1669 ) 

1670 

1671 stage_bwd_set = self._collect_stage_bwd_nodes( 

1672 graph_module.graph, split.node_stage, split.node_phase, stage_idx 

1673 ) 

1674 

1675 # Gradient-output nodes of this stage's trainable params (by state 

1676 # index identity, not module attribution — post-trace inserts like 

1677 # zeros_like carry no module stack). A LIST — output order is the 

1678 # positional gradient contract; only membership uses the set. 

1679 stage_grad_nodes = self._stage_grad_nodes( 

1680 graph_module.graph, trainable_indices, set(state_indices) 

1681 ) 

1682 stage_grad_set = set(stage_grad_nodes) 

1683 

1684 for node in graph_module.graph.nodes: 

1685 if node.op in ("placeholder", "output"): 

1686 continue 

1687 if node not in stage_bwd_set and node not in stage_grad_set: 

1688 continue 

1689 self._copy_node(builder, node) 

1690 

1691 out_nodes = [builder.env[gn] for gn in stage_grad_nodes] 

1692 out_nodes.extend(builder.env[gn] for gn in boundaries.grad_out) 

1693 builder.g.output(tuple(out_nodes)) 

1694 return fx.GraphModule(nn.Module(), builder.g) 

1695 

1696 @staticmethod 

1697 def _collect_stage_bwd_nodes( 

1698 graph: fx.Graph, 

1699 node_stage: Dict[fx.Node, int], 

1700 node_phase: Dict[fx.Node, str], 

1701 stage_idx: int, 

1702 ) -> Set[fx.Node]: 

1703 """Bwd-phase nodes owned by ``stage_idx``, as a set. 

1704 

1705 Membership is probed once per graph node by ``_build_bwd_graph`` 

1706 and real joint graphs carry tens of thousands of nodes. 

1707 """ 

1708 return { 

1709 node 

1710 for node in graph.nodes 

1711 if node.op not in ("placeholder", "output") 

1712 and node_phase[node] == _BWD 

1713 and node_stage[node] == stage_idx 

1714 } 

1715 

1716 def _create_bwd_placeholders( 

1717 self, 

1718 builder: _SubgraphBuilder, 

1719 graph_module: fx.GraphModule, 

1720 split: _StageSplit, 

1721 boundaries: _Boundaries, 

1722 stage_idx: int, 

1723 num_stages: int, 

1724 state_indices: Sequence[int], 

1725 state_fqns: Sequence[str], 

1726 fwd_out_orig: List[fx.Node], 

1727 ) -> None: 

1728 """Create the bwd subgraph's placeholders (state / grads / fwd outs). 

1729 

1730 The schedule passes the received boundary gradients and then the 

1731 forward outputs verbatim (on the last stage a single 

1732 ``ones_like(loss)`` takes the gradient slots' place). The 

1733 ``foreign`` entries double as the remap for backward nodes 

1734 referencing forward values. 

1735 """ 

1736 for i, orig_idx in enumerate(state_indices): 

1737 ph = builder.g.placeholder( 

1738 self._unique(_sanitize(state_fqns[i]), builder.used) 

1739 ) 

1740 ph.meta = dict(split.state_phs[orig_idx].meta) 

1741 builder.env[split.state_phs[orig_idx]] = ph 

1742 

1743 for i, grad in enumerate(boundaries.grad_in): 

1744 ph = builder.g.placeholder(self._unique(f"pp_grad_in_{i}", builder.used)) 

1745 ph.meta = dict(grad.meta) 

1746 builder.foreign[grad] = ph 

1747 if not boundaries.grad_in and stage_idx == num_stages - 1: 

1748 # Last stage: the schedule feeds ones_like(loss) into the 

1749 # single gradient slot. 

1750 ph = builder.g.placeholder(self._unique("pp_grad_ones", builder.used)) 

1751 loss = self._loss_node(graph_module) 

1752 if loss is not None: 

1753 ph.meta = dict(loss.meta) 

1754 

1755 # One placeholder per forward output, same order — the schedule 

1756 # passes the forward outputs verbatim after the gradients. 

1757 for orig in fwd_out_orig: 

1758 ph = builder.g.placeholder( 

1759 self._unique(f"pp_fwd_{orig.name}", builder.used) 

1760 ) 

1761 ph.meta = dict(orig.meta) 

1762 builder.foreign[orig] = ph 

1763 

1764 @staticmethod 

1765 def _stage_grad_nodes( 

1766 graph: fx.Graph, trainable_indices: List[int], stage_state_set: Set[int] 

1767 ) -> List[fx.Node]: 

1768 """Gradient-output nodes of the stage's trainable params.""" 

1769 output = next((n for n in graph.nodes if n.op == "output"), None) 

1770 returned = output.args[0] if output is not None else [] 

1771 stage_grad_nodes: List[fx.Node] = [] 

1772 for i, grad_node in enumerate(returned[1:]): 

1773 if i >= len(trainable_indices): 

1774 break 

1775 if trainable_indices[i] in stage_state_set: 

1776 stage_grad_nodes.append(grad_node) 

1777 return stage_grad_nodes 

1778 

1779 # ------------------------------------------------------------------ 

1780 # Live model pruning 

1781 # ------------------------------------------------------------------ 

1782 

1783 def _prune_live_model(self, model: nn.Module, keep_fqns: List[str]) -> None: 

1784 """Remove foreign-stage submodules from the live model in place. 

1785 

1786 Mirrors torchtitan's ``_split_module`` (``None``-out unkept 

1787 children, filter ModuleList/ModuleDict entries) while PRESERVING 

1788 original index keys — reindexing a ModuleList would rename the 

1789 surviving parameters' FQNs and break ``run_traced_graph``'s state 

1790 validation against the sliced ``state_fqns``. Manual plans may cut 

1791 inside a container element; such elements are recursed into 

1792 (mixed subtree), exactly like plain modules. 

1793 """ 

1794 keep = set(keep_fqns) 

1795 

1796 def is_kept(fqn: str) -> bool: 

1797 """True when ``fqn`` equals or descends from a stage module.""" 

1798 return any(fqn == kf or fqn.startswith(kf + ".") for kf in keep) 

1799 

1800 def prune_container_child(child: nn.Module, cfqn: str) -> None: 

1801 """Prune a ModuleList/ModuleDict child, preserving index keys. 

1802 

1803 Reindexing would rename the surviving parameters' FQNs and break 

1804 ``run_traced_graph``'s state validation. An element is kept 

1805 whole, deleted, or recursed into when a manual plan cuts inside 

1806 it (same rule as plain modules). 

1807 """ 

1808 children = child._modules # pylint: disable=protected-access 

1809 keys = list(children.keys()) 

1810 if all(is_kept(f"{cfqn}.{key}") for key in keys): 

1811 return 

1812 for key in keys: 

1813 elem_fqn = f"{cfqn}.{key}" 

1814 if is_kept(elem_fqn): 

1815 continue 

1816 elem = children[key] 

1817 descendants = [f for f, _ in elem.named_modules() if f] 

1818 if any(is_kept(f"{elem_fqn}.{f}") for f in descendants): 

1819 prune(elem, elem_fqn + ".") 

1820 else: 

1821 del children[key] 

1822 

1823 def prune_plain_child( 

1824 mod: nn.Module, name: str, child: nn.Module, cfqn: str 

1825 ) -> None: 

1826 """Prune a non-container child: delete it or recurse when mixed.""" 

1827 descendants = [f for f, _ in child.named_modules() if f] 

1828 if not descendants: 

1829 # Leaf module (e.g. a bare Linear): keep only if declared. 

1830 if not is_kept(cfqn): 

1831 setattr(mod, name, None) 

1832 return 

1833 if not any(is_kept(f"{cfqn}.{f}") for f in descendants): 

1834 setattr(mod, name, None) 

1835 else: 

1836 # Mixed subtree (manual plans may cut inside a child). 

1837 prune(child, cfqn + ".") 

1838 

1839 def prune(mod: nn.Module, prefix: str) -> None: 

1840 """Recursively prune non-stage children under ``prefix``.""" 

1841 for name, child in list(mod.named_children()): 

1842 cfqn = f"{prefix}{name}" 

1843 if is_kept(cfqn): 

1844 # Declared (or ancestor of a declared) stage module — 

1845 # keep the whole subtree. 

1846 continue 

1847 if isinstance(child, (nn.ModuleList, nn.ModuleDict)): 

1848 prune_container_child(child, cfqn) 

1849 continue 

1850 prune_plain_child(mod, name, child, cfqn) 

1851 

1852 prune(model, "") 

1853 

1854 # ------------------------------------------------------------------ 

1855 # Schedule + stub installation 

1856 # ------------------------------------------------------------------ 

1857 

1858 def _trainable_state_indices( 

1859 self, 

1860 state_fqns: Sequence[str], 

1861 state_is_param: Sequence[bool], 

1862 model: Optional[nn.Module], 

1863 ) -> List[int]: 

1864 """Trainable-parameter state indices, mirroring the tracer's order.""" 

1865 param_lookup = ( 

1866 dict(model.named_parameters(remove_duplicate=False)) 

1867 if model is not None 

1868 else {} 

1869 ) 

1870 trainable: List[int] = [] 

1871 for idx, fqn in enumerate(state_fqns): 

1872 if idx < len(state_is_param) and not state_is_param[idx]: 

1873 continue 

1874 param = param_lookup.get(fqn) 

1875 if param is not None and param.requires_grad: 

1876 trainable.append(idx) 

1877 return trainable 

1878 

1879 @staticmethod 

1880 def _value_spec( 

1881 nodes: Sequence[fx.Node], stage_idx: int, kind: str 

1882 ) -> List[Tuple[Any, ...]]: 

1883 """Build the schedule's recv descriptor for a boundary value list. 

1884 

1885 Tensor values yield ``("tensor", shape, dtype, device)``; int/SymInt 

1886 scalars (dynamic-shape ``sym_size`` nodes) yield ``("scalar",)`` 

1887 and are shipped as 0-d int64 tensors, unwrapped via ``item()``. 

1888 Scalars carry no device of their own — the schedule anchors them to 

1889 the first tensor entry's device, so a non-empty list must contain 

1890 at least one tensor. 

1891 """ 

1892 spec: List[Tuple[Any, ...]] = [] 

1893 for v in nodes: 

1894 val = v.meta.get("val") 

1895 if isinstance(val, torch.Tensor): 

1896 # int(s): trace-time dims are SymInts (symbolic over the 

1897 # sample input); the schedule allocates real buffers from 

1898 # this spec, and ATen rejects symbolic sizes at runtime. 

1899 # Training uses static shapes per compile, so the hinted 

1900 # concrete values are stable. 

1901 shape = tuple(int(s) for s in val.shape) 

1902 spec.append(("tensor", shape, val.dtype, val.device)) 

1903 elif val is None: 

1904 raise ValueError( 

1905 f"Boundary {kind} value '{v.name}' on stage {stage_idx} " 

1906 f"has no 'val' meta (traced without FakeTensor " 

1907 f"metadata) — cannot size the P2P receive buffer" 

1908 ) 

1909 else: 

1910 spec.append(("scalar",)) 

1911 if nodes and not any(entry[0] == "tensor" for entry in spec): 

1912 raise ValueError( 

1913 f"Boundary {kind} list on stage {stage_idx} carries only " 

1914 f"scalar values — no tensor entry to anchor the P2P " 

1915 f"receive device on" 

1916 ) 

1917 return spec 

1918 

1919 def _build_schedule( 

1920 self, 

1921 stage_graphs: Sequence[_StageGraphs], 

1922 pass_config: PassConfig, 

1923 run_ctx: _RunContext, 

1924 split: _StageSplit, 

1925 trainable_indices: Sequence[int], 

1926 state_positions: Dict[int, int], 

1927 ) -> PipelineScheduleBase: 

1928 """Assemble this rank's pipeline schedule (``pass_config.pp_schedule``). 

1929 

1930 Receive descriptors come from the boundary values' fake-tensor 

1931 ``val`` metas; the send side packs by value type at runtime. The 

1932 single-stage schedules take one graph pair; the interleaved schedule 

1933 takes every virtual stage this rank owns. 

1934 """ 

1935 schedule_cls = get_schedule_class(pass_config.pp_schedule) 

1936 if issubclass(schedule_cls, ScheduleInterleaved1F1B): 

1937 return self._build_interleaved_schedule( 

1938 schedule_cls, 

1939 stage_graphs, 

1940 pass_config, 

1941 run_ctx, 

1942 split, 

1943 trainable_indices, 

1944 state_positions, 

1945 ) 

1946 if run_ctx.chunks != 1: 

1947 raise ValueError( 

1948 f"pp_chunks={run_ctx.chunks} requires the interleaved " 

1949 f"schedule, got {pass_config.pp_schedule!r}" 

1950 ) 

1951 return self._build_single_stage_schedule( 

1952 schedule_cls, 

1953 stage_graphs[0], 

1954 pass_config, 

1955 run_ctx, 

1956 split, 

1957 ) 

1958 

1959 def _build_single_stage_schedule( 

1960 self, 

1961 schedule_cls: Any, 

1962 stage: _StageGraphs, 

1963 pass_config: PassConfig, 

1964 run_ctx: _RunContext, 

1965 split: _StageSplit, 

1966 ) -> PipelineScheduleBase: 

1967 """Build one of the single-virtual-stage schedules (GPipe / 1F1B).""" 

1968 boundaries = stage.boundaries 

1969 stage_idx = stage.stage_idx 

1970 recv_spec = ( 

1971 self._value_spec(boundaries.act_in, stage_idx, "activation") 

1972 if stage_idx > 0 

1973 else [] 

1974 ) 

1975 grad_recv_spec = ( 

1976 self._value_spec(boundaries.grad_in, stage_idx, "gradient") 

1977 if stage_idx < run_ctx.num_stages - 1 

1978 else [] 

1979 ) 

1980 return schedule_cls( 

1981 stage.fwd_gm, 

1982 stage.bwd_gm, 

1983 stage_idx=stage_idx, 

1984 pp_degree=run_ctx.num_stages, 

1985 num_state=len(split.stage_state_fqns), 

1986 num_trainable=stage.num_trainable, 

1987 num_send=len(boundaries.act_out), 

1988 grad_send_count=len(boundaries.grad_out), 

1989 microbatch_size=pass_config.pp_microbatch_size, 

1990 pp_group=run_ctx.group, 

1991 recv_spec=recv_spec, 

1992 grad_recv_spec=grad_recv_spec, 

1993 user_input_stages=split.user_input_stages, 

1994 user_input_stage_sets=split.user_input_stage_sets, 

1995 ) 

1996 

1997 def _build_interleaved_schedule( # pylint: disable=too-many-locals 

1998 self, 

1999 schedule_cls: Any, 

2000 stage_graphs: Sequence[_StageGraphs], 

2001 pass_config: PassConfig, 

2002 run_ctx: _RunContext, 

2003 split: _StageSplit, 

2004 trainable_indices: Sequence[int], 

2005 state_positions: Dict[int, int], 

2006 ) -> PipelineScheduleBase: 

2007 """Build the VPP schedule from this rank's virtual-stage graph list.""" 

2008 owned_pos = {v: i for i, v in enumerate(run_ctx.stage_indices)} 

2009 grad_owner = [ 

2010 owned_pos[split.node_stage[split.state_phs[idx]]] 

2011 for idx in trainable_indices 

2012 if idx in state_positions 

2013 ] 

2014 state_positions_per_stage = [ 

2015 [state_positions[i] for i in stage.state_indices] for stage in stage_graphs 

2016 ] 

2017 recv_specs = [ 

2018 self._recv_spec_for(stage, run_ctx.num_stages, gradient=False) 

2019 for stage in stage_graphs 

2020 ] 

2021 grad_recv_specs = [ 

2022 self._recv_spec_for(stage, run_ctx.num_stages, gradient=True) 

2023 for stage in stage_graphs 

2024 ] 

2025 return schedule_cls( 

2026 [stage.fwd_gm for stage in stage_graphs], 

2027 [stage.bwd_gm for stage in stage_graphs], 

2028 stage_indices=list(run_ctx.stage_indices), 

2029 num_stages=run_ctx.num_stages, 

2030 pp_degree=run_ctx.pp_degree, 

2031 state_positions=state_positions_per_stage, 

2032 num_states=[len(stage.state_indices) for stage in stage_graphs], 

2033 num_trainables=[stage.num_trainable for stage in stage_graphs], 

2034 num_sends=[len(stage.boundaries.act_out) for stage in stage_graphs], 

2035 grad_send_counts=[len(stage.boundaries.grad_out) for stage in stage_graphs], 

2036 microbatch_size=pass_config.pp_microbatch_size, 

2037 pp_group=run_ctx.group, 

2038 recv_specs=recv_specs, 

2039 grad_recv_specs=grad_recv_specs, 

2040 user_input_stages=split.user_input_stages, 

2041 user_input_stage_sets=split.user_input_stage_sets, 

2042 grad_owner=grad_owner, 

2043 ) 

2044 

2045 def _recv_spec_for( 

2046 self, 

2047 stage: _StageGraphs, 

2048 num_stages: int, 

2049 gradient: bool, 

2050 ) -> List[Tuple[Any, ...]]: 

2051 """Build one virtual stage's activation or gradient receive specs.""" 

2052 if gradient: 

2053 if stage.stage_idx >= num_stages - 1: 

2054 return [] 

2055 return self._value_spec( 

2056 stage.boundaries.grad_in, stage.stage_idx, "gradient" 

2057 ) 

2058 if stage.stage_idx == 0: 

2059 return [] 

2060 return self._value_spec(stage.boundaries.act_in, stage.stage_idx, "activation") 

2061 

2062 def _install_stub( 

2063 self, 

2064 graph_module: fx.GraphModule, 

2065 sched: PipelineScheduleBase, 

2066 stage_state_fqns: List[str], 

2067 state_is_param: Sequence[bool], 

2068 stage_state_indices: List[int], 

2069 num_user_inputs: int, 

2070 ) -> None: 

2071 """Rewrite the graph module into a schedule-dispatching stub. 

2072 

2073 The schedule is added as a submodule and invoked through a 

2074 ``call_module`` node, so it survives any later ``recompile()`` and 

2075 the trainer's ``graph_module(*flat_inputs)`` dispatches to it 

2076 unchanged. The stub takes ``(*state, *user_inputs)`` — every rank 

2077 receives the full flattened user-input list; the schedule picks 

2078 the inputs routed to this stage. ``state_fqns`` / 

2079 ``state_is_param`` are updated IN PLACE (the JointGraph shares the 

2080 list objects). 

2081 """ 

2082 g = fx.Graph() 

2083 used: Set[str] = set() 

2084 state_phs = [ 

2085 g.placeholder(self._unique(_sanitize(fqn), used)) 

2086 for fqn in stage_state_fqns 

2087 ] 

2088 user_phs = [ 

2089 g.placeholder(self._unique(f"pp_input_{i}", used)) 

2090 for i in range(num_user_inputs) 

2091 ] 

2092 

2093 graph_module.add_module("pp_schedule", sched) 

2094 call = g.call_module("pp_schedule", args=tuple(state_phs) + tuple(user_phs)) 

2095 g.output(call) 

2096 

2097 graph_module.graph = g 

2098 graph_module.state_fqns[:] = stage_state_fqns 

2099 graph_module.state_is_param[:] = [ 

2100 state_is_param[i] for i in stage_state_indices 

2101 ] 

2102 graph_module.num_state_inputs = len(stage_state_fqns) 

2103 graph_module.recompile()