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

642 statements  

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

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14# ============================================================================ 

15""" 

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 (``ScheduleGPipe`` by 

44 default; ``Schedule1F1B`` when ``PassConfig.pp_schedule == "1f1b"``) as a 

45 ``call_module`` node in a stub graph — the trainer's 

46 ``graph_module(*flat_inputs)`` dispatches to it unchanged, and the stub 

47 survives any later ``recompile()`` because the schedule lives in the 

48 graph itself, not in an overwritten ``forward``. 

49 

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

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

52reduce_scatter nodes land inside its slices untouched, while foreign 

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

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

55list, breaking its grad-index contract. 

56""" 

57 

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

59 

60import logging 

61from dataclasses import dataclass 

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

63 

64import torch 

65import torch.distributed as dist 

66from torch import fx, nn 

67 

68from ...pass_config import PassConfig 

69from ..base import GraphPass 

70from ...graph_parallel_plan import GraphParallelPlan 

71from .pp_schedule import PipelineScheduleBase, get_schedule_class 

72 

73_LOG = logging.getLogger(__name__) 

74 

75_FWD = "fwd" 

76_BWD = "bwd" 

77 

78 

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

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

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

82 

83 

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

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

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

87 while stack: 

88 item = stack.pop() 

89 if isinstance(item, fx.Node): 

90 yield item 

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

92 stack.extend(item) 

93 elif isinstance(item, dict): 

94 stack.extend(item.values()) 

95 

96 

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

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

99 

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

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

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

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

104 the gradient itself is born downstream). 

105 """ 

106 stages = [] 

107 for arg in _iter_node_args(node): 

108 stage = node_stage.get(arg) 

109 if stage is None: 

110 continue 

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

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

113 continue 

114 stages.append(stage) 

115 return stages 

116 

117 

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

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

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

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

122 

123 

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

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

126 

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

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

129 """ 

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

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

132 cursor = 0 

133 for s in range(pp_degree): 

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

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

136 cursor += size 

137 return stages 

138 

139 

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

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

142 

143 Mirrors torchtitan's ``_generate_llm_fqn_per_model_part`` weighting 

144 without hardcoding LLM module names: 

145 

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

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

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

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

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

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

152 ``_even_split``). 

153 

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

155 in registration order. 

156 

157 Returns: 

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

159 """ 

160 if pp_degree < 2: 

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

162 

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

164 

165 container: Optional[str] = None 

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

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

168 container = name 

169 break 

170 

171 if container is None: 

172 if len(children) < pp_degree: 

173 raise ValueError( 

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

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

176 f"GraphParallelPlan.pp_stage()" 

177 ) 

178 return _even_split(children, pp_degree) 

179 

180 container_mod = getattr(model, container) 

181 if isinstance(container_mod, nn.ModuleDict): 

182 elem_keys = list(container_mod.keys()) 

183 else: 

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

185 

186 idx = children.index(container) 

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

188 

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

190 stages[0] = before + stages[0] 

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

192 return stages 

193 

194 

195@dataclass 

196class _RunContext: 

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

198 

199 model: nn.Module 

200 pp_degree: int 

201 group: Any 

202 stage_idx: int 

203 

204 

205@dataclass 

206class _StageSplit: 

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

208 

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

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

211 parallel locals through every call. 

212 """ 

213 

214 state_fqns: List[str] 

215 state_is_param: List[bool] 

216 stage_plan: List[List[str]] 

217 node_stage: Dict[fx.Node, int] 

218 node_phase: Dict[fx.Node, str] 

219 user_input_phs: List[fx.Node] 

220 user_input_stages: List[int] 

221 state_phs: List[fx.Node] 

222 stage_state_indices: List[int] 

223 stage_state_fqns: List[str] 

224 

225 

226@dataclass 

227class _GraphClassification: 

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

229 

230 stage_state_indices: List[int] 

231 node_stage: Dict[fx.Node, int] 

232 node_phase: Dict[fx.Node, str] 

233 user_input_phs: List[fx.Node] 

234 user_input_stages: List[int] 

235 state_phs: List[fx.Node] 

236 

237 

238@dataclass 

239class _Boundaries: 

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

241 

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

243 consumer of a cut agree on P2P ordering: 

244 

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

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

247 graph pushes across a cut). 

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

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

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

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

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

253 """ 

254 

255 act_in: List[fx.Node] 

256 act_out: List[fx.Node] 

257 grad_in: List[fx.Node] 

258 grad_out: List[fx.Node] 

259 saved: List[fx.Node] 

260 

261 

262@dataclass 

263class _SubgraphBuilder: 

264 """Accumulator for assembling one stage subgraph. 

265 

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

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

268 taken placeholder names for ``_unique``. 

269 """ 

270 

271 g: fx.Graph 

272 used: Set[str] 

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

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

275 

276 

277class PpPass(GraphPass): 

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

279 

280 name = "pp_parallel" 

281 

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

283 """Initialize PP pass state. 

284 

285 Args: 

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

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

288 split. 

289 """ 

290 super().__init__() 

291 self._parallel_plan = parallel_plan 

292 

293 def run( 

294 self, 

295 graph_module: fx.GraphModule, 

296 pass_config: PassConfig, 

297 **kwargs: Any, 

298 ) -> fx.GraphModule: 

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

300 

301 Args: 

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

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

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

305 read directly. 

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

307 include ``parallel_plan``. 

308 

309 Returns: 

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

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

312 (``ScheduleGPipe`` / ``Schedule1F1B`` per 

313 ``pass_config.pp_schedule``). 

314 """ 

315 run_ctx = self._resolve_run_context(pass_config, kwargs) 

