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

168 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"""Model/config/checkpoint inventory and classified structure differences.""" 

16 

17from __future__ import annotations 

18 

19import inspect 

20import json 

21from dataclasses import asdict, dataclass, field 

22from enum import Enum 

23from pathlib import Path 

24from typing import Any, Callable, Optional 

25 

26 

27class DifferenceCategory(str, Enum): 

28 """Action category for one authoritative-versus-candidate difference.""" 

29 

30 GENERIC_HF = "generic_hf_supported" 

31 MODEL_ADAPTER = "model_adapter" 

32 COMPONENT_REPLACEMENT = "component_replacement" 

33 CUSTOM_MODEL = "hf_component_custom_model" 

34 FRAMEWORK_GAP = "framework_contract_gap" 

35 OUT_OF_SCOPE = "decode_cache_or_quantization_out_of_scope" 

36 

37 

38@dataclass(frozen=True) 

39class InventoryDifference: 

40 """One classified structural difference.""" 

41 

42 path: str 

43 reference: Any 

44 candidate: Any 

45 category: DifferenceCategory 

46 reason: str 

47 

48 def to_dict(self) -> dict[str, Any]: 

49 """Return a JSON-serializable representation.""" 

50 payload = asdict(self) 

51 payload["category"] = self.category.value 

52 return payload 

53 

54 

55@dataclass 

56class ModelInventory: 

57 """Serializable inventory of a model's final module and state tree.""" 

58 

59 source: dict[str, Any] = field(default_factory=dict) 

60 config: dict[str, Any] = field(default_factory=dict) 

61 modules: list[dict[str, Any]] = field(default_factory=list) 

62 parameters: list[dict[str, Any]] = field(default_factory=list) 

63 buffers: list[dict[str, Any]] = field(default_factory=list) 

64 aliases: dict[str, list[str]] = field(default_factory=dict) 

65 checkpoint: dict[str, Any] = field(default_factory=dict) 

66 

67 def to_dict(self) -> dict[str, Any]: 

68 """Return a JSON-serializable representation.""" 

69 return asdict(self) 

70 

71 

72def _qualified_type(value: Any) -> str: 

73 value_type = type(value) 

74 return f"{value_type.__module__}.{value_type.__qualname__}" 

75 

76 

77def _shape(value: Any) -> list[int]: 

78 return [int(size) for size in getattr(value, "shape", ())] 

79 

80 

81def _layout(value: Any) -> dict[str, Any] | None: 

82 layout = getattr(value, "layout", None) 

83 if layout is None: 

84 placements = getattr(value, "placements", None) 

85 mesh = getattr(value, "device_mesh", None) 

86 else: 

87 placements = getattr(layout, "placements", None) 

88 mesh = getattr(layout, "mesh", None) 

89 if placements is None and mesh is None: 

90 return None 

91 return { 

92 "mesh_dim_names": list(getattr(mesh, "mesh_dim_names", ()) or ()), 

93 "mesh_shape": list(getattr(getattr(mesh, "mesh", None), "shape", ()) or ()), 

94 "placements": [repr(placement) for placement in (placements or ())], 

95 } 

96 

97 

98def _forward_signature(module: Any) -> str | None: 

99 forward = getattr(module, "forward", None) 

100 if not callable(forward): 

101 return None 

102 try: 

103 return str(inspect.signature(forward)) 

104 except (TypeError, ValueError): 

105 return None 

106 

107 

108def _config_dict(model: Any) -> dict[str, Any]: 

109 config = getattr(model, "config", None) 

110 if config is None: 

111 return {} 

112 to_dict = getattr(config, "to_dict", None) 

113 if callable(to_dict): 

114 return to_dict() 

115 if hasattr(config, "__dict__"): 

116 return { 

117 name: value 

118 for name, value in vars(config).items() 

119 if isinstance(value, (str, int, float, bool, type(None), list, dict)) 

120 } 

121 return {"repr": repr(config)} 

122 

123 

124def build_model_inventory( 

125 model: Any, 

126 *, 

127 source: Optional[dict[str, Any]] = None, 

128 checkpoint: Optional[dict[str, Any]] = None, 

129) -> ModelInventory: 

