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

165 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-08-04 05: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"""Search runner -- bridges NormalizedConfig to the ND search engine. 

16 

17Converts a :class:`NormalizedConfig` into a temporary HyperParallel 

18``train.yaml``, runs the ND search via :class:`Parallelize`, 

19post-filters by user candidate lists, and returns the optimal strategy. 

20""" 

21 

22import logging 

23import os 

24import tempfile 

25from typing import Any, Dict, List, Optional, Set, Tuple, TYPE_CHECKING 

26 

27import yaml # type: ignore[import-untyped] 

28 

29from hyper_parallel.auto_parallel.config_adapter._normalized_config import NormalizedConfig 

30 

31 

32CONFIG_OVERRIDE_FIELDS = [ 

33 "hidden_size", "num_hidden_layers", "num_attention_heads", "vocab_size", 

34 "intermediate_size", "num_key_value_heads", "max_position_embeddings", 

35 "num_experts", "num_experts_per_tok", "num_shared_experts", 

36 "moe_intermediate_size", "first_k_dense_replace", "mtp_depth", 

37 "multiple_of", "ffn_dim_multiplier", "kv_lora_rank", "q_lora_rank", 

38 "qk_rope_head_dim", "v_head_dim", "capacity_factor", "offset", 

39 "param_init_type", "compute_dtype", "softmax_compute_type", 

40] 

41 

42if TYPE_CHECKING: 

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

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

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

46 

47logger = logging.getLogger(__name__) 

48 

49def _get_dim_module(): 

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

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

52 return dim_mod 

53 

54 

55def _get_machine_mod(): 

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

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

58 return hw_mod 

59 

60 

61def _search_dim_map(): 

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

63 

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

65 """ 

66 dim_mod = _get_dim_module() 

67 return { 

68 "data_parallel_replicate_degree": dim_mod.DP, 

69 "tensor_parallel_degree": dim_mod.TP, 

70 "pipeline_parallel_degree": dim_mod.PP, 

71 "context_parallel_degree": dim_mod.CP, 

72 "expert_parallel_degree": dim_mod.EP, 

73 "micro_batch_num": dim_mod.MBN, 

74 } 

75 

76# Accelerator field names for fixed dimensions (written into the temp YAML). 

77_ACCEL_FIELD_MAP: Dict[str, str] = { 

78 "data_parallel_shard_degree": "dp_shard", 

79 "data_parallel_replicate_degree": "dp_replicate", 

80 "tensor_parallel_degree": "tp_degree", 

81 "pipeline_parallel_degree": "pipeline_parallel_degree", 

82 "context_parallel_degree": "context_parallel_degree", 

83 "expert_parallel_degree": "expert_parallel_degree", 

84 "micro_batch_num": "micro_batch_num", 

85} 

86 

87 

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

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

90 

91 Raises: 

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

93 """ 

94 model = config.model_spec 

95 required = { 

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

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

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

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

100 "cluster_spec": config.cluster_spec, 

101 } 

102 missing = [] 

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

104 if name == "cluster_spec": 

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

106 missing.append(name) 

107 elif value <= 0: 

108 missing.append(name) 

109 if missing: 

110 raise ValueError( 

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

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

113 ) 

114 

115 

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

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

118 

119 All ``config_overrides`` field names in *model* already match the 

120 HP YAML convention, so they are passed through directly without 

121 any name mapping. 

122 

123 Args: 

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

125 

126 Returns: 

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

128 """ 

129 model_dict: Dict[str, Any] = { 

130 "name": model.get("name", "custom"), 

131 "config_overrides": {}, 

132 } 

133 overrides = model_dict["config_overrides"] 

134 for key in CONFIG_OVERRIDE_FIELDS: 

135 val = model.get(key) 

136 if val is not None: 

137 overrides[key] = val 

138 

139 return model_dict 

140 

141 

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

143 """Build a HyperParallel ``train.yaml`` dict from *config*. 

144 

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

146 into ``train.accelerator``. Dimensions with search-space candidates 

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

