Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / auto_parallel / config_adapter / _search_runner.py: 96%

246 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"""Search runner -- bridges NormalizedConfig to the ND search engine. 

16 

17Hands a :class:`NormalizedConfig` to the ND search as the mapping 

18``Parallelize`` parses directly, post-filters by user candidate lists and 

19memory budget, and returns the optimal strategy. 

20""" 

21 

22import copy 

23import logging 

24from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Set, Tuple, TYPE_CHECKING 

25 

26from hyper_parallel.auto_parallel._hf_model_spec import EXEC_OVERRIDE_KEYS 

27from hyper_parallel.auto_parallel._model_spec import ModelSpec, model_fields 

28from hyper_parallel.auto_parallel.config_adapter._normalized_config import NormalizedConfig 

29 

30 

31if TYPE_CHECKING: 

32 import hyper_parallel.auto_parallel.sapp_nd.nd.parallelize as Par 

33 import hyper_parallel.auto_parallel.sapp_nd.nd.dimensions as Dim 

34 import hyper_parallel.auto_parallel.sapp_nd.nd.common.hardware as Hard 

35 

36logger = logging.getLogger(__name__) 

37 

38# The activation_checkpoint modes HyperParallel's trainer runs every layer 

39# with, that recompute "auto" chooses among. Its selective mode recomputes 

40# every other matmul, which the cost model's formulas do not price; a census 

41# of the layers measures it (IR phase 5), so a run that states one offers it 

42# too, where the census priced it. 

43TRAINER_RECOMPUTE_MODES = ("off", "full") 

44TRAINER_CENSUS_MODES = ("off", "selective", "full") 

45# The search config's recompute values that choose among those modes: "auto" 

46# one for every layer, "per_layer" one for each layer, which the trainer runs 

47# as activation_checkpoint.layers. 

48CHOSEN_RECOMPUTE = ("auto", "per_layer") 

49 

50 

51def _get_dim_module(): 

52 """Lazy-import the sapp_nd dimensions module.""" 

53 import hyper_parallel.auto_parallel.sapp_nd.nd.dimensions as dim_mod # pylint: disable=C0415 

54 return dim_mod 

55 

56 

57def _get_machine_mod(): 

58 """Lazy-import the sapp_nd hardware module.""" 

59 import hyper_parallel.auto_parallel.sapp_nd.nd.common.hardware as hw_mod # pylint: disable=C0415 

60 return hw_mod 

61 

62 

63def _search_dim_map(): 

64 """Return the mapping of NormalizedConfig keys to sapp_nd Dimension objects. 

65 

66 Lazily loaded to avoid importing sapp_nd at module-import time. 

67 """ 

68 dim_mod = _get_dim_module() 

69 return { 

70 "data_parallel_replicate_degree": dim_mod.DP, 

71 "tensor_parallel_degree": dim_mod.TP, 

72 "pipeline_parallel_degree": dim_mod.PP, 

73 "context_parallel_degree": dim_mod.CP, 

74 "expert_parallel_degree": dim_mod.EP, 

75 "micro_batch_num": dim_mod.MBN, 

76 # OP carries the FSDP/optimizer shard degree (ccfg.os_max_shard), 

77 # so mapping it here is what lets ND search that dimension. 

78 "data_parallel_shard_degree": dim_mod.OP, 

79 } 

80 

81 

82def _validate_before_search(config: NormalizedConfig) -> None: 

83 """Check required model fields are populated (>0) before search. 

84 

85 Raises: 

86 ValueError: If any required field is missing or zero. 

87 """ 

88 model = config.model_spec 

89 required = { 

90 "model_spec.num_hidden_layers": model.get("num_hidden_layers", 0), 

91 "model_spec.hidden_size": model.get("hidden_size", 0), 

92 "model_spec.num_attention_heads": model.get("num_attention_heads", 0), 

93 "model_spec.vocab_size": model.get("vocab_size", 0), 

94 "cluster_spec": config.cluster_spec, 

95 } 

96 missing = [] 

97 for name, value in required.items(): 

98 if name == "cluster_spec": 

99 if not isinstance(value, dict) or not value: 

100 missing.append(name) 

