Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / distributed / encoder_parallel / runtime.py: 83%

156 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +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"""Per-batch visual encoder planning and packed execution.""" 

16 

17from __future__ import annotations 

18 

19import fnmatch 

20import hashlib 

21from dataclasses import dataclass 

22from typing import Any, Mapping 

23 

24import torch # pylint: disable=forbidden-backend-import 

25import torch.distributed as dist # pylint: disable=forbidden-backend-import 

26from torch import nn # pylint: disable=forbidden-backend-import 

27 

28from hyper_parallel.core.dtensor._utils import differentiable_variable_all_gather 

29from hyper_parallel.distributed.encoder_parallel.attention import ( 

30 EncoderAttentionContext, 

31 activate_encoder_attention, 

32) 

33from hyper_parallel.distributed.encoder_parallel.config import EncoderParallelConfig 

34from hyper_parallel.distributed.encoder_parallel.contracts import ( 

35 EncoderFeature, 

36 EncoderParallelAdapterSpec, 

37 EncoderSegment, 

38 EncoderWorkItem, 

39) 

40from hyper_parallel.distributed.encoder_parallel.exchange import ( 

41 FeatureExchangeMode, 

42 route_features, 

43 select_feature_exchange, 

44 unpack_records, 

45) 

46from hyper_parallel.distributed.encoder_parallel.mesh import derive_reshaped_submesh 

47from hyper_parallel.distributed.encoder_parallel.planning import ( 

48 DecoderSequenceLayout, 

49 assign_lpt, 

50 build_destinations, 

51 build_segments, 

52) 

53 

54 

55def _invalid_head_count(count: Any) -> bool: 

56 """Reject booleans, nonintegers, and zero-sized QKV head axes.""" 

57 return isinstance(count, bool) or not isinstance(count, int) or count < 1 

58 

59 

60def validate_ulysses_head_divisibility(*, encoder_cp_size: int, head_counts: Mapping[str, int], 

61 attention_pattern: str, model: nn.Module) -> None: 

62 """Check every Q/K/V head count before deriving any Encoder-CP group.""" 

63 required = ("query", "key", "value") 

64 missing = [name for name in required if name not in head_counts] 

65 invalid = {name: head_counts[name] for name in required if name in head_counts 

66 and _invalid_head_count(head_counts[name])} 

67 non_divisible = {name: head_counts[name] for name in required if name in head_counts 

68 and name not in invalid and head_counts[name] % encoder_cp_size} 

69 if missing or invalid or non_divisible: 

70 matched = sorted(name for name, _ in model.named_modules() 

71 if fnmatch.fnmatchcase(name, attention_pattern)) 

72 raise ValueError( 

73 "Encoder-CP/Ulysses head divisibility check failed; " 

74 "constraint=head_count % encoder_cp_size == 0 for every Q/K/V; " 

75 "config_path=accelerator.encoder_parallel.encoder_cp_size; " 

76 f"encoder_cp_size={encoder_cp_size}; head_counts={dict(head_counts)}; " 

77 f"missing={missing}; invalid_values={invalid}; non_divisible={non_divisible}; " 

78 f"attention_pattern={attention_pattern!r}; matched_modules={matched}" 

79 ) 

80 

81 

82def validate_attention_replacement(model: nn.Module, spec: EncoderParallelAdapterSpec) -> frozenset[str]: 

83 """Require an exact structural replacement for every visual attention. 

84 

85 Args: 

86 model: Final model after module replacements. 

87 spec: Model-owned attention matching and replacement contract. 

88 """ 

89 expected = frozenset(name for name, _ in model.named_modules() 

90 if fnmatch.fnmatchcase(name, spec.attention_pattern)) 

91 recorded = set(getattr(model, "_hp_replaced_module_fqns", ()) or ()) 

92 recorded_visual = {name for name in recorded if fnmatch.fnmatchcase(name, spec.attention_pattern)} 

93 missing = sorted(expected - recorded_visual) 

94 unexpected = sorted(recorded_visual - expected) 

95 wrong_type = sorted(name for name in expected 

96 if not isinstance(model.get_submodule(name), spec.replacement_attention_type)) 

97 if not expected or missing or unexpected or wrong_type: 

