Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / codegen / plan / freeze.py: 23%

189 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# Licensed under the Apache License, Version 2.0 

3# ============================================================================ 

4"""Freeze a ``ShardingPlan`` into JSON-safe structures for meta / generated code. 

5 

6The planner's plan objects are live (``Placement`` instances, ``Target`` 

7references, callables, internal ``init=False`` fields). The generated 

8modeling file and ``codegen_meta.json`` must consume the SAME plan without 

9re-running the planner — and meta must stay JSON-serializable. The freeze 

10layer is that projection: placements become stable strings / dicts, targets 

11become ``to_dict()``, internal EP metadata is made explicit, and the full 

12sharded-parameter tree is expanded to concrete FQNs. 

13 

14Everything here is a pure function over planner output; nothing imports 

15torch or the training stack. 

16""" 

17from __future__ import annotations 

18 

19import re 

20from dataclasses import dataclass, field 

21from typing import Any 

22 

23from hyper_parallel.codegen.plan.spec_fields import ( 

24 INJECTION_SPEC_FIELDS, 

25 PARAM_PLAN_SPEC_FIELDS, 

26) 

27 

28# --------------------------------------------------------------------------- 

29# Placement serialization (placement_types.py) 

30# --------------------------------------------------------------------------- 

31 

32# Registry of known placement types (imported lazily — freeze.py must stay 

33# importable without torch) mapped to their string-form serializers. The 

34# registry stays empty until the first serialization; ``_register_placement_types`` 

35# is the ONLY writer. The serializer classes are stateless holders for the 

36# per-kind string logic — they must NOT self-register (a decorated registry 

37# keyed by the serializer classes would poison the isinstance dispatch below). 

38_PLACEMENT_SERIALIZERS: dict[type, type] = {} 

39 

40 

41class _StridedShardSerializer: 

42 @staticmethod 

43 def to_string(p) -> str: 

44 dim = p.dim if not p.uneven_shard else -p.dim - 1 

45 return f"SS({dim},{p.split_factor})" 

46 

47 

48class _ShardSerializer: 

49 @staticmethod 

50 def to_string(p) -> str: 

51 dim = p.dim if not p.uneven_shard else -p.dim - 1 

52 return f"S({dim})" 

53 

54 

55class _ReplicateSerializer: 

56 @staticmethod 

57 def to_string(p) -> str: 

58 return "R" 

59 

60 

61class _PartialSerializer: 

62 @staticmethod 

63 def to_string(p) -> str: 

64 return f"P({p.reduce_op})" 

65 

66 

67def _register_placement_types() -> None: 

68 """Register the real placement classes against their serializers. 

69 

70 ``placement_types`` is imported lazily (torch dependency) — the registry 

71 stays empty until the first actual serialization, so an environment 

72 without torch can still import ``freeze.py``. 

73 

74 Order matters: ``StridedShard`` subclasses ``Shard``, so it must be 

75 registered FIRST or ``isinstance`` dispatch would serialize every 

76 strided shard as a plain ``Shard``. 

77 """ 

78 from hyper_parallel.core.dtensor.placement_types import ( 

79 Partial, 

80 Replicate, 

81 Shard, 

82 StridedShard, 

83 ) 

84 

85 for cls, serializer in ( 

86 (StridedShard, _StridedShardSerializer), 

87 (Shard, _ShardSerializer), 

88 (Replicate, _ReplicateSerializer), 

89 (Partial, _PartialSerializer), 

90 ): 

91 _PLACEMENT_SERIALIZERS[cls] = serializer 

92 

93 

94def placement_to_string(placement: Any) -> str: 

95 """Serialize a ``Placement`` instance to its canonical string form. 

96 

97 Shard(dim) -> "S(dim)"; Replicate() -> "R"; Partial(reduce_op) -> "P"; 

98 StridedShard(dim, split_factor) -> "SS(dim,factor)". ``uneven_shard`` is 

99 encoded as a negative dim (``Shard(-dim-1)``) so it survives the 

100 round-trip without an extra field. Unknown placement types fail fast — 

101 a silently dropped placement would corrupt the frozen plan. 

102 """ 

103 if not _PLACEMENT_SERIALIZERS: 

104 _register_placement_types() 

