Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / tools / model_integration / module_parity.py: 80%

212 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"""Generic final-module forward/intermediate/backward parity runner.""" 

16# pylint: disable=forbidden-backend-import 

17 

18from __future__ import annotations 

19 

20import fnmatch 

21import traceback 

22from dataclasses import dataclass 

23from typing import Any, Mapping 

24 

25import torch 

26from torch.utils._pytree import tree_flatten, tree_map 

27 

28from hyper_parallel.tools.model_integration.evidence_store import EvidenceStore 

29from hyper_parallel.tools.model_integration.schemas import ( 

30 ComparisonMetric, 

31 tolerance_value, 

32) 

33from hyper_parallel.models.validation_spec import ModelValidationSpec, ModuleParityCase 

34 

35 

36@dataclass(frozen=True) 

37class ParityContext: 

38 """Resolved execution context supplied to model-owned parity builders.""" 

39 

40 manifest: Any 

41 dtype: str 

42 device: str 

43 evidence_store: EvidenceStore 

44 options: dict[str, Any] 

45 

46 

47@dataclass 

48class ParityExecution: 

49 """Values exposed to observation getters after one forward/backward.""" 

50 

51 module: Any 

52 output: Any 

53 args: tuple[Any, ...] 

54 kwargs: dict[str, Any] 

55 

56 

57def _select_module(value: Any, selector: str) -> Any: 

58 if selector in ("", "<root>"): 

59 return value 

60 module_by_fqn = dict(value.named_modules()) 

61 selected = module_by_fqn.get(selector) 

62 if selected is None: 

63 raise ValueError(f"candidate selector matched no final module: {selector!r}") 

64 return selected 

65 

66 

67def _clone_value(value: Any) -> Any: 

68 if not torch.is_tensor(value): 

69 return value 

70 clone = value.detach().clone() 

71 if value.requires_grad and (clone.is_floating_point() or clone.is_complex()): 

72 clone.requires_grad_(True) 

73 return clone 

74 

75 

76def _clone_call_inputs(inputs: Any) -> tuple[tuple[Any, ...], dict[str, Any]]: 

77 if isinstance(inputs, Mapping) and ("args" in inputs or "kwargs" in inputs): 

78 args = tuple(inputs.get("args", ())) 

79 kwargs = dict(inputs.get("kwargs", {})) 

80 elif isinstance(inputs, Mapping): 

81 args = () 

82 kwargs = dict(inputs) 

83 elif isinstance(inputs, tuple): 

84 args = inputs 

85 kwargs = {} 

86 else: 

87 args = (inputs,) 

88 kwargs = {} 

89 return tree_map(_clone_value, args), tree_map(_clone_value, kwargs) 

90 

91 

92def _floating_tensors(value: Any) -> list[torch.Tensor]: 

93 return [ 

94 item for item in tree_flatten(value)[0] 

95 if torch.is_tensor(item) and (item.is_floating_point() or item.is_complex()) 

96 ] 

97 

98 

99def _default_objective(output: Any) -> torch.Tensor: 

100 tensors = _floating_tensors(output) 

101 if not tensors: 

102 raise ValueError("parity output contains no differentiable tensor") 

103 return sum(tensor.float().sum() for tensor in tensors) 

104 

105 

106def _metric( 

107 name: str, 

108 reference: torch.Tensor, 

109 candidate: torch.Tensor, 

110 *, 

111 exact: bool, 

112 atol: float, 

113 rtol: float, 

114 relative_l2_limit: float | None = None, 

115) -> ComparisonMetric: 

116 reference_cpu = reference.detach().float().cpu() 

117 candidate_cpu = candidate.detach().float().cpu() 

118 same_shape = tuple(reference_cpu.shape) == tuple(candidate_cpu.shape) 

119 finite = bool(torch.isfinite(reference_cpu).all() and torch.isfinite(candidate_cpu).all()) 

120 if not same_shape: 