101 elif value <= 0: 

102 missing.append(name) 

103 if missing: 

104 raise ValueError( 

105 "Required fields missing or zero before ND search: " 

106 f"{', '.join(missing)}" 

107 ) 

108 

109 

110def _build_model_dict(model: Dict[str, Any]) -> Dict[str, Any]: 

111 """Build the ``model`` section of the HP YAML from *model* spec. 

112 

113 The model's fields go through the model spec, whose names are the HP 

114 YAML's, and the run keys the parser reads from ``config_overrides`` 

115 pass through as they are. The adapter's own keys, such as the 

116 micro-batch size, reach the HP YAML through their own sections. 

117 

118 Args: 

119 model: The ``model_spec`` dict from :class:`NormalizedConfig`. 

120 

121 Returns: 

122 A dict suitable for the ``model`` key of a HP ``train.yaml``. 

123 """ 

124 spec = ModelSpec.from_dict(model_fields(model)) 

125 overrides = spec.to_dict() 

126 overrides.pop("name", None) 

127 overrides.update({key: model[key] for key in EXEC_OVERRIDE_KEYS if key in model}) 

128 return {"name": spec.name, "config_overrides": overrides} 

129 

130 

131# The strategy, under every name the cost model reads it by, in the sections 

132# a train.yaml states its run in: the search sets it, from its candidates or 

133# its own config, so the train.yaml's values give way. The rest of each 

134# section is the run's. 

135_STRATEGY_KEYS: Dict[str, FrozenSet[str]] = { 

136 "accelerator": frozenset({ 

137 "dp_replicate", "dp_shard", "tp_size", "tp_degree", "pp_size", "pipeline_parallel_degree", 

138 "cp_size", "context_parallel_degree", "ep_size", "expert_parallel_degree", 

139 "expert_tensor_parallel_degree", "micro_batch_num", "optimizer_weight_shard_size", 

140 "pipeline_scheduler", "pp_interleave_num", 

141 }), 

142 "fsdp_config": frozenset({"dp_shard_size"}), 

143 "training": frozenset({"global_batch_size", "micro_batch_size", "micro_batch_num"}), 

144} 

145 

146 

147def _stated_run(config: NormalizedConfig) -> Dict[str, Any]: 

148 """Return the run the train.yaml states, less the strategy the search sets. 

149 

150 Args: 

151 config: The normalized config whose ``run`` the train.yaml filled. 

152 

153 Returns: 

154 A copy of ``config.run``, its sections without the strategy's keys. 

155 """ 

156 run = copy.deepcopy(config.run) 

157 for section, strategy in _STRATEGY_KEYS.items(): 

158 stated = run.get(section) 

159 if isinstance(stated, dict): 

160 run[section] = {key: value for key, value in stated.items() if key not in strategy} 

161 return run 

162 

163 

164def _memory_budget_gb(config: NormalizedConfig) -> float: 

165 """Return the per-device memory budget in GB, or ``0.0`` if unconstrained. 

166 

167 ``constraint.memory_limit_gb`` is the budget the user asked for and 

168 ``cluster_spec.device_memory_gb`` the hardware ceiling, so the search 

169 must respect whichever of the two is tighter. 

170 

171 Args: 

172 config: The normalized config carrying constraint and cluster spec. 

173 

174 Returns: 

175 The tighter positive budget in GB, or ``0.0`` when neither is set. 

176 """ 

177 limit = float(config.constraint.get("memory_limit_gb", 0.0) or 0.0) 

178 device = float(config.cluster_spec.get("device_memory_gb", 0.0) or 0.0) 

179 budgets = [value for value in (limit, device) if value > 0] 

180 return min(budgets) if budgets else 0.0 

181 

182 

183def _pinned_or_first(config: NormalizedConfig, constraint_key: str, space_key: str, 

184 default: List[Any]) -> Any: 

185 """Return the degree *constraint_key* pins, else the first candidate. 

186 

187 Args: 

188 config: The normalized config carrying constraint and search space. 

189 constraint_key: The ``constraint`` entry that pins the degree. 

190 space_key: The ``search_space`` entry listing its candidates. 

191 default: The candidates when the search space lists none. 

192 

193 Returns: 

194 The pinned degree when it is positive, else the first candidate, 

195 a placeholder the search replaces. 

196 """ 