316 if run_ctx is None: 

317 return graph_module 

318 

319 split = self._classify_stage(graph_module, run_ctx) 

320 boundaries = self._find_boundaries( 

321 graph_module, 

322 split.node_stage, 

323 split.node_phase, 

324 run_ctx.pp_degree, 

325 run_ctx.stage_idx, 

326 ) 

327 self._build_and_install_stage( 

328 graph_module, pass_config, run_ctx, split, boundaries 

329 ) 

330 return graph_module 

331 

332 def _resolve_run_context( 

333 self, 

334 pass_config: PassConfig, 

335 kwargs: Dict[str, Any], 

336 ) -> Optional[_RunContext]: 

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

338 

339 Returns: 

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

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

342 ``pp_degree < 2``). 

343 """ 

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

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

346 return None 

347 

348 model = kwargs.get("model") 

349 if model is None: 

350 raise ValueError( 

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

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

353 ) 

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

355 

356 # Skip BEFORE _resolve_group_and_stage so a disabled pass does not 

357 # execute the collective dist.new_group. 

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

359 if pp_degree < 2: 

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

361 return None 

362 pp_degree, group, stage_idx = self._resolve_group_and_stage(pass_config) 

363 _LOG.info( 

364 "Running: stage %s/%s, world_size=%s", 

365 stage_idx, 

366 pp_degree, 

367 dist.get_world_size(), 

368 ) 

369 return _RunContext( 

370 model=model, pp_degree=pp_degree, group=group, stage_idx=stage_idx 

371 ) 

372 

373 def _classify_stage( 

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

375 ) -> _StageSplit: 

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

377 

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

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

380 propagation, and the adjacent-dataflow validation. 

381 

382 Args: 

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

384 run_ctx: This rank's PP execution context. 

385 

386 Returns: 

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

388 classification for the construction phase. 

389 """ 

390 state_fqns = self._require_state_list(graph_module) 

391 state_is_param = self._require_is_param(graph_module, state_fqns) 

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

393 

394 stage_plan = self._resolve_stage_plan(run_ctx.model, run_ctx.pp_degree) 

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

396 

397 graph_cls = self._classify_graph( 

398 graph_module, 

399 run_ctx, 

400 stage_of_fqn, 

401 state_fqns, 

402 num_state_inputs, 

403 ) 

404 

405 self._anchor_grad_outputs( 

406 graph_module, 

407 graph_cls.node_stage, 

408 stage_of_fqn, 

409 state_fqns, 

410 run_ctx.model, 

411 ) 

412 self._propagate_anchor_stages( 

413 graph_module, graph_cls.node_stage, graph_cls.node_phase 

414 ) 

415 self._validate_adjacent_dataflow(graph_module, graph_cls.node_stage) 

416 

417 return _StageSplit( 

418 state_fqns=state_fqns, 

419 state_is_param=state_is_param, 

420 stage_plan=stage_plan, 

421 node_stage=graph_cls.node_stage, 

422 node_phase=graph_cls.node_phase, 

423 user_input_phs=graph_cls.user_input_phs, 

424 user_input_stages=graph_cls.user_input_stages, 

425 state_phs=graph_cls.state_phs, 

426 stage_state_indices=graph_cls.stage_state_indices, 

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

428 ) 

429 

430 def _build_and_install_stage( 

431 self, 

432 graph_module: fx.GraphModule, 

433 pass_config: PassConfig, 

434 run_ctx: _RunContext, 

435 split: _StageSplit, 

436 boundaries: _Boundaries, 

437 ) -> None: 

438 """Build the stage subgraphs, prune the live model, install the stub. 

439 

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

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

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

443 

444 Args: 

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

446 pass_config: Parallel configuration (microbatch size read). 

447 run_ctx: This rank's PP execution context. 

448 split: Stage plan and per-node classification. 

449 boundaries: Boundary value lists from ``_find_boundaries``. 

450 """ 

451 trainable_indices = self._trainable_state_indices( 

452 split.state_fqns, split.state_is_param, run_ctx.model 

453 ) 

454 stage_state_set = set(split.stage_state_indices) 

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

456 

457 fwd_gm, bwd_gm = self._build_stage_graphs( 

458 graph_module, run_ctx, split, boundaries, trainable_indices 

459 ) 

460 

461 self._prune_live_model(run_ctx.model, split.stage_plan[run_ctx.stage_idx]) 

462 

463 sched = self._build_schedule( 

464 fwd_gm, 

465 bwd_gm, 

466 pass_config, 

467 run_ctx, 

468 split, 

469 len(stage_trainable), 

470 boundaries, 

471 ) 

472 self._install_stub( 

473 graph_module, 

474 sched, 

475 split.stage_state_fqns, 

476 split.state_is_param, 

477 split.stage_state_indices, 

478 len(split.user_input_phs), 

479 ) 

480 

481 _LOG.info( 

482 "Completed: stage %s owns %d state entries, %d trainable params, " 

483 "%d fwd / %d bwd boundary values", 

484 run_ctx.stage_idx, 

485 len(split.stage_state_fqns), 

486 len(stage_trainable), 

487 len(boundaries.act_out), 

488 len(boundaries.grad_out), 

489 ) 

490 