98 raise RuntimeError( 

99 "incomplete vision attention replacement: " 

100 f"expected={len(expected)}; missing={missing}; unexpected={unexpected}; wrong_type={wrong_type}" 

101 ) 

102 return expected 

103 

104 

105def validate_encoder_execution_options(options: Mapping[str, Any], bridge: Any) -> None: 

106 """Reject unsupported execution modes before creating Encoder groups. 

107 

108 Args: 

109 options: Trainer microbatch, compile, and placement settings. 

110 bridge: Model-bound validator for vision-specific restrictions. 

111 """ 

112 bridge.validate_training(options) 

113 if options.get("compile_enabled"): 

114 raise NotImplementedError("v1 requires compile.enabled=false") 

115 if options.get("validate_placement"): 

116 raise NotImplementedError("v1 requires model.validate_placement=false") 

117 

118 

119@dataclass 

120class EncoderParallelPlan: 

121 """One microbatch of derived visual work and feature routing.""" 

122 

123 runtime: "EncoderParallelRuntime" 

124 active: bool 

125 decoder_layout: DecoderSequenceLayout 

126 buckets: tuple[tuple[EncoderWorkItem, ...], ...] 

127 destinations: dict[int, tuple[int, ...]] 

128 local_segments: tuple[EncoderSegment, ...] 

129 k_fsdp_max: int 

130 

131 def _gather_features(self, hidden: torch.Tensor) -> tuple[torch.Tensor, ...]: 

132 """All-gather each local segment and trim dummy padding.""" 

133 gathered = [] 

134 offset = 0 

135 for segment in self.local_segments: 

136 local = hidden[offset:offset + segment.local_padded_length] 

137 offset += segment.local_padded_length 

138 full = differentiable_variable_all_gather( 

139 local, output_splits=[segment.local_padded_length] * self.runtime.config.encoder_cp_size, 

140 group=self.runtime.encoder_cp_mesh.get_group(), 

141 ) if self.runtime.config.encoder_cp_size > 1 else local 

142 gathered.append(full if segment.is_dummy else full[:segment.item.sequence_length]) 

143 return tuple(gathered) 

144 

145 def _execute_local(self, image_inputs: Mapping[str, Any] 

146 ) -> tuple[tuple[EncoderFeature, ...], torch.Tensor, torch.Tensor]: 

147 """Run the local ViT pass and build routed feature records.""" 

148 bridge = self.runtime.bridge 

149 context = EncoderAttentionContext( 

150 self.local_segments, 

151 self.runtime.encoder_cp_mesh, 

152 bridge.head_counts, 

153 self.runtime.expected, 

154 ) 

155 with activate_encoder_attention(context): 

156 hidden = bridge.encode_local(image_inputs, self.local_segments) 

157 context.assert_exactly_once() 

158 full_features = self._gather_features(hidden) 

159 records = [] 

160 anchors = [feature.sum() * 0.0 for feature in full_features] 

161 prototype = None 

162 for slot in range(self.k_fsdp_max): 

163 segment = self.local_segments[slot] if slot < len(self.local_segments) else None 

164 real = segment is not None and not segment.is_dummy and self.runtime.encoder_cp_rank == 0 

165 if real: 

166 feature = bridge.project(full_features[slot], segment.item) 

167 if feature.ndim != 2 or feature.shape[0] != segment.item.output_rows: 

168 raise ValueError( 

169 "aligner output does not match planned rows; " 

170 f"item_id={segment.item.item_id}; actual_shape={tuple(feature.shape)}; " 

171 f"expected_rows={segment.item.output_rows}" 

172 ) 

173 records.append((segment.item, feature)) 

174 else: 

175 feature = bridge.project(hidden, None) 

176 anchors.append(feature.sum() * 0.0) 

177 prototype = feature 

178 if prototype is None: 

179 raise RuntimeError("active plan must have an aligner slot") 

180 anchor = torch.stack(anchors).sum() if anchors else prototype.sum() * 0.0 

181 return tuple(records), prototype, anchor 

182 

183 def encode_and_route(self, image_inputs: Mapping[str, Any] 

184 ) -> tuple[list[EncoderFeature], torch.Tensor | None]: 