105 for cls, serializer in _PLACEMENT_SERIALIZERS.items(): 

106 if isinstance(placement, cls): 

107 return serializer.to_string(placement) 

108 raise TypeError( 

109 f"cannot serialize placement {placement!r} of unknown type " 

110 f"{type(placement).__name__}" 

111 ) 

112 

113 

114# --------------------------------------------------------------------------- 

115# Named-placement serialization 

116# --------------------------------------------------------------------------- 

117 

118def named_placement_to_dict(named: Any) -> dict[str, str]: 

119 """Serialize a ``NamedPlacement`` / output-contract dict to JSON. 

120 

121 ``in_*`` entries are keyed by input name (``{"hidden_states": ...}``); 

122 ``out_*`` entries are normalized by the planner into 

123 ``{"output": ...}`` — both wrap the same axis -> ``Placement`` 

124 dictionary, so the value at every level may itself be a dict and is 

125 recursed until a leaf ``Placement`` (or a plain scalar) is reached. 

126 Axis keys are normalized to strings and sorted for stability; a 

127 ``None`` value (unset) serializes to an empty dict. 

128 """ 

129 if not named: 

130 return {} 

131 return { 

132 str(getattr(axis, "value", axis)): _value_to_dict(placement) 

133 for axis, placement in named.items() 

134 } 

135 

136 

137# --------------------------------------------------------------------------- 

138# Placement deserialization (inverse of the serializers above) 

139# --------------------------------------------------------------------------- 

140 

141def parse_placement(text: str) -> Any: 

142 """Inverse of :func:`placement_to_string`: canonical string -> Placement. 

143 

144 Compact forms: ``S(dim)`` / ``R`` / ``P(reduce_op)`` / ``SS(dim,factor)``; 

145 ``uneven_shard`` is recovered from the negative-dim encoding 

146 (``S(-1)`` -> ``Shard(0, uneven_shard=True)``). Unknown or malformed 

147 forms fail fast — a silently downgraded placement would corrupt the 

148 frozen plan (e.g. quietly becoming ``Replicate()``). 

149 """ 

150 from hyper_parallel.core.dtensor.placement_types import ( 

151 Partial, 

152 Replicate, 

153 Shard, 

154 StridedShard, 

155 ) 

156 

157 if not isinstance(text, str) or not text: 

158 raise ValueError(f"cannot parse placement from {text!r}") 

159 

160 if text == "R": 

161 return Replicate() 

162 

163 match = re.fullmatch(r"([SP]|SS)\((.*)\)", text) 

164 if match is None: 

165 raise ValueError( 

166 f"unknown placement form {text!r} (expected S(dim) / R / " 

167 f"P(reduce_op) / SS(dim,factor))" 

168 ) 

169 kind, args = match.groups() 

170 

171 if kind == "P": 

172 reduce_op = args 

173 if not reduce_op: 

174 raise ValueError(f"malformed partial placement {text!r}: missing reduce_op") 

175 return Partial(reduce_op) 

176 

177 parts = [part.strip() for part in args.split(",")] 

178 try: 

179 dim = int(parts[0]) 

180 except (IndexError, ValueError) as exc: 

181 raise ValueError(f"malformed placement {text!r}: invalid dim") from exc 

182 uneven = dim < 0 

183 dim = -dim - 1 if uneven else dim 

184 

185 if kind == "S": 

186 if len(parts) != 1: 

187 raise ValueError(f"malformed shard placement {text!r}: too many args") 

188 return Shard(dim, uneven_shard=uneven) 

189 if kind == "SS": 

190 if len(parts) != 2: 

191 raise ValueError(f"malformed strided shard {text!r}: expected SS(dim,factor)") 

192 try: 

193 split_factor = int(parts[1]) 

194 except ValueError as exc: 

195 raise ValueError( 

196 f"malformed strided shard {text!r}: invalid split_factor" 

197 ) from exc 

198 return StridedShard(dim, split_factor, uneven_shard=uneven) 

199 raise ValueError(f"unknown placement kind {kind!r} in {text!r}") 

200 

201 

202def _value_from_dict(value: Any) -> Any: 