491 def _build_stage_graphs( 

492 self, 

493 graph_module: fx.GraphModule, 

494 run_ctx: _RunContext, 

495 split: _StageSplit, 

496 boundaries: _Boundaries, 

497 trainable_indices: List[int], 

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

499 """Build this stage's forward and backward subgraphs.""" 

500 fwd_gm, fwd_out_orig = self._build_fwd_graph( 

501 graph_module, run_ctx, split, boundaries 

502 ) 

503 bwd_gm = self._build_bwd_graph( 

504 graph_module, run_ctx, split, boundaries, fwd_out_orig, trainable_indices 

505 ) 

506 return fwd_gm, bwd_gm 

507 

508 # ------------------------------------------------------------------ 

509 # Tracer contract helpers 

510 # ------------------------------------------------------------------ 

511 

512 @staticmethod 

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

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

515 

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

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

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

519 model. Copying here would desynchronize the two. 

520 """ 

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

522 if state_fqns is None: 

523 raise ValueError( 

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

525 "joint graph (attach it via trace_model_graph)" 

526 ) 

527 return state_fqns 

528 

529 @staticmethod 

530 def _require_is_param( 

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

532 ) -> List[bool]: 

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

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

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

536 if state_is_param is None: 

537 state_is_param = [True] * len(state_fqns) 

538 graph_module.state_is_param = state_is_param 

539 return state_is_param 

540 

541 @staticmethod 

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

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

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

545 if output is None: 

546 return None 

547 returned = output.args[0] 

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

549 first = returned[0] 

550 if isinstance(first, fx.Node): 

551 return first 

552 return None 

553 

554 # ------------------------------------------------------------------ 

555 # Group / plan resolution 

556 # ------------------------------------------------------------------ 

557 

558 def _resolve_group_and_stage(self, pass_config: PassConfig) -> Tuple[int, Any, int]: 

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

560 

561 A single PP process group carries both forward activations and 

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

563 neighbour pair's FIFO complementary (see ``Schedule1F1B``). 

564 

565 v1 supports the pure-PP layout (``pp_degree == world_size``, one 

566 stage per rank); ``pp_degree == 1`` disables the pass. Any other 

567 layout is rejected loudly: DP replication over one stage would 

568 funnel every replica's P2P into a single peer rank (the group is 

569 the whole world), and the PP+FSDP hybrid requires a mesh 

570 expressing both dimensions (``mesh_context`` path) — the trainer's 

571 1-D fallback mesh shards FSDP across the whole world and cannot 

572 host PP, so that combination would silently mis-shard. 

573 """ 

574 world_size = dist.get_world_size() 

575 rank = dist.get_rank() 

576 pp_degree = pass_config.pp_degree or world_size 

577 

578 if pp_degree not in (1, world_size): 

579 raise ValueError( 

580 f"v1 supports pure-PP only: pp_degree={pp_degree} must " 

581 f"equal world_size={world_size} (or be 1 to disable PP). " 

582 f"DP-replication and PP+FSDP hybrid layouts require a " 

583 f"mesh_context with an explicit pp dim." 

584 ) 

585 if pass_config.fsdp_enabled: 

586 raise ValueError( 

587 "PP+FSDP hybrid requires a mesh expressing both the PP and " 

588 "FSDP dimensions (mesh_context path). The trainer's 1-D " 

589 "fallback FSDP mesh spans the whole world and cannot host " 

590 "PP — FSDP would shard parameters across stage boundaries. " 

591 "Run pure-PP (fsdp_enabled=False, pp_degree == world_size) " 

592 "or provide a mesh_context with a pp dim." 

593 ) 

594 

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

596 # v1 is pure-PP (pp_degree == world_size, checked above), so each 

597 # rank owns exactly one stage. Keep the general formula so a future 

598 # DP-replication layout (pp_degree < world_size) maps rank->stage 

599 # contiguously rather than silently assuming 1:1. 

600 stage_idx = rank * pp_degree // world_size 

601 return pp_degree, group, stage_idx 

602 

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

604 """Resolve the per-stage module FQN lists (manual plan or auto split).""" 

605 manual = ( 

606 self._parallel_plan.pp_module_fqns_per_stage 

607 if self._parallel_plan is not None 

608 else None 

609 ) 

610 if manual is not None: 

611 if len(manual) != pp_degree: 

612 raise ValueError( 

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

614 f"pp_degree={pp_degree}" 

615 ) 

616 seen: Set[str] = set() 

617 for s, fqns in enumerate(manual): 

618 for fqn in fqns: 

619 if fqn in seen: 

620 raise ValueError( 

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

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

623 ) 

624 seen.add(fqn) 

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

626 else: 

627 stage_plan = _auto_stage_split(model, pp_degree) 

628 

629 known = dict(model.named_modules()) 

630 for s, fqns in enumerate(stage_plan): 

631 for fqn in fqns: 

632 if fqn not in known: 

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

634 self._warn_unassigned_modules(model, stage_plan) 

635 return stage_plan 

636 

637 @staticmethod 

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

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

640 

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

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

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

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

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

646 child, so they never warn. 

647 """ 

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

649 unassigned: List[str] = [] 

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

651 if not fqn: 

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

653 continue 

654 covered = any( 

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

656 for d in declared 

657 ) 

658 if not covered: 

659 unassigned.append(fqn) 

660 if not unassigned: 

661 return 

662 shown = unassigned[:10] 

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

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

665 _LOG.warning( 

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

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

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

669 shown, 

670 ) 

671 

672 # ------------------------------------------------------------------ 

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

674 # ------------------------------------------------------------------ 

675 

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

677 self, 

678 graph_module: fx.GraphModule, 

679 run_ctx: _RunContext, 

680 stage_of_fqn: Dict[str, int], 

681 state_fqns: Sequence[str], 

682 num_state_inputs: int, 

683 ) -> _GraphClassification: 

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

685 

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

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

688 bwd phase through their grad arguments). 

689 

690 Stage resolution, in priority order: 

691 

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

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

694 win, since backward compute nodes reference their params) 

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

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

697 consumers, minimum when several — resolved in reverse topo 

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

699 

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

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

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

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

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

705 positional heuristics. 

706 

707 ``stage_state_indices`` lists the state entries owned by THIS 

708 stage (``stage_idx``); every state placeholder still gets a stage 

709 so foreign consumers can be attributed. 

710 

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

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

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

714 """ 

715 graph = graph_module.graph 

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

717 state_ph_set = set(placeholders[:num_state_inputs]) 

718 num_stages = run_ctx.pp_degree 

719 

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

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

722 

723 ( 

724 state_phs, 

725 deferred_state, 

726 stage_state_indices, 

727 user_input_phs, 

728 ) = self._classify_placeholders( 

729 placeholders, 

730 node_stage, 

731 node_phase, 

732 state_fqns, 

733 stage_of_fqn, 

734 num_state_inputs, 

735 run_ctx, 

736 ) 

737 self._classify_forward_nodes( 

738 graph, state_ph_set, stage_of_fqn, num_stages, node_stage, node_phase 

739 ) 

740 self._classify_backward_nodes( 

741 graph, state_ph_set, num_stages, node_stage, node_phase 

742 ) 

743 user_input_stages = self._route_user_inputs( 

744 user_input_phs, node_stage, num_stages 

745 ) 

746 self._assign_deferred_state( 

747 deferred_state, 

748 state_phs, 

749 state_fqns, 

750 node_stage, 

751 run_ctx.stage_idx, 

752 stage_state_indices, 

753 ) 

754 

755 return _GraphClassification( 

756 stage_state_indices=sorted(stage_state_indices), 

757 node_stage=node_stage, 

758 node_phase=node_phase, 

759 user_input_phs=user_input_phs, 

760 user_input_stages=user_input_stages, 

761 state_phs=state_phs, 

762 ) 

763 

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

765 self, 

766 placeholders: List[fx.Node], 

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

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

769 state_fqns: Sequence[str], 

770 stage_of_fqn: Dict[str, int], 

771 num_state_inputs: int, 

772 run_ctx: _RunContext, 

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

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

775 

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

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

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

779 

780 Args: 

781 placeholders: All placeholder nodes in graph order. 

782 node_stage: Stage assignment accumulator to fill. 

783 node_phase: Phase assignment accumulator to fill. 

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

785 stage_of_fqn: Module FQN -> owning stage map. 

786 num_state_inputs: Number of leading state placeholders. 

787 run_ctx: This rank's PP execution context. 

788 

789 Returns: 

790 ``(state_phs, deferred_state, stage_state_indices, 

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

792 indices whose stage is resolved later from their consumers. 

793 """ 

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

795 deferred_state: List[int] = [] 

796 stage_state_indices: List[int] = [] 

797 for idx, ph in enumerate(placeholders): 

798 node_phase[ph] = _FWD 

799 if idx < num_state_inputs: 

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

801 state_phs.append(ph) 

802 if stage is None: 

803 deferred_state.append(idx) 

804 else: 

805 node_stage[ph] = stage 

806 if stage == run_ctx.stage_idx: 

807 stage_state_indices.append(idx) 

808 user_input_phs = list(placeholders[num_state_inputs:]) 

809 return state_phs, deferred_state, stage_state_indices, user_input_phs 

810 

811 @staticmethod 

812 def _route_user_inputs( 

813 user_input_phs: List[fx.Node], 

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

815 num_stages: int, 

816 ) -> List[int]: 

817 """Assign each user-input placeholder to the stage consuming it. 

818 

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

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

821 anchors — see ``_classify_graph``). Each consumed input must feed 

822 exactly one stage: the schedule ships user inputs directly to their 

823 owner (no P2P), so an input consumed on several stages has no 

824 route. An input consumed by NO node (an ignored kwarg — the 

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

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

827 inputs have always ridden. 

828 

829 Args: 

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

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

832 num_stages: Total PP stage count. 

833 

834 Returns: 

835 The stage index per user input, in stub arg order. 

836 

837 Raises: 

838 ValueError: When an input is consumed by nodes on more than 

839 one stage. 

840 """ 

841 stages: List[int] = [] 

842 for ph in user_input_phs: 

843 consumer_stages = { 

844 node_stage[u] 

845 for u in ph.users 

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

847 } 

848 if not consumer_stages: 

849 _LOG.warning( 

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

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

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

853 "tensor unused too)", 

854 ph.name, 

855 ) 

856 stage = num_stages - 1 

857 elif len(consumer_stages) > 1: 

858 raise ValueError( 

859 f"Model input '{ph.name}' is consumed on stages " 

860 f"{sorted(consumer_stages)} — PP ships each user input " 

861 f"directly to the single stage that consumes it; split " 

862 f"the input per stage or restructure the graph" 

863 ) 

864 else: 

865 stage = next(iter(consumer_stages)) 

866 node_stage[ph] = stage 

867 stages.append(stage) 

868 _LOG.info( 

869 "User input '%s' routed to stage %s of %s", ph.name, stage, num_stages 

870 ) 

871 return stages 

872 

873 def _classify_forward_nodes( 

874 self, 

875 graph: fx.Graph, 

876 state_ph_set: Set[fx.Node], 

877 stage_of_fqn: Dict[str, int], 

878 num_stages: int, 

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

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

881 ) -> None: 

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

883 

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

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

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

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

888 """ 

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

890 for node in graph.nodes: 

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

892 continue 

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

894 if phase == _FWD: 

895 for arg in _iter_node_args(node): 

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

897 phase = _BWD 

898 break 

899 node_phase[node] = phase 

900 if phase == _FWD: 

901 stage = self._resolve_fwd_stage( 

902 node, node_stage, state_ph_set, stage_of_fqn 

903 ) 

904 if stage is None: 

905 floating.append(node) 

906 else: 

907 node_stage[node] = stage 

908 

909 self._assign_floating_fwd_nodes( 

910 graph, floating, num_stages, node_stage, node_phase 

911 ) 

912 

913 def _resolve_fwd_stage( 

914 self, 

915 node: fx.Node, 

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

917 state_ph_set: Set[fx.Node], 

918 stage_of_fqn: Dict[str, int], 

919 ) -> Optional[int]: 

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

921 

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

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

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

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

926 """ 

927 stage = self._stage_from_stack(node, stage_of_fqn) 

928 if stage is None: 

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

930 if stage is None: 

931 arg_stages = _tensor_arg_stages(node, node_stage) 

932 if arg_stages: 

933 stage = max(arg_stages) 

934 return stage 

935 

936 @staticmethod 

937 def _assign_floating_fwd_nodes( 

938 graph: fx.Graph, 

939 floating: List[fx.Node], 

940 num_stages: int, 

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

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

943 ) -> None: 

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

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

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

947 continue 

948 consumer_stages = { 

949 node_stage[u] 

950 for u in node.users 

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

952 } 

953 node_stage[node] = ( 

954 min(consumer_stages) if consumer_stages else num_stages - 1 

955 ) 

956 

957 def _classify_backward_nodes( 

958 self, 

959 graph: fx.Graph, 

960 state_ph_set: Set[fx.Node], 

961 num_stages: int, 

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

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

964 ) -> None: 

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

966 

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

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

969 upstream activations arrive as replayed forward outputs. Weight- 

970 gradient nodes that consume early activations are corrected by 

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

972 """ 

973 for node in graph.nodes: 

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

975 continue 

976 if node_phase[node] != _BWD: 

977 continue 

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

979 if stage is None: 

980 arg_stages = _tensor_arg_stages(node, node_stage) 

981 if arg_stages: 

982 stage = min(arg_stages) 

983 if stage is None: 

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

985 consumer_stages = { 

986 node_stage[u] 

987 for u in node.users 

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

989 } 

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

991 node_stage[node] = stage 

992 

993 def _assign_deferred_state( 

994 self, 

995 deferred_state: List[int], 

996 state_phs: List[fx.Node], 

997 state_fqns: Sequence[str], 

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

999 stage_idx: int, 

1000 stage_state_indices: List[int], 

1001 ) -> None: 

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

1003 

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

1005 fails with an actionable message. 

1006 """ 

1007 for idx in deferred_state: 

1008 ph = state_phs[idx] 

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

1010 if len(consumer_stages) > 1: 

1011 raise ValueError( 

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

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

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

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

1016 f"it per stage)" 

1017 ) 

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

1019 node_stage[ph] = stage 

1020 if stage == stage_idx: 

1021 stage_state_indices.append(idx) 

1022 _LOG.info( 

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

1024 state_fqns[idx], 

1025 stage, 

1026 ) 

1027 

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

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

1030 parts = fqn.split(".") 

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

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

1033 if stage is not None: 

1034 return stage 

1035 return stage_of_fqn.get(fqn) 

1036 

1037 def _stage_from_stack( 

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

1039 ) -> Optional[int]: 

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

1041 for fqn in _innermost_fqns(node): 

1042 stage = stage_of_fqn.get(fqn) 

1043 if stage is not None: 

1044 return stage 

1045 return None 

1046 

1047 def _stage_from_state_arg( 

1048 self, 

1049 node: fx.Node, 

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

1051 state_ph_set: Set[fx.Node], 

1052 ) -> Optional[int]: 

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

1054 

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

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

1057 node to that stage. Generic argument inheritance is deliberately 

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

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

1060 ``_classify_graph``). 

1061 """ 

1062 for arg in _iter_node_args(node): 

1063 if arg in state_ph_set: 

1064 return node_stage.get(arg) 

1065 return None 

1066 

1067 @staticmethod 

1068 def _propagate_anchor_stages( 

1069 graph_module: fx.GraphModule, 

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

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

1072 ) -> None: 

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

1074 

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

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

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

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

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

1080 untouched — they arrive via the grad crossings. 

1081 

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

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

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

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

1086 the worst case on large joint graphs. 

1087 """ 

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

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

1090 continue 

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

1092 continue 

1093 stage = node_stage.get(node) 

1094 if stage is None: 

1095 continue 

1096 for arg in _iter_node_args(node): 

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

1098 continue 

1099 if node_stage[arg] < stage: 

1100 node_stage[arg] = stage 

1101 

1102 # ------------------------------------------------------------------ 

1103 # Boundary detection 

1104 # ------------------------------------------------------------------ 

1105 

1106 @staticmethod 

1107 def _validate_adjacent_dataflow( 

1108 graph_module: fx.GraphModule, 

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

1110 ) -> None: 

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

1112 

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

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

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

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

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

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

1119 cause. 

1120 

1121 Raises: 

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

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

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

1125 the dataflow fallbacks). 

1126 """ 

1127 for node in graph_module.graph.nodes: 

1128 if node.op == "output": 

1129 continue 

1130 producer = node_stage.get(node) 

1131 if producer is None: 

1132 continue 

1133 for user in node.users: 

1134 if user.op == "output": 

1135 continue 

1136 consumer = node_stage.get(user) 

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

1138 continue 

1139 raise ValueError( 

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

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

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

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

1144 f"leaves modules unattributed produces this — adjust " 

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

1146 f"at a time." 

1147 ) 

1148 

1149 def _anchor_grad_outputs( 

1150 self, 

1151 graph_module: fx.GraphModule, 

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

1153 stage_of_fqn: Dict[str, int], 

1154 state_fqns: Sequence[str], 

1155 model: Optional[nn.Module], 

1156 ) -> None: 

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

1158 

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

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

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

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

1163 activation, replayed from the forward outputs). 

1164 """ 

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

1166 if output is None or model is None: 

1167 return 

1168 returned = output.args[0] 

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

1170 return 

1171 state_is_param = getattr( 

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

1173 ) 

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

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

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

1177 continue 

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

1179 if pstage is not None: 

1180 node_stage[grad_node] = pstage 

1181 

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

1183 self, 

1184 graph_module: fx.GraphModule, 

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

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

1187 pp_degree: int, 

1188 stage_idx: int, 

1189 ) -> _Boundaries: 

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

1191 

1192 Returns: 

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

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

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

1196 """ 

1197 k = stage_idx 

1198 is_last = k == pp_degree - 1 

1199 nodes = [ 

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

1201 ] 

1202 

1203 def crossing( 

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

1205 ) -> List[fx.Node]: 

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

1207 

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

1209 gradients as well as functionalized fwd views). 

