Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / auto_parallel / sapp_nd / nd / dimensions.py: 95%

219 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +0800

1# Copyright 2025-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"""parallel dimensions""" 

16from __future__ import annotations 

17 

18import sys 

19from typing import TYPE_CHECKING, Any, Optional, Union 

20 

21from hyper_parallel.auto_parallel.sapp_nd.nd.logger import logger 

22from hyper_parallel.auto_parallel.sapp_nd.nd.common.cp_types import ( 

23 CPValidationResult, 

24 CPConstraintParams, 

25) 

26 

27if TYPE_CHECKING: 

28 from hyper_parallel.auto_parallel.sapp_nd.nd.common._cost_model_variables import _CostModVar 

29 

30 

31class Dimension: 

32 """Output dimension""" 

33 

34 def __init__( 

35 self, 

36 acronym, 

37 cost_model_var_name, 

38 from_str, 

39 default=1, 

40 ): 

41 self.name = acronym 

42 self.cost_model_var = cost_model_var_name 

43 self.default = default 

44 self.bound = None 

45 self.from_str = from_str 

46 

47 def __str__(self): 

48 return self.name 

49 

50 def __repr__(self): 

51 return str(self) 

52 

53 def lname(self): 

54 """lower case name""" 

55 return self.name.lower() 

56 

57 def from_config(self, ccfg: _CostModVar): 

58 """Get dimension value from cost model config""" 

59 try: 

60 value = ccfg.__dict__[self.cost_model_var] 

61 except KeyError: 

62 logger.error( 

63 "variable %s does not exist in the cost model: %s", 

64 self.cost_model_var, 

65 str(ccfg.__dict__), 

66 ) 

67 sys.exit(1) 

68 return value 

69 

70 def reset_bound(self): 

71 """Reset bound the dimension space""" 

72 self.bound = None 

73 

74 def set_bound(self, bound): 

75 """Bound the dimension space""" 

76 if self.bound: 

77 logger.debug( 

78 "bound(%s) = min (%d, %d)", self.name, bound, self.bound 

79 ) 

80 self.bound = min(bound, self.bound) 

81 else: 

82 logger.debug("bound(%s) = %d", self.name, bound) 

83 self.bound = bound 

84 

85 def get_bound(self): 

86 """Return dimension bound""" 

87 return self.bound 

88 

89 def is_valid(self, value): 

90 """Check dimension value validity""" 

91 invalid = False 

92 if isinstance(value, bool): 

93 return not invalid 

94 if isinstance(value, int): 

95 invalid = self.bound and value > self.bound 

96 invalid = invalid or value < 1 

97 if invalid: 

98 logger.warning( 

99 "Dimension %s = %s is invalid", self.name, str(value) 

100 ) 

101 return not invalid 

102 

103 

104DP = Dimension( 

105 "DP", 

106 "d", 

107 default=1, 

108 from_str=int, 

109) 

110EP = Dimension( 

111 "EP", 

112 "ep", 

113 default=1, 

114 from_str=int, 

115) 

116TP = Dimension( 

117 "MP", 

118 "t", 

119 default=1, 

120 from_str=int, 

121) 

122CP = Dimension( 

123 "CP", 

124 "cp", 

125 default=1, 

126 from_str=int, 

127) 

128PP = Dimension( 

129 "PP", 

130 "p", 

131 default=1, 

132 from_str=int, 

133) 

134MBN = Dimension( 

135 "MB", 

136 "m", 

137 default=1, 

138 from_str=int, 

139) 

140MBS = Dimension( 

141 "MBS", 

142 "b", 

143 default=1, 

144 from_str=int, 

145) 

146SP = Dimension( 

147 "SP", 

148 "sp", 

149 default=True, 

150 from_str=bool, 

151) 

152OP = Dimension( 

153 "OP", 

154 "os_max_shard", 

155 # "op_weight_shard", 

156 default=1, 

157 from_str=int, 

158) 

159VPP = Dimension( 

160 "VPP", 

161 "vp", 

162 default=1, 

163 from_str=int, 

164) 