203 """Inverse of :func:`_value_to_dict`: JSON-safe value -> planner-ish value. 

204 

205 Placement strings become ``Placement`` objects; nested dicts / lists 

206 recurse; plain scalars pass through unchanged. ``Target`` objects 

207 serialized via ``to_dict()`` are NOT reconstructed — the frozen boundary 

208 contract is data, and the generated module resolves runtime targets 

209 itself. 

210 """ 

211 if isinstance(value, str): 

212 return parse_placement(value) 

213 if isinstance(value, dict): 

214 return {str(getattr(k, "value", k)): _value_from_dict(v) for k, v in value.items()} 

215 if isinstance(value, list): 

216 return [_value_from_dict(v) for v in value] 

217 return value 

218 

219 

220def parse_named_placement(data: dict[str, Any]) -> Any: 

221 """Inverse of :func:`named_placement_to_dict`: JSON dict -> named placement. 

222 

223 Round-trips ``named_placement_to_dict()`` output back to a 

224 ``NamedPlacement`` / output-contract dict with real ``Placement`` leaves. 

225 """ 

226 if not data: 

227 return {} 

228 return _value_from_dict(data) 

229 

230 

231# --------------------------------------------------------------------------- 

232# FrozenPlan 

233# --------------------------------------------------------------------------- 

234 

235@dataclass 

236class FrozenPlan: 

237 """JSON-safe projection of a ``ShardingPlan`` for meta / generated code. 

238 

239 - ``param_plan``: per-boundary parameter placements (the sharding 

240 contract the generated file executes). 

241 - ``injections``: per-boundary injection declarations (CP wrappers / 

242 local compute / inner targets), already serialized to strings/dicts. 

243 - ``tied_pairs``: tied-weight FQN pairs. 

244 - ``special_handlers``: special-handler name per parameter. 

245 - ``frozen_sharded_params``: the full FQN list of every parameter that 

246 takes a non-``Replicate`` placement anywhere in the plan — what the 

247 generated file must create as DTensors at load time. 

248 - ``boundary_classes``: {边界FQN: 类名} — the class that owns each 

249 boundary's ``forward``. The frozen plan is keyed by 

250 boundary FQN, but the generated file rewrites ``class.forward``, which 

251 one class shares across many instances; the emitter groups FQNs by this 

252 map (failed to resolve a class = a boundary whose forward is never 

253 rewritten = silent drop). 

254 - ``mesh_dim_names``: the plan's active axes (``ShardingPlan. 

255 mesh_dim_names`` verbatim — ``tp``/``cp``/``ep`` with size-1 axes and 

256 dp* axes already stripped). The runtime slices the live mesh down to 

257 these axes (mirroring ``_get_active_mesh``) so a placement never lands 

258 on a dp axis the plan never shards on. 

259 """ 

260 

261 param_plan: dict[str, dict[str, Any]] = field(default_factory=dict) 

262 injections: list[dict[str, Any]] = field(default_factory=list) 

263 tied_pairs: list[list[str]] = field(default_factory=list) 

264 special_handlers: dict[str, str] = field(default_factory=dict) 

265 frozen_sharded_params: list[str] = field(default_factory=list) 

266 boundary_classes: dict[str, str] = field(default_factory=dict) 

267 mesh_dim_names: tuple[str, ...] = () 

268 

269 

270def freeze_plan(plan: Any, model: Any) -> FrozenPlan: 

271 """Freeze a planner ``ShardingPlan`` into a ``FrozenPlan``. 

272 

273 ``model`` is the (meta-device) model the plan was derived from — used to 

274 expand the sharded-parameter tree to concrete FQNs and to record each 

275 boundary FQN's owning class (``boundary_classes``). ``model`` 

276 may be ``None`` when only the plan shape is frozen (class names are then 

277 left unresolvable — the manager passes a live model). 

278 """ 

279 if plan is None: 

280 return FrozenPlan() 

281 return FrozenPlan( 

282 param_plan=freeze_param_plan(plan), 

283 injections=freeze_injections(plan), 

284 tied_pairs=freeze_tied_pairs(plan), 

285 special_handlers=dict(getattr(plan, "special_handlers", {}) or {}), 

286 frozen_sharded_params=expand_frozen_sharded_params(plan), 

287 boundary_classes=_freeze_boundary_classes(plan, model), 

288 mesh_dim_names=tuple(getattr(plan, "mesh_dim_names", ()) or ()), 

289 ) 

