Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / components / modules / module_family.py: 92%

248 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"""Shared construction of concrete family members for direct and source models.""" 

16 

17from __future__ import annotations 

18 

19import inspect 

20from collections.abc import Mapping 

21from copy import deepcopy 

22from dataclasses import dataclass, replace 

23from types import MappingProxyType 

24from typing import Any, Callable 

25 

26from torch import nn 

27 

28from hyper_parallel.components.modules.family import ( 

29 ModuleFamily, 

30 SelectorSpec, 

31 VariantRule, 

32 _FAMILIES, 

33 _validate_deferred_arguments, 

34 _select_variant, 

35 get_family_selector_specs, 

36 get_module_family, 

37 register_module_family, 

38 resolve_module_family_target, 

39) 

40 

41 

42__all__ = [ 

43 "ModuleFamily", "SelectorSpec", "VariantRule", "FamilyRequest", "ResolvedFamily", 

44 "FamilyBuildInfo", "FamilyBoundary", "register_module_family", "get_module_family", 

45 "get_family_selector_specs", "resolve_module_family_target", "resolve_family", "build_family", 

46 "get_family_build_info", "collect_family_modules", "validate_family_context", 

47 "FamilyShardingContext", 

48] 

49 

50 

51@dataclass(frozen=True) 

52class FamilyShardingContext: 

53 """Logical computation axes and global shapes; no communication resources.""" 

54 

55 mesh_dim_names: tuple[str, ...] 

56 axis_sizes: Mapping[str, int] 

57 parameter_shapes: Mapping[str, tuple[int, ...]] 

58 sequence_parallel: bool = False 

59 loss_parallel: bool = False 

60 

61 def __post_init__(self) -> None: 

62 object.__setattr__(self, "axis_sizes", MappingProxyType(dict(self.axis_sizes))) 

63 object.__setattr__(self, "parameter_shapes", MappingProxyType(dict(self.parameter_shapes))) 

64 

65 

66@dataclass(frozen=True) 

67class FamilyRequest: 

68 """A public family and its explicit construction arguments, before selection.""" 

69 

70 target: Callable 

71 kwargs: Mapping[str, Any] 

72 

73 def __post_init__(self) -> None: 

74 family = get_module_family(self.target) 

75 if family is None or family.default_variant is None: 

76 raise ValueError("FamilyRequest requires a family with a concrete default_variant") 

77 _validate_deferred_arguments(family, self.kwargs) 

78 object.__setattr__(self, "kwargs", MappingProxyType(deepcopy(dict(self.kwargs)))) 

79 

80 

81@dataclass(frozen=True) 

82class ResolvedFamily: 

83 """One validated implementation choice without parameters or communication resources.""" 

84 

85 family: ModuleFamily 

86 variant: str 

87 constructor: Callable 

88 arguments: Mapping[str, Any] 

89 selectors: Mapping[str, Any] 

90 axis_sizes: Mapping[str, int] 

91 

92 @property 

93 def sharding(self) -> Callable | None: 

94 """Return the selected family's layout provider.""" 

95 return self.family.sharding 

96 

97 

98@dataclass(frozen=True) 

99class _FamilyLayout: 

100 """Borrowed mesh and completed component layout, outside serialized requests.""" 

101 

102 mesh: Any 

103 sharding: Any 

104 parallel: Any 

105 parameter_shapes: Mapping[str, tuple[int, ...]] 

106 source_shard_info: Mapping[str, Any] 

107 applied: bool = True 

108 

109 

110@dataclass(frozen=True) 

111class FamilyBuildInfo: 

112 """Construction provenance and layout records; execution resources stay on the module.""" 

113 

114 request: FamilyRequest 

115 resolved: ResolvedFamily 

116 config_snapshot: Any 

117 origin: str 

118 layout: _FamilyLayout | None = None 

119 

120 

121@dataclass(frozen=True) 

122class FamilyBoundary: 

123 """One concrete family instance with all its paths in the final model tree.""" 

124 

125 module: nn.Module 

126 fqns: tuple[str, ...] 

127 info: FamilyBuildInfo 

128 

129 

130def _axis_sizes(context: Mapping[str, Any]) -> Mapping[str, int]: 

131 """Read and validate logical parallel degrees from the construction context.""" 

132 result = {} 

133 for axis in ("tp", "cp", "ep", "pp"): 

134 size = context.get(f"{axis}_size") 

135 if size is None: 

136 if context.get(axis): 

137 raise ValueError(f"{axis}_size is required when {axis} is active") 

138 size = 1 

139 actual_type = type(size) 

140 if actual_type is not int or size < 1: 

141 raise ValueError(f"{axis}_size must be a positive integer") 

142 result[axis] = size 

143 return MappingProxyType(result) 

144 

145 

146def _mesh_axis_size(mesh: Any, axis: str) -> int: 