1210 """ 

1211 found = [] 

1212 for v in nodes: 

1213 if node_stage[v] != producer: 

1214 continue 

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

1216 continue 

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

1218 found.append(v) 

1219 return found 

1220 

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

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

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

1224 # gradients flow back via grad crossings below. 

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

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

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

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

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

1230 # during the backward sweep just the same. 

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

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

1233 

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

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

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

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

1238 stage_owned_phs = { 

1239 p 

1240 for p in graph_module.graph.nodes 

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

1242 } 

1243 saved = self._collect_saved_values( 

1244 nodes, 

1245 node_stage, 

1246 node_phase, 

1247 k, 

1248 stage_owned_phs, 

1249 set(act_in_list), 

1250 grad_out_list, 

1251 ) 

1252 return _Boundaries( 

1253 act_in=act_in_list, 

1254 act_out=act_out_list, 

1255 grad_in=grad_in_list, 

1256 grad_out=grad_out_list, 

1257 saved=saved, 

1258 ) 

1259 

1260 @staticmethod 

1261 def _collect_saved_values( 

1262 nodes: List[fx.Node], 

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

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

1265 stage_idx: int, 

1266 stage_owned_phs: Set[fx.Node], 

1267 act_in_set: Set[fx.Node], 

1268 grad_out_list: List[fx.Node], 

1269 ) -> List[fx.Node]: 

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

1271 

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

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

1274 placeholders cannot be shipped and fail loudly during the slice 

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

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

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

1278 the bwd subgraph, and appended to its outputs. 

1279 """ 

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

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