185 """Run packed ViT and route aligned features to this decoder owner. 

186 

187 Args: 

188 image_inputs: Original image tensors shared across the parent ranks. 

189 

190 Returns: 

191 Routed image records and a zero-valued gradient anchor, or no work. 

192 """ 

193 if not self.active: 

194 return [], None 

195 records, prototype, local_anchor = self._execute_local(image_inputs) 

196 mode = self.runtime.feature_exchange_mode 

197 exchange_mesh = self.runtime.tp_mesh if mode in ( 

198 FeatureExchangeMode.TP_VARIABLE_ALL_GATHER, FeatureExchangeMode.TP_VARIABLE_A2A 

199 ) else self.runtime.parent_mesh 

200 rows, received_items = route_features( 

201 mode=mode, producer_records=records, prototype=prototype, 

202 parent_rank=self.runtime.parent_rank, destinations=self.destinations, 

203 buckets=self.buckets, encoder_cp_size=self.runtime.config.encoder_cp_size, exchange_mesh=exchange_mesh, 

204 ) 

205 unpacked = unpack_records(rows, received_items) 

206 return list(unpacked), local_anchor + rows.sum() * 0.0 

207 

208 

209class EncoderParallelRuntime: 

210 """Own static topology and create one plan per Omni microbatch.""" 

211 

212 def __init__(self, *, config: EncoderParallelConfig, mesh_context: Any, 

213 model: nn.Module, spec: EncoderParallelAdapterSpec, 

214 execution_options: Mapping[str, Any]) -> None: 

215 """Validate the static contract and derive Encoder-CP groups.""" 

216 self.config = config 

217 self.mesh_context = mesh_context 

218 self.model = model 

219 self.spec = spec 

220 self.bridge = spec.bind(model) 

221 validate_encoder_execution_options( 

222 {**execution_options, "loss_parallel": mesh_context.loss_parallel}, self.bridge, 

223 ) 

224 parent_size = mesh_context.cp_size * mesh_context.tp_size 

225 if mesh_context.pp_size != 1: 

226 raise NotImplementedError("v1 requires pp_size=1") 

227 if parent_size % config.encoder_cp_size: 

228 raise ValueError("encoder_cp_size must divide cp_size*tp_size") 

229 validate_ulysses_head_divisibility(encoder_cp_size=config.encoder_cp_size, 

230 head_counts=self.bridge.head_counts, 

231 attention_pattern=spec.attention_pattern, model=model) 

232 self.expected = validate_attention_replacement(model, spec) 

233 self.parent_rank = mesh_context.cp_rank * mesh_context.tp_size + mesh_context.tp_rank 

234 self.encoder_cp_rank = self.parent_rank % config.encoder_cp_size 

235 self.encoder_dp_rank = self.parent_rank // config.encoder_cp_size 

236 self.encoder_dp_size = parent_size // config.encoder_cp_size 

237 self.parent_mesh = mesh_context.device_mesh[("cp", "tp")].flatten("encoder_parent") 

238 encoder_mesh = derive_reshaped_submesh( 

239 mesh_context.device_mesh, source_dims=("cp", "tp"), 

240 target_sizes=(self.encoder_dp_size, config.encoder_cp_size), 

241 target_names=("encoder_dp", "encoder_cp"), 

242 ) 

243 self.encoder_cp_mesh = encoder_mesh["encoder_cp"] 

244 self.tp_mesh = mesh_context.device_mesh["tp"] 

245 self.global_mesh = mesh_context.device_mesh.flatten("encoder_global") 

246 self.feature_exchange_mode = select_feature_exchange(mesh_context) 

247 

248 def preflight_batch(self, batch: Mapping[str, Any] 

249 ) -> tuple[tuple[EncoderWorkItem, ...], DecoderSequenceLayout]: 

250 """Validate local metadata and SP geometry before control collectives. 

251 

252 Args: 

253 batch: Device batch with decoder tokens and optional image fields. 

254 """ 

255 layout = DecoderSequenceLayout.build( 

256 mesh_context=self.mesh_context, cp_local_length=int(batch["input_ids"].shape[1]) 

257 ) 

258 present = [batch.get(name) is not None for name in self.bridge.input_fields] 