130 """Inventory the instantiated final model tree, including aliases. 

131 

132 Args: 

133 model: Torch-compatible module exposing named module/state iterators. 

134 source: Recorded source/revision/import metadata. 

135 checkpoint: Optional checkpoint index inventory. 

136 

137 Returns: 

138 Serializable model inventory. 

139 """ 

140 modules = [] 

141 for module_fqn, module in model.named_modules(remove_duplicate=False): 

142 modules.append( 

143 { 

144 "fqn": module_fqn or "<root>", 

145 "type": _qualified_type(module), 

146 "forward_signature": _forward_signature(module), 

147 "training": bool(getattr(module, "training", False)), 

148 } 

149 ) 

150 

151 parameter_aliases: dict[int, list[str]] = {} 

152 parameter_by_id = {} 

153 for parameter_fqn, parameter in model.named_parameters(remove_duplicate=False): 

154 parameter_aliases.setdefault(id(parameter), []).append(parameter_fqn) 

155 parameter_by_id[id(parameter)] = parameter 

156 parameters = [] 

157 for parameter_id, fqns in sorted(parameter_aliases.items(), key=lambda item: min(item[1])): 

158 parameter = parameter_by_id[parameter_id] 

159 parameters.append( 

160 { 

161 "fqn": min(fqns), 

162 "aliases": sorted(fqns), 

163 "shape": _shape(parameter), 

164 "dtype": str(getattr(parameter, "dtype", None)), 

165 "device": str(getattr(parameter, "device", None)), 

166 "requires_grad": bool(getattr(parameter, "requires_grad", False)), 

167 "is_meta": bool(getattr(parameter, "is_meta", False)), 

168 "layout": _layout(parameter), 

169 } 

170 ) 

171 

172 non_persistent_ids = set() 

173 for module in model.modules(): 

174 for name in getattr(module, "_non_persistent_buffers_set", set()): 

175 buffer_value = getattr(module, name, None) 

176 if buffer_value is not None: 

177 non_persistent_ids.add(id(buffer_value)) 

178 buffer_aliases: dict[int, list[str]] = {} 

179 buffer_by_id = {} 

180 for buffer_fqn, buffer in model.named_buffers(remove_duplicate=False): 

181 buffer_aliases.setdefault(id(buffer), []).append(buffer_fqn) 

182 buffer_by_id[id(buffer)] = buffer 

183 buffers = [] 

184 for buffer_id, fqns in sorted(buffer_aliases.items(), key=lambda item: min(item[1])): 

185 buffer = buffer_by_id[buffer_id] 

186 buffers.append( 

187 { 

188 "fqn": min(fqns), 

189 "aliases": sorted(fqns), 

190 "shape": _shape(buffer), 

191 "dtype": str(getattr(buffer, "dtype", None)), 

192 "device": str(getattr(buffer, "device", None)), 

193 "is_meta": bool(getattr(buffer, "is_meta", False)), 

194 "persistent": buffer_id not in non_persistent_ids, 

195 "layout": _layout(buffer), 

196 } 

197 ) 

198 aliases = { 

199 "parameters": [fqns for fqns in parameter_aliases.values() if len(fqns) > 1], 

200 "buffers": [fqns for fqns in buffer_aliases.values() if len(fqns) > 1], 

201 } 

202 return ModelInventory( 

203 source=dict(source or {}), 

204 config=_config_dict(model), 

205 modules=modules, 

206 parameters=parameters, 

207 buffers=buffers, 

208 aliases=aliases, 

209 checkpoint=dict(checkpoint or {}), 

210 ) 

211 

212 

213def inspect_local_model_assets(model_path: str | Path) -> dict[str, Any]: 

214 """Inspect local config and checkpoint indices without loading tensors.""" 

215 root = Path(model_path).expanduser().resolve() 

216 if not root.is_dir(): 

217 raise ValueError(f"model asset path is not a directory: {root}") 

218 config_path = root / "config.json" 

219 config = json.loads(config_path.read_text(encoding="utf-8")) if config_path.is_file() else {} 

220 checkpoint = { 

221 "keys": [], 

222 "weight_map": {}, 

223 "metadata": {}, 

224 "lfs_pointers": {}, 

225 } 

226 

227 def lfs_pointer(path: Path) -> dict[str, Any] | None: 

228 """Parse one small Git-LFS pointer without resolving its payload.""" 

229 if path.stat().st_size > 1024: 

230 return None 