1282 for node in nodes: 

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

1284 continue 

1285 for arg in _iter_node_args(node): 

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

1287 continue 

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

1289 # values are only shippable when they arrived as boundary 

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

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

1292 continue 

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

1294 continue 

1295 seen.add(arg) 

1296 saved.append(arg) 

1297 for v in grad_out_list: 

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

1299 seen.add(v) 

1300 saved.append(v) 

1301 return saved 

1302 

1303 # ------------------------------------------------------------------ 

1304 # Subgraph construction 

1305 # ------------------------------------------------------------------ 

1306 

1307 @staticmethod 

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

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

1310 candidate = name 

1311 suffix = 1 

1312 while candidate in used: 

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

1314 suffix += 1 

1315 used.add(candidate) 

1316 return candidate 

1317 

1318 def _copy_node( 

1319 self, 

1320 builder: _SubgraphBuilder, 

1321 node: fx.Node, 

1322 ) -> None: 

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

1324 builder's env/foreign maps. 

1325 

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

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

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

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

1330 """ 

1331 if node.op == "get_attr": 

1332 raise ValueError( 

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

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

1335 ) 

1336 

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

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

1339 if isinstance(a, fx.Node): 

1340 if a in builder.env: 

1341 return builder.env[a] 

1342 if a in builder.foreign: 

1343 return builder.foreign[a] 

1344 raise ValueError( 

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

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

1347 f"the stage plan is inconsistent" 

1348 ) 

1349 return a 

1350 

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

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

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

1354 new_node.meta = dict(node.meta) 

1355 builder.env[node] = new_node 

1356 

1357 def _build_fwd_graph( 

1358 self, 

1359 graph_module: fx.GraphModule, 

1360 run_ctx: _RunContext, 

1361 split: _StageSplit, 

1362 boundaries: _Boundaries, 

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

1364 """Build this stage's forward subgraph. 