165 

166ALL_DIMS = [DP, EP, TP, CP, PP, VPP, MBN, MBS, SP, OP] 

167 

168 

169class Dimensions: 

170 """All output dimensions, of a run that *accumulates* gradients over micro-batches or not. 

171 

172 Attributes: 

173 rank: One-based position in the full memory-fitting search ranking, 

174 or None before the configuration has been ranked. 

175 """ 

176 

177 def __init__(self, config, all_dims=None, accumulates=False, recompute=None): 

178 self.accumulates = accumulates 

179 # The activation checkpoint mode of a search with a recompute 

180 # dimension, which is no degree (recompute_dimension); None elsewhere. 

181 self.recompute = recompute 

182 self.rank: Optional[int] = None 

183 if isinstance(config, list): 

184 self.all_dims = [d for d, _ in config] 

185 self.dims_val = dict(config) 

186 # elif isinstance(config, dict): 

187 # self.all_dims = ALL_DIMS 

188 # self.dims_val = {d: d.from_config(config) for d in self.all_dims} 

189 elif isinstance(config, bool): 

190 self.all_dims = ALL_DIMS 

191 self.dims_val = {d: d.default for d in self.all_dims} 

192 else: 

193 raise TypeError( 

194 f"Dimensions cannot be constructed from type {type(config)}" 

195 ) 

196 if all_dims: 

197 self.all_dims = all_dims 

198 

199 def __str__(self): 

200 mode = getattr(self, "recompute", None) 

201 return f"{self.dims_val} recompute={mode}" if mode else str(self.dims_val) 

202 

203 def __repr__(self): 

204 return str(self) 

205 

206 def keys(self): 

207 """Return dimensions""" 

208 return list(self.dims_val) 

209 

210 def global_batch_size(self): 

211 """Compute the global batch size""" 

212 gbs = self.dims_val[DP] * self.dims_val[MBS] 

213 has_pp = self.has_dim(PP) and self.dims_val[PP] > 1 

214 has_mbn = self.has_dim(MBN) and self.dims_val[MBN] > 1 

215 if has_pp and has_mbn: 

216 gbs *= self.dims_val[MBN] 

217 return gbs 

218 

219 def values(self): 

220 """Return dimension value""" 

221 return [str(self.dims_val[d]) for d in self.dims_val] 

222 

223 def unique_name(self): 

224 """Return all values as a unique string, the recompute mode last where there is one""" 

225 mode = getattr(self, "recompute", None) 

226 return "_".join(self.values() + ([mode] if mode else [])) 

227 

228 def has_dim(self, d): 

229 """Check that this dimension has a value in the parallel config""" 

230 return d in self.dims_val 

231 

232 @staticmethod 

233 def _check_mbn_pp(dims_val, all_dims, accumulates=False): 

234 """Return True if MBN/PP combination is valid. 

235 

236 A pipeline needs a micro-batch per stage at least. Without one, 

237 micro-batches are gradient accumulation steps, which only a run 

238 that *accumulates* its gradients over them takes. 

239 """ 

240 if MBN not in dims_val or PP not in all_dims: 

241 return True 

242 valid = dims_val[MBN] >= dims_val[PP] 

243 valid = valid and (dims_val[PP] > 1 or dims_val[MBN] == 1 or accumulates) 

244 if not valid: 

245 logger.warning("PP and MBN were deemed not suitable") 

246 return valid 

247 

248 @staticmethod 

249 def _check_power_of_two(dim_obj, value): 

250 """Return True if *value* is a power of 2 (for TP / OP).""" 

251 if not value & (value - 1) == 0: 

252 logger.warning("%s must be a power of 2", str(dim_obj)) 

253 return False 

254 return True 

255 

256 def is_valid(self): 

257 """Check if all dimensions values are valid""" 

258 if not self._check_mbn_pp(self.dims_val, self.all_dims, getattr(self, "accumulates", False)): 

259 return False 