148 by the ``dimensions`` parameter passed to :class:`Parallelize`. 

149 """ 

150 model = config.model_spec 

151 constraint = config.constraint 

152 space = config.search_space 

153 

154 accel: Dict[str, Any] = {} 

155 

156 # Fixed dimensions -- write actual value. 

157 fixed_map = { 

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

159 "fixed_fsdp_degree": ("dp_shard", "data_parallel_shard_degree", [1]), 

160 "fixed_tp_degree": ("tp_degree", "tensor_parallel_degree", [1]), 

161 "fixed_pp_degree": ("pipeline_parallel_degree", "pipeline_parallel_degree", [1]), 

162 "fixed_cp_degree": ("context_parallel_degree", "context_parallel_degree", [1]), 

163 "fixed_ep_degree": ("expert_parallel_degree", "expert_parallel_degree", [1]), 

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

165 } 

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

167 fixed_val = constraint.get(constraint_key) 

168 if fixed_val is not None and fixed_val > 0: 

169 accel[accel_key] = fixed_val 

170 else: 

171 candidates = space.get(space_key, default) 

172 accel[accel_key] = candidates[0] 

173 

174 # Enable parallel optimizer by default. 

175 accel.setdefault("enable_parallel_optimizer", True) 

176 

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

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

179 if cp_algo: 

180 accel["context_parallel_algo"] = cp_algo 

181 

182 # Optional accelerator fields that affect memory estimation. 

183 owss = model.get("optimizer_weight_shard_size") 

184 if owss and owss > 0: 

185 accel["optimizer_weight_shard_size"] = owss 

186 

187 use_sp = model.get("use_seq_parallel", True) 

188 accel.setdefault("use_seq_parallel", bool(use_sp)) 

189 

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

191 

192 cluster = config.cluster_spec 

193 device_mem_gb = cluster.get("device_memory_gb", 0) 

194 context: Dict[str, Any] = {} 

195 if device_mem_gb > 0: 

196 context["max_device_memory"] = f"{device_mem_gb}GB" 

197 

198 gc_dict: Dict[str, Any] = {"activation_checkpoint": recompute} 

199 recompute_slice = model.get("recompute_slice_activation") 

200 if recompute_slice is not None: 

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

202 

203 model_dict = _build_model_dict(model) 

204 

205 hp_yaml: dict = { 

206 "model": model_dict, 

207 "train": { 

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

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

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

211 "accelerator": accel, 

212 "gradient_checkpointing": gc_dict, 

213 "mixed_precision": { 

214 "enabled": True, 

215 "param_dtype": model.get("compute_dtype", "bfloat16"), 

216 }, 

217 }, 

218 "data": { 

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

220 }, 

221 } 

222 

223 if context: 

224 hp_yaml["context"] = context 

225 

226 return hp_yaml 

227 

228 

229def _write_temp_hp_yaml(config: NormalizedConfig) -> str: 

230 """Write a temp ``train.yaml`` and return its absolute path.""" 

231 data = _build_hp_yaml_dict(config) 

232 fd, path = tempfile.mkstemp(suffix=".yaml", prefix="hp_search_") 

233 os.close(fd) 

234 with open(path, "w", encoding="utf-8") as fh: 

235 yaml.dump(data, fh, default_flow_style=False, sort_keys=False) 

236 logger.debug("Temp HP YAML written to %s", path) 

237 return path 

238 

239 

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

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

242 hw_mod = _get_machine_mod() 

243 cluster = config.cluster_spec 

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

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

246 total_devices = nodes * cards_per_node 

247 device_type = cluster.get("device_type", "A2") 

248 # Map generic names to sapp_nd device codes. 

249 device_code_map = {"ascend": "A2", "ascend910": "A2", "ascend910b": "A3"} 

250 device_type = device_code_map.get(str(device_type).lower(), device_type) 

251 return hw_mod.Machine(total_devices, device_type) 

252 

253 

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

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

256 

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

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

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

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

261 

262 Returns: 

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

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

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

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

267 """ 