1365 

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

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

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

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

1370 full output tuple into the backward subgraph. 

1371 

1372 Returns: 

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

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

1375 builder can key its placeholder mapping on them. 

1376 """ 

1377 k = run_ctx.stage_idx 

1378 is_last = k == run_ctx.pp_degree - 1 

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

1380 

1381 self._create_fwd_placeholders(builder, run_ctx, split, boundaries) 

1382 

1383 for node in graph_module.graph.nodes: 

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

1385 continue 

1386 if split.node_phase[node] != _FWD or split.node_stage[node] != k: 

1387 continue 

1388 self._copy_node(builder, node) 

1389 

1390 loss_node = self._loss_node(graph_module) 

1391 out_orig = self._resolve_fwd_outputs( 

1392 boundaries.act_out, boundaries.saved, loss_node, is_last 

1393 ) 

1394 out_nodes = [ 

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

1396 ] 

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

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

1399 

1400 def _create_fwd_placeholders( 

1401 self, 

1402 builder: _SubgraphBuilder, 

1403 run_ctx: _RunContext, 

1404 split: _StageSplit, 

1405 boundaries: _Boundaries, 

1406 ) -> None: 

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

1408 

1409 Incoming boundary values (stage k > 0) become placeholders; each 

1410 stage additionally takes the user inputs routed to it (stage 0 

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

1412 ``_route_user_inputs``). 

1413 """ 