260 if TP in self.all_dims and not self._check_power_of_two(TP, self.dims_val[TP]): 

261 return False 

262 for d in self.dims_val: 

263 if not d.is_valid(self.dims_val[d]): 

264 logger.warning("Dimension %d is not valid", d) 

265 return False 

266 if SP in self.all_dims and CP in self.all_dims: 

267 if self.dims_val[SP] and self.dims_val[CP] > 1: 

268 logger.warning("SP & CP cannot coexist") 

269 return False 

270 if OP in self.all_dims and not self._check_power_of_two(OP, self.dims_val[OP]): 

271 return False 

272 return True 

273 

274 def val(self, dim): 

275 """Get Dimension value""" 

276 return self.dims_val[dim] 

277 

278 def set(self, dim, val): 

279 """Get Dimension value""" 

280 self.dims_val[dim] = val 

281 

282 def steal(self, factor, dim_from, dim_to): 

283 """Assign a dimension factor to another dimension""" 

284 self.dims_val[dim_from] = self.dims_val[dim_from] // factor 

285 self.dims_val[dim_to] = self.dims_val[dim_to] * factor 

286 

287 

288def get_dim(acronym): 

289 """Return the dimension of the given string acronym""" 

290 dname = str(acronym).upper() 

291 for d in ALL_DIMS: 

292 if d.name == dname: 

293 return d 

294 raise ValueError(f"Dimension {dname} does NOT exist") 

295 

296 

297def get_dims(dims): 

298 """Return all dimensions considered""" 

299 if dims is None: 

300 return ALL_DIMS 

301 return [get_dim(acronym) for acronym in dims] 

302 

303 

304def _cp_ok_result(**overrides): 

305 """Build a passing CPValidationResult with sensible defaults.""" 

306 defaults = { 

307 "is_valid": True, "error_message": None, "warning_message": None, 

308 "seq_len_divisible": True, "topology_feasible": True, 

309 "device_sufficient": True, "memory_within_limit": True, 

310 "recommended_cp_max": None, "topology_penalty": None, 

311 "unsupported_reason": None, 

312 } 

313 defaults.update(overrides) 

314 return CPValidationResult(**defaults) 

315 

316 

317def _cp_check_ulysses_heads(p): 

318 """Check Ulysses KV-head divisibility. Returns error message or None. 

319 

320 Ulysses CP shards KV heads across tp×cp ranks, so num_kv_heads must be 

321 divisible by tp×cp. When TP replicates KV (GQA with tp ≤ kv-head-groups) 

322 the check is conservative but safe — it never lets a crashing config 

323 through. num_kv_heads=0 falls back to attention_heads (MHA semantics, 

324 matching compute_kv_dim). 

325 """ 

326 if p.cp_algo != "ulysses_cp": 

327 return None 

328 kv_heads = p.num_kv_heads if p.num_kv_heads > 0 else p.attention_heads 

329 if kv_heads <= 0: 

330 return None 

331 shards = p.tp_degree * p.cp_degree 

332 if shards <= 0: 

333 return None 

334 if kv_heads % shards != 0: 

335 return ( 

336 f"Ulysses CP requires num_kv_heads ({kv_heads}) divisible by " 

337 f"tp_degree × cp_degree ({p.tp_degree} × {p.cp_degree} = {shards})." 

338 ) 

339 return None 

340 

341 

342def _cp_check_device_sufficiency(p): 

343 """Check tp*cp*pp <= total_devices. Returns error message or None.""" 

344 if p.total_devices > 0 and p.tp_degree * p.cp_degree * p.pp_degree > p.total_devices: 

345 return ( 

346 f"tp×cp×pp ({p.tp_degree}×{p.cp_degree}×{p.pp_degree} = " 

347 f"{p.tp_degree * p.cp_degree * p.pp_degree}) exceeds " 

348 f"total available devices ({p.total_devices})." 

349 ) 

350 return None 

351 

352 

353def _cp_collect_warnings(p): 

354 """Collect warning messages and derived topology/recommendation info.""" 

