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"""parse config for cost model"""
16import inspect
17import os
18from collections import Counter, defaultdict
19from contextlib import contextmanager
20from typing import Any, DefaultDict, Iterator, List
21import re
22from copy import deepcopy
23from enum import Enum
24from pprint import pformat
25
26from hyper_parallel.auto_parallel.sapp_nd.nd.common.generate_partitions import PartitionGenerator
27from hyper_parallel.auto_parallel.sapp_nd.memory_estimation.logger import logger
28from hyper_parallel.auto_parallel.sapp_nd.nd.common.apply_exec import apply_exec, strategy_exec
29
30
31class AttentionType(Enum):
32 """Attention type enumeration."""
33 MHA = "mha"
34 GQA = "gqa"
35 MLA = "mla"
36
37
38def detect_attention_type(ccfg: "CostModelConfig") -> AttentionType:
39 """Detect attention type from cost model config.
40
41 Detection rules:
42 1. If the keys and values come from a compressed latent (dc_kv > 0,
43 the latent width every parser states): MLA
44 2. If n_kv < a: GQA
45 3. Otherwise: MHA
46
47 Args:
48 ccfg: Cost model config.
49
50 Returns:
51 AttentionType enum.
52
53 Example:
54 >>> ccfg.dc_kv = 512
55 >>> ccfg.a = 64
56 >>> ccfg.n_kv = 64
57 >>> detect_attention_type(ccfg)
58 <AttentionType.MLA: 'mla'>
59 """
60 if ccfg.dc_kv > 0:
61 return AttentionType.MLA
62 if ccfg.n_kv < ccfg.a:
63 return AttentionType.GQA
64 return AttentionType.MHA
65
66
67def compute_kv_dim(ccfg: Any) -> float:
68 """Return the width, per TP rank, of each of the K and V that CP exchanges.
69
70 Context parallelism exchanges the attention's own keys and values,
71 after the projections, whose heads TP splits. An MLA layer builds every
72 head's key and value from its latent before attention, the key at the
73 head width plus the rotary part every head shares, the value at the
74 head width, and those are what CP exchanges, not the latent: it acts on
75 the attention's inputs. Their mean width is returned, since K and V
76 are counted as two tensors of one width.
77
78 Args:
79 ccfg: Cost model config with attributes a, n_kv, dh, dhr, h, t, dc_kv.
80
81 Returns:
82 Effective KV dimension per TP rank (float).
83 """
84 attention_type = detect_attention_type(ccfg)
85 t = max(1, ccfg.t)
86 if attention_type == AttentionType.MLA:
87 return ccfg.n_kv * (2 * ccfg.dh + ccfg.dhr) / 2 / t
88 if attention_type == AttentionType.GQA:
89 n_kv = min(ccfg.n_kv if ccfg.n_kv > 0 else ccfg.a, ccfg.a)
90 return n_kv * ccfg.dh / t
91 return ccfg.h / t
92
93
94# Each read of a config field no parser set, by field and by the function
95# that read it. The config hands such a field over as 0, so a model feature
96# nobody parsed costs nothing unless its reader has a fallback of its own, and
97# the estimate stays plausible; a search says which were read, under its
98# ranking. A reader with a fallback, such as _flavour_tables' three
99# feed-forward matmuls, is named too: the record cannot tell it apart.
100UNSET_READS: DefaultDict[str, Counter] = defaultdict(Counter)
101# The fields whose absence means there is nothing to price: a submodule
102# copied without its parent's hooks takes the predefined ones.
103ABSENT_MEANS_NOTHING = frozenset({"hooks_dict"})
104# Whether such a read is refused rather than priced as 0 (run_nd --strict).
105_STRICT = {"on": False}
106
107
108def set_strict(strict: bool) -> None:
109 """Refuse, rather than price as 0, a config field no parser set where the estimate reads it.
110
111 Under it a read such as ``ccfg.n_gather`` raises AttributeError naming
112 the field and its reader, while ``getattr(ccfg, name, default)``, which
113 states what absence means, gets its default.
114
115 Args:
116 strict: True to refuse; False to price an unset field as 0, as by default.
117 """
118 _STRICT["on"] = bool(strict)
119
120
121def unset_reads_report() -> List[str]:
122 """What the config fields no parser set were read by, one line, and forget them; none where none was read."""
123 if not UNSET_READS:
124 return []
125 count = len(UNSET_READS)
126 fields = "; ".join(
127 f"{attr} by {', '.join(reader for reader, _ in readers.most_common())}"
128 for attr, readers in sorted(UNSET_READS.items())
129 )
130 UNSET_READS.clear()
131 return [f"The estimate read {count} config field(s) no parser set, each handed over as 0, which a reader "
132 f"without a fallback of its own prices as 0 (run_nd --strict refuses them): {fields}"]
133
134
135# class CostModelConfig(Config) :
136# The strategy fields a config armed by a search takes only through
137# set_strategy or apply_exec.
138STRATEGY_GUARDED = frozenset(("d", "t", "ep", "p", "vp", "cp", "os_max_shard"))
139# Where a config keeps whether its guard is armed; a copy never takes it.
140_GUARD = "_strategy_guard"
141# What a deep copy shares with its original: the parser and the raw yaml, set
142# when the config is parsed and written by no estimate. A search deep-copies
143# its config twice a candidate, and copying these was most of a copy's time.
144_SHARED_ON_COPY = frozenset(("parser", "config"))
145
146
147class CostModelConfig(PartitionGenerator):
148 """cost model variables class"""
149
150 def __init__(
151 self,
152 input_config: Any = None,
153 hook_cls: Any = None,
154 framework: Any = None,
155 source_code: Any = None,
156 devices: int = 0,
157 ) -> None:
158 """Initialise the cost model from a config, hooks and framework name, and the devices a caller states."""
159 super().__init__(input_config, hook_cls, framework, source_code, devices)
160 logger.debug(
161 "parser = %s for %s", str(self.parser), str(self.model_name)
162 )
163
164 def __str__(self) -> str:
165 """Return the cost model variables as readable text."""
166 return "CostModelConfig attributes:\n" + pformat(
167 {
168 k: v
169 for k, v in vars(self).items()
170 if isinstance(v, (int, float, str, bool))
171 }
172 )
173
174 def __getattr__(self, attr):
175 call_source = inspect.currentframe().f_back.f_code.co_name
176 if attr not in self.__dict__:
177 if not attr.startswith("_") and attr not in ABSENT_MEANS_NOTHING:
178 frame = inspect.currentframe().f_back
179 reader = f"{os.path.basename(frame.f_code.co_filename)}:{frame.f_code.co_name}"
180 UNSET_READS[attr][reader] += 1
181 if _STRICT["on"]:
182 raise AttributeError(
183 f"{reader} reads {attr}, which no parser set: run_nd --strict refuses it rather than "
184 f"pricing it as 0"
185 )
186 logger.warning(
187 "[%s] Attribute %s does not exist. "
188 "Value '0' will be assigned.",
189 call_source,
190 attr,
191 )
192 return 0
193 return self.__dict__[attr]
194
195 def __setattr__(self, name: str, value: Any) -> None:
196 if name in STRATEGY_GUARDED and self.__dict__.get(_GUARD):
197 raise AttributeError(f"Cannot directly modify {name}, use set_strategy()")
198 object.__setattr__(self, name, value)
199
200 def __copy__(self):
201 res = object.__new__(type(self))
202 res.__dict__.update(self.__dict__)
203 res.__dict__.pop(_GUARD, None)
204 return res
205
206 def __deepcopy__(self, memo):
207 res = object.__new__(type(self))
208 for k, v in self.__dict__.items():
209 if k != _GUARD:
210 setattr(res, k, v if k in _SHARED_ON_COPY else deepcopy(v, memo))
211 return res
212
213 def __getstate__(self) -> dict:
214 """Return instance state for multiprocessing serialization."""
215 state = self.__dict__.copy()
216 state.pop(_GUARD, None)
217 return state
218
219 def __setstate__(self, state: dict) -> None:
220 """Restore instance state after multiprocessing deserialization."""
221 self.__dict__.update(state)
222
223 def fp_bytes(self, precision: Any) -> int:
224 """Return bytes size for datatype"""
225 if precision and isinstance(precision, str):
226 res = re.match(r"[^0-9]*([0-9]+)[^0-9]*", precision)
227 if res:
228 return int(res.group(1)) // 8
229 logger.warning("No bytes detected from FP Precision: %s", precision)
230 return 0
231
232 def print_stages_i(self, stage_id: Any, stage: Any) -> None:
233 """for print_stages"""
234 stage_layers = []
235 for chunk in stage:
236 chunk_lay_occ = []
237 if chunk:
238 layer, count = chunk[0], 1
239 for lay_id in range(1, len(chunk)):
240 if chunk[lay_id] == layer:
241 count += 1
242 else:
243 chunk_lay_occ += [f"{count}{layer.name[0]}"]
244 layer, count = chunk[lay_id], 1
245 chunk_lay_occ += [f"{count}{layer.name[0]}"]
246 stage_layers += [chunk_lay_occ]
247 logger.info("stage _%s : %s", stage_id, stage_layers)
248
249 def print_stages(self, stages: Any, spec_stage_id: Any = -1) -> None:
250 """Call after generate_partitions"""
251 if spec_stage_id == -1:
252 for stage_id, stage in enumerate(stages):
253 self.print_stages_i(stage_id, stage)
254 elif 0 <= spec_stage_id < len(stages):
255 self.print_stages_i(spec_stage_id, stages[spec_stage_id])
256 else:
257 logger.warning("Incorrect spec_stage_id")
258
259 def count_layers(self, stages: Any) -> int:
260 """Count non-embedding and non-output layers in generated stages."""
261 return sum(sum(len(layer) for layer in chunk) for chunk in stages) - 2
262
263 def print_parallelism(self) -> None:
264 """strategy pretty printer"""
265 if not self.multimodal:
266 logger.info("%s Parallelism used :", self.model_name)
267 logger.info(
268 "DP %s, TP %s, PP %s, EP %s, CP %s, VPP %s",
269 self.d,
270 self.t,
271 self.p,
272 self.ep,
273 self.cp,
274 self.vp,
275 )
276 logger.info(
277 "d_exp %s, t_exp %s, os_max_shard %s, etp %s",
278 self.d_exp,
279 self.t_exp,
280 self.os_max_shard,
281 self.etp,
282 )
283 logger.info(
284 "shard_grad_exp %s, shard_grad_non_exp %s",
285 self.shard_grad_exp,
286 self.shard_grad_non_exp,
287 )
288 logger.info(
289 "shard_p_os_exp %s, shard_p_os_non_exp %s",
290 self.shard_p_os_exp,
291 self.shard_p_os_non_exp,
292 )
293 logger.info(
294 "shard_embed %s, shard_output_activ %s, shard_rec_input %s",
295 self.shard_embed,
296 self.shard_output_activ,
297 self.shard_recompute_input,
298 )
299 else:
300 for m in self.mm_ccfgs:
301 self.mm_ccfgs[m].print_parallelism()
302
303 def strategy_num_devices(self) -> float:
304 """total num devices"""
305 return self.d * self.t * self.cp * self.p
306
307 def is_consistent_pp_config(self) -> bool:
308 """check if pp/offset/recomputation consistency"""
309
310 def is_valid_cfg(cfg: Any) -> bool:
311 """Return whether one per-stage config matches the pipeline shape."""
312 if cfg is None or isinstance(cfg, (int, bool)):
313 return True
314 if not isinstance(cfg, list) or not cfg:
315 return False
316 if isinstance(cfg[0], int):
317 return len(cfg) == self.p
318 if isinstance(cfg[0], list):
319 return len(cfg) == self.vp and all(
320 isinstance(c, list) and len(c) == self.p for c in cfg
321 )
322 return False
323
324 return (
325 is_valid_cfg(self.offset)
326 and is_valid_cfg(self.full_rec)
327 and is_valid_cfg(self.sel_rec)
328 )
329
330 def __strategy_target(self, model_name):
331 """Get the config object targeted by a strategy update."""
332 if not self.multimodal:
333 return self
334 if model_name in self.mm_ccfgs:
335 return self.mm_ccfgs[model_name]
336 raise TypeError(
337 f"{self.model_name}: model_name is required (multimodal)"
338 )
339
340 def set_shared_strategy(self, **kwargs: Any) -> None:
341 """Apply a strategy to this config itself, a multimodal parent included.
342
343 :meth:`set_strategy` targets a submodule of a multimodal config,
344 never the parent; but the parent holds the strategy its submodules
345 share, and the partitions it combines are of that pipeline, so a
346 caller that gives every config of one pipeline its own placement
347 writes each of them through this.
348 """
349 apply_exec(self, strategy_exec(self, kwargs))
350
351 def set_strategy(self, **kwargs: Any) -> None:
352 """Apply a keyword strategy: the ExecSpec it states, and what follows.
353
354 The keywords are ``strategy_exec``'s; ``model_name`` picks the
355 submodule of a multimodal config, and without it every submodule
356 takes the strategy.
357 """
358 model_name = kwargs.get("model_name", None)
359 if self.multimodal and model_name is None:
360 # Submodules share one pipeline, so a strategy update with no
361 # explicit target has to reach every one of them.
362 for sub_name in self.mm_order:
363 self.mm_ccfgs[sub_name].set_strategy(
364 **{**kwargs, "model_name": None}
365 )
366 model_name = self.mm_main if self.mm_main else self.mm_order[-1]
367 target_ccfg = self.__strategy_target(model_name)
368 apply_exec(target_ccfg, strategy_exec(target_ccfg, kwargs))
369 logger.debug(
370 "in ccfg: DP = %d, TP = %d, EP = %d, CP = %d, "
371 "PP = %d, MB = %d, MBS = %d, VPP = %d",
372 target_ccfg.d,
373 target_ccfg.t,
374 target_ccfg.ep,
375 target_ccfg.cp,
376 target_ccfg.p,
377 target_ccfg.m,
378 target_ccfg.b,
379 target_ccfg.vp,
380 )
381 if not target_ccfg.is_consistent_pp_config():
382 raise AttributeError(
383 f"{target_ccfg.model_name}: "
384 "Inconsistent pipeline parallel variables "
385 f"pp {target_ccfg.p} vpp {target_ccfg.vp} "
386 f"offset {target_ccfg.offset} "
387 f"full_rec {target_ccfg.full_rec} "
388 f"sel_rec {target_ccfg.sel_rec}"
389 )
390
391 def get_strategy(self) -> Any:
392 """return parallelism/recompute strategies"""
393
394 def strategy(mm: Any) -> Any:
395 """Return the strategy dict for one submodule."""
396 return {
397 "dp": mm.d,
398 "tp": mm.t,
399 "pp": mm.p,
400 "ep": mm.ep,
401 "cp": mm.cp,
402 "vpp": mm.vp,
403 "op": mm.os_max_shard,
404 "gbs": mm.b * mm.m * mm.d,
405 "sched": mm.pp_sched,
406 "offset": mm.offset,
407 "full_rec": mm.full_rec,
408 "sel_rec": mm.sel_rec,
409 }
410
411 # logger.output("get_strat ccfg")
412 if self.multimodal:
413 return {mm.model_name: strategy(mm) for mm in self.mm_ccfgs.values()}
414 return strategy(self)
415
416
417def arm_strategy_guard(ccfg: Any, armed: bool = True) -> None:
418 """Refuse, or allow again, a direct write of a strategy field.
419
420 Armed, the config and its submodules take a new degree only through
421 ``set_strategy`` or ``apply_exec``, and a layer kind's own degrees
422 through ``apply_layer_strategy``. A copy of an armed config starts
423 unarmed, so an estimator can change its own.
424
425 Args:
426 ccfg: The config, multimodal or not.
427 armed: Whether to arm the guard or lift it.
428 """
429 object.__setattr__(ccfg, _GUARD, armed)
430 for sub in (getattr(ccfg, "mm_ccfgs", None) or {}).values():
431 arm_strategy_guard(sub, armed)
432
433
434@contextmanager
435def strategy_guarded(ccfg: Any) -> Iterator[Any]:
436 """Refuse a direct write of a strategy field on *ccfg* inside the block.
437
438 Afterwards the config is armed exactly as it was before.
439 """
440 before = ccfg.__dict__.get(_GUARD)
441 object.__setattr__(ccfg, _GUARD, True)
442 try:
443 yield ccfg
444 finally:
445 if before is None:
446 ccfg.__dict__.pop(_GUARD, None)
447 else:
448 object.__setattr__(ccfg, _GUARD, before)