268 dims: List[Any] = [] 

269 candidate_dims: Set[Any] = set() 

270 space = config.search_space 

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

272 candidates = space.get(space_key) 

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

274 dims.append(dim_obj) 

275 candidate_dims.add(dim_obj) 

276 elif space_key not in space: 

277 dims.append(dim_obj) 

278 return dims, candidate_dims 

279 

280 

281def _post_filter( 

282 scored_space: list, 

283 config: NormalizedConfig, 

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

285) -> list: 

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

287 

288 Args: 

289 scored_space: The scored strategy list from ND engine. 

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

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

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

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

294 

295 Returns: 

296 A filtered list. May be empty if no entry satisfies all 

297 candidate constraints -- the caller decides how to handle this. 

298 """ 

299 space = config.search_space 

300 if candidate_dims is None: 

301 candidate_dims = set() 

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

303 candidates = space.get(space_key) 

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

305 candidate_dims.add(dim_obj) 

306 

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

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

309 if dim_obj not in candidate_dims: 

310 continue 

311 candidates = space.get(space_key) 

312 if candidates is not None: 

313 candidate_map[dim_obj] = candidates 

314 

315 filtered = [] 

316 for entry in scored_space: 

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

318 keep = True 

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

320 actual = dims_val.get(dim_obj) 

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

322 keep = False 

323 break 

324 if keep: 

325 filtered.append(entry) 

326 

327 if not filtered and scored_space: 

328 logger.warning( 

329 "Post-filter removed ALL %d candidates; " 

330 "no strategy matches the user's candidate constraints.", 

331 len(scored_space), 

332 ) 

333 return scored_space[:1] 

334 return filtered 

335 

336 

337def _format_result(best_entry: tuple) -> Dict[str, Any]: 

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

339 dim_mod = _get_dim_module() 

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

341 dim_to_key = { 

342 dim_mod.DP: "dp", 

343 dim_mod.TP: "tp", 

344 dim_mod.PP: "pp", 

345 dim_mod.CP: "cp", 

346 dim_mod.EP: "ep", 

347 dim_mod.MBN: "micro_batch_num", 

348 } 

349 result: Dict[str, Any] = { 

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

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

352 } 

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

354 if dim_obj in dims_val: 

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

356 result.setdefault("cp", 1) 

357 result.setdefault("ep", 1) 

358 return result 

359 

360 

361def search_strategies(config: NormalizedConfig) -> Dict[str, Any]: 

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

363 

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

365 

366 1. Validates required model fields. 

367 2. Converts the ``NormalizedConfig`` to a temporary HyperParallel 

368 ``train.yaml`` and writes it to disk. 

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

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

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

372 

373 Args: 

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

375 :func:`read_search_config` or :func:`read_hp_yaml_config`. 

376 

377 Returns: 

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

379 ``micro_batch_num``, ``memory_estimate_mb``, and ``score``. 

380 

381 Raises: 

382 ValueError: If required fields are missing or no strategy is found. 

383 ImportError: If PyYAML is not installed. 

384 """ 

385 _validate_before_search(config) 

386 

387 yaml_path = _write_temp_hp_yaml(config) 

388 machine = _build_machine(config) 

389 dims, candidate_dims = _resolve_search_dimensions(config) 

390 

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

392 try: 

393 nd_runner = _Par.Parallelize( 

394 "hyper_v2", 

395 yaml_path, 

396 machine, 

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

398 dimensions=dims, 

399 ) 

400 scored_space = nd_runner.run_generation_to_ordering( 

401 yaml_folder=None, 

402 threads_num=None, 

403 top_num=None, 

404 ) 

405 finally: 

406 try: 

407 os.remove(yaml_path) 

408 except OSError: 

409 pass 

410 

411 if not scored_space: 

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

413 

414 filtered = _post_filter(scored_space, config, candidate_dims) 

415 best = filtered[0] 

416 result = _format_result(best) 

417 

418 logger.info( 

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

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

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

422 result, 

423 ) 

424 return result