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

129 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-07-06 05:41 +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, TYPE_CHECKING 

26 

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

28 

29from hyper_parallel.auto_parallel.config_adapter._normalized_config import NormalizedConfig 

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 

38def _get_dim_module(): 

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

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

41 return dim_mod 

42 

43 

44def _get_machine_mod(): 

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

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

47 return hw_mod 

48 

49 

50def _search_dim_map(): 

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

52 

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

54 """ 

55 dim_mod = _get_dim_module() 

56 return { 

57 "data_parallel_replicate_degree": dim_mod.DP, 

58 "tensor_parallel_degree": dim_mod.TP, 

59 "pipeline_parallel_degree": dim_mod.PP, 

60 "context_parallel_degree": dim_mod.CP, 

61 "expert_parallel_degree": dim_mod.EP, 

62 "micro_batch_num": dim_mod.MBN, 

63 } 

64 

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

66_ACCEL_FIELD_MAP: Dict[str, str] = { 

67 "data_parallel_shard_degree": "dp_shard", 

68 "data_parallel_replicate_degree": "dp_replicate", 

69 "tensor_parallel_degree": "tp_degree", 

70 "pipeline_parallel_degree": "pipeline_parallel_degree", 

71 "context_parallel_degree": "context_parallel_degree", 

72 "expert_parallel_degree": "expert_parallel_degree", 

73 "micro_batch_num": "micro_batch_num", 

74} 

75 

76 

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

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

79 

80 Raises: 

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

82 """ 

83 model = config.model_spec 

84 required = { 

85 "model_spec.n_layers": model.get("n_layers", 0), 

86 "model_spec.dim": model.get("dim", 0), 

87 "model_spec.n_heads": model.get("n_heads", 0), 

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

89 "cluster_spec": config.cluster_spec, 

90 } 

91 missing = [] 

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

93 if name == "cluster_spec": 

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

95 missing.append(name) 

96 elif value <= 0: 

97 missing.append(name) 

98 if missing: 

99 raise ValueError( 

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

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

102 ) 

103 

104 

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

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

107 

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

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

110 use the first candidate as a placeholder — the actual search is driven 

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

112 """ 

113 model = config.model_spec 

114 constraint = config.constraint 

115 space = config.search_space 

116 

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

118 

119 # Fixed dimensions → write actual value. 

120 fixed_map = { 

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

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

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

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

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

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

127 } 

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

129 fixed_val = constraint.get(constraint_key) 

130 if fixed_val is not None and fixed_val > 0: 

131 accel[accel_key] = fixed_val 

132 else: 

133 candidates = space.get(space_key, default) 

134 accel[accel_key] = candidates[0] 

135 

136 # Enable parallel optimizer by default. 

137 accel.setdefault("enable_parallel_optimizer", True) 

138 

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

140 

141 hp_yaml: dict = { 

142 "model": { 

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

144 "config_overrides": { 

145 "hidden_size": model.get("dim", 4096), 

146 "num_hidden_layers": model.get("n_layers", 32), 

147 "num_attention_heads": model.get("n_heads", 32), 

148 "vocab_size": model.get("vocab_size", 128256), 

149 }, 

150 }, 

151 "train": { 

152 "global_batch_size": constraint.get("global_batch_size", 0) or 1, 

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

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

155 "accelerator": accel, 

156 "gradient_checkpointing": { 

157 "activation_checkpoint": recompute, 

158 }, 

159 "mixed_precision": { 

160 "enabled": True, 

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

162 }, 

163 }, 

164 "data": { 

165 "max_seq_len": model.get("seq_len", 4096), 

166 }, 

167 } 

168 

169 # Optional model fields. 

170 overrides = hp_yaml["model"]["config_overrides"] 

171 if model.get("inter_dim"): 

172 overrides["intermediate_size"] = model["inter_dim"] 

173 if model.get("n_kv_heads"): 

174 overrides["num_key_value_heads"] = model["n_kv_heads"] 

175 

176 return hp_yaml 

177 

178 

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

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

181 data = _build_hp_yaml_dict(config) 

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

183 os.close(fd) 

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

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

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

187 return path 

188 

189 

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

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

192 hw_mod = _get_machine_mod() 

193 cluster = config.cluster_spec 

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

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

196 total_devices = nodes * cards_per_node 

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

198 # Map generic names to sapp_nd device codes. 

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

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

201 return hw_mod.Machine(total_devices, device_type) 

202 

203 

204def _resolve_search_dimensions(config: NormalizedConfig) -> List[Any]: 

205 """Return a list of ``Dim`` objects whose candidates contain >1 value. 

206 

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

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

209 ``search_space`` (``"auto"`` in YAML) are also included — they 

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

211 """ 

212 dims: List[Any] = [] 

213 space = config.search_space 

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

215 candidates = space.get(space_key) 

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

217 dims.append(dim_obj) 

218 elif space_key not in space: 

219 dims.append(dim_obj) 

220 return dims 

221 

222 

223def _post_filter( 

224 scored_space: list, 

225 config: NormalizedConfig, 

226) -> list: 

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

228 space = config.search_space 

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

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

231 candidates = space.get(space_key) 

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

233 candidate_map[dim_obj] = candidates 

234 

235 filtered = [] 

236 for entry in scored_space: 

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

238 keep = True 

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

240 actual = dims_val.get(dim_obj) 

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

242 keep = False 

243 break 

244 if keep: 

245 filtered.append(entry) 

246 

247 if not filtered and scored_space: 

248 logger.warning( 

249 "Post-filter removed ALL %d candidates. " 

250 "Returning unfiltered best entry.", 

251 len(scored_space), 

252 ) 

253 return scored_space[:1] 

254 return filtered 

255 

256 

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

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

259 dim_mod = _get_dim_module() 

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

261 dim_to_key = { 

262 dim_mod.DP: "dp", 

263 dim_mod.TP: "tp", 

264 dim_mod.PP: "pp", 

265 dim_mod.CP: "cp", 

266 dim_mod.EP: "ep", 

267 dim_mod.MBN: "micro_batch_num", 

268 } 

269 result: Dict[str, Any] = { 

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

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

272 } 

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

274 if dim_obj in dims_val: 

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

276 return result 

277 

278 

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

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

281 

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

283 

284 1. Validates required model fields. 

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

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

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

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

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

290 

291 Args: 

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

293 :func:`read_search_config` or :func:`read_hp_yaml_config`. 

294 

295 Returns: 

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

297 ``micro_batch_num``, ``memory_estimate_mb``, and ``score``. 

298 

299 Raises: 

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

301 ImportError: If PyYAML is not installed. 

302 """ 

303 _validate_before_search(config) 

304 

305 yaml_path = _write_temp_hp_yaml(config) 

306 machine = _build_machine(config) 

307 dims = _resolve_search_dimensions(config) 

308 

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

310 try: 

311 nd_runner = _Par.Parallelize( 

312 "hyper_v2", 

313 yaml_path, 

314 machine, 

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

316 dimensions=dims, 

317 ) 

318 scored_space = nd_runner.run_generation_to_ordering( 

319 yaml_folder=None, 

320 threads_num=None, 

321 top_num=None, 

322 ) 

323 finally: 

324 try: 

325 os.remove(yaml_path) 

326 except OSError: 

327 pass 

328 

329 if not scored_space: 

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

331 

332 filtered = _post_filter(scored_space, config) 

333 best = filtered[0] 

334 result = _format_result(best) 

335 

336 logger.info( 

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

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

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

340 result, 

341 ) 

342 return result