121 return ComparisonMetric( 

122 name=name, 

123 status="FAIL", 

124 max_abs=None, 

125 relative_l2=None, 

126 finite=finite, 

127 reference_shape=tuple(reference_cpu.shape), 

128 candidate_shape=tuple(candidate_cpu.shape), 

129 reason="shape mismatch", 

130 ) 

131 equal = bool(torch.equal(reference_cpu, candidate_cpu)) 

132 delta = candidate_cpu.double() - reference_cpu.double() 

133 max_abs = float(delta.abs().max()) if delta.numel() else 0.0 

134 reference_norm = torch.linalg.vector_norm( # pylint: disable=not-callable 

135 reference_cpu.double().reshape(-1) 

136 ) 

137 delta_norm = torch.linalg.vector_norm(delta.reshape(-1)) # pylint: disable=not-callable 

138 relative_l2 = float(delta_norm / reference_norm.clamp_min(torch.finfo(torch.float64).eps)) 

139 passed = equal if exact else bool(torch.allclose(reference_cpu, candidate_cpu, atol=atol, rtol=rtol)) 

140 if not exact and relative_l2_limit is not None: 

141 passed = passed and relative_l2 <= relative_l2_limit 

142 passed = passed and finite 

143 return ComparisonMetric( 

144 name=name, 

145 status="PASS" if passed else "FAIL", 

146 max_abs=max_abs, 

147 relative_l2=relative_l2, 

148 finite=finite, 

149 reference_shape=tuple(reference_cpu.shape), 

150 candidate_shape=tuple(candidate_cpu.shape), 

151 ) 

152 

153 

154def _compare_tree( 

155 prefix: str, 

156 reference: Any, 

157 candidate: Any, 

158 *, 

159 exact: bool, 

160 atol: float, 

161 rtol: float, 

162 relative_l2_limit: float | None = None, 

163) -> list[ComparisonMetric]: 

164 reference_values, reference_spec = tree_flatten(reference) 

165 candidate_values, candidate_spec = tree_flatten(candidate) 

166 if reference_spec != candidate_spec: 

167 return [ 

168 ComparisonMetric( 

169 name=prefix, 

170 status="FAIL", 

171 max_abs=None, 

172 relative_l2=None, 

173 finite=True, 

174 reference_shape=(), 

175 candidate_shape=(), 

176 reason=( 

177 "output structure mismatch: " 

178 f"reference={reference_spec!r}, candidate={candidate_spec!r}" 

179 ), 

180 ) 

181 ] 

182 metrics = [] 

183 for index, (reference_value, candidate_value) in enumerate(zip(reference_values, candidate_values)): 

184 name = f"{prefix}.{index}" 

185 if torch.is_tensor(reference_value) and torch.is_tensor(candidate_value): 

186 tensor_exact = exact or not ( 

187 reference_value.is_floating_point() or reference_value.is_complex() 

188 ) 

189 metrics.append( 

190 _metric( 

191 name, 

192 reference_value, 

193 candidate_value, 

194 exact=tensor_exact, 

195 atol=atol, 

196 rtol=rtol, 

197 relative_l2_limit=relative_l2_limit, 

198 ) 

199 ) 

200 elif reference_value != candidate_value: 

201 metrics.append( 

202 ComparisonMetric( 

203 name=name, 

204 status="FAIL", 

205 max_abs=None, 

206 relative_l2=None, 

207 finite=True, 

208 reference_shape=(), 

209 candidate_shape=(), 

210 reason=f"non-tensor values differ: {reference_value!r} != {candidate_value!r}", 

211 ) 

212 ) 

213 return metrics 

214 

215 

216def _parameter_mapping( 

217 case: ModuleParityCase, 

218 reference: Any, 

219 candidate: Any, 

220 context: ParityContext, 

221) -> dict[str, str]: 

222 if case.weight_adapter is None: 

223 raise TypeError("in-process parity case requires weight_adapter") 

224 mapping_result = case.weight_adapter(reference, candidate, context) 