355 warnings = [] 

356 topology_feasible = True 

357 topology_penalty = None 

358 recommended_cp_max = None 

359 

360 if p.tp_degree * p.cp_degree > p.device_per_node: 

361 topology_feasible = False 

362 topology_penalty = 1.0 - (p.bw_inter / p.bw_intra) 

363 warnings.append( 

364 f"CP will cross node boundary (tp={p.tp_degree} × cp={p.cp_degree} = " 

365 f"{p.tp_degree * p.cp_degree} > {p.device_per_node} devices/node). " 

366 f"Communication will use slower inter-node bandwidth." 

367 ) 

368 

369 if p.seq_len < 8192: 

370 warnings.append( 

371 f"CP not recommended for short sequences (seq_len={p.seq_len} < 8192). " 

372 f"Communication overhead may outweigh memory benefits." 

373 ) 

374 

375 attn_upper = p.attention_type_str.upper() 

376 if attn_upper == "MLA": 

377 recommended_cp_max = 16 

378 elif attn_upper == "GQA": 

379 recommended_cp_max = 8 

380 else: 

381 recommended_cp_max = 4 

382 

383 if recommended_cp_max is not None and p.cp_degree > recommended_cp_max: 

384 warnings.append( 

385 f"cp_degree ({p.cp_degree}) exceeds recommended max ({recommended_cp_max}) " 

386 f"for {attn_upper} attention." 

387 ) 

388 

389 return warnings, topology_feasible, topology_penalty, recommended_cp_max 

390 

391 

392def _cp_check_memory(p, warnings, topology_feasible, topology_penalty, recommended_cp_max): 

393 """Check CP memory capacity. Returns CPValidationResult or None.""" 

394 if not (p.cp_memory_per_layer > 0 and p.device_capacity > 0 and p.num_layers > 0): 

395 return None 

396 total_cp_memory = p.cp_memory_per_layer * p.num_layers 

397 if total_cp_memory <= p.device_capacity: 

398 return None 

399 return _cp_ok_result( 

400 is_valid=False, 

401 error_message=( 

402 f"CP memory per card ({total_cp_memory / 1e6:.1f} MB) exceeds " 

403 f"device capacity ({p.device_capacity / 1e6:.1f} MB)." 

404 ), 

405 warning_message="; ".join(warnings) if warnings else None, 

406 topology_feasible=topology_feasible, 

407 topology_penalty=topology_penalty, 

408 recommended_cp_max=recommended_cp_max, 

409 memory_within_limit=False, 

410 ) 

411 

412 

413_CP_POSITIONAL_FIELDS = ( 

414 "cp_degree", "tp_degree", "pp_degree", "device_per_node", "attention_type_str", 

415 "bw_intra", "bw_inter", "total_devices", "cp_memory_per_layer", "device_capacity", 

416 "num_layers", "cp_algo", "attention_heads", "sp_enabled", "num_kv_heads", 

417) 

418 

419 

420def _cp_params_from_args(seq_len, args, kwargs): 

421 """Preserve the legacy positional field order when building CP parameters.""" 

422 if len(args) > len(_CP_POSITIONAL_FIELDS): 

423 raise TypeError("validate_cp_constraints accepts at most 16 positional arguments") 

424 options = dict(zip(_CP_POSITIONAL_FIELDS, args)) 

425 duplicates = options.keys() & kwargs.keys() 

426 if duplicates: 

427 raise TypeError(f"Multiple values for CP arguments: {sorted(duplicates)}") 

428 unknown = kwargs.keys() - set(_CP_POSITIONAL_FIELDS) 

429 if unknown: 

430 raise TypeError(f"Unexpected CP arguments: {sorted(unknown)}") 

431 if isinstance(seq_len, CPConstraintParams): 

432 return seq_len 

433 return CPConstraintParams(seq_len=seq_len, **{"cp_degree": 1, **options, **kwargs}) 

434 

435 