147 """Validate declared sizes against the actual borrowed axis views.""" 

148 device_mesh = mesh.device_mesh 

149 region = getattr(mesh, "fsdp_moe_mesh", None) if axis == "ep" else None 

150 if region is None: 

151 region = device_mesh 

152 names = tuple(getattr(region, "mesh_dim_names", ()) or ()) 

153 actual = region[axis].size() if axis in names else 1 

154 size = getattr(mesh, f"{axis}_size", actual) 

155 actual_type = type(size) 

156 if actual_type is not int or size < 1: 

157 raise ValueError(f"{axis}_size must be a positive integer") 

158 if axis in names and size != actual: 

159 raise ValueError(f"{axis}_size={size} disagrees with the mesh size {actual}") 

160 if axis not in names and size > 1 and axis != "pp": 

161 raise ValueError(f"{axis}_size={size} requires an axis named {axis!r}; available mesh axes: {names!r}") 

162 return size 

163 

164 

165def _context_from_mesh(mesh: Any, context: Mapping[str, Any] | None) -> Mapping[str, Any]: 

166 """Read actual job axes without copying mesh resources into a request.""" 

167 result = dict(context or {}) 

168 if mesh is None: 

169 mesh = result.get("mesh") 

170 if getattr(mesh, "device_mesh", None) is None: 

171 return result 

172 device_mesh = getattr(mesh, "device_mesh", None) 

173 if device_mesh is None: 

174 raise ValueError("mesh requires a prebuilt device_mesh") 

175 previous = result.get("mesh") 

176 if previous is not None and not _same_mesh(previous, mesh): 

177 raise ValueError("context and mesh belong to different jobs") 

178 for axis in ("tp", "cp", "ep", "pp"): 

179 size = _mesh_axis_size(mesh, axis) 

180 if f"{axis}_size" in result and result[f"{axis}_size"] != size: 

181 raise ValueError(f"context and mesh disagree on {axis}_size") 

182 result[f"{axis}_size"] = size 

183 result[axis] = size > 1 

184 result["mesh"] = mesh 

185 result["sequence_parallel"] = bool(getattr(mesh, "sequence_parallel", False)) 

186 result["loss_parallel"] = bool(getattr(mesh, "loss_parallel", False)) 

187 return result 

188 

189 

190def resolve_family(request: FamilyRequest, *, context: Mapping[str, Any] | None = None) -> ResolvedFamily: 

191 """Resolve exactly one allowlisted member using the current job context. 

192 

193 Args: 

194 request: Original family and explicit arguments. 

195 context: Read-only job topology used for automatic CP/EP selection. 

196 

197 Returns: 

198 Concrete construction information without allocating model resources. 

199 """ 

200 family = get_module_family(request.target) 

201 context = context or {} 

202 axes = _axis_sizes(context) 

203 args = deepcopy(dict(request.kwargs)) 

204 parallel = family.parallel_axis is not None and axes[family.parallel_axis] > 1 

205 constructor, variant, selectors = _select_variant(family, args, parallel=parallel) 

206 if constructor is family.baseline: 

207 raise ValueError("a concrete family member cannot be its public factory") 

208 return ResolvedFamily( 

209 family, variant, constructor, MappingProxyType(args), MappingProxyType(selectors), axes, 

210 ) 

211 

212 

213def _constructor_arguments( 

214 member: ResolvedFamily, config: Any, module: nn.Module | None, fqn: str, context: Mapping[str, Any], 

215) -> dict[str, Any]: 

216 """Bind source or config construction arguments accepted by the selected member.""" 

217 signature = inspect.signature(member.constructor) 

218 required = "config" if module is None else "module" 

219 if required not in signature.parameters: 

220 raise TypeError(f"member {member.variant!r} must explicitly accept {required}") 

221 kwargs = dict(member.arguments) 

222 unknown = set(kwargs) - set(signature.parameters) 

223 if unknown: 

224 raise TypeError(f"member {member.variant!r} does not accept {sorted(unknown)}") 

225 runtime = {"config": config, "module": module, "module_fqn": fqn, "context": context} 

226 kwargs.update({name: value for name, value in runtime.items() if name in signature.parameters}) 

227 signature.bind(**kwargs) 

228 return kwargs 

229 

230 

231def _normalize_family_request(target, kwargs): 

232 """Resolve a public name without selecting a topology-dependent member.""" 

233 if isinstance(target, str): 

234 matches = [family.baseline for family in _FAMILIES.values() if family.name == target] 

235 if len(matches) != 1: 

236 raise ValueError(f"family name {target!r} must identify exactly one registration") 

237 target = matches[0] 

238 if isinstance(target, FamilyRequest): 

239 if kwargs: 

240 raise ValueError("request arguments cannot be combined with additional keywords") 

241 return target 

242 return FamilyRequest(target, kwargs) 