225 if mapping_result is None: 

226 mapping = { 

227 name: name for name, parameter in candidate.named_parameters() 

228 if parameter.requires_grad 

229 } 

230 elif isinstance(mapping_result, Mapping): 

231 mapping = {str(key): str(value) for key, value in mapping_result.items()} 

232 else: 

233 raise TypeError("parity weight_adapter must return a candidate-to-reference mapping or None") 

234 candidate_trainable = { 

235 name for name, parameter in candidate.named_parameters() if parameter.requires_grad 

236 } 

237 reference_names = dict(reference.named_parameters()) 

238 missing = sorted(candidate_trainable - set(mapping)) 

239 invalid = sorted(set(mapping.values()) - set(reference_names)) 

240 if missing or invalid: 

241 raise ValueError( 

242 "parity weight coverage is incomplete: " 

243 f"missing_candidate={missing}, unknown_reference={invalid}" 

244 ) 

245 for pattern in case.required_parameter_patterns: 

246 if not any(fnmatch.fnmatchcase(name, pattern) for name in candidate_trainable): 

247 raise ValueError(f"required parity parameter pattern matched nothing: {pattern!r}") 

248 return mapping 

249 

250 

251def _input_gradients(args: tuple[Any, ...], kwargs: dict[str, Any]) -> list[torch.Tensor | None]: 

252 return [ 

253 value.grad if torch.is_tensor(value) and value.requires_grad else None 

254 for value in tree_flatten((args, kwargs))[0] 

255 ] 

256 

257 

258def _build_parity_modules(case: ModuleParityCase, context: ParityContext) -> tuple[Any, Any]: 

259 """Instantiate reference/candidate and reject aliased implementations.""" 

260 candidate_builder = case.candidate_builder 

261 reference_builder = case.reference_builder 

262 input_builder = case.input_builder 

263 if not all(callable(value) for value in (candidate_builder, reference_builder, input_builder)): 

264 raise TypeError("in-process parity case has an incomplete callable contract") 

265 candidate_tree = candidate_builder(context) 

266 candidate = _select_module(candidate_tree, case.candidate_selector) 

267 reference = reference_builder(context) 

268 if candidate is reference or type(candidate) is type(reference): 

269 raise ValueError( 

270 "reference and candidate must be independent implementations; " 

271 f"got {type(candidate).__module__}.{type(candidate).__qualname__}" 

272 ) 

273 return candidate, reference 

274 

275 

276def _resolve_parity_tolerance( 

277 context: ParityContext, 

278) -> tuple[float, float, float | None]: 

279 """Read the scalar tolerance contract from the parity context options.""" 

280 tolerance = context.options.get("tolerance", {}) 

281 atol = tolerance_value(tolerance, "atol", "max_abs") 

282 rtol = tolerance_value(tolerance, "rtol") 

283 relative_l2_limit = tolerance.get("relative_l2") 

284 if relative_l2_limit is not None: 

285 relative_l2_limit = float(relative_l2_limit) 

286 return atol, rtol, relative_l2_limit 

287 

288 

289def _observation_metrics( 

290 case: ModuleParityCase, 

291 reference_execution: ParityExecution, 

292 candidate_execution: ParityExecution, 

293 reference_output: Any, 

294 candidate_output: Any, 

295 atol: float, 

296 rtol: float, 

297 relative_l2_limit: float | None, 

298) -> list[ComparisonMetric]: 

299 """Compare every declared observation between reference and candidate.""" 

300 metrics = [] 

301 for observation in case.observations: 

302 reference_value = ( 

303 observation.reference_getter(reference_execution) 

304 if observation.reference_getter is not None 

305 else reference_output 

306 ) 

307 candidate_value = ( 

308 observation.candidate_getter(candidate_execution) 

309 if observation.candidate_getter is not None 

310 else candidate_output 

311 ) 

312 if reference_value is None or candidate_value is None: 

313 if observation.required: 