1414 for i, orig_idx in enumerate(split.stage_state_indices): 

1415 ph = builder.g.placeholder( 

1416 self._unique(_sanitize(split.stage_state_fqns[i]), builder.used) 

1417 ) 

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

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

1420 

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

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

1423 ph.meta = dict(act.meta) 

1424 builder.foreign[act] = ph 

1425 

1426 for i, (ph, stage) in enumerate( 

1427 zip(split.user_input_phs, split.user_input_stages) 

1428 ): 

1429 if stage != run_ctx.stage_idx: 

1430 continue 

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

1432 in_ph.meta = dict(ph.meta) 

1433 builder.env[ph] = in_ph 

1434 

1435 @staticmethod 

1436 def _resolve_fwd_outputs( 

1437 act_out_list: List[fx.Node], 

1438 saved: List[fx.Node], 

1439 loss_node: Optional[fx.Node], 

1440 is_last: bool, 

1441 ) -> List[fx.Node]: 

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

1443 if is_last: 

1444 if loss_node is None: 

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

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

1447 else: 

1448 if not act_out_list: 

1449 raise ValueError( 

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

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

1452 ) 

1453 out_orig = list(act_out_list) 

1454 

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

1456 for node in saved: 

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

1458 continue 

1459 out_orig.append(node) 

1460 return out_orig 

1461 

1462 def _build_bwd_graph( 

1463 self, 

1464 graph_module: fx.GraphModule, 

1465 run_ctx: _RunContext, 

1466 split: _StageSplit, 

1467 boundaries: _Boundaries, 

1468 fwd_out_orig: List[fx.Node], 

1469 trainable_indices: List[int], 

1470 ) -> fx.GraphModule: 

1471 """Build this stage's backward subgraph. 

1472 

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

1474 passes the received boundary gradients and then the forward 

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

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

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

1478 """ 

1479 k = run_ctx.stage_idx 

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

1481 self._create_bwd_placeholders( 

1482 builder, graph_module, run_ctx, split, boundaries, fwd_out_orig 

1483 ) 

1484 

1485 stage_bwd_set = self._collect_stage_bwd_nodes( 

1486 graph_module.graph, split.node_stage, split.node_phase, k 

1487 ) 

1488 

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

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

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

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

1493 stage_grad_nodes = self._stage_grad_nodes( 

1494 graph_module.graph, trainable_indices, set(split.stage_state_indices) 

1495 ) 

1496 stage_grad_set = set(stage_grad_nodes) 

1497 

1498 for node in graph_module.graph.nodes: 

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

1500 continue 

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

1502 continue 

1503 self._copy_node(builder, node) 

1504 

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

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

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

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

1509 

1510 @staticmethod 

1511 def _collect_stage_bwd_nodes( 

1512 graph: fx.Graph, 

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

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

1515 stage_idx: int, 

1516 ) -> Set[fx.Node]: 

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

1518 

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

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

1521 """ 

1522 return { 

1523 node 

1524 for node in graph.nodes 

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

1526 and node_phase[node] == _BWD 

1527 and node_stage[node] == stage_idx 

1528 } 

1529 

1530 def _create_bwd_placeholders( 

1531 self, 

1532 builder: _SubgraphBuilder, 

1533 graph_module: fx.GraphModule, 

1534 run_ctx: _RunContext, 

1535 split: _StageSplit, 

1536 boundaries: _Boundaries, 

1537 fwd_out_orig: List[fx.Node], 

1538 ) -> None: 

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

1540 

1541 The schedule passes the received boundary gradients and then the 

1542 forward outputs verbatim (on the last stage a single 

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

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

1545 referencing forward values. 

1546 """ 

1547 for i, orig_idx in enumerate(split.stage_state_indices): 

1548 ph = builder.g.placeholder( 

1549 self._unique(_sanitize(split.stage_state_fqns[i]), builder.used) 

1550 ) 

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

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

1553 

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

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

1556 ph.meta = dict(grad.meta) 

1557 builder.foreign[grad] = ph 

1558 if not boundaries.grad_in and run_ctx.stage_idx == run_ctx.pp_degree - 1: 

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

1560 # single gradient slot. 

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

1562 loss = self._loss_node(graph_module) 

1563 if loss is not None: 

1564 ph.meta = dict(loss.meta) 

1565 

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

1567 # passes the forward outputs verbatim after the gradients. 

1568 for orig in fwd_out_orig: 

1569 ph = builder.g.placeholder( 

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

1571 ) 

1572 ph.meta = dict(orig.meta) 

1573 builder.foreign[orig] = ph 

1574 

1575 @staticmethod 

1576 def _stage_grad_nodes( 

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

1578 ) -> List[fx.Node]: 

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

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

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

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

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

1584 if i >= len(trainable_indices): 

1585 break 

1586 if trainable_indices[i] in stage_state_set: 

1587 stage_grad_nodes.append(grad_node) 

1588 return stage_grad_nodes 

1589 

1590 # ------------------------------------------------------------------ 

1591 # Live model pruning 

1592 # ------------------------------------------------------------------ 

1593 

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

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

1596 

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

1598 children, filter ModuleList/ModuleDict entries) while PRESERVING 