290 

291 

292def _freeze_boundary_classes(plan: Any, model: Any) -> dict[str, str]: 

293 """Map each boundary FQN to the class that owns its ``forward``. 

294 

295 The generated file rewrites ``class.forward``, which one class shares 

296 across many instances — but the frozen plan is keyed by boundary FQN. For 

297 every ``plan.modules`` key (``model.layers.0.self_attn``), record the type 

298 of the submodule at that FQN. ``get_submodule("")`` returns the root 

299 module and yields the model class itself. 

300 """ 

301 if model is None: 

302 return {} 

303 modules = getattr(plan, "modules", {}) or {} 

304 result: dict[str, str] = {} 

305 for fqn in modules: 

306 cls = type(model.get_submodule(fqn)).__name__ 

307 result[fqn] = cls 

308 return result 

309 

310 

311def freeze_param_plan(plan: Any) -> dict[str, dict[str, Any]]: 

312 """Freeze the per-boundary parameter sharding plan. 

313 

314 Output shape (one entry per boundary module):: 

315 

316 { 

317 "model.layers.0.self_attn": { 

318 "params": { # per-parameter placement 

319 "q_proj.weight": {"tp": "S(0)"}, 

320 "k_proj.weight": {"tp": "S(0)"}, 

321 ... 

322 }, 

323 "in_src": {"hidden_states": {"tp": "S(-1)"}}, # optional 

324 ... 

325 }, 

326 ... 

327 } 

328 

329 Non-``Replicate`` placements are the sharding contract; ``Replicate`` 

330 entries are kept too (an explicit "this stays replicated") so the 

331 generated file never has to guess. Placement values are always strings 

332 via :func:`placement_to_string`; parameter names are kept as declared 

333 (with the ``.weight`` / ``.bias`` suffix). 

334 

335 Which fields are frozen (and how) is declared in 

336 :mod:`hyper_parallel.codegen.plan.spec_fields` — the table both sides 

337 of the meta boundary walk, so freeze and rebuild cannot drift apart. 

338 """ 

339 modules = getattr(plan, "modules", {}) or {} 

340 result: dict[str, dict[str, Any]] = {} 

341 for fqn, spec in modules.items(): 

342 entry: dict[str, Any] = {} 

343 for spec_field in PARAM_PLAN_SPEC_FIELDS: 

344 frozen = _freeze_spec_field(spec_field, getattr(spec, spec_field.name, None)) 

345 if frozen is not _MISSING: 

346 entry[spec_field.key] = frozen 

347 result[fqn] = entry 

348 return result 

349 

350 

351#: Sentinel distinguishing "field absent from the frozen entry" from a 

352#: legitimately frozen ``None``/empty value (``{"params": {}}``). 

353_MISSING = object() 

354 

355 

356def _freeze_spec_field(spec_field: Any, value: Any) -> Any: 

357 """Serialize one spec field per its :class:`SpecField` kind contract. 

358 

359 Returns ``_MISSING`` when the field must not appear in the frozen 

360 output. The write condition is part of the kind (see spec_fields.py): 

361 ``params`` uses ``is not None`` so an explicit empty dict ("shards 

362 nothing") survives — a truthy check here used to drop it and leave the 

363 rebuilt spec with ``params=None``, which Phase A cannot iterate. 

364 """ 

365 kind = spec_field.freeze 

366 if kind in ("placement_map", "named_placements", "scalar", 

367 "value_to_dict", "plain", "attr_list"): 

368 if value is None: 

369 return _MISSING 

370 elif not value: # name_list / dict_copy / int_truthy: truthy to write 

371 return _MISSING 

372 if kind == "placement_map": 

373 return { 

374 name: { 

375 str(getattr(axis, "value", axis)): placement_to_string(placement) 

376 for axis, placement in named.items() 

377 } 

378 for name, named in value.items() 

379 } 

380 if kind == "named_placements": 

381 return named_placement_to_dict(value) 

382 if kind in ("name_list", "attr_list"): 

383 return list(value) 

384 if kind == "value_to_dict": 

385 return _value_to_dict(value) 

386 if kind == "dict_copy": 

387 return dict(value) 