197 pinned = config.constraint.get(constraint_key) 

198 if pinned is not None and pinned > 0: 

199 return pinned 

200 return config.search_space.get(space_key, default)[0] 

201 

202 

203def _build_strategy_dicts( 

204 config: NormalizedConfig, run: Dict[str, Any], 

205) -> Tuple[Dict[str, Any], Dict[str, Any]]: 

206 """Build the ``accelerator`` and ``fsdp_config`` sections of the HP YAML. 

207 

208 The strategy goes over the sections the train.yaml states, which this 

209 takes out of *run*. 

210 """ 

211 accel: Dict[str, Any] = dict(run.pop("accelerator", None) or {}) 

212 fsdp: Dict[str, Any] = dict(run.pop("fsdp_config", None) or {}) 

213 

214 # Fixed dimensions -- write actual value. 

215 fixed_map = { 

216 "fixed_dp_degree": ("dp_replicate", "data_parallel_replicate_degree", [1]), 

217 "fixed_tp_degree": ("tp_size", "tensor_parallel_degree", [1]), 

218 "fixed_pp_degree": ("pp_size", "pipeline_parallel_degree", [1]), 

219 "fixed_cp_degree": ("cp_size", "context_parallel_degree", [1]), 

220 "fixed_ep_degree": ("ep_size", "expert_parallel_degree", [1]), 

221 "fixed_etp_degree": ("expert_tensor_parallel_degree", "expert_tensor_parallel_degree", [0]), 

222 } 

223 for constraint_key, (accel_key, space_key, default) in fixed_map.items(): 

224 accel[accel_key] = _pinned_or_first(config, constraint_key, space_key, default) 

225 

226 # micro_batch_num has no accelerator entry of its own, so a fixed value 

227 # would be dropped and the global-batch-size check would then reject the 

228 # only config the user asked for. 

229 accel["micro_batch_num"] = int( 

230 _pinned_or_first(config, "fixed_micro_batch_num", "micro_batch_num", [1]) 

231 ) 

232 fsdp["dp_shard_size"] = int( 

233 _pinned_or_first(config, "fixed_fsdp_degree", "data_parallel_shard_degree", [1]) 

234 ) 

235 

236 # CP algorithm: propagate to yaml so CostModelParserHyperV2 can read it. 

237 cp_algo = config.estimator.get("cp_algo") 

238 if cp_algo: 

239 accel["context_parallel_algo"] = cp_algo 

240 

241 # Optional accelerator fields that affect memory estimation. 

242 owss = config.model_spec.get("optimizer_weight_shard_size") 

243 if owss and owss > 0: 

244 accel["optimizer_weight_shard_size"] = owss 

245 

246 use_sp = config.model_spec.get("use_seq_parallel", True) 

247 accel.setdefault("sequence_parallel", bool(use_sp)) 

248 return accel, fsdp 

249 

250 

251def _build_hp_yaml_dict(config: NormalizedConfig) -> dict: 

252 """Build an AutoModels-shaped cost-model YAML dict from *config*. 

253 

254 Fixed dimensions (``constraint.fixed_*_degree``) are written directly 

255 into the strategy sections. Dimensions with search-space candidates 

256 use the first candidate as a placeholder -- the actual search is driven 

257 by the ``dimensions`` parameter passed to :class:`Parallelize`. The 

258 strategy goes over the run the train.yaml states, which stays as stated. 

259 """ 

260 model = config.model_spec 

261 constraint = config.constraint 

262 run = _stated_run(config) 

263 accel, fsdp = _build_strategy_dicts(config, run) 

264 

265 recompute = config.estimator.get("recompute_strategy", "none") 

266 

267 cluster = config.cluster_spec 

268 # The train.yaml's pricing options, beneath the search's own device count 

269 # and memory budget. 

270 context: Dict[str, Any] = dict(run.pop("context", None) or {}) 

271 # ND prunes the space against ccfg.device_capacity, which the parser reads 

272 # from this field, so the user's memory_limit_gb has to reach it here. 