243 

244 

245def _build_family_member( 

246 request: FamilyRequest, 

247 member: ResolvedFamily, 

248 *, 

249 config: Any = None, 

250 module: nn.Module | None = None, 

251 module_fqn: str = "", 

252 context: Mapping[str, Any], 

253) -> nn.Module: 

254 """Construct an already resolved choice without parsing the same request again.""" 

255 context = MappingProxyType(dict(context)) 

256 if member.family.validate is not None: 

257 member.family.validate(config=config, module=module, context=context, member=member) 

258 config = deepcopy(config) 

259 result = member.constructor(**_constructor_arguments(member, config, module, module_fqn, context)) 

260 if not isinstance(result, nn.Module): 

261 raise TypeError("a family constructor must return nn.Module") 

262 info = FamilyBuildInfo(request, member, deepcopy(config), "direct" if module is None else "replacement") 

263 result._hp_family_build_info = info # pylint: disable=protected-access 

264 if config is not None and callable(getattr(result, "reset_parameters", None)): 

265 result._hp_reset_after_materialization = True # pylint: disable=protected-access 

266 return result 

267 

268 

269def build_family( 

270 target: Callable | str | FamilyRequest, 

271 *, 

272 config: Any = None, 

273 mesh: Any = None, 

274 module: nn.Module | None = None, 

275 module_fqn: str = "", 

276 context: Mapping[str, Any] | None = None, 

277 **kwargs: Any, 

278) -> nn.Module: 

279 """Construct a selected member and retain its request for model preparation. 

280 

281 Args: 

282 target: Public family callable, registered name, or preserved request. 

283 config: Complete logical model configuration for direct construction. 

284 mesh: Prebuilt job mesh context; select, shard and bind this component when supplied. 

285 Active CP requires a 'cp' axis in device_mesh. Active EP requires an 'ep' axis 

286 in fsdp_moe_mesh, or device_mesh when no expert view is supplied. 

287 module: Existing source module; mutually exclusive with config. 

288 module_fqn: Source path when converting an existing module. 

289 context: Optional job constraints; without mesh, may supply logical topology for selection. 

290 **kwargs: Explicit selectors and ordinary public factory arguments. 

291 

292 Returns: 

293 The concrete member with construction provenance. Logical/meta builds finish physical work at runtime. 

294 """ 

295 request = _normalize_family_request(target, kwargs) 

296 arguments = dict(request.kwargs) 

297 if "config" in arguments: 

298 if config is not None: 

299 raise ValueError("config was supplied twice") 

300 config = arguments.pop("config") 

301 request = FamilyRequest(request.target, arguments) 

302 if (config is None) == (module is None): 

303 raise ValueError("provide exactly one of config or module") 

304 context = _context_from_mesh(mesh, context) 

305 member = resolve_family(request, context=context) 

306 result = _build_family_member(request, member, config=config, module=module, 

307 module_fqn=module_fqn, context=context) 

308 if mesh is not None: 

309 # Layout discovery imports this module; defer its runtime dependency to avoid a cycle. 

310 from hyper_parallel.distributed._builder.family_layout import _prepare_family_layout # pylint: disable=C0415,R0401 

311 _prepare_family_layout(result, mesh) 

312 return result 

313 

314 

315def get_family_build_info(module: nn.Module) -> FamilyBuildInfo | None: 

316 """Return only framework-created family provenance, or None for ordinary modules. 

317 

318 Args: 

319 module: Module whose construction provenance is queried. 

320 """ 

321 value = getattr(module, "_hp_family_build_info", None) 

322 return value if isinstance(value, FamilyBuildInfo) else None 

323 

324 

325def _family_parameter_shapes(model: nn.Module) -> dict[str, tuple[int, ...]]: 

326 """Return logical parameter shapes even after component construction localized weights.""" 

327 by_identity = {} 

328 for boundary in collect_family_modules(model): 

329 layout = boundary.info.layout 

330 if layout is not None: 

331 for name, shape in layout.parameter_shapes.items(): 

332 identity = id(boundary.module.get_parameter(name)) 

333 if by_identity.setdefault(identity, shape) != shape: 

334 raise ValueError(f"shared family parameter {name!r} has conflicting logical shapes") 

335 return {name: by_identity.get(id(value), tuple(value.shape)) 

336 for name, value in model.named_parameters(remove_duplicate=False)} 

337 

338 

339def _module_aliases(model: nn.Module) -> dict[int, tuple[nn.Module, tuple[str, ...]]]: 

340 """Inventory registered module identities and all paths, including the root.""" 

341 found: dict[int, tuple[nn.Module, list[str]]] = {} 

342 for fqn, module in model.named_modules(remove_duplicate=False): 

343 found.setdefault(id(module), (module, []))[1].append(fqn) 