314 raise ValueError( 

315 f"required parity observation {observation.name!r} was not produced: " 

316 f"reference={reference_value is not None}, " 

317 f"candidate={candidate_value is not None}" 

318 ) 

319 if reference_value is None and candidate_value is None: 

320 continue 

321 metrics.extend( 

322 _compare_tree( 

323 observation.name, 

324 reference_value, 

325 candidate_value, 

326 exact=observation.comparison == "exact", 

327 atol=observation.atol if observation.atol is not None else atol, 

328 rtol=observation.rtol if observation.rtol is not None else rtol, 

329 relative_l2_limit=relative_l2_limit, 

330 ) 

331 ) 

332 return metrics 

333 

334 

335def _input_gradient_metrics( 

336 reference_args: tuple[Any, ...], 

337 reference_kwargs: dict[str, Any], 

338 candidate_args: tuple[Any, ...], 

339 candidate_kwargs: dict[str, Any], 

340 atol: float, 

341 rtol: float, 

342 relative_l2_limit: float | None, 

343) -> list[ComparisonMetric]: 

344 """Compare flattened input gradients pairwise after backward.""" 

345 metrics = [] 

346 reference_input_grads = _input_gradients(reference_args, reference_kwargs) 

347 candidate_input_grads = _input_gradients(candidate_args, candidate_kwargs) 

348 for index, (reference_grad, candidate_grad) in enumerate( 

349 zip(reference_input_grads, candidate_input_grads) 

350 ): 

351 if reference_grad is None and candidate_grad is None: 

352 continue 

353 if reference_grad is None or candidate_grad is None: 

354 raise ValueError(f"input gradient presence differs at flattened input {index}") 

355 metrics.append( 

356 _metric( 

357 f"input_grad.{index}", 

358 reference_grad, 

359 candidate_grad, 

360 exact=False, 

361 atol=atol, 

362 rtol=rtol, 

363 relative_l2_limit=relative_l2_limit, 

364 ) 

365 ) 

366 return metrics 

367 

368 

369def _parameter_gradient_metrics( 

370 mapping: dict[str, str], 

371 reference: Any, 

372 candidate: Any, 

373 atol: float, 

374 rtol: float, 

375 relative_l2_limit: float | None, 

376) -> list[ComparisonMetric]: 

377 """Compare mapped parameter gradients pairwise after backward.""" 

378 metrics = [] 

379 reference_parameters = dict(reference.named_parameters()) 

380 candidate_parameters = dict(candidate.named_parameters()) 

381 for candidate_name, reference_name in sorted(mapping.items()): 

382 reference_grad = reference_parameters[reference_name].grad 

383 candidate_grad = candidate_parameters[candidate_name].grad 

384 if reference_grad is None and candidate_grad is None: 

385 continue 

386 if reference_grad is None or candidate_grad is None: 

387 raise ValueError( 

388 f"parameter gradient presence differs: {candidate_name} -> {reference_name}" 

389 ) 

390 metrics.append( 

391 _metric( 

392 f"parameter_grad.{candidate_name}", 

393 reference_grad, 

394 candidate_grad, 

395 exact=False, 

396 atol=atol, 

397 rtol=rtol, 

398 relative_l2_limit=relative_l2_limit, 

399 ) 

400 ) 

401 return metrics 

402 

403 

404def _run_in_process_case(case: ModuleParityCase, context: ParityContext) -> dict[str, Any]: 

405 candidate, reference = _build_parity_modules(case, context) 

406 mapping = _parameter_mapping(case, reference, candidate, context) 

407 inputs = case.input_builder(context) 

408 reference_args, reference_kwargs = _clone_call_inputs(inputs) 

409 candidate_args, candidate_kwargs = _clone_call_inputs(inputs) 

410 reference_output = reference(*reference_args, **reference_kwargs) 

411 candidate_output = candidate(*candidate_args, **candidate_kwargs) 

412 atol, rtol, relative_l2_limit = _resolve_parity_tolerance(context) 