273 budget_gb = _memory_budget_gb(config) 

274 if budget_gb > 0: 

275 context["max_device_memory"] = f"{budget_gb}GB" 

276 device_num = cluster.get("num_nodes", 0) * cluster.get("cards_per_node", 0) 

277 if device_num > 0: 

278 context["device_num"] = int(device_num) 

279 visual_seq_len = model.get("visual_seq_len") 

280 if visual_seq_len: 

281 context["visual_seq_len"] = int(visual_seq_len) 

282 

283 # Choosing the recompute, the search keeps what fits fully recomputed: 

284 # describe it so. 

285 stated_mode = "full" if recompute in CHOSEN_RECOMPUTE else {"none": "off"}.get(recompute, recompute) 

286 gc_dict: Dict[str, Any] = {"mode": stated_mode} 

287 recompute_slice = model.get("recompute_slice_activation") 

288 if recompute_slice is not None: 

289 gc_dict["recompute_slice_activation"] = bool(recompute_slice) 

290 

291 model_dict = _build_model_dict(model) 

292 

293 hp_yaml: dict = { 

294 **run, 

295 "model": {**run.get("model", {}), **model_dict}, 

296 "training": { 

297 **run.get("training", {}), 

298 "global_batch_size": constraint.get("global_batch_size", 0), 

299 "micro_batch_size": model.get("local_batch_size", 1), 

300 "micro_batch_num": accel.pop("micro_batch_num", 1), 

301 }, 

302 "accelerator": accel, 

303 "fsdp_config": fsdp, 

304 "activation_checkpoint": gc_dict, 

305 "dataset": { 

306 "data_transform": { 

307 "max_seq_len": model.get("max_position_embeddings", 4096), 

308 }, 

309 }, 

310 } 

311 

312 if context: 

313 hp_yaml["context"] = context 

314 

315 return hp_yaml 

316 

317 

318 

319# The names a search config may give its devices, and the sapp_nd device 

320# codes they mean: the Ascend 910B is the Atlas A2 series' chip, and the 

321# 910C, CANN's ascend910_93, the A3's. A generic "ascend" stays A2. 

322_DEVICE_CODES: Dict[str, str] = { 

323 "a2": "A2", "a3": "A3", "v100": "V100", 

324 "ascend": "A2", "ascend910": "A2", "ascend910b": "A2", 

325 "ascend910c": "A3", "ascend910_93": "A3", 

326} 

327 

328 

329def _build_machine(config: NormalizedConfig) -> Any: 

330 """Build a ``Hard.Machine`` from cluster_spec.""" 

331 hw_mod = _get_machine_mod() 

332 cluster = config.cluster_spec 

333 nodes = max(1, cluster.get("num_nodes", 1)) 

334 cards_per_node = max(1, cluster.get("cards_per_node", 8)) 

335 total_devices = nodes * cards_per_node 

336 device_type = str(cluster.get("device_type", "A2")) 

337 return hw_mod.Machine(total_devices, _DEVICE_CODES.get(device_type.lower(), device_type)) 

338 

339 

340def _resolve_search_dimensions(config: NormalizedConfig) -> Tuple[List[Any], Set[Any]]: 

341 """Return search dimensions and the set of dimensions with user candidates. 

342 

343 List-valued entries in ``config.search_space`` are treated as 

344 **output** (search) dimensions. Entries absent from 

345 ``config.search_space`` (``"auto"`` in YAML) are also included -- they 

346 will be determined by ND's ``bound_space()``. 

347 

348 Returns: 

349 A tuple ``(dims, candidate_dims)`` where *dims* is the list of 

350 ``Dim`` objects to pass to ND and *candidate_dims* is the set of 

351 Dim objects for which the user supplied an explicit candidate list 

352 (used by :func:`_post_filter`). 

353 """ 

354 dims: List[Any] = [] 

355 candidate_dims: Set[Any] = set() 

356 space = config.search_space 

357 for space_key, dim_obj in _search_dim_map().items(): 

358 candidates = space.get(space_key) 

359 if candidates is not None and len(candidates) > 1: 

360 dims.append(dim_obj) 

361 candidate_dims.add(dim_obj) 