231 lines = path.read_text(encoding="utf-8").splitlines() 

232 if not lines or lines[0] != "version https://git-lfs.github.com/spec/v1": 

233 return None 

234 fields = {} 

235 for line in lines[1:]: 

236 name, _, value = line.partition(" ") 

237 fields[name] = value 

238 oid = fields.get("oid", "") 

239 size = fields.get("size", "") 

240 if not oid.startswith("sha256:") or not size.isdigit(): 

241 raise ValueError(f"invalid Git-LFS pointer: {path}") 

242 return { 

243 "oid": oid, 

244 "payload_size": int(size), 

245 "payload_available": False, 

246 } 

247 

248 index_paths = sorted(root.glob("*.index.json")) 

249 for index_path in index_paths: 

250 pointer = lfs_pointer(index_path) 

251 if pointer is not None: 

252 checkpoint["lfs_pointers"][index_path.name] = pointer 

253 checkpoint["metadata"][index_path.name] = {"git_lfs_pointer": True} 

254 continue 

255 try: 

256 index = json.loads(index_path.read_text(encoding="utf-8")) 

257 except json.JSONDecodeError as exc: 

258 raise ValueError(f"checkpoint index is neither JSON nor a Git-LFS pointer: {index_path}") from exc 

259 weight_map = index.get("weight_map", {}) 

260 checkpoint["weight_map"].update(weight_map) 

261 checkpoint["metadata"][index_path.name] = index.get("metadata", {}) 

262 for weight_path in sorted(root.glob("*.safetensors")): 

263 pointer = lfs_pointer(weight_path) 

264 if pointer is not None: 

265 checkpoint["lfs_pointers"][weight_path.name] = pointer 

266 checkpoint["keys"] = sorted(checkpoint["weight_map"]) 

267 checkpoint["weights_available"] = not checkpoint["lfs_pointers"] 

268 return { 

269 "path": str(root), 

270 "config": config, 

271 "checkpoint": checkpoint, 

272 "files": sorted(path.name for path in root.iterdir() if path.is_file()), 

273 } 

274 

275 

276def diff_inventories( 

277 reference: ModelInventory, 

278 candidate: ModelInventory, 

279 classifier: Optional[Callable[[str, Any, Any], tuple[DifferenceCategory, str]]] = None, 

280) -> list[InventoryDifference]: 

281 """Compare module/state records and classify every difference.""" 

282 differences = [] 

283 

284 def classify(path: str, expected: Any, actual: Any) -> tuple[DifferenceCategory, str]: 

285 """Classify one inventory difference into its owning integration layer.""" 

286 if classifier is not None: 

287 return classifier(path, expected, actual) 

288 if path.startswith("parameters.") or path.startswith("buffers."): 

289 return DifferenceCategory.MODEL_ADAPTER, "state layout/name requires explicit adapter mapping" 

290 if path.startswith("modules."): 

291 return DifferenceCategory.COMPONENT_REPLACEMENT, "module structure requires replacement review" 

292 return DifferenceCategory.CUSTOM_MODEL, "configuration differs from the authoritative architecture" 

293 

294 for collection_name in ("modules", "parameters", "buffers"): 

295 reference_entries = { 

296 entry["fqn"]: entry for entry in getattr(reference, collection_name) 

297 } 

298 candidate_entries = { 

299 entry["fqn"]: entry for entry in getattr(candidate, collection_name) 

300 } 

301 for fqn in sorted(set(reference_entries) | set(candidate_entries)): 

302 expected = reference_entries.get(fqn) 

303 actual = candidate_entries.get(fqn) 

304 if expected == actual: 

305 continue 

306 path = f"{collection_name}.{fqn}" 

307 category, reason = classify(path, expected, actual) 

308 differences.append( 

309 InventoryDifference(path, expected, actual, category, reason) 

310 ) 

311 if reference.config != candidate.config: 

312 category, reason = classify("config", reference.config, candidate.config) 

313 differences.append( 

314 InventoryDifference("config", reference.config, candidate.config, category, reason) 

315 ) 

316 return differences 

317 

318 

319__all__ = [ 

320 "DifferenceCategory", 

321 "InventoryDifference", 

322 "ModelInventory", 

323 "build_model_inventory", 

324 "diff_inventories", 

325 "inspect_local_model_assets", 

326]