Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / compile / passes / overlap / overlap_param_gather.py: 89%

202 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"""Prefetch FSDP parameters in a joint forward/backward FX graph. 

16 

17Adapted from https://gitcode.com/Ascend/pytorch/pull/38283/diffs: 

18group gathers by their first real consumer, launch ahead of compute, and sink 

19waits through parameter views. HyperParallel identifies gathers by FSDPPass 

20metadata rather than by placeholder inputs (which would also match TP). 

21Forward and backward are scheduled separately; storage-release boundaries 

22remain explicit constraints. Like manual overlap scheduling, collective 

23reordering is part of this pass and requires consistent participating graphs. 

24""" 

25 

26__all__ = ["overlap_param_gather"] 

27 

28import logging 

29from dataclasses import dataclass 

30 

31import torch 

32from torch import fx 

33from torch.ops import _c10d_functional 

34 

35from ...pass_config import PassConfig 

36from .utils import bucket_preparation, is_barrier, is_op, is_view 

37 

38_LOG = logging.getLogger(__name__) 

39_GATHER_TYPES = ("fsdp_all_gather", "fsdp_all_gather_backward") 

40_HEAVY_OPS = {"aten.mm", "aten.addmm", "aten.bmm", "aten.matmul", "aten.linear"} 

41 

42# Mutable module policies keep graph consumer lookahead separate from eager FSDP configuration. 

43fsdp_forward_prefetch_depth: int = 1 

44fsdp_backward_prefetch_depth: int = 1 

45 

46 

47@dataclass 

48class _Gather: 

49 """One tagged FSDP gather and its complete wait/view realization DAG.""" 

50 

51 launch: fx.Node 

52 realization: list[fx.Node] 

53 first_user: fx.Node 

54 

55 

56def _indices(graph: fx.Graph) -> dict[fx.Node, int]: 

57 return {node: index for index, node in enumerate(graph.nodes)} 

58 

59 

60def _is_attention(node: fx.Node) -> bool: 

61 return node.op == "call_function" and any( 

62 name in str(node.target) 

63 for name in ("npu_fusion_attention", "scaled_dot_product", "flash_attention") 

64 ) 

65 

66 

67def _is_heavy(node: fx.Node) -> bool: 

68 name = str(node.target) 

69 return node.op == "call_function" and ( 

70 name.rsplit(".", 1)[0] in _HEAVY_OPS or _is_attention(node) 

71 ) 

72 

73 

74def _collect_gathers(graph: fx.Graph) -> list[_Gather]: 

75 """Find real consumers through views and the bucket's explicit copy-out nodes.""" 

76 order = _indices(graph) 

77 matches = [] 

78 for launch in graph.nodes: 

79 if launch.meta.get("comm_type") not in _GATHER_TYPES: 

80 continue 

81 if not is_op(launch, _c10d_functional.all_gather_into_tensor): 

82 continue 

83 waits = list(launch.users) 

84 if len(waits) != 1 or not is_op(waits[0], _c10d_functional.wait_tensor): 

85 continue 

86 realization = {waits[0]} 

87 stack = [waits[0]] 

88 consumers = set() 

89 while stack: 

90 current = stack.pop() 

91 for user in current.users: 

92 copy_out = (launch.meta.get("fsdp_bucket_id") is not None 

93 and user.meta.get("fsdp_bucket_copy_out") == launch.meta["fsdp_bucket_id"] 

94 and user.target is torch.ops.aten.clone.default) 

95 if is_view(user) or copy_out: 

96 if user not in realization: 

97 realization.add(user) 

98 stack.append(user) 

99 else: 

100 consumers.add(user) 

101 if consumers: 

102 matches.append(_Gather(launch, sorted(realization, key=order.get), min(consumers, key=order.get))) 

103 return matches 

104 

105 

106def _launch_chain(launch: fx.Node) -> list[fx.Node]: 

107 """Move the private bucket pack DAG or an original private shard cast.""" 

108 if launch.meta.get("fsdp_bucket_id"): 

109 return [*bucket_preparation(launch, _indices(launch.graph)), launch] 

110 shard = launch.args[0] 

111 if isinstance(shard, fx.Node) and len(shard.users) == 1 and shard.op == "call_function": 

112 schema = getattr(shard.target, "_schema", None) 

113 if schema is not None and schema.name in ("prims::convert_element_type", "aten::_to_copy"): 

114 if shard.all_input_nodes and all(node.op == "placeholder" for node in shard.all_input_nodes): 

115 return [shard, launch] 

116 return [launch] 

117 

118 

119def _gather_nbytes(launch: fx.Node) -> int | None: 

120 nbytes = launch.meta.get("fsdp_unsharded_nbytes") 

121 return nbytes if isinstance(nbytes, int) and not isinstance(nbytes, bool) and nbytes >= 0 else None 