436def validate_cp_constraints( 

437 seq_len: Union[CPConstraintParams, int], 

438 *args: Any, 

439 **kwargs: Any, 

440) -> CPValidationResult: 

441 """Validate CP constraints for a given parallel configuration. 

442 

443 Accepts either a CPConstraintParams dataclass or its fields as positional 

444 and keyword arguments for backward compatibility. 

445 

446 Args: 

447 seq_len: Sequence length, or a CPConstraintParams dataclass. 

448 *args: Legacy positional fields, from cp_degree through num_kv_heads. 

449 The final two fields retain the order sp_enabled, num_kv_heads. 

450 **kwargs: The other CPConstraintParams fields, read only when seq_len 

451 is an int. An omitted field takes the dataclass default, except 

452 cp_degree, which defaults to 1. 

453 

454 cp_degree: CP degree. 

455 tp_degree: TP degree. 

456 pp_degree: PP degree. 

457 device_per_node: Number of devices per node. 

458 attention_type_str: Attention type string ("mha", "gqa", "mla"). 

459 bw_intra: Intra-node bandwidth in GB/s (default: 300.0 for Ascend A2). 

460 bw_inter: Inter-node bandwidth in GB/s (default: 25.0). 

461 total_devices: Total number of available devices (0 = skip check). 

462 cp_memory_per_layer: CP memory per layer in bytes (0 = skip check). 

463 device_capacity: Device memory capacity in bytes (0 = skip check). 

464 num_layers: Number of transformer layers (0 = skip check). 

465 cp_algo: CP algorithm ("colossalai_cp", "hybrid_cp", "ulysses_cp"). 

466 attention_heads: Number of attention heads (0 = skip Ulysses head check). 

467 sp_enabled: Whether sequence parallelism is enabled (SP and CP are incompatible). 

468 num_kv_heads: Number of KV heads for Ulysses divisibility check 

469 (0 = fall back to attention_heads, matching compute_kv_dim). 

470 

471 Returns: 

472 CPValidationResult with validation outcome. 

473 """ 

474 p = _cp_params_from_args(seq_len, args, kwargs) 

475 

476 if p.cp_degree <= 1: 

477 return _cp_ok_result() 

478 

479 if p.sp_enabled: 

480 return _cp_ok_result( 

481 is_valid=False, 

482 error_message=( 

483 f"Context Parallelism (cp={p.cp_degree}) and Sequence Parallelism " 

484 f"are incompatible and cannot be used together." 

485 ), 

486 unsupported_reason="CP+SP incompatible", 

487 ) 

488 

489 if p.seq_len % (p.cp_degree * 2) != 0: 

490 return _cp_ok_result( 

491 is_valid=False, seq_len_divisible=False, 

492 error_message=( 

493 f"Sequence length {p.seq_len} must be divisible by " 

494 f"cp_degree × 2 = {p.cp_degree * 2}. " 

495 f"Current remainder: {p.seq_len % (p.cp_degree * 2)}" 

496 ), 

497 ) 

498 

499 ulysses_err = _cp_check_ulysses_heads(p) 

500 if ulysses_err: 

501 return _cp_ok_result( 

502 is_valid=False, error_message=ulysses_err, 

503 unsupported_reason="Ulysses insufficient heads", 

504 ) 

505 

506 device_err = _cp_check_device_sufficiency(p) 

507 if device_err: 

508 return _cp_ok_result( 

509 is_valid=False, error_message=device_err, device_sufficient=False, 

510 ) 

511 

512 warnings, topology_feasible, topology_penalty, recommended_cp_max = ( 

513 _cp_collect_warnings(p) 

514 ) 

515 

516 mem_result = _cp_check_memory( 

517 p, warnings, topology_feasible, topology_penalty, recommended_cp_max) 

518 if mem_result is not None: 

519 return mem_result 

520 

521 return _cp_ok_result( 

522 warning_message="; ".join(warnings) if warnings else None, 

523 topology_feasible=topology_feasible, 

524 topology_penalty=topology_penalty, 

525 recommended_cp_max=recommended_cp_max, 

526 )