388 # scalar / plain / int_truthy: the value is already JSON-safe. 

389 return value 

390 

391 

392def freeze_injections(plan: Any) -> list[dict[str, Any]]: 

393 """Freeze per-boundary injection declarations. 

394 

395 CP attention wrappers (``inner_wrapper``), local compute regions 

396 (``local_compute_fn``), and inner targets — everything the generated 

397 file must attach at apply time. ``Target`` values are resolved to their 

398 ``to_dict()`` form; callables are recorded by name (they cannot cross 

399 the meta boundary — the generated file re-resolves them from the same 

400 runtime registry). Field set and write conditions come from 

401 :mod:`hyper_parallel.codegen.plan.spec_fields`. 

402 """ 

403 modules = getattr(plan, "modules", {}) or {} 

404 injections: list[dict[str, Any]] = [] 

405 for fqn, spec in modules.items(): 

406 entry: dict[str, Any] = {} 

407 for spec_field in INJECTION_SPEC_FIELDS: 

408 frozen = _freeze_spec_field(spec_field, getattr(spec, spec_field.name, None)) 

409 if frozen is not _MISSING: 

410 entry[spec_field.key] = frozen 

411 if entry: 

412 injections.append({"match": fqn, **entry}) 

413 return injections 

414 

415 

416def freeze_tied_pairs(plan: Any) -> list[list[str]]: 

417 """Freeze tied-weight pairs as stable string lists.""" 

418 pairs = getattr(plan, "tied_pairs", None) or [] 

419 return [list(pair) for pair in pairs] 

420 

421 

422def expand_frozen_sharded_params(plan: Any) -> list[str]: 

423 """Expand every sharded parameter in the plan to a concrete FQN. 

424 

425 A parameter is "sharded" when any axis of its placement is a non- 

426 ``Replicate`` placement (``Shard`` / ``StridedShard`` / ``Partial``). 

427 The FQN is the module FQN + ``.weight`` / ``.bias`` suffix — the same 

428 key the generated runtime uses to look up the parameter. 

429 """ 

430 modules = getattr(plan, "modules", {}) or {} 

431 fqns: list[str] = [] 

432 seen: set[str] = set() 

433 for fqn, spec in modules.items(): 

434 for name, named in (getattr(spec, "params", {}) or {}).items(): 

435 if _is_sharded(named): 

436 fqn_key = f"{fqn}.{name}" if name else fqn 

437 if fqn_key not in seen: 

438 seen.add(fqn_key) 

439 fqns.append(fqn_key) 

440 return sorted(fqns) 

441 

442 

443# --------------------------------------------------------------------------- 

444# helpers 

445# --------------------------------------------------------------------------- 

446 

447def _is_sharded(named: Any) -> bool: 

448 """Whether any axis of a named placement is non-``Replicate``.""" 

449 if not named: 

450 return False 

451 for placement in named.values(): 

452 if placement is None: 

453 continue 

454 kind = type(placement).__name__ 

455 if kind in ("Shard", "StridedShard", "Partial"): 

456 return True 

457 return False 

458 

459 

460def _value_to_dict(value: Any) -> Any: 

461 """Serialize a spec value: Target -> to_dict(), Placement -> string, 

462 named placement -> dict, callable -> name.""" 

463 from hyper_parallel.core.dtensor.placement_types import Placement 

464 

465 if isinstance(value, Placement): 

466 return placement_to_string(value) 

467 if hasattr(value, "to_dict"): 

468 return value.to_dict() 

469 if isinstance(value, dict): 

470 return { 

471 str(getattr(k, "value", k)): _value_to_dict(v) 

472 for k, v in value.items() 

473 } 

474 if isinstance(value, (list, tuple)): 

475 return [_value_to_dict(v) for v in value] 

476 if callable(value): 

477 return getattr(value, "__name__", str(value)) 

478 return value 

479 

480 

481__all__ = [ 

482 "FrozenPlan", 

483 "expand_frozen_sharded_params", 

484 "freeze_injections", 

485 "freeze_param_plan", 

486 "freeze_plan", 

487 "freeze_tied_pairs", 

488 "named_placement_to_dict", 

489 "parse_named_placement", 

490 "parse_placement", 

491 "placement_to_string", 

492]