362 elif space_key not in space: 

363 dims.append(dim_obj) 

364 return dims, candidate_dims 

365 

366 

367def _filter_by_memory(scored_space: list, budget_gb: float) -> list: 

368 """Drop entries whose peak-memory estimate exceeds *budget_gb*. 

369 

370 ND already prunes against the device capacity while it generates the 

371 space; this is the gate that holds when a strategy is scored under a 

372 capacity looser than the budget the caller asked for. 

373 

374 Args: 

375 scored_space: Scored entries ``(strategy, memory_mb, score, ...)``. 

376 budget_gb: Per-device budget in GB; ``0`` disables the gate. 

377 

378 Returns: 

379 The entries that fit. May be empty, which means the budget rules 

380 out every strategy the search found. 

381 """ 

382 if budget_gb <= 0: 

383 return list(scored_space) 

384 budget_mb = budget_gb * 1024.0 

385 kept = [entry for entry in scored_space if float(entry[1]) <= budget_mb] 

386 if len(kept) < len(scored_space): 

387 logger.info( 

388 "Memory filter dropped %d of %d strategies above %.1f GB.", 

389 len(scored_space) - len(kept), 

390 len(scored_space), 

391 budget_gb, 

392 ) 

393 return kept 

394 

395 

396def _post_filter( 

397 scored_space: list, 

398 config: NormalizedConfig, 

399 candidate_dims: Optional[Set[Any]] = None, 

400) -> list: 

401 """Keep only entries whose dimension values are in the user's candidate lists. 

402 

403 Args: 

404 scored_space: The scored strategy list from ND engine. 

405 config: The normalized config containing ``search_space``. 

406 candidate_dims: The set of Dim objects that have user-supplied 

407 candidate lists with more than one value. If *None*, the 

408 set is derived from *config* (backward-compatible). 

409 

410 Returns: 

411 The entries that fit the memory budget and sit in every list. Empty 

412 when none does: a strategy outside the lists is never returned in 

413 their place, and the caller refuses the search. 

414 """ 

415 space = config.search_space 

416 if candidate_dims is None: 

417 candidate_dims = set() 

418 for space_key, dim_obj in _search_dim_map().items(): 

419 candidates = space.get(space_key) 

420 if candidates is not None and len(candidates) > 1: 

421 candidate_dims.add(dim_obj) 

422 

423 candidate_map: Dict[Any, List[int]] = {} 

424 for space_key, dim_obj in _search_dim_map().items(): 

425 if dim_obj not in candidate_dims: 

426 continue 

427 candidates = space.get(space_key) 

428 if candidates is not None: 

429 candidate_map[dim_obj] = candidates 

430 

431 feasible = _filter_by_memory(scored_space, _memory_budget_gb(config)) 

432 

433 filtered = [] 

434 for entry in feasible: 

435 dims_val = entry[0].dims_val # type: ignore[index] 

436 keep = True 

437 for dim_obj, allowed in candidate_map.items(): 

438 actual = dims_val.get(dim_obj) 

439 if actual is not None and actual not in allowed: 

440 keep = False 

441 break 

442 if keep: 

443 filtered.append(entry) 

444 return filtered 

445 

446 

447def _no_candidate_match(feasible: list, config: NormalizedConfig, candidate_dims: Set[Any]) -> str: 

448 """Say why no strategy that fits the budget is in the candidate lists. 

449 

450 ND searches each dimension within its own bounds and the lists only 

451 filter what it found, so a list can ask for degrees the search never 

452 reached. Naming the degrees it did reach shows which. 

453 

454 Args: 

455 feasible: The scored entries that fit the memory budget. 

456 config: The normalized config whose ``search_space`` holds the lists. 

457 candidate_dims: The dimensions that have a list of their own. 

458 

459 Returns: 

460 The refusal, naming each list no fitting strategy meets. 

461 """ 

462 unmet = [] 

463 for space_key, dim_obj in _search_dim_map().items(): 

464 if dim_obj not in candidate_dims: 

465 continue 

466 asked = list(config.search_space.get(space_key, [])) 

467 found = sorted({entry[0].dims_val.get(dim_obj) for entry in feasible} - {None}) 