122 

123 

124def _within_budget( 

125 match: _Gather, 

126 target: fx.Node, 

127 prefetched: list[_Gather], 

128 order: dict[fx.Node, int], 

129 budget: int, 

130) -> bool: 

131 """Bound full replicas launched early but not yet consumed, across anchors.""" 

132 nbytes = _gather_nbytes(match.launch) 

133 if nbytes is None: 

134 return False 

135 events = [(2 * order[target] - 1, nbytes), (2 * order[match.first_user], -nbytes)] 

136 for previous in prefetched: 

137 if previous is match: 

138 continue 

139 previous_bytes = _gather_nbytes(previous.launch) 

140 if previous_bytes is None: 

141 return False 

142 events.extend(((2 * order[previous.launch], previous_bytes), 

143 (2 * order[previous.first_user], -previous_bytes))) 

144 live_bytes = 0 

145 for _, delta in sorted(events): 

146 live_bytes += delta 

147 if live_bytes > budget: 

148 return False 

149 return True 

150 

151 

152def _add_attention_interval_anchors( 

153 groups: dict[fx.Node, list[_Gather]], 

154 nodes: list[fx.Node], 

155 order: dict[fx.Node, int], 

156 anchors: dict[fx.Node, fx.Node], 

157) -> None: 

158 """Merge earlier attention-interval candidates into the lookahead anchors.""" 

159 users = list(groups) 

160 attention = [node for node in nodes if _is_attention(node)] 

161 boundaries = [-1, *[order[node] for node in attention], len(order)] 

162 for index in range(len(boundaries) - 1): 

163 left, right = boundaries[index:index + 2] 

164 heavy = [node for node in nodes if _is_heavy(node) and max(left, 0) <= order[node] <= right] 

165 if not attention or not heavy: 

166 continue 

167 end = boundaries[index + 2] if index + 2 < len(boundaries) else right 

168 targets = [user for user in users if right < order[user] <= end] 

169 if index == 0: 

170 targets = [user for user in users if left < order[user] <= right] + targets 

171 for target_index, user in enumerate(targets): 