344 return {identity: (module, tuple(paths)) for identity, (module, paths) in found.items()} 

345 

346 

347def collect_family_modules(model: nn.Module) -> tuple[FamilyBoundary, ...]: 

348 """Discover final family boundaries by object identity, including root and aliases. 

349 

350 Args: 

351 model: Final or source model tree for this build. 

352 """ 

353 boundaries = [] 

354 for module, paths in _module_aliases(model).values(): 

355 info = get_family_build_info(module) 

356 if info is not None: 

357 boundaries.append(FamilyBoundary(module, paths, info)) 

358 return tuple(boundaries) 

359 

360 

361def validate_family_context(model: nn.Module, context: Mapping[str, Any]) -> None: 

362 """Validate that model preparation has finalized members for the job context. 

363 

364 Args: 

365 model: Final or source model tree for this build. 

366 context: Read-only job context and effective model facts. 

367 """ 

368 axes = _axis_sizes(_context_from_mesh(None, context)) 

369 for boundary in collect_family_modules(model): 

370 if boundary.info.layout is not None and boundary.info.layout.applied: 

371 _context_from_mesh(boundary.info.layout.mesh, context) 

372 member = boundary.info.resolved 

373 for axis in ("cp", "ep"): 

374 if member.axis_sizes[axis] != axes[axis]: 

375 raise ValueError( 

376 f"family at {boundary.fqns} was constructed with {axis}={member.axis_sizes[axis]}, " 

377 f"job requires {axes[axis]}; call prepare_model_components with the job context before layout" 

378 ) 

379 

380 

381def _validate_applied_layout(cached: _FamilyLayout, spec: Any, fqn: str) -> None: 

382 """Reject post-construction changes to a component's declared computation layout.""" 

383 original = cached.sharding 

384 if any(name not in original.params or named != original.params[name] for name, named in spec.params.items()): 

385 raise ValueError(f"layout at {fqn!r} conflicts with its constructed family layout") 

386 for field_name in ("in_src", "in_dst", "out_src", "out_dst", "out_names", "is_boundary"): 

387 if getattr(spec, field_name) != getattr(original, field_name): 

388 raise ValueError(f"{field_name} at {fqn!r} conflicts with its constructed family layout") 

389 

390 

391def _record_family_layouts(model, plan, mesh, shapes, source_shard_info, *, applied=True): 

392 """Retain relative declarations once for constructor and model-level application.""" 

393 for boundary in collect_family_modules(model): 

394 fqn = boundary.fqns[0] 

395 if fqn not in plan.modules or boundary.info.resolved.sharding is None: 

396 continue 

397 existing = boundary.info.layout 

398 if existing is not None and existing.applied: 

399 continue 

400 prefix = fqn + "." if fqn else "" 

401 names = plan.modules[fqn].params 

402 relative_shapes = {name: shapes[prefix + name] for name in names} 

403 source = {name: source_shard_info[prefix + name] for name in names 

404 if source_shard_info is not None and prefix + name in source_shard_info} 

405 layout = _FamilyLayout(mesh, deepcopy(plan.modules[fqn]), plan.parallel_modules.get(fqn), 

406 MappingProxyType(relative_shapes), MappingProxyType(source), applied) 

407 boundary.module._hp_family_build_info = replace(boundary.info, layout=layout) # pylint: disable=protected-access 

408 

409 

410def _family_source_shard_info(model: nn.Module) -> dict[str, Any]: 

411 """Gather constructed component source layouts under their final model paths.""" 

412 result = {} 

413 for boundary in collect_family_modules(model): 

414 layout = boundary.info.layout 

415 if layout is not None and layout.applied: 

416 for fqn in boundary.fqns: 

417 result.update({f"{fqn}.{name}" if fqn else name: value 

418 for name, value in layout.source_shard_info.items()}) 

419 return result 

420 

421 

422def _same_mesh(first: Any, second: Any) -> bool: 

423 """Compare borrowed job views without copying or reconstructing their groups.""" 

424 return (getattr(first, "device_mesh", first) is getattr(second, "device_mesh", second) 

425 and getattr(first, "fsdp_moe_mesh", None) is getattr(second, "fsdp_moe_mesh", None)) 

426 

427 

428def _family_mesh(model: nn.Module, mesh: Any) -> Any: 

429 """Reuse the complete context when a caller passes its dense DeviceMesh view.""" 

430 layouts = [boundary.info.layout for boundary in collect_family_modules(model) 

431 if boundary.info.layout is not None and boundary.info.layout.applied] 

432 if not hasattr(mesh, "device_mesh"): 

433 mesh = next((layout.mesh for layout in layouts if layout.mesh.device_mesh is mesh), mesh) 

434 if any(not _same_mesh(layout.mesh, mesh) for layout in layouts): 

435 raise ValueError("constructed families cannot be applied with a different job mesh") 

436 return mesh