468 if not set(found) & set(asked): 

469 unmet.append(f"{space_key} asks {asked}, the strategies that fit take {found}") 

470 why = "; ".join(unmet) if unmet else "each list is met by some of them, but never all at once" 

471 return ( 

472 f"None of the {len(feasible)} strategies that fit the memory budget is in the candidate " 

473 f"lists: {why}. ND searches each dimension within its own bounds and the lists only " 

474 "filter what it finds: widen the lists, or the budget." 

475 ) 

476 

477 

478def _pinned_degree(config: NormalizedConfig, constraint_key: str, space_key: str) -> int: 

479 """Return the degree of a dimension the search did not vary. 

480 

481 Args: 

482 config: The normalized config carrying constraint and search space. 

483 constraint_key: The ``constraint`` entry that pins the degree. 

484 space_key: The ``search_space`` entry listing its candidates. 

485 

486 Returns: 

487 The pinned degree, else the only candidate, else 1. 

488 """ 

489 pinned = config.constraint.get(constraint_key) 

490 if not pinned: 

491 candidates = config.search_space.get(space_key) or [] 

492 pinned = candidates[0] if len(candidates) == 1 else 1 

493 return int(pinned) 

494 

495 

496def _format_result(best_entry: tuple, config: NormalizedConfig) -> Dict[str, Any]: 

497 """Convert the best ND result entry into a flat result dict.""" 

498 dim_mod = _get_dim_module() 

499 dims_val = best_entry[0].dims_val # type: ignore[index] 

500 dim_to_key = { 

501 dim_mod.DP: "dp", 

502 dim_mod.TP: "tp", 

503 dim_mod.PP: "pp", 

504 dim_mod.CP: "cp", 

505 dim_mod.EP: "ep", 

506 dim_mod.MBN: "micro_batch_num", 

507 dim_mod.OP: "dp_shard", 

508 } 

509 result: Dict[str, Any] = { 

510 "memory_estimate_mb": float(best_entry[1]), 

511 "score": float(best_entry[2]), 

512 } 

513 for dim_obj, key in dim_to_key.items(): 

514 if dim_obj in dims_val: 

515 result[key] = int(dims_val[dim_obj]) 

516 # A pinned dimension is absent from dims_val, so fill it from what pinned 

517 # it: the searcher never varied it, but every consumer still reads it. 

518 fixed_from = { 

519 "dp": ("fixed_dp_degree", "data_parallel_replicate_degree"), 

520 "tp": ("fixed_tp_degree", "tensor_parallel_degree"), 

521 "pp": ("fixed_pp_degree", "pipeline_parallel_degree"), 

522 "cp": ("fixed_cp_degree", "context_parallel_degree"), 

523 "ep": ("fixed_ep_degree", "expert_parallel_degree"), 

524 "micro_batch_num": ("fixed_micro_batch_num", "micro_batch_num"), 

525 } 

526 for key, (constraint_key, space_key) in fixed_from.items(): 

527 if key not in result: 

528 result[key] = _pinned_degree(config, constraint_key, space_key) 

529 total_dp = result.get("dp", 1) 

530 if "dp_shard" not in result: 

531 # OP absent from the searched dimensions: fall back to the declared 

532 # degree, which is what the parser used for the whole run. 

533 fsdp_candidates = config.search_space.get("data_parallel_shard_degree", [1]) 

534 configured_fsdp = config.constraint.get("fixed_fsdp_degree") 

535 result["dp_shard"] = int(configured_fsdp or fsdp_candidates[0]) 

536 # The yaml the search prices is AutoModels-shaped, whose FSDP domain is 

537 # DP times CP: the shard may span it, and the trainer replicates the rest 

538 # (MeshContext.build_meshs). Bounded by DP, a shard the search priced 

539 # under CP was cut back and its replicas undercounted. 

540 domain = total_dp * max(1, int(result.get("cp") or 1)) 

541 dp_shard = max(1, min(int(result["dp_shard"]), domain)) 

542 result["dp_shard"] = dp_shard 