413 metrics = _compare_tree( 

414 "output", 

415 reference_output, 

416 candidate_output, 

417 exact=False, 

418 atol=atol, 

419 rtol=rtol, 

420 relative_l2_limit=relative_l2_limit, 

421 ) 

422 reference_execution = ParityExecution(reference, reference_output, reference_args, reference_kwargs) 

423 candidate_execution = ParityExecution(candidate, candidate_output, candidate_args, candidate_kwargs) 

424 metrics.extend( 

425 _observation_metrics( 

426 case, 

427 reference_execution, 

428 candidate_execution, 

429 reference_output, 

430 candidate_output, 

431 atol, 

432 rtol, 

433 relative_l2_limit, 

434 ) 

435 ) 

436 objective = case.objective or _default_objective 

437 objective(reference_output).backward() 

438 objective(candidate_output).backward() 

439 metrics.extend( 

440 _input_gradient_metrics( 

441 reference_args, 

442 reference_kwargs, 

443 candidate_args, 

444 candidate_kwargs, 

445 atol, 

446 rtol, 

447 relative_l2_limit, 

448 ) 

449 ) 

450 metrics.extend( 

451 _parameter_gradient_metrics( 

452 mapping, 

453 reference, 

454 candidate, 

455 atol, 

456 rtol, 

457 relative_l2_limit, 

458 ) 

459 ) 

460 status = "PASS" if all(metric.status == "PASS" for metric in metrics) else "FAIL" 

461 return { 

462 "case": case.name, 

463 "status": status, 

464 "candidate_type": f"{type(candidate).__module__}.{type(candidate).__qualname__}", 

465 "reference_type": f"{type(reference).__module__}.{type(reference).__qualname__}", 

466 "weight_mapping": mapping, 

467 "metrics": [metric.to_dict() for metric in metrics], 

468 } 

469 

470 

471def run_module_parity( 

472 validation_spec: ModelValidationSpec, 

473 context: ParityContext, 

474) -> dict[str, Any]: 

475 """Execute every declared parity case and persist per-case evidence.""" 

476 reports = [] 

477 for case in validation_spec.module_cases: 

478 if context.dtype not in case.required_dtypes: 

479 continue 

480 try: 

481 if case.execution == "isolated_process": 

482 report = case.isolated_runner(context) # type: ignore[misc] 

483 if not isinstance(report, dict) or report.get("status") not in ( 

484 "PASS", "FAIL", "BLOCKED" 

485 ): 

486 raise ValueError("isolated parity runner returned an invalid report") 

487 else: 

488 report = _run_in_process_case(case, context) 

489 except NotImplementedError as error: 

490 report = { 

491 "case": case.name, 

492 "status": "BLOCKED", 

493 "reason": "OPERATOR_UNSUPPORTED", 

494 "error": str(error), 

495 } 

496 except Exception as error: # pylint: disable=broad-except 

497 report = { 

498 "case": case.name, 

499 "status": "FAIL", 

500 "error_type": type(error).__name__, 

501 "error": str(error), 

502 "traceback": traceback.format_exc(), 

503 } 

504 reports.append(report) 

505 context.evidence_store.write_json( 

506 f"module_parity/{case.name}/comparison.json", 

507 report, 

508 ) 

509 statuses = [report["status"] for report in reports] 

510 status = ( 

511 "FAIL" if "FAIL" in statuses 

512 else "BLOCKED" if "BLOCKED" in statuses or not statuses 

513 else "PASS" 

514 ) 

515 aggregate = {"status": status, "cases": reports} 

516 if not reports: 

517 aggregate["reason"] = ( 

518 "no module parity case supports the requested dtype, or the adapter " 

519 "declared no authoritative case" 

520 ) 

521 context.evidence_store.write_json("module_parity/comparison.json", aggregate) 

522 return aggregate 

523 

524 

525__all__ = ["ParityContext", "ParityExecution", "run_module_parity"]