Coverage for / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / distributed / tensor_parallel / head_count.py: 94%
124 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-25 05:45 +0800
« 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"""TP-local head-count adjustment for non-TP-tolerant modeling code (D-17).
17Some HF modeling code reshapes/splits with an explicit (global) head count,
18e.g. ``q.view(b, s, self.num_heads, self.head_dim)``, instead of the
19TP-tolerant ``-1`` form. After TP colwise sharding each rank holds
20``num_heads / tp`` heads locally, so any forward that runs on **local
21tensors** needs the local count (AutoModel-style adaptation).
23Dual-mode rule -- a module's cached attributes are rewritten only when its
24forward sees local tensors in the current mode:
26- production: parameters are permanently unwrapped to local tensors, so
27 every head-sharded module is rewritten;
28- validate: boundary modules run DTensor dispatch on the GLOBAL logical
29 shape (a local count would break the reshape), so they are never
30 rewritten; local-region modules (``local_compute_fn`` /
31 ``region_dispatch=False``) unwrap to local inside the region in both
32 modes, so they are rewritten in validate too.
34The config object is never touched (``head_dim`` / RoPE derivations keep
35working); only module-instance cached attributes are rewritten, idempotently
36-- originals are stashed in ``module._hp_full_head_counts`` and repeat calls
37are no-ops.
38"""
40import logging
41from dataclasses import dataclass
42from typing import Any, Optional, Sequence, Tuple
44from hyper_parallel.distributed.recipe_spec import resolve_placements
45from hyper_parallel.core.dtensor.placement_types import Shard
47logger = logging.getLogger(__name__)
50@dataclass(frozen=True)
51class TpLocalAttrPlan:
52 """Planner-generated TP-local module attribute adjustment plan."""
54 auto_divide: Tuple[str, ...] = ()
55 user_divide: Tuple[str, ...] = ()
57# Attribute list: survey of transformers/src/transformers/models (2026-07),
58# counting forward-time reshape/split usages of cached head-count attributes:
59# - q head count: num_heads (x393) / num_attention_heads (x122) / n_heads
60# (x50) / num_attn_heads (prophetnet) / n_head (openai, xlnet) /
61# heads, num_head (falcon, qwen2_5_omni, ...);
62# - kv head count: num_key_value_heads (widespread) / num_kv_heads, kv_heads
63# (falcon, gpt_bigcode);
64# - excluded: head_dim / attention_head_size / head_size (the head dimension,
65# never sharded) and num_key_value_groups (a ratio -- TP-invariant).
66Q_HEAD_ATTRS = (
67 "num_heads", "num_attention_heads", "n_heads", "num_attn_heads",
68 "n_head", "heads", "num_head",
69)
70KV_HEAD_ATTRS = ("num_key_value_heads", "num_kv_heads", "kv_heads")
72_ORIGINALS_ATTR = "_hp_full_head_counts"
73_TP_LOCAL_ORIGINALS_ATTR = "_hp_full_tp_local_attrs"
75_FORBIDDEN_USER_ATTRS = frozenset({
76 "head_dim", "attention_head_size", "head_size",
77 "num_key_value_groups", "training", "dtype", "device",
78})
80# q/k/v projections whose colwise Shard(0) splits the head dimension
81# (q_b_proj covers the MLA up-projection, D-14).
82_QKV_WEIGHT_SUFFIXES = (
83 "q_proj.weight", "q_b_proj.weight", "k_proj.weight", "v_proj.weight",
84 "qkv_proj.weight", "qkv.weight",
85)
88def _is_plain_int(value: Any) -> bool:
89 """Return whether a value is an integer but not a boolean."""
90 return isinstance(value, int) and not isinstance(value, bool)
93def _is_head_sharded(spec, mesh_dim_names) -> bool:
94 """True when the spec column-wise shards any q/k/v projection on the TP axis."""
95 if "tp" not in mesh_dim_names:
96 return False
97 tp_idx = tuple(mesh_dim_names).index("tp")
98 for name, placement in spec.params.items():
99 if not name.endswith(_QKV_WEIGHT_SUFFIXES):
100 continue
101 if resolve_placements(placement, mesh_dim_names)[tp_idx] == Shard(0):
102 return True
103 return False
106def _tp_degree(mesh, mesh_dim_names) -> int:
107 if "tp" not in mesh_dim_names:
108 return 1
109 return mesh["tp"].size()
112def update_module_head_counts(module: Any, tp_size: int, module_fqn: str = "") -> int:
113 """Divide a module's cached head-count attributes by tp_size (idempotent).
115 Only plain-int attributes whose name is in Q_HEAD_ATTRS / KV_HEAD_ATTRS
116 are rewritten. Originals are recorded in ``module._hp_full_head_counts``;
117 repeat calls (e.g. applying a plan twice) are no-ops. A non-divisible
118 value is left unchanged with a loud warning (planner-side
119 validate_model_compatibility normally fails first on config-level
120 divisibility).
122 Returns the number of attributes rewritten on this call.
123 """
124 if tp_size <= 1:
125 return 0
126 originals = getattr(module, _ORIGINALS_ATTR, None)
127 if originals is None:
128 originals = {}
129 setattr(module, _ORIGINALS_ATTR, originals)
130 n = 0
131 for attr in Q_HEAD_ATTRS + KV_HEAD_ATTRS:
132 if attr in originals:
133 continue
134 value = getattr(module, attr, None)
135 if not _is_plain_int(value):
136 continue
137 originals[attr] = value
138 if value % tp_size != 0:
139 logger.warning(
140 "head-count adjustment: %s.%s = %d is not divisible by "
141 "tp_size=%d -- left unchanged",
142 module_fqn, attr, value, tp_size)
143 continue
144 setattr(module, attr, value // tp_size)
145 logger.info("head-count adjustment: %s.%s %d -> %d (tp_size=%d)",
146 module_fqn, attr, value, value // tp_size, tp_size)
147 n += 1
148 return n
151def collect_auto_head_attrs(
152 module: Any, spec: Any, mesh_dim_names: Sequence[str],
153) -> tuple[str, ...]:
154 """Collect canonical head-count attributes derived from TP placements."""
155 if not _is_head_sharded(spec, mesh_dim_names):
156 return ()
157 return tuple(
158 attr for attr in Q_HEAD_ATTRS + KV_HEAD_ATTRS
159 if _is_plain_int(getattr(module, attr, None))
160 )
163def normalize_tp_divide_attrs(
164 attrs: Optional[Sequence[str]], *, module: Any, module_fqn: str,
165 tp_size: int,
166) -> tuple[str, ...]:
167 """Validate and normalize user-declared TP-local integer attributes."""
168 if attrs is None:
169 return ()
170 if not isinstance(attrs, (list, tuple)):
171 raise ValueError(
172 f"{module_fqn}: tp_divide_attrs must be a list of attribute names, "
173 f"got {type(attrs).__name__}")
174 normalized = []
175 seen = set()
176 for attr in attrs:
177 if not isinstance(attr, str) or not attr or not attr.isidentifier():
178 raise ValueError(
179 f"{module_fqn}: tp_divide_attrs entries must be non-empty "
180 f"Python identifiers, got {attr!r}")
181 if attr.startswith("_hp_") or attr in _FORBIDDEN_USER_ATTRS:
182 raise ValueError(
183 f"{module_fqn}: tp_divide_attrs cannot adjust protected "
184 f"attribute {attr!r}")
185 if attr in seen:
186 raise ValueError(
187 f"{module_fqn}: tp_divide_attrs contains duplicate {attr!r}")
188 seen.add(attr)
189 value = getattr(module, attr, None)
190 if not _is_plain_int(value):
191 actual = type(value).__name__ if hasattr(module, attr) else "missing"
192 raise ValueError(
193 f"{module_fqn}: tp_divide_attrs attribute {attr!r} must "
194 f"exist and be a plain int, got {actual}")
195 if value <= 0 or value % tp_size != 0:
196 raise ValueError(
197 f"{module_fqn}: tp_divide_attrs attribute {attr!r}={value} "
198 f"must be positive and divisible by tp_size={tp_size}")
199 normalized.append(attr)
200 return tuple(normalized)
203def build_tp_local_attr_plan(
204 module: Any, spec: Any, module_fqn: str, tp_size: int,
205 mesh_dim_names: Sequence[str],
206) -> TpLocalAttrPlan:
207 """Build the internal auto/user TP-local attribute plan for one module."""
208 auto_attrs = collect_auto_head_attrs(module, spec, mesh_dim_names)
209 user_attrs = normalize_tp_divide_attrs(
210 spec.tp_divide_attrs, module=module, module_fqn=module_fqn,
211 tp_size=tp_size,
212 )
213 duplicates = sorted(set(auto_attrs) & set(user_attrs))
214 if duplicates:
215 raise ValueError(
216 f"{module_fqn}: tp_divide_attrs redundantly declares D-17 "
217 f"automatic head attributes {duplicates}; remove them from YAML")
218 return TpLocalAttrPlan(auto_divide=auto_attrs, user_divide=user_attrs)
221def _update_user_tp_attrs(
222 module: Any, attrs: Sequence[str], tp_size: int, module_fqn: str,
223) -> int:
224 """Apply validated user TP-local attribute divisions idempotently."""
225 if tp_size <= 1 or not attrs:
226 return 0
227 originals = getattr(module, _TP_LOCAL_ORIGINALS_ATTR, None)
228 if originals is None:
229 originals = {}
230 setattr(module, _TP_LOCAL_ORIGINALS_ATTR, originals)
231 count = 0
232 for attr in attrs:
233 if attr in originals:
234 expected = originals[attr] // tp_size
235 current = getattr(module, attr)
236 if current != expected:
237 raise ValueError(
238 f"{module_fqn}: tp_divide_attrs attribute {attr!r} was "
239 f"already adjusted from {originals[attr]} to {current}, "
240 f"which is incompatible with tp_size={tp_size} "
241 f"(expected {expected})")
242 continue
243 value = getattr(module, attr)
244 if not _is_plain_int(value):
245 raise ValueError(
246 f"{module_fqn}: tp_divide_attrs attribute {attr!r} must "
247 f"exist and be a plain int, got {type(value).__name__}")
248 if value <= 0 or value % tp_size != 0:
249 raise ValueError(
250 f"{module_fqn}: tp_divide_attrs attribute {attr!r}={value} "
251 f"must be positive and divisible by tp_size={tp_size}")
252 originals[attr] = value
253 setattr(module, attr, value // tp_size)
254 logger.info(
255 "TP-local attribute adjustment: %s.%s %d -> %d (tp_size=%d, source=user)",
256 module_fqn, attr, value, value // tp_size, tp_size,
257 )
258 count += 1
259 return count
262def maybe_update_head_counts(
263 module: Any,
264 spec: Any,
265 module_fqn: str,
266 mesh: Any,
267 mesh_dim_names: Sequence[str],
268) -> None:
269 """Adjust cached head counts when the spec head-shards the module.
271 Called by the applier exactly where the module's forward is guaranteed to
272 see local tensors: production (Phase A, all specs) and validate (Phase C,
273 local-region specs only).
274 """
275 tp_size = _tp_degree(mesh, mesh_dim_names)
276 attr_plan = getattr(spec, "_tp_local_attr_plan", None)
277 if attr_plan is None:
278 # Backward-compatible path for manually assembled plans that did not
279 # pass through ShardingPlanner.finalize.
280 if _is_head_sharded(spec, mesh_dim_names):
281 update_module_head_counts(module, tp_size, module_fqn)
282 _update_user_tp_attrs(
283 module, getattr(spec, "tp_divide_attrs", ()) or (),
284 tp_size, module_fqn,
285 )
286 return
287 if attr_plan.auto_divide:
288 update_module_head_counts(module, tp_size, module_fqn)
289 _update_user_tp_attrs(
290 module, attr_plan.user_divide, tp_size, module_fqn,
291 )