543 result["dp_replicate"] = max(1, domain // dp_shard) 

544 return result 

545 

546 

547def _recompute_result(nd_runner: Any, best_entry: tuple) -> Dict[str, Any]: 

548 """The recompute the search chose for the best configuration, for the result. 

549 

550 Args: 

551 nd_runner: The search, run with recompute "auto" or "per_layer". 

552 best_entry: The best scored entry. 

553 

554 Returns: 

555 ``activation_checkpoint``, the mode the trainer runs every layer 

556 with: the chosen one, or ``full``, the policy the configuration was 

557 scored with when there was nothing to choose. Under "per_layer", 

558 ``activation_checkpoint_layers`` gives the layers that run another 

559 mode, as the trainer's ``activation_checkpoint.layers`` states them, 

560 where there are any: the resolved yaml writes them where the 

561 installed trainer declares that field, and else runs every layer the 

562 mode and logs them as advice (``_strategy_output``). With a choice 

563 per layer possible, 

564 ``recompute_per_layer`` also gives each layer's own fastest option of 

565 its kind's front and the score and memory it would reach: what the 

566 trainer would gain from running each layer its own way, switch by 

567 switch. ``offloaded_layers`` names the layers a choice offloads, as 

568 ``"first"`` or ``"first-last"``, where it offloads any: the plan 

569 states them off, the trainer running no offload yet. 

570 ``offloaded_gib`` gives, by the same names, the GiB each of those 

571 layers moves to the host per micro-batch, and ``offloaded_share`` 

572 the share of what it keeps that is. 

573 """ 

574 # pylint: disable=C0415 

575 from hyper_parallel.auto_parallel.sapp_nd.recompute.candidate import offloaded_share, to_records, trainer_plan 

576 choice = nd_runner.recompute_choices.get(best_entry[0]) 

577 mode, layers = trainer_plan(choice) if choice is not None else ("full", {}) 

578 result: Dict[str, Any] = {"activation_checkpoint": mode} 

579 if layers: 

580 result["activation_checkpoint_layers"] = layers 

581 # Only a choice per layer offloads; one mode for every layer states no ranges of its own. 

582 offloaded = [item for item in getattr(choice, "ranges", ()) if item.option.link_bandwidth] 

583 if offloaded: 

584 names = [str(item.first) if item.count == 1 else f"{item.first}-{item.first + item.count - 1}" 

585 for item in offloaded] 

586 result["offloaded_layers"] = names 

587 result["offloaded_gib"] = {name: item.option.link_bandwidth / 2 ** 30 for name, item in zip(names, offloaded)} 

588 result["offloaded_share"] = {name: offloaded_share(item.option) for name, item in zip(names, offloaded)} 

589 per_layer, score = nd_runner.recompute_per_layer(best_entry[0]) 

590 if per_layer is not None: 

591 result["recompute_per_layer"] = { 

592 "score": float(score), 

593 "memory_estimate_mb": float(per_layer.memory), 

594 "ranges": to_records(per_layer), 

595 } 

596 return result 

597 

598 

599def search_strategies(config: NormalizedConfig, offload: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: 

600 """Run the ND strategy search and return the optimal strategy. 

601 

602 This is the main entry point for end-to-end strategy search: 

603 

604 1. Validates required model fields. 

605 2. Shapes the ``NormalizedConfig`` into the cost-model config ND 

606 parses, as a mapping rather than a file. 

607 3. Launches the ND search engine (:class:`Parallelize`). 

608 4. Post-filters results against the user's candidate lists. 

609 5. Returns the best strategy as a flat dictionary. 

610 

611 Args: 

612 config: A fully populated ``NormalizedConfig`` from 

613 :func:`read_search_config` or :func:`read_hp_yaml_config`. 

614 ``config.estimator["ratios_file"]``, when set, names the ratios 

615 file ``run_nd --write_ratios`` fits, which ND scores with 

616 (``run_nd -s ... -c`` sets it); without it ND scores raw. 

617 offload: With a choice of a recompute mode per layer, let each 

618 stage's first layers run off and offload what they keep, over the 

619 device's host link with these of its figures replaced 

620 (:meth:`HostLink.of`), ``None`` for one left as the device states 

621 it. ``None`` offloads nothing. 

622 

623 Returns: 

624 A dict with keys ``dp``, ``tp``, ``pp``, ``cp``, ``ep``, 

625 ``micro_batch_num``, ``memory_estimate_mb``, and ``score``. 

626 

627 Raises: 

628 ValueError: If required fields are missing or no strategy is found, 

629 or for *offload* without a choice of a mode per layer. 

630 """ 

631 _validate_before_search(config) 

632 

633 hp_config = _build_hp_yaml_dict(config) 

634 machine = _build_machine(config) 

635 dims, candidate_dims = _resolve_search_dimensions(config) 

636 

637 import hyper_parallel.auto_parallel.sapp_nd.nd.parallelize as _Par # pylint: disable=C0415 

638 from hyper_parallel.auto_parallel.sapp_nd.nd.common.derive import ( # pylint: disable=C0415 

639 HYPER_SELECTIVE_REC_OP, 

640 ) 

641 strategy = config.estimator.get("recompute_strategy") 

642 auto = strategy in CHOSEN_RECOMPUTE 

643 census = bool((hp_config.get("context") or {}).get("census")) 

644 trainer = { 

645 "auto_recompute": True, 

646 # The search config may narrow the modes, as to leave out a mode the 

647 # estimate prices faster than the device runs it. 

648 "recompute_modes": tuple(config.estimator.get("recompute_modes") 

649 or (TRAINER_CENSUS_MODES if census else TRAINER_RECOMPUTE_MODES)), 

650 # The trainer's selective mode runs its policy, whatever switches 

651 # the mode the model is described with sets. 

652 "recompute_selective": dict(HYPER_SELECTIVE_REC_OP), 

653 "recompute_mode_per_layer": strategy == "per_layer", 

654 } 

655 if offload is not None: 

656 if strategy != "per_layer": 

657 raise ValueError("offload is priced in a choice of a recompute mode per layer: give the search config " 

658 "recompute: per_layer") 

659 from hyper_parallel.auto_parallel.sapp_nd.nd.common.hardware import HostLink # pylint: disable=C0415 

660 trainer.update(auto_offload=True, host_link=HostLink.of(machine.device.host_link, offload)) 

661 nd_runner = _Par.Parallelize( 

662 "hyper_v2", 

663 hp_config, 

664 machine, 

665 global_batch_size=config.constraint.get("global_batch_size", 0), 

666 dimensions=dims, 

667 **(trainer if auto else {}), 

668 ) 

669 scored_space = nd_runner.run_generation_to_ordering( 

670 yaml_folder=None, 

671 threads_num=None, 

672 top_num=None, 

673 cache_file=config.estimator.get("ratios_file"), 

674 ) 

675 

676 if not scored_space: 

677 raise ValueError("ND search returned no valid strategies.") 

678 

679 filtered = _post_filter(scored_space, config, candidate_dims) 

680 if not filtered: 

681 feasible = _filter_by_memory(scored_space, _memory_budget_gb(config)) 

682 if feasible: 

683 raise ValueError(_no_candidate_match(feasible, config, candidate_dims)) 

684 lightest_gb = min(float(entry[1]) for entry in scored_space) / 1024.0 

685 raise ValueError( 

686 "No strategy fits the memory budget of " 

687 f"{_memory_budget_gb(config):.1f} GB; the lightest strategy " 

688 f"found needs {lightest_gb:.1f} GB. Raise " 

689 "constraint.memory_limit_gb or widen the search space." 

690 ) 

691 best = filtered[0] 

692 result = _format_result(best, config) 

693 if auto: 

694 result.update(_recompute_result(nd_runner, best)) 

695 else: 

696 # Without "auto" the search prices every candidate fully recomputed, 

697 # whatever the search yaml says, so the trainer runs what was priced. 

698 result["activation_checkpoint"] = "full" 

699 

700 logger.info( 

701 "Optimal strategy found: dp=%(dp)s tp=%(tp)s pp=%(pp)s " 

702 "cp=%(cp)s ep=%(ep)s mb_num=%(micro_batch_num)s " 

703 "recompute=%(activation_checkpoint)s " 

704 "mem=%(memory_estimate_mb).0f MB score=%(score).2e", 

705 result, 

706 ) 

707 return result