172 anchor = heavy[target_index * len(heavy) // len(targets)] 

173 for match in groups[user]: 

174 existing = anchors.get(match.launch, user) 

175 if order[anchor] < order[existing]: 

176 anchors[match.launch] = anchor 

177 

178 

179def _prefetch_anchors( 

180 matches: list[_Gather], nodes: list[fx.Node], depth: int, order: dict[fx.Node, int] 

181) -> dict[fx.Node, fx.Node]: 

182 """Combine consumer lookahead with the PR's attention-interval anchors.""" 

183 groups: dict[fx.Node, list[_Gather]] = {} 

184 for match in sorted(matches, key=lambda item: order[item.first_user]): 

185 groups.setdefault(match.first_user, []).append(match) 

186 users = list(groups) 

187 anchors = {} 

188 for index, user in enumerate(users): 

189 if index > 0: 

190 for match in groups[user]: 

191 anchors[match.launch] = users[max(0, index - depth)] 

192 

193 _add_attention_interval_anchors(groups, nodes, order, anchors) 

194 return anchors 

195 

196 

197def _launch_constraints(graph: fx.Graph) -> dict[fx.Node, list[fx.Node]]: 

198 """Preserve effects and parameter lifetimes during collective reordering.""" 

199 constraints = {} 

200 previous_effect = None 

201 frees = {} 

202 for node in graph.nodes: 

203 if node.meta.get("comm_type") in _GATHER_TYPES: 

204 names = node.meta.get("fsdp_bucket_members", (node.meta.get("param_name"),)) 

205 predecessors = [previous_effect, *(frees.get(name) for name in names)] 

206 constraints[node] = [item for item in predecessors if item is not None] 

207 if is_barrier(node): 

208 previous_effect = node 

209 if node.meta.get("comm_type") in ("fsdp_reshard_free", "fsdp_reshard_free_backward"): 

210 frees[node.meta.get("param_name")] = node 

211 return constraints 

212 

213 

214def _prefetch_phase( 

215 graph: fx.Graph, matches: list[_Gather], depth: int, budget: int, constraints: dict[fx.Node, list[fx.Node]] 

216) -> int: 

217 """Prefetch one phase within input, effect, collective, and byte constraints.""" 

218 if not matches or depth == 0 or budget == 0: 

219 return 0 

220 backward = bool(matches[0].launch.meta.get("autograd_backward", False)) 

221 nodes = [node for node in graph.nodes if node.op not in ("placeholder", "get_attr") 

222 and bool(node.meta.get("autograd_backward", False)) == backward] 

223 order = _indices(graph) 

224 phase_start = nodes[0] 

225 anchors = _prefetch_anchors(matches, nodes, depth, order) 

226 prefetched = [match for match in matches if "fsdp_prefetch_anchor" in match.launch.meta] 

227 changed = 0 

228 for match in matches: 

229 anchor = anchors.get(match.launch) 

230 if anchor is None: 

231 continue 

232 order = _indices(graph) 

233 chain = _launch_chain(match.launch) 

234 predecessors = list(constraints[match.launch]) 

235 predecessors.extend(arg for node in chain for arg in node.all_input_nodes if arg not in chain) 

236 position = max(order[anchor], order[phase_start], *(order[node] + 1 for node in predecessors)) 

237 if position >= order[chain[0]]: 

238 continue 

239 target = list(graph.nodes)[position] 

240 if not _within_budget(match, target, prefetched, order, budget): 

241 continue 

242 for node in chain: 

243 target.prepend(node) 

244 match.launch.meta["fsdp_prefetch_anchor"] = anchor.name 

245 if match not in prefetched: 

246 prefetched.append(match) 

247 changed += 1 

248 return changed 

249 

250 

251def _sink_realization(graph: fx.Graph, matches: list[_Gather]) -> int: 

252 """Sink every branch in reverse topological order, keeping all users valid.""" 

253 changed = 0 

254 for match in matches: 

255 next_realization = None 

256 for node in reversed(match.realization): 

257 if not node.users: 

258 continue 

259 nodes = list(graph.nodes) 

260 order = {item: index for index, item in enumerate(nodes)} 

261 # Preserve independent branch order, including copy-outs sharing a 

262 # consumer; otherwise reverse traversal flips them on every run. 

263 successors = list(node.users) 

264 if next_realization is not None: 

265 successors.append(next_realization) 

266 target = min(successors, key=order.get) 

267 # Other parameter frees may be crossed, but this storage's free 

268 # is already a user of its realization and bounds the sinking. 

269 for between in nodes[order[node] + 1:order[target]]: 

270 if is_barrier(between) or ( 

271 bool(between.meta.get("autograd_backward", False)) 

272 != bool(match.launch.meta.get("autograd_backward", False)) 

273 ): 

274 target = between 

275 break 

276 if order[target] > order[node] + 1: 

277 target.prepend(node) 

278 changed += 1 

279 next_realization = node 

280 return changed 

281 

282 

283def overlap_param_gather(graph_module: fx.GraphModule, pass_config: PassConfig) -> fx.GraphModule: 

284 """Schedule tagged FSDP gathers without changing tensor or collective semantics. 

285 

286 Args: 

287 graph_module: Joint graph after FSDPPass (including reshard/rematerialization). 

288 pass_config: Independent prefetch switch and maximum early replicas in GiB. 

289 

290 Returns: 

291 The transformed graph. Unknown replica sizes are conservatively not prefetched. 

292 The budget covers early launches until first use, not total model/activation memory. 

293 

294 Note: 

295 Collective reordering requires identical static graphs and 

296 configuration across participating ranks. Only FSDP gathers move; other 

297 launches retain their relative order. Input, effect and free boundaries 

298 still apply. Module variables fsdp_forward_prefetch_depth and 

299 fsdp_backward_prefetch_depth set consumer-group lookahead (default 1). 

300 Patch them consistently on all ranks before compilation; existing 

301 compiled graphs are unaffected. Zero disables early gathers in that 

302 phase, including attention-interval prefetch, but still sinks waits/views. 

303 """ 

304 if not pass_config.fsdp_allgather_prefetch: 

305 return graph_module 

306 depths = (fsdp_forward_prefetch_depth, fsdp_backward_prefetch_depth) 

307 for phase, depth in zip(("forward", "backward"), depths): 

308 if not isinstance(depth, int) or isinstance(depth, bool) or depth < 0: 

309 raise ValueError(f"fsdp_{phase}_prefetch_depth must be a non-negative integer") 

310 graph = graph_module.graph 

311 graph.lint() 

312 matches = _collect_gathers(graph) 

313 constraints = _launch_constraints(graph) 

314 budget = int(pass_config.max_in_flight_gb * 1024 * 1024 * 1024) 

315 counts = [] 

316 for backward, depth in zip((False, True), depths): 

317 phase_matches = [match for match in matches 

318 if bool(match.launch.meta.get("autograd_backward", False)) == backward] 

319 counts.append(_prefetch_phase(graph, phase_matches, depth, budget, constraints)) 

320 sunk = _sink_realization(graph, matches) 

321 graph.lint() 

322 graph_module.recompile() 

323 graph_module.meta["fsdp_prefetch"] = {"forward": counts[0], "backward": counts[1], "sunk": sunk} 

324 _LOG.info("FSDP parameter prefetch: forward=%s backward=%s sunk=%s budget_bytes=%s depths=%s", 

325 counts[0], counts[1], sunk, budget, depths) 

326 return graph_module