259 if any(present) and not all(present): 

260 missing = [name for name in self.bridge.input_fields if batch.get(name) is None] 

261 raise ValueError(f"incomplete image metadata bundle: missing={missing}") 

262 return tuple(self.bridge.parse_images(batch)), layout 

263 

264 def finalize_plan(self, items: tuple[EncoderWorkItem, ...], 

265 layout: DecoderSequenceLayout) -> EncoderParallelPlan: 

266 """Agree on active mode, then assign images and exact decoder owners. 

267 

268 Args: 

269 items: Locally validated image jobs. 

270 layout: Locally validated decoder token ownership. 

271 """ 

272 marker = torch.tensor(int(bool(items)), device=self.model.device if hasattr(self.model, "device") 

273 else next(self.model.parameters()).device) 

274 if dist.is_initialized() and self.parent_mesh.size() > 1: 

275 signature = tuple( 

276 (item.item_id, item.batch_index, item.sequence_length, item.output_rows, 

277 item.decoder_token_start, item.decoder_token_length, item.payload_start, item.payload_stop, 

278 tuple(sorted(item.metadata.items()))) 

279 for item in items 

280 ) 

281 digest = hashlib.sha256(repr((layout.cp_local_length, signature)).encode()).digest() 

282 digest_value = int.from_bytes(digest[:8], "big") & ((1 << 63) - 1) 

283 local_digest = torch.tensor(digest_value, dtype=torch.int64, device=marker.device) 

284 peer_digests = [torch.empty_like(local_digest) for _ in range(self.parent_mesh.size())] 

285 dist.all_gather(peer_digests, local_digest, group=self.parent_mesh.get_group()) 

286 if any(int(peer.item()) != digest_value for peer in peer_digests): 

287 raise ValueError("image metadata differs across decoder parent ranks") 

288 if dist.is_initialized() and self.global_mesh.size() > 1: 

289 dist.all_reduce(marker, op=dist.ReduceOp.MAX, group=self.global_mesh.get_group()) 

290 active = bool(marker.item()) 

291 buckets = assign_lpt(items, self.encoder_dp_size, self.config.encoder_cp_size) 

292 layouts = {rank: DecoderSequenceLayout.build(mesh_context=self.mesh_context, 

293 cp_local_length=layout.cp_local_length, 

294 cp_rank=rank // self.mesh_context.tp_size, 

295 tp_rank=rank % self.mesh_context.tp_size) 

296 for rank in range(self.mesh_context.cp_size * self.mesh_context.tp_size)} 

297 destinations = build_destinations(items, layouts) 

298 segments = build_segments(buckets[self.encoder_dp_rank], self.encoder_cp_rank, 

299 self.config.encoder_cp_size) 

300 slot_count = torch.tensor(len(buckets[self.encoder_dp_rank]), device=marker.device) 

301 if dist.is_initialized() and self.global_mesh.size() > 1: 

302 dist.all_reduce(slot_count, op=dist.ReduceOp.MAX, group=self.global_mesh.get_group()) 

303 return EncoderParallelPlan( 

304 runtime=self, 

305 active=active, decoder_layout=layout, buckets=buckets, 

306 destinations=destinations, local_segments=segments, 

307 k_fsdp_max=max(1, int(slot_count.item())), 

308 ) 

309 

310 

311def build_encoder_parallel_runtime(*, config: EncoderParallelConfig, mesh_context: Any, 

312 model: nn.Module | None, model_adapter_spec: Any, 

313 execution_options: Mapping[str, Any] | None) -> EncoderParallelRuntime | None: 

314 """Create the model-neutral runtime only when the feature is enabled.""" 

315 if not config.enabled: 

316 return None 

317 if model is None or model_adapter_spec is None or model_adapter_spec.encoder_parallel is None: 

318 raise ValueError("enabled encoder parallel requires a model adapter provider") 

319 spec = model_adapter_spec.encoder_parallel() 

320 if not isinstance(spec, EncoderParallelAdapterSpec): 

321 raise TypeError("encoder_parallel provider returned an invalid spec") 

322 return EncoderParallelRuntime(config=config, mesh_context=mesh_context, model=model, 

323 spec=spec, execution_options=execution_options or {})