1599 original index keys — reindexing a ModuleList would rename the 

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

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

1602 inside a container element; such elements are recursed into 

1603 (mixed subtree), exactly like plain modules. 

1604 """ 

1605 keep = set(keep_fqns) 

1606 

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

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

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

1610 

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

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

1613 

1614 Reindexing would rename the surviving parameters' FQNs and break 

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

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

1617 it (same rule as plain modules). 

1618 """ 

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

1620 keys = list(children.keys()) 

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

1622 return 

1623 for key in keys: 

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

1625 if is_kept(elem_fqn): 

1626 continue 

1627 elem = children[key] 

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

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

1630 prune(elem, elem_fqn + ".") 

1631 else: 

1632 del children[key] 

1633 

1634 def prune_plain_child( 

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

1636 ) -> None: 

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

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

1639 if not descendants: 

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

1641 if not is_kept(cfqn): 

1642 setattr(mod, name, None) 

1643 return 

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

1645 setattr(mod, name, None) 

1646 else: 

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

1648 prune(child, cfqn + ".") 

1649 

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

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

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

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

1654 if is_kept(cfqn): 

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

1656 # keep the whole subtree. 

1657 continue 

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

1659 prune_container_child(child, cfqn) 

1660 continue 

1661 prune_plain_child(mod, name, child, cfqn) 

1662 

1663 prune(model, "") 

1664 

1665 # ------------------------------------------------------------------ 

1666 # Schedule + stub installation 

1667 # ------------------------------------------------------------------ 

1668 

1669 def _trainable_state_indices( 

1670 self, 

1671 state_fqns: Sequence[str], 

1672 state_is_param: Sequence[bool], 

1673 model: Optional[nn.Module], 

1674 ) -> List[int]: 

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

1676 param_lookup = ( 

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

1678 if model is not None 

1679 else {} 

1680 ) 

1681 trainable: List[int] = [] 

1682 for idx, fqn in enumerate(state_fqns): 

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

1684 continue 

1685 param = param_lookup.get(fqn) 

1686 if param is not None and param.requires_grad: 

1687 trainable.append(idx) 

1688 return trainable 

1689 

1690 @staticmethod 

1691 def _value_spec( 

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

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

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

1695 

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

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

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

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

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

1701 at least one tensor. 

1702 """ 

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

1704 for v in nodes: 

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

1706 if isinstance(val, torch.Tensor): 

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

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

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

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

1711 # concrete values are stable. 

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

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

1714 elif val is None: 

1715 raise ValueError( 

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

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

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

1719 ) 

1720 else: 

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

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

1723 raise ValueError( 

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

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

1726 f"receive device on" 

1727 ) 

1728 return spec 

1729 

1730 def _build_schedule( 

1731 self, 

1732 fwd_gm: fx.GraphModule, 

1733 bwd_gm: fx.GraphModule, 

1734 pass_config: PassConfig, 

1735 run_ctx: _RunContext, 

1736 split: _StageSplit, 

1737 num_trainable: int, 

1738 boundaries: _Boundaries, 

1739 ) -> PipelineScheduleBase: 

1740 """Assemble this stage's pipeline schedule (``pass_config.pp_schedule``). 

1741 

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

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

1744 """ 

1745 recv_spec = ( 

1746 self._value_spec(boundaries.act_in, run_ctx.stage_idx, "activation") 

1747 if run_ctx.stage_idx > 0 

1748 else [] 

1749 ) 

1750 grad_recv_spec = ( 

1751 self._value_spec(boundaries.grad_in, run_ctx.stage_idx, "gradient") 

1752 if run_ctx.stage_idx < run_ctx.pp_degree - 1 

1753 else [] 

1754 ) 

1755 schedule_cls = get_schedule_class(pass_config.pp_schedule) 

1756 return schedule_cls( 

1757 fwd_gm, 

1758 bwd_gm, 

1759 stage_idx=run_ctx.stage_idx, 

1760 pp_degree=run_ctx.pp_degree, 

1761 num_state=len(split.stage_state_fqns), 

1762 num_trainable=num_trainable, 

1763 num_send=len(boundaries.act_out), 

1764 grad_send_count=len(boundaries.grad_out), 

1765 microbatch_size=pass_config.pp_microbatch_size, 

1766 pp_group=run_ctx.group, 

1767 recv_spec=recv_spec, 

1768 grad_recv_spec=grad_recv_spec, 

1769 user_input_stages=split.user_input_stages, 

1770 ) 

1771 

1772 def _install_stub( 

1773 self, 

1774 graph_module: fx.GraphModule, 

1775 sched: PipelineScheduleBase, 

1776 stage_state_fqns: List[str], 

1777 state_is_param: Sequence[bool], 

1778 stage_state_indices: List[int], 

1779 num_user_inputs: int, 

1780 ) -> None: 

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

1782 

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

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

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

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

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

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

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

1790 list objects). 

1791 """ 

1792 g = fx.Graph() 

1793 used: Set[str] = set() 

1794 state_phs = [ 

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

1796 for fqn in stage_state_fqns 

1797 ] 

1798 user_phs = [ 

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

1800 for i in range(num_user_inputs) 

1801 ] 

1802 

1803 graph_module.add_module("pp_schedule", sched) 

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

1805 g.output(call) 

1806 

1807 graph_module.graph = g 

1808 graph_module.state_fqns[:] = stage_state_fqns 

1809 graph_module.state_is_param[:] = [ 

1810 state_is_param[i] for i in stage_state_indices 

1811 ] 

1812 graph_module.num_state_inputs = len(stage_state_fqns) 

1813 graph_module.recompile()