haoline 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- haoline/.streamlit/config.toml +10 -0
- haoline/__init__.py +248 -0
- haoline/analyzer.py +935 -0
- haoline/cli.py +2712 -0
- haoline/compare.py +811 -0
- haoline/compare_visualizations.py +1564 -0
- haoline/edge_analysis.py +525 -0
- haoline/eval/__init__.py +131 -0
- haoline/eval/adapters.py +844 -0
- haoline/eval/cli.py +390 -0
- haoline/eval/comparison.py +542 -0
- haoline/eval/deployment.py +633 -0
- haoline/eval/schemas.py +833 -0
- haoline/examples/__init__.py +15 -0
- haoline/examples/basic_inspection.py +74 -0
- haoline/examples/compare_models.py +117 -0
- haoline/examples/hardware_estimation.py +78 -0
- haoline/format_adapters.py +1001 -0
- haoline/formats/__init__.py +123 -0
- haoline/formats/coreml.py +250 -0
- haoline/formats/gguf.py +483 -0
- haoline/formats/openvino.py +255 -0
- haoline/formats/safetensors.py +273 -0
- haoline/formats/tflite.py +369 -0
- haoline/hardware.py +2307 -0
- haoline/hierarchical_graph.py +462 -0
- haoline/html_export.py +1573 -0
- haoline/layer_summary.py +769 -0
- haoline/llm_summarizer.py +465 -0
- haoline/op_icons.py +618 -0
- haoline/operational_profiling.py +1492 -0
- haoline/patterns.py +1116 -0
- haoline/pdf_generator.py +265 -0
- haoline/privacy.py +250 -0
- haoline/pydantic_models.py +241 -0
- haoline/report.py +1923 -0
- haoline/report_sections.py +539 -0
- haoline/risks.py +521 -0
- haoline/schema.py +523 -0
- haoline/streamlit_app.py +2024 -0
- haoline/tests/__init__.py +4 -0
- haoline/tests/conftest.py +123 -0
- haoline/tests/test_analyzer.py +868 -0
- haoline/tests/test_compare_visualizations.py +293 -0
- haoline/tests/test_edge_analysis.py +243 -0
- haoline/tests/test_eval.py +604 -0
- haoline/tests/test_format_adapters.py +460 -0
- haoline/tests/test_hardware.py +237 -0
- haoline/tests/test_hardware_recommender.py +90 -0
- haoline/tests/test_hierarchical_graph.py +326 -0
- haoline/tests/test_html_export.py +180 -0
- haoline/tests/test_layer_summary.py +428 -0
- haoline/tests/test_llm_patterns.py +540 -0
- haoline/tests/test_llm_summarizer.py +339 -0
- haoline/tests/test_patterns.py +774 -0
- haoline/tests/test_pytorch.py +327 -0
- haoline/tests/test_report.py +383 -0
- haoline/tests/test_risks.py +398 -0
- haoline/tests/test_schema.py +417 -0
- haoline/tests/test_tensorflow.py +380 -0
- haoline/tests/test_visualizations.py +316 -0
- haoline/universal_ir.py +856 -0
- haoline/visualizations.py +1086 -0
- haoline/visualize_yolo.py +44 -0
- haoline/web.py +110 -0
- haoline-0.3.0.dist-info/METADATA +471 -0
- haoline-0.3.0.dist-info/RECORD +70 -0
- haoline-0.3.0.dist-info/WHEEL +4 -0
- haoline-0.3.0.dist-info/entry_points.txt +5 -0
- haoline-0.3.0.dist-info/licenses/LICENSE +22 -0
haoline/report.py
ADDED
|
@@ -0,0 +1,1923 @@
|
|
|
1
|
+
# Copyright (c) 2025 HaoLine Contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Report generation and ModelInspector orchestrator for HaoLine.
|
|
6
|
+
|
|
7
|
+
This module contains:
|
|
8
|
+
- InspectionReport: The main data structure holding all analysis results
|
|
9
|
+
- ModelInspector: Orchestrator that coordinates all analysis components
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import pathlib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .analyzer import (
|
|
23
|
+
FlopCounts,
|
|
24
|
+
MemoryEstimates,
|
|
25
|
+
MetricsEngine,
|
|
26
|
+
ONNXGraphLoader,
|
|
27
|
+
ParamCounts,
|
|
28
|
+
)
|
|
29
|
+
from .hardware import (
|
|
30
|
+
HardwareEstimates,
|
|
31
|
+
HardwareProfile,
|
|
32
|
+
)
|
|
33
|
+
from .operational_profiling import (
|
|
34
|
+
BatchSizeSweep,
|
|
35
|
+
ResolutionSweep,
|
|
36
|
+
SystemRequirements,
|
|
37
|
+
)
|
|
38
|
+
from .patterns import Block, PatternAnalyzer
|
|
39
|
+
from .risks import RiskAnalyzer, RiskSignal
|
|
40
|
+
from .universal_ir import UniversalGraph
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ModelMetadata:
|
|
45
|
+
"""Basic model metadata extracted from ONNX proto."""
|
|
46
|
+
|
|
47
|
+
path: str
|
|
48
|
+
ir_version: int
|
|
49
|
+
producer_name: str
|
|
50
|
+
producer_version: str
|
|
51
|
+
domain: str
|
|
52
|
+
model_version: int
|
|
53
|
+
doc_string: str
|
|
54
|
+
opsets: dict[str, int] # domain -> version
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class GraphSummary:
|
|
59
|
+
"""Summary statistics about the ONNX graph."""
|
|
60
|
+
|
|
61
|
+
num_nodes: int
|
|
62
|
+
num_inputs: int
|
|
63
|
+
num_outputs: int
|
|
64
|
+
num_initializers: int
|
|
65
|
+
input_shapes: dict[str, list[int | str]] # name -> shape (may have symbolic dims)
|
|
66
|
+
output_shapes: dict[str, list[int | str]]
|
|
67
|
+
op_type_counts: dict[str, int] # op_type -> count
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class DatasetInfo:
|
|
72
|
+
"""Dataset and class information extracted from model metadata."""
|
|
73
|
+
|
|
74
|
+
task: str | None = None # "detect", "classify", "segment", etc.
|
|
75
|
+
num_classes: int | None = None
|
|
76
|
+
class_names: list[str] = field(default_factory=list)
|
|
77
|
+
source: str | None = None # "ultralytics", "output_shape", etc.
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def infer_num_classes_from_output(
|
|
81
|
+
output_shapes: dict[str, list[int | str]],
|
|
82
|
+
architecture_type: str = "unknown",
|
|
83
|
+
) -> DatasetInfo | None:
|
|
84
|
+
"""
|
|
85
|
+
Infer number of classes from model output shapes.
|
|
86
|
+
|
|
87
|
+
Analyzes output tensor shapes to detect common patterns:
|
|
88
|
+
- Classification: [batch, num_classes] or [batch, 1, num_classes]
|
|
89
|
+
- Detection (YOLO-style): [batch, num_boxes, 4+num_classes] or [batch, num_boxes, 5+num_classes]
|
|
90
|
+
- Segmentation: [batch, num_classes, height, width]
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
output_shapes: Dictionary mapping output names to their shapes.
|
|
94
|
+
architecture_type: Detected architecture type (helps disambiguate patterns).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
DatasetInfo with inferred num_classes and task, or None if inference failed.
|
|
98
|
+
"""
|
|
99
|
+
if not output_shapes:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
# Get the primary output (usually the first one, or look for common names)
|
|
103
|
+
primary_output = None
|
|
104
|
+
primary_shape = None
|
|
105
|
+
|
|
106
|
+
# Priority: look for outputs named 'output', 'logits', 'predictions', etc.
|
|
107
|
+
# Use exact match or prefix match to avoid matching "some_random_output" with "output"
|
|
108
|
+
priority_names = ["logits", "predictions", "probs", "classes", "output0", "output"]
|
|
109
|
+
for name in priority_names:
|
|
110
|
+
for out_name, shape in output_shapes.items():
|
|
111
|
+
out_lower = out_name.lower()
|
|
112
|
+
# Exact match or starts with the priority name
|
|
113
|
+
if out_lower == name or out_lower.startswith(name + "_"):
|
|
114
|
+
primary_output = out_name
|
|
115
|
+
primary_shape = shape
|
|
116
|
+
break
|
|
117
|
+
if primary_output:
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
# Fallback to first output
|
|
121
|
+
if not primary_output:
|
|
122
|
+
primary_output = next(iter(output_shapes.keys()))
|
|
123
|
+
primary_shape = output_shapes[primary_output]
|
|
124
|
+
|
|
125
|
+
if not primary_shape:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Convert shape to list of ints where possible (handle symbolic dims)
|
|
129
|
+
def to_int(dim):
|
|
130
|
+
if isinstance(dim, int):
|
|
131
|
+
return dim
|
|
132
|
+
if isinstance(dim, str):
|
|
133
|
+
# Try to parse as int, otherwise return None
|
|
134
|
+
try:
|
|
135
|
+
return int(dim)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
shape = [to_int(d) for d in primary_shape]
|
|
141
|
+
|
|
142
|
+
# Need at least 2 dimensions
|
|
143
|
+
if len(shape) < 2:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
# Classification pattern: [batch, num_classes] or [batch, 1, num_classes]
|
|
147
|
+
# Typical num_classes: 2-10000 (ImageNet=1000, CIFAR=10/100, etc.)
|
|
148
|
+
if len(shape) == 2:
|
|
149
|
+
# [batch, num_classes]
|
|
150
|
+
_batch, num_classes = shape
|
|
151
|
+
if isinstance(num_classes, int) and 2 <= num_classes <= 10000:
|
|
152
|
+
return DatasetInfo(
|
|
153
|
+
task="classify",
|
|
154
|
+
num_classes=num_classes,
|
|
155
|
+
source="output_shape",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if len(shape) == 3:
|
|
159
|
+
_batch, dim1, dim2 = shape
|
|
160
|
+
# Could be [batch, 1, num_classes] for classification
|
|
161
|
+
if isinstance(dim1, int) and isinstance(dim2, int):
|
|
162
|
+
if dim1 == 1 and 2 <= dim2 <= 10000:
|
|
163
|
+
return DatasetInfo(
|
|
164
|
+
task="classify",
|
|
165
|
+
num_classes=dim2,
|
|
166
|
+
source="output_shape",
|
|
167
|
+
)
|
|
168
|
+
# Could be [batch, num_boxes, 4+nc] or [batch, num_boxes, 5+nc] for detection
|
|
169
|
+
# YOLO format: boxes * (x, y, w, h, obj_conf, class_probs...)
|
|
170
|
+
# Common box counts: 8400, 25200, etc. (depends on input size)
|
|
171
|
+
# Detection heuristic: many boxes, last dim is 4+nc or 5+nc
|
|
172
|
+
# Minimum: 4 box coords + 1 class = 5
|
|
173
|
+
if dim1 >= 100 and dim2 >= 5:
|
|
174
|
+
# Try to infer num_classes from detection output
|
|
175
|
+
# Format could be: [x, y, w, h, class1, class2, ...] (4 + nc) - YOLOv8 format
|
|
176
|
+
# Or: [x, y, w, h, obj_conf, class1, class2, ...] (5 + nc) - YOLOv5 format
|
|
177
|
+
# Assume YOLOv8 format (no obj_conf) which is more common now
|
|
178
|
+
nc = dim2 - 4
|
|
179
|
+
if nc >= 1: # At least 1 class
|
|
180
|
+
return DatasetInfo(
|
|
181
|
+
task="detect",
|
|
182
|
+
num_classes=nc,
|
|
183
|
+
source="output_shape",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if len(shape) == 4:
|
|
187
|
+
_batch, dim1, dim2, dim3 = shape
|
|
188
|
+
# Segmentation pattern: [batch, num_classes, height, width]
|
|
189
|
+
# Height/width are typically >= 32 and often equal
|
|
190
|
+
if (
|
|
191
|
+
isinstance(dim1, int)
|
|
192
|
+
and isinstance(dim2, int)
|
|
193
|
+
and isinstance(dim3, int)
|
|
194
|
+
and 2 <= dim1 <= 1000 # num_classes
|
|
195
|
+
and dim2 >= 32 # height
|
|
196
|
+
and dim3 >= 32 # width
|
|
197
|
+
):
|
|
198
|
+
# Additional check: h/w should be similar (typical segmentation output)
|
|
199
|
+
ratio = max(dim2, dim3) / min(dim2, dim3) if min(dim2, dim3) > 0 else 999
|
|
200
|
+
if ratio <= 4: # Reasonable aspect ratio
|
|
201
|
+
return DatasetInfo(
|
|
202
|
+
task="segment",
|
|
203
|
+
num_classes=dim1,
|
|
204
|
+
source="output_shape",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class InspectionReport:
|
|
212
|
+
"""
|
|
213
|
+
Complete inspection report for an ONNX model.
|
|
214
|
+
|
|
215
|
+
This is the primary output of ModelInspector.inspect() and contains
|
|
216
|
+
all analysis results in a structured format suitable for JSON serialization
|
|
217
|
+
or Markdown rendering.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
# Metadata
|
|
221
|
+
metadata: ModelMetadata
|
|
222
|
+
generated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
|
223
|
+
autodoc_version: str = "0.1.0"
|
|
224
|
+
|
|
225
|
+
# Graph structure
|
|
226
|
+
graph_summary: GraphSummary | None = None
|
|
227
|
+
|
|
228
|
+
# Metrics
|
|
229
|
+
param_counts: ParamCounts | None = None
|
|
230
|
+
flop_counts: FlopCounts | None = None
|
|
231
|
+
memory_estimates: MemoryEstimates | None = None
|
|
232
|
+
|
|
233
|
+
# Patterns
|
|
234
|
+
detected_blocks: list[Block] = field(default_factory=list)
|
|
235
|
+
architecture_type: str = "unknown" # "transformer", "cnn", "mlp", "hybrid", "unknown"
|
|
236
|
+
|
|
237
|
+
# Risks
|
|
238
|
+
risk_signals: list[RiskSignal] = field(default_factory=list)
|
|
239
|
+
|
|
240
|
+
# Hardware estimates (optional, set by CLI if --hardware specified)
|
|
241
|
+
hardware_profile: HardwareProfile | None = None
|
|
242
|
+
hardware_estimates: HardwareEstimates | None = None
|
|
243
|
+
|
|
244
|
+
# System Requirements & Scaling (Epic 6C)
|
|
245
|
+
system_requirements: SystemRequirements | None = None
|
|
246
|
+
batch_size_sweep: BatchSizeSweep | None = None
|
|
247
|
+
resolution_sweep: ResolutionSweep | None = None
|
|
248
|
+
|
|
249
|
+
# LLM summary (optional, set by CLI if --llm-summary specified)
|
|
250
|
+
llm_summary: dict[str, Any] | None = None
|
|
251
|
+
|
|
252
|
+
# Dataset info (optional, extracted from model metadata)
|
|
253
|
+
dataset_info: DatasetInfo | None = None
|
|
254
|
+
|
|
255
|
+
# Extra data (profiling results, GPU metrics, etc.)
|
|
256
|
+
extra_data: dict[str, Any] | None = None
|
|
257
|
+
|
|
258
|
+
# Universal IR (optional, format-agnostic graph representation)
|
|
259
|
+
# Enables cross-format comparison, structural diff, and advanced visualization
|
|
260
|
+
universal_graph: UniversalGraph | None = None
|
|
261
|
+
|
|
262
|
+
def to_dict(self) -> dict[str, Any]:
|
|
263
|
+
"""Convert report to a JSON-serializable dictionary."""
|
|
264
|
+
import numpy as np
|
|
265
|
+
|
|
266
|
+
# Track visited objects to prevent circular references
|
|
267
|
+
visited: set = set()
|
|
268
|
+
|
|
269
|
+
def _serialize(obj: Any, depth: int = 0) -> Any:
|
|
270
|
+
# Prevent infinite recursion
|
|
271
|
+
if depth > 50:
|
|
272
|
+
return str(obj)
|
|
273
|
+
|
|
274
|
+
# Check for circular references using object id
|
|
275
|
+
obj_id = id(obj)
|
|
276
|
+
if obj_id in visited:
|
|
277
|
+
return "<circular reference>"
|
|
278
|
+
if not isinstance(obj, (str, int, float, bool, type(None))):
|
|
279
|
+
visited.add(obj_id)
|
|
280
|
+
|
|
281
|
+
if obj is None:
|
|
282
|
+
return None
|
|
283
|
+
if isinstance(obj, (str, int, float, bool)):
|
|
284
|
+
return obj
|
|
285
|
+
if isinstance(obj, np.ndarray):
|
|
286
|
+
return obj.tolist()
|
|
287
|
+
if isinstance(obj, (np.integer, np.floating)):
|
|
288
|
+
return obj.item()
|
|
289
|
+
# Handle UniversalGraph specially (Pydantic model with to_dict method)
|
|
290
|
+
if hasattr(obj, "to_dict") and hasattr(obj, "num_nodes"):
|
|
291
|
+
# This is a UniversalGraph - serialize without weights
|
|
292
|
+
return obj.to_dict(include_weights=False)
|
|
293
|
+
# Handle dataclasses (but not by calling to_dict which would recurse)
|
|
294
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
295
|
+
return {k: _serialize(getattr(obj, k), depth + 1) for k in obj.__dataclass_fields__}
|
|
296
|
+
# Handle Pydantic models
|
|
297
|
+
if hasattr(obj, "model_dump"):
|
|
298
|
+
return obj.model_dump()
|
|
299
|
+
if isinstance(obj, list):
|
|
300
|
+
return [_serialize(item, depth + 1) for item in obj]
|
|
301
|
+
if isinstance(obj, dict):
|
|
302
|
+
return {str(k): _serialize(v, depth + 1) for k, v in obj.items()}
|
|
303
|
+
# Fallback: convert to string
|
|
304
|
+
return str(obj)
|
|
305
|
+
|
|
306
|
+
result = _serialize(self)
|
|
307
|
+
# _serialize on a dataclass always returns a dict
|
|
308
|
+
assert isinstance(result, dict)
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
def to_json(self, indent: int = 2) -> str:
|
|
312
|
+
"""Serialize report to JSON string."""
|
|
313
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
314
|
+
|
|
315
|
+
def validate(self) -> tuple[bool, list[str]]:
|
|
316
|
+
"""
|
|
317
|
+
Validate this report against the JSON schema.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Tuple of (is_valid, list of error messages).
|
|
321
|
+
If jsonschema is not installed, returns (True, []) with a warning.
|
|
322
|
+
|
|
323
|
+
Example:
|
|
324
|
+
report = inspector.inspect("model.onnx")
|
|
325
|
+
is_valid, errors = report.validate()
|
|
326
|
+
if not is_valid:
|
|
327
|
+
for error in errors:
|
|
328
|
+
print(f"Validation error: {error}")
|
|
329
|
+
"""
|
|
330
|
+
from .schema import validate_report
|
|
331
|
+
|
|
332
|
+
return validate_report(self.to_dict())
|
|
333
|
+
|
|
334
|
+
def validate_strict(self) -> None:
|
|
335
|
+
"""
|
|
336
|
+
Validate this report, raising ValidationError on failure.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ValidationError: If validation fails with details of errors.
|
|
340
|
+
|
|
341
|
+
Example:
|
|
342
|
+
report = inspector.inspect("model.onnx")
|
|
343
|
+
try:
|
|
344
|
+
report.validate_strict()
|
|
345
|
+
print("Report is valid!")
|
|
346
|
+
except ValidationError as e:
|
|
347
|
+
print(f"Invalid report: {e.errors}")
|
|
348
|
+
"""
|
|
349
|
+
from .schema import validate_report_strict
|
|
350
|
+
|
|
351
|
+
validate_report_strict(self.to_dict())
|
|
352
|
+
|
|
353
|
+
def to_markdown(self) -> str:
|
|
354
|
+
"""Generate a Markdown model card from this report."""
|
|
355
|
+
lines = []
|
|
356
|
+
|
|
357
|
+
# Header
|
|
358
|
+
model_name = pathlib.Path(self.metadata.path).stem
|
|
359
|
+
lines.append(f"# Model Card: {model_name}")
|
|
360
|
+
lines.append("")
|
|
361
|
+
lines.append(f"*Generated by HaoLine v{self.autodoc_version} on {self.generated_at}*")
|
|
362
|
+
lines.append("")
|
|
363
|
+
|
|
364
|
+
# Executive Summary (if LLM summary available)
|
|
365
|
+
if self.llm_summary and self.llm_summary.get("success"):
|
|
366
|
+
lines.append("## Executive Summary")
|
|
367
|
+
lines.append("")
|
|
368
|
+
if self.llm_summary.get("short_summary"):
|
|
369
|
+
lines.append(f"{self.llm_summary['short_summary']}")
|
|
370
|
+
lines.append("")
|
|
371
|
+
if self.llm_summary.get("detailed_summary"):
|
|
372
|
+
lines.append("")
|
|
373
|
+
lines.append(self.llm_summary["detailed_summary"])
|
|
374
|
+
lines.append("")
|
|
375
|
+
if self.llm_summary.get("model"):
|
|
376
|
+
lines.append(f"*Generated by {self.llm_summary['model']}*")
|
|
377
|
+
lines.append("")
|
|
378
|
+
|
|
379
|
+
# Metadata section
|
|
380
|
+
lines.append("## Metadata")
|
|
381
|
+
lines.append("")
|
|
382
|
+
lines.append("| Property | Value |")
|
|
383
|
+
lines.append("|----------|-------|")
|
|
384
|
+
lines.append(f"| IR Version | {self.metadata.ir_version} |")
|
|
385
|
+
lines.append(
|
|
386
|
+
f"| Producer | {self.metadata.producer_name} {self.metadata.producer_version} |"
|
|
387
|
+
)
|
|
388
|
+
if self.metadata.opsets:
|
|
389
|
+
opset_str = ", ".join(f"{d}:{v}" for d, v in self.metadata.opsets.items())
|
|
390
|
+
lines.append(f"| Opsets | {opset_str} |")
|
|
391
|
+
lines.append("")
|
|
392
|
+
|
|
393
|
+
# Graph summary
|
|
394
|
+
if self.graph_summary:
|
|
395
|
+
lines.append("## Graph Summary")
|
|
396
|
+
lines.append("")
|
|
397
|
+
lines.append(f"- **Nodes**: {self.graph_summary.num_nodes}")
|
|
398
|
+
lines.append(f"- **Inputs**: {self.graph_summary.num_inputs}")
|
|
399
|
+
lines.append(f"- **Outputs**: {self.graph_summary.num_outputs}")
|
|
400
|
+
lines.append(f"- **Initializers**: {self.graph_summary.num_initializers}")
|
|
401
|
+
lines.append("")
|
|
402
|
+
|
|
403
|
+
# Input/output shapes
|
|
404
|
+
if self.graph_summary.input_shapes:
|
|
405
|
+
lines.append("### Inputs")
|
|
406
|
+
lines.append("")
|
|
407
|
+
for name, shape in self.graph_summary.input_shapes.items():
|
|
408
|
+
lines.append(f"- `{name}`: {shape}")
|
|
409
|
+
lines.append("")
|
|
410
|
+
|
|
411
|
+
if self.graph_summary.output_shapes:
|
|
412
|
+
lines.append("### Outputs")
|
|
413
|
+
lines.append("")
|
|
414
|
+
for name, shape in self.graph_summary.output_shapes.items():
|
|
415
|
+
lines.append(f"- `{name}`: {shape}")
|
|
416
|
+
lines.append("")
|
|
417
|
+
|
|
418
|
+
# Top operators
|
|
419
|
+
if self.graph_summary.op_type_counts:
|
|
420
|
+
lines.append("### Operator Distribution")
|
|
421
|
+
lines.append("")
|
|
422
|
+
lines.append("| Operator | Count |")
|
|
423
|
+
lines.append("|----------|-------|")
|
|
424
|
+
sorted_ops = sorted(self.graph_summary.op_type_counts.items(), key=lambda x: -x[1])
|
|
425
|
+
for op, count in sorted_ops[:15]: # Top 15
|
|
426
|
+
lines.append(f"| {op} | {count} |")
|
|
427
|
+
if len(sorted_ops) > 15:
|
|
428
|
+
lines.append(f"| ... | ({len(sorted_ops) - 15} more) |")
|
|
429
|
+
lines.append("")
|
|
430
|
+
|
|
431
|
+
# Metrics
|
|
432
|
+
if self.param_counts or self.flop_counts or self.memory_estimates:
|
|
433
|
+
lines.append("## Complexity Metrics")
|
|
434
|
+
lines.append("")
|
|
435
|
+
|
|
436
|
+
if self.param_counts:
|
|
437
|
+
lines.append(
|
|
438
|
+
f"- **Total Parameters**: {self._format_number(self.param_counts.total)}"
|
|
439
|
+
)
|
|
440
|
+
lines.append(f" - Trainable: {self._format_number(self.param_counts.trainable)}")
|
|
441
|
+
lines.append(
|
|
442
|
+
f" - Non-trainable: {self._format_number(self.param_counts.non_trainable)}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Shared weights info
|
|
446
|
+
if self.param_counts.num_shared_weights > 0:
|
|
447
|
+
lines.append(
|
|
448
|
+
f" - Shared Weights: {self.param_counts.num_shared_weights} "
|
|
449
|
+
f"(used by multiple nodes)"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Precision breakdown
|
|
453
|
+
if self.param_counts.precision_breakdown:
|
|
454
|
+
breakdown_parts = [
|
|
455
|
+
f"{dtype}: {self._format_number(count)}"
|
|
456
|
+
for dtype, count in sorted(
|
|
457
|
+
self.param_counts.precision_breakdown.items(),
|
|
458
|
+
key=lambda x: -x[1],
|
|
459
|
+
)[:4]
|
|
460
|
+
]
|
|
461
|
+
if breakdown_parts:
|
|
462
|
+
lines.append(f" - By Precision: {', '.join(breakdown_parts)}")
|
|
463
|
+
|
|
464
|
+
# Quantization info
|
|
465
|
+
if self.param_counts.is_quantized:
|
|
466
|
+
lines.append(" - **Quantization Detected**")
|
|
467
|
+
if self.param_counts.quantized_ops:
|
|
468
|
+
lines.append(
|
|
469
|
+
f" - Quantized Ops: {', '.join(self.param_counts.quantized_ops[:5])}"
|
|
470
|
+
)
|
|
471
|
+
if len(self.param_counts.quantized_ops) > 5:
|
|
472
|
+
lines.append(
|
|
473
|
+
f" - ... and {len(self.param_counts.quantized_ops) - 5} more"
|
|
474
|
+
)
|
|
475
|
+
lines.append("")
|
|
476
|
+
|
|
477
|
+
if self.flop_counts:
|
|
478
|
+
lines.append(
|
|
479
|
+
f"- **Estimated FLOPs**: {self._format_number(self.flop_counts.total)}"
|
|
480
|
+
)
|
|
481
|
+
lines.append("")
|
|
482
|
+
|
|
483
|
+
if self.memory_estimates:
|
|
484
|
+
lines.append(
|
|
485
|
+
f"- **Model Size**: {self._format_bytes(self.memory_estimates.model_size_bytes)}"
|
|
486
|
+
)
|
|
487
|
+
lines.append(
|
|
488
|
+
f"- **Peak Activation Memory** (batch=1): "
|
|
489
|
+
f"{self._format_bytes(self.memory_estimates.peak_activation_bytes)}"
|
|
490
|
+
)
|
|
491
|
+
# KV cache info for transformers
|
|
492
|
+
if self.memory_estimates.kv_cache_bytes_per_token > 0:
|
|
493
|
+
lines.append("")
|
|
494
|
+
lines.append("### KV Cache (Transformer Inference)")
|
|
495
|
+
lines.append("")
|
|
496
|
+
config = self.memory_estimates.kv_cache_config
|
|
497
|
+
lines.append(
|
|
498
|
+
f"- **Per Token**: {self._format_bytes(self.memory_estimates.kv_cache_bytes_per_token)}"
|
|
499
|
+
)
|
|
500
|
+
if config.get("seq_len"):
|
|
501
|
+
lines.append(
|
|
502
|
+
f"- **Full Context** (seq={config['seq_len']}): "
|
|
503
|
+
f"{self._format_bytes(self.memory_estimates.kv_cache_bytes_full_context)}"
|
|
504
|
+
)
|
|
505
|
+
if config.get("num_layers"):
|
|
506
|
+
lines.append(f"- **Layers**: {config['num_layers']}")
|
|
507
|
+
if config.get("hidden_dim"):
|
|
508
|
+
lines.append(f"- **Hidden Dim**: {config['hidden_dim']}")
|
|
509
|
+
|
|
510
|
+
# Memory breakdown by component
|
|
511
|
+
if self.memory_estimates.breakdown:
|
|
512
|
+
bd = self.memory_estimates.breakdown
|
|
513
|
+
if bd.weights_by_op_type:
|
|
514
|
+
lines.append("")
|
|
515
|
+
lines.append("### Memory Breakdown by Op Type")
|
|
516
|
+
lines.append("")
|
|
517
|
+
lines.append("| Component | Size |")
|
|
518
|
+
lines.append("|-----------|------|")
|
|
519
|
+
sorted_weights = sorted(bd.weights_by_op_type.items(), key=lambda x: -x[1])
|
|
520
|
+
for op_type, size in sorted_weights[:8]:
|
|
521
|
+
lines.append(f"| {op_type} | {self._format_bytes(size)} |")
|
|
522
|
+
lines.append("")
|
|
523
|
+
|
|
524
|
+
# Architecture
|
|
525
|
+
if self.architecture_type != "unknown" or self.detected_blocks:
|
|
526
|
+
lines.append("## Architecture")
|
|
527
|
+
lines.append("")
|
|
528
|
+
lines.append(f"**Detected Type**: {self.architecture_type}")
|
|
529
|
+
lines.append("")
|
|
530
|
+
|
|
531
|
+
if self.detected_blocks:
|
|
532
|
+
lines.append("### Detected Blocks")
|
|
533
|
+
lines.append("")
|
|
534
|
+
# Group by block type
|
|
535
|
+
block_types: dict[str, int] = {}
|
|
536
|
+
for block in self.detected_blocks:
|
|
537
|
+
block_types[block.block_type] = block_types.get(block.block_type, 0) + 1
|
|
538
|
+
for bt, count in sorted(block_types.items(), key=lambda x: -x[1]):
|
|
539
|
+
lines.append(f"- {bt}: {count}")
|
|
540
|
+
lines.append("")
|
|
541
|
+
|
|
542
|
+
# Highlight non-standard residual patterns if present
|
|
543
|
+
nonstandard_residuals = [
|
|
544
|
+
b
|
|
545
|
+
for b in self.detected_blocks
|
|
546
|
+
if b.block_type in ("ResidualConcat", "ResidualGate", "ResidualSub")
|
|
547
|
+
]
|
|
548
|
+
if nonstandard_residuals:
|
|
549
|
+
# Group by type for summary
|
|
550
|
+
by_type: dict[str, int] = {}
|
|
551
|
+
for block in nonstandard_residuals:
|
|
552
|
+
by_type[block.block_type] = by_type.get(block.block_type, 0) + 1
|
|
553
|
+
|
|
554
|
+
type_labels = {
|
|
555
|
+
"ResidualConcat": "Concat-based (DenseNet-style)",
|
|
556
|
+
"ResidualGate": "Gated skip (Highway/attention)",
|
|
557
|
+
"ResidualSub": "Subtraction-based",
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
lines.append("### Non-Standard Skip Connections")
|
|
561
|
+
lines.append("")
|
|
562
|
+
lines.append(
|
|
563
|
+
f"This model uses {len(nonstandard_residuals)} non-standard skip connection(s):"
|
|
564
|
+
)
|
|
565
|
+
lines.append("")
|
|
566
|
+
for block_type, count in by_type.items():
|
|
567
|
+
label = type_labels.get(block_type, block_type)
|
|
568
|
+
lines.append(f"- **{label}**: {count}")
|
|
569
|
+
lines.append("")
|
|
570
|
+
|
|
571
|
+
# Dataset info (if available)
|
|
572
|
+
if self.dataset_info:
|
|
573
|
+
lines.append("## Dataset Info")
|
|
574
|
+
lines.append("")
|
|
575
|
+
if self.dataset_info.task:
|
|
576
|
+
lines.append(f"**Task**: {self.dataset_info.task}")
|
|
577
|
+
if self.dataset_info.num_classes:
|
|
578
|
+
lines.append(f"**Number of Classes**: {self.dataset_info.num_classes}")
|
|
579
|
+
if self.dataset_info.class_names:
|
|
580
|
+
lines.append("")
|
|
581
|
+
lines.append("### Class Names")
|
|
582
|
+
lines.append("")
|
|
583
|
+
for idx, name in enumerate(self.dataset_info.class_names):
|
|
584
|
+
lines.append(f"- `{idx}`: {name}")
|
|
585
|
+
if self.dataset_info.source:
|
|
586
|
+
lines.append("")
|
|
587
|
+
lines.append(f"*Metadata source: {self.dataset_info.source}*")
|
|
588
|
+
lines.append("")
|
|
589
|
+
|
|
590
|
+
# Hardware estimates
|
|
591
|
+
if self.hardware_estimates and self.hardware_profile:
|
|
592
|
+
hw = self.hardware_estimates
|
|
593
|
+
lines.append("## Hardware Estimates")
|
|
594
|
+
lines.append("")
|
|
595
|
+
lines.append(f"**Target Device**: {self.hardware_profile.name}")
|
|
596
|
+
lines.append(f"**Precision**: {hw.precision} | **Batch Size**: {hw.batch_size}")
|
|
597
|
+
lines.append("")
|
|
598
|
+
lines.append("| Metric | Value |")
|
|
599
|
+
lines.append("|--------|-------|")
|
|
600
|
+
lines.append(f"| VRAM Required | {self._format_bytes(hw.vram_required_bytes)} |")
|
|
601
|
+
lines.append(f"| Fits in VRAM | {'Yes' if hw.fits_in_vram else 'No'} |")
|
|
602
|
+
if hw.fits_in_vram:
|
|
603
|
+
lines.append(f"| Theoretical Latency | {hw.theoretical_latency_ms:.2f} ms |")
|
|
604
|
+
lines.append(f"| Bottleneck | {hw.bottleneck} |")
|
|
605
|
+
lines.append(f"| Compute Utilization | {hw.compute_utilization_estimate:.0%} |")
|
|
606
|
+
# GPU Saturation: what % of GPU's 1-second capacity this inference uses
|
|
607
|
+
lines.append(
|
|
608
|
+
f"| GPU Saturation | {hw.gpu_saturation:.2e} ({hw.gpu_saturation * 100:.4f}%) |"
|
|
609
|
+
)
|
|
610
|
+
lines.append("")
|
|
611
|
+
|
|
612
|
+
# Add device specs
|
|
613
|
+
lines.append("### Device Specifications")
|
|
614
|
+
lines.append("")
|
|
615
|
+
lines.append(f"- **VRAM**: {self._format_bytes(self.hardware_profile.vram_bytes)}")
|
|
616
|
+
lines.append(f"- **FP32 Peak**: {self.hardware_profile.peak_fp32_tflops:.1f} TFLOPS")
|
|
617
|
+
lines.append(f"- **FP16 Peak**: {self.hardware_profile.peak_fp16_tflops:.1f} TFLOPS")
|
|
618
|
+
if self.hardware_profile.tdp_watts:
|
|
619
|
+
lines.append(f"- **TDP**: {self.hardware_profile.tdp_watts}W")
|
|
620
|
+
lines.append("")
|
|
621
|
+
|
|
622
|
+
# System Requirements (Story 6C.2)
|
|
623
|
+
if self.system_requirements:
|
|
624
|
+
reqs = self.system_requirements
|
|
625
|
+
lines.append("## System Requirements")
|
|
626
|
+
lines.append("")
|
|
627
|
+
lines.append("| Level | Device | VRAM |")
|
|
628
|
+
lines.append("|-------|--------|------|")
|
|
629
|
+
min_name = reqs.minimum_gpu.device if reqs.minimum_gpu else "N/A"
|
|
630
|
+
rec_name = reqs.recommended_gpu.device if reqs.recommended_gpu else "N/A"
|
|
631
|
+
opt_name = reqs.optimal_gpu.device if reqs.optimal_gpu else "N/A"
|
|
632
|
+
min_vram = f"{reqs.minimum_vram_gb} GB" if reqs.minimum_vram_gb is not None else "-"
|
|
633
|
+
rec_vram = (
|
|
634
|
+
f"{reqs.recommended_vram_gb} GB" if reqs.recommended_vram_gb is not None else "-"
|
|
635
|
+
)
|
|
636
|
+
lines.append(f"| Minimum | {min_name} | {min_vram} |")
|
|
637
|
+
lines.append(f"| Recommended | {rec_name} | {rec_vram} |")
|
|
638
|
+
lines.append(f"| Optimal | {opt_name} | - |")
|
|
639
|
+
lines.append("")
|
|
640
|
+
|
|
641
|
+
# Batch Size Sweep (Story 6C.1)
|
|
642
|
+
if self.batch_size_sweep:
|
|
643
|
+
batch_sweep = self.batch_size_sweep
|
|
644
|
+
lines.append("## Batch Size Scaling")
|
|
645
|
+
lines.append("")
|
|
646
|
+
lines.append(f"**Optimal Batch Size**: {batch_sweep.optimal_batch_size}")
|
|
647
|
+
lines.append("")
|
|
648
|
+
lines.append("| Batch Size | Latency (ms) | Throughput (inf/s) | VRAM (GB) |")
|
|
649
|
+
lines.append("|------------|--------------|--------------------|-----------|")
|
|
650
|
+
for i, bs in enumerate(batch_sweep.batch_sizes):
|
|
651
|
+
lines.append(
|
|
652
|
+
f"| {bs} | {batch_sweep.latencies[i]:.2f} | {batch_sweep.throughputs[i]:.1f} | {batch_sweep.vram_usage_gb[i]:.2f} |"
|
|
653
|
+
)
|
|
654
|
+
lines.append("")
|
|
655
|
+
|
|
656
|
+
# Resolution Sweep (Story 6.8)
|
|
657
|
+
if self.resolution_sweep:
|
|
658
|
+
res_sweep = self.resolution_sweep
|
|
659
|
+
lines.append("## Resolution Scaling")
|
|
660
|
+
lines.append("")
|
|
661
|
+
lines.append(f"**Max Resolution**: {res_sweep.max_resolution}")
|
|
662
|
+
lines.append(f"**Optimal Resolution**: {res_sweep.optimal_resolution}")
|
|
663
|
+
lines.append("")
|
|
664
|
+
lines.append(
|
|
665
|
+
"| Resolution | FLOPs | Memory (GB) | Latency (ms) | Throughput | VRAM (GB) |"
|
|
666
|
+
)
|
|
667
|
+
lines.append(
|
|
668
|
+
"|------------|-------|-------------|--------------|------------|-----------|"
|
|
669
|
+
)
|
|
670
|
+
for i, res in enumerate(res_sweep.resolutions):
|
|
671
|
+
flops_str = self._format_number(res_sweep.flops[i])
|
|
672
|
+
lat_str = (
|
|
673
|
+
f"{res_sweep.latencies[i]:.2f}"
|
|
674
|
+
if res_sweep.latencies[i] != float("inf")
|
|
675
|
+
else "OOM"
|
|
676
|
+
)
|
|
677
|
+
tput_str = (
|
|
678
|
+
f"{res_sweep.throughputs[i]:.1f}" if res_sweep.throughputs[i] > 0 else "-"
|
|
679
|
+
)
|
|
680
|
+
lines.append(
|
|
681
|
+
f"| {res} | {flops_str} | {res_sweep.memory_gb[i]:.2f} | "
|
|
682
|
+
f"{lat_str} | {tput_str} | {res_sweep.vram_usage_gb[i]:.2f} |"
|
|
683
|
+
)
|
|
684
|
+
lines.append("")
|
|
685
|
+
|
|
686
|
+
# Risks
|
|
687
|
+
if self.risk_signals:
|
|
688
|
+
lines.append("## Risk Signals")
|
|
689
|
+
lines.append("")
|
|
690
|
+
for risk in self.risk_signals:
|
|
691
|
+
severity_icon = {"info": "INFO", "warning": "WARN", "high": "HIGH"}
|
|
692
|
+
icon = severity_icon.get(risk.severity, "")
|
|
693
|
+
lines.append(f"### [{icon}] {risk.id}")
|
|
694
|
+
lines.append("")
|
|
695
|
+
lines.append(risk.description)
|
|
696
|
+
lines.append("")
|
|
697
|
+
if risk.recommendation:
|
|
698
|
+
lines.append(f"**Recommendation**: {risk.recommendation}")
|
|
699
|
+
lines.append("")
|
|
700
|
+
|
|
701
|
+
return "\n".join(lines)
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def _format_number(n: int | float) -> str:
|
|
705
|
+
"""Format large numbers with K/M/B suffixes."""
|
|
706
|
+
if n >= 1e9:
|
|
707
|
+
return f"{n / 1e9:.2f}B"
|
|
708
|
+
if n >= 1e6:
|
|
709
|
+
return f"{n / 1e6:.2f}M"
|
|
710
|
+
if n >= 1e3:
|
|
711
|
+
return f"{n / 1e3:.2f}K"
|
|
712
|
+
return str(int(n))
|
|
713
|
+
|
|
714
|
+
@staticmethod
|
|
715
|
+
def _format_bytes(b: int) -> str:
|
|
716
|
+
"""Format bytes with KB/MB/GB suffixes."""
|
|
717
|
+
if b >= 1e9:
|
|
718
|
+
return f"{b / 1e9:.2f} GB"
|
|
719
|
+
if b >= 1e6:
|
|
720
|
+
return f"{b / 1e6:.2f} MB"
|
|
721
|
+
if b >= 1e3:
|
|
722
|
+
return f"{b / 1e3:.2f} KB"
|
|
723
|
+
return f"{b} bytes"
|
|
724
|
+
|
|
725
|
+
def to_html(
|
|
726
|
+
self,
|
|
727
|
+
image_paths: dict[str, pathlib.Path] | None = None,
|
|
728
|
+
graph_html: str | None = None,
|
|
729
|
+
layer_table_html: str | None = None,
|
|
730
|
+
eval_metrics_html: str | None = None,
|
|
731
|
+
) -> str:
|
|
732
|
+
"""
|
|
733
|
+
Generate a self-contained HTML report with embedded images.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
image_paths: Dictionary mapping image names to file paths.
|
|
737
|
+
Images will be embedded as base64.
|
|
738
|
+
graph_html: Optional HTML for interactive graph visualization (Task 5.7.8).
|
|
739
|
+
This should be the inner graph container, not full HTML document.
|
|
740
|
+
layer_table_html: Optional HTML for per-layer summary table (Story 5.8).
|
|
741
|
+
eval_metrics_html: Optional HTML for evaluation metrics section (Task 12.5.4).
|
|
742
|
+
Generated by haoline.eval.comparison.generate_eval_metrics_html().
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
Complete HTML document as a string.
|
|
746
|
+
"""
|
|
747
|
+
import base64
|
|
748
|
+
|
|
749
|
+
# Embed images as base64
|
|
750
|
+
def embed_image(path: pathlib.Path) -> str:
|
|
751
|
+
if path.exists():
|
|
752
|
+
with open(path, "rb") as f:
|
|
753
|
+
data = base64.b64encode(f.read()).decode("utf-8")
|
|
754
|
+
return f"data:image/png;base64,{data}"
|
|
755
|
+
return ""
|
|
756
|
+
|
|
757
|
+
images = {}
|
|
758
|
+
if image_paths:
|
|
759
|
+
for name, path in image_paths.items():
|
|
760
|
+
images[name] = embed_image(path)
|
|
761
|
+
|
|
762
|
+
# Build HTML
|
|
763
|
+
model_name = pathlib.Path(self.metadata.path).stem if self.metadata else "Model"
|
|
764
|
+
|
|
765
|
+
html_parts = [self._html_head(model_name)]
|
|
766
|
+
html_parts.append('<body><div class="container">')
|
|
767
|
+
|
|
768
|
+
# Header
|
|
769
|
+
html_parts.append(
|
|
770
|
+
f"""
|
|
771
|
+
<header>
|
|
772
|
+
<h1>{model_name}</h1>
|
|
773
|
+
<p class="subtitle">ONNX Model Analysis Report</p>
|
|
774
|
+
<p class="timestamp">Generated by HaoLine v0.1.0 on {datetime.utcnow().isoformat()}Z</p>
|
|
775
|
+
</header>
|
|
776
|
+
"""
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# Executive Summary (LLM) - Prominent AI-generated summary
|
|
780
|
+
if self.llm_summary and self.llm_summary.get("success"):
|
|
781
|
+
html_parts.append(
|
|
782
|
+
"""
|
|
783
|
+
<section class="executive-summary">
|
|
784
|
+
<h2>AI Executive Summary</h2>
|
|
785
|
+
"""
|
|
786
|
+
)
|
|
787
|
+
if self.llm_summary.get("short_summary"):
|
|
788
|
+
html_parts.append(f'<p class="tldr">{self.llm_summary["short_summary"]}</p>')
|
|
789
|
+
if self.llm_summary.get("detailed_summary"):
|
|
790
|
+
html_parts.append(f"<p>{self.llm_summary['detailed_summary']}</p>")
|
|
791
|
+
html_parts.append(
|
|
792
|
+
f'<p class="llm-credit">Generated by {self.llm_summary.get("model", "LLM")}</p>'
|
|
793
|
+
)
|
|
794
|
+
html_parts.append("</section>")
|
|
795
|
+
|
|
796
|
+
# Key Metrics Cards
|
|
797
|
+
html_parts.append('<section class="metrics-cards">')
|
|
798
|
+
if self.param_counts:
|
|
799
|
+
html_parts.append(
|
|
800
|
+
f"""
|
|
801
|
+
<div class="card">
|
|
802
|
+
<div class="card-value">{self._format_number(self.param_counts.total)}</div>
|
|
803
|
+
<div class="card-label">Parameters</div>
|
|
804
|
+
</div>
|
|
805
|
+
"""
|
|
806
|
+
)
|
|
807
|
+
if self.flop_counts:
|
|
808
|
+
html_parts.append(
|
|
809
|
+
f"""
|
|
810
|
+
<div class="card">
|
|
811
|
+
<div class="card-value">{self._format_number(self.flop_counts.total)}</div>
|
|
812
|
+
<div class="card-label">FLOPs</div>
|
|
813
|
+
</div>
|
|
814
|
+
"""
|
|
815
|
+
)
|
|
816
|
+
if self.memory_estimates:
|
|
817
|
+
html_parts.append(
|
|
818
|
+
f"""
|
|
819
|
+
<div class="card">
|
|
820
|
+
<div class="card-value">{self._format_bytes(self.memory_estimates.model_size_bytes)}</div>
|
|
821
|
+
<div class="card-label">Model Size</div>
|
|
822
|
+
</div>
|
|
823
|
+
"""
|
|
824
|
+
)
|
|
825
|
+
if self.architecture_type:
|
|
826
|
+
html_parts.append(
|
|
827
|
+
f"""
|
|
828
|
+
<div class="card">
|
|
829
|
+
<div class="card-value">{self.architecture_type.upper()}</div>
|
|
830
|
+
<div class="card-label">Architecture</div>
|
|
831
|
+
</div>
|
|
832
|
+
"""
|
|
833
|
+
)
|
|
834
|
+
# Quantization indicator card
|
|
835
|
+
if self.param_counts and self.param_counts.is_quantized:
|
|
836
|
+
html_parts.append(
|
|
837
|
+
"""
|
|
838
|
+
<div class="card" style="border-color: #4CAF50;">
|
|
839
|
+
<div class="card-value" style="color: #4CAF50;">Yes</div>
|
|
840
|
+
<div class="card-label">Quantized</div>
|
|
841
|
+
</div>
|
|
842
|
+
"""
|
|
843
|
+
)
|
|
844
|
+
html_parts.append("</section>")
|
|
845
|
+
|
|
846
|
+
# Interactive Graph Visualization - Placed prominently after metrics (Task 5.7.8)
|
|
847
|
+
if graph_html:
|
|
848
|
+
html_parts.append('<section class="graph-section">')
|
|
849
|
+
html_parts.append("<h2>Neural Network Architecture</h2>")
|
|
850
|
+
html_parts.append(
|
|
851
|
+
'<p class="section-desc">Click nodes to expand/collapse blocks. '
|
|
852
|
+
"Use the search box to find specific operations.</p>"
|
|
853
|
+
)
|
|
854
|
+
html_parts.append('<div class="graph-container">')
|
|
855
|
+
html_parts.append(graph_html)
|
|
856
|
+
html_parts.append("</div></section>")
|
|
857
|
+
|
|
858
|
+
# Evaluation Metrics (Task 12.5.4)
|
|
859
|
+
if eval_metrics_html:
|
|
860
|
+
html_parts.append(eval_metrics_html)
|
|
861
|
+
|
|
862
|
+
# Complexity Metrics Details (KV Cache + Memory Breakdown)
|
|
863
|
+
if self.memory_estimates:
|
|
864
|
+
# KV Cache section (Task 4.4.2)
|
|
865
|
+
if self.memory_estimates.kv_cache_bytes_per_token > 0:
|
|
866
|
+
html_parts.append('<section class="kv-cache">')
|
|
867
|
+
html_parts.append("<h2>KV Cache (Transformer Inference)</h2>")
|
|
868
|
+
config = self.memory_estimates.kv_cache_config
|
|
869
|
+
html_parts.append("<table>")
|
|
870
|
+
html_parts.append("<tr><th>Metric</th><th>Value</th></tr>")
|
|
871
|
+
html_parts.append(
|
|
872
|
+
f"<tr><td>Per Token</td><td>{self._format_bytes(self.memory_estimates.kv_cache_bytes_per_token)}</td></tr>"
|
|
873
|
+
)
|
|
874
|
+
if config.get("seq_len"):
|
|
875
|
+
html_parts.append(
|
|
876
|
+
f"<tr><td>Full Context (seq={config['seq_len']})</td>"
|
|
877
|
+
f"<td>{self._format_bytes(self.memory_estimates.kv_cache_bytes_full_context)}</td></tr>"
|
|
878
|
+
)
|
|
879
|
+
if config.get("num_layers"):
|
|
880
|
+
html_parts.append(f"<tr><td>Layers</td><td>{config['num_layers']}</td></tr>")
|
|
881
|
+
if config.get("hidden_dim"):
|
|
882
|
+
html_parts.append(
|
|
883
|
+
f"<tr><td>Hidden Dim</td><td>{config['hidden_dim']}</td></tr>"
|
|
884
|
+
)
|
|
885
|
+
html_parts.append("</table></section>")
|
|
886
|
+
|
|
887
|
+
# Memory Breakdown by Op Type (Task 4.4.3)
|
|
888
|
+
if self.memory_estimates.breakdown:
|
|
889
|
+
bd = self.memory_estimates.breakdown
|
|
890
|
+
if bd.weights_by_op_type:
|
|
891
|
+
html_parts.append('<section class="memory-breakdown">')
|
|
892
|
+
html_parts.append("<h2>Memory Breakdown by Op Type</h2>")
|
|
893
|
+
html_parts.append("<table>")
|
|
894
|
+
html_parts.append("<tr><th>Component</th><th>Size</th></tr>")
|
|
895
|
+
sorted_weights = sorted(bd.weights_by_op_type.items(), key=lambda x: -x[1])
|
|
896
|
+
for op_type, size in sorted_weights[:8]:
|
|
897
|
+
html_parts.append(
|
|
898
|
+
f"<tr><td>{op_type}</td><td>{self._format_bytes(size)}</td></tr>"
|
|
899
|
+
)
|
|
900
|
+
if len(sorted_weights) > 8:
|
|
901
|
+
remaining = sum(s for _, s in sorted_weights[8:])
|
|
902
|
+
html_parts.append(
|
|
903
|
+
f"<tr><td>Other ({len(sorted_weights) - 8} types)</td>"
|
|
904
|
+
f"<td>{self._format_bytes(remaining)}</td></tr>"
|
|
905
|
+
)
|
|
906
|
+
html_parts.append("</table></section>")
|
|
907
|
+
|
|
908
|
+
# Parameter Details section (shared weights, precision, quantization)
|
|
909
|
+
if self.param_counts:
|
|
910
|
+
has_content = (
|
|
911
|
+
self.param_counts.num_shared_weights > 0
|
|
912
|
+
or self.param_counts.precision_breakdown
|
|
913
|
+
or self.param_counts.is_quantized
|
|
914
|
+
)
|
|
915
|
+
if has_content:
|
|
916
|
+
html_parts.append('<section class="param-details">')
|
|
917
|
+
html_parts.append("<h2>Parameter Details</h2>")
|
|
918
|
+
|
|
919
|
+
# Precision breakdown
|
|
920
|
+
if self.param_counts.precision_breakdown:
|
|
921
|
+
html_parts.append("<h3>Precision Breakdown</h3>")
|
|
922
|
+
html_parts.append("<table>")
|
|
923
|
+
html_parts.append("<tr><th>Data Type</th><th>Parameters</th></tr>")
|
|
924
|
+
for dtype, count in sorted(
|
|
925
|
+
self.param_counts.precision_breakdown.items(),
|
|
926
|
+
key=lambda x: -x[1],
|
|
927
|
+
):
|
|
928
|
+
html_parts.append(
|
|
929
|
+
f"<tr><td>{dtype}</td><td>{self._format_number(count)}</td></tr>"
|
|
930
|
+
)
|
|
931
|
+
html_parts.append("</table>")
|
|
932
|
+
|
|
933
|
+
# Shared weights info
|
|
934
|
+
if self.param_counts.num_shared_weights > 0:
|
|
935
|
+
html_parts.append("<h3>Shared Weights</h3>")
|
|
936
|
+
html_parts.append(
|
|
937
|
+
f"<p><strong>{self.param_counts.num_shared_weights}</strong> weights are shared across multiple nodes.</p>"
|
|
938
|
+
)
|
|
939
|
+
if self.param_counts.shared_weights:
|
|
940
|
+
html_parts.append("<details><summary>Show shared weight details</summary>")
|
|
941
|
+
html_parts.append("<table>")
|
|
942
|
+
html_parts.append("<tr><th>Weight Name</th><th>Used By Nodes</th></tr>")
|
|
943
|
+
for name, nodes in list(self.param_counts.shared_weights.items())[:10]:
|
|
944
|
+
nodes_str = ", ".join(nodes[:5])
|
|
945
|
+
if len(nodes) > 5:
|
|
946
|
+
nodes_str += f" (+{len(nodes) - 5} more)"
|
|
947
|
+
html_parts.append(f"<tr><td>{name}</td><td>{nodes_str}</td></tr>")
|
|
948
|
+
if len(self.param_counts.shared_weights) > 10:
|
|
949
|
+
html_parts.append(
|
|
950
|
+
f"<tr><td colspan='2'>... and {len(self.param_counts.shared_weights) - 10} more shared weights</td></tr>"
|
|
951
|
+
)
|
|
952
|
+
html_parts.append("</table></details>")
|
|
953
|
+
|
|
954
|
+
# Quantization info
|
|
955
|
+
if self.param_counts.is_quantized:
|
|
956
|
+
html_parts.append("<h3>Quantization</h3>")
|
|
957
|
+
html_parts.append(
|
|
958
|
+
'<p style="color: #4CAF50; font-weight: bold;">Model is quantized</p>'
|
|
959
|
+
)
|
|
960
|
+
if self.param_counts.quantized_ops:
|
|
961
|
+
html_parts.append(
|
|
962
|
+
f"<p>Quantized operations: {', '.join(self.param_counts.quantized_ops)}</p>"
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
html_parts.append("</section>")
|
|
966
|
+
|
|
967
|
+
# Visualizations
|
|
968
|
+
if images:
|
|
969
|
+
html_parts.append('<section class="visualizations">')
|
|
970
|
+
html_parts.append("<h2>Visualizations</h2>")
|
|
971
|
+
html_parts.append('<div class="chart-grid">')
|
|
972
|
+
for name, data_uri in images.items():
|
|
973
|
+
if data_uri:
|
|
974
|
+
label = name.replace("_", " ").title()
|
|
975
|
+
html_parts.append(
|
|
976
|
+
f"""
|
|
977
|
+
<div class="chart-container">
|
|
978
|
+
<img src="{data_uri}" alt="{label}">
|
|
979
|
+
</div>
|
|
980
|
+
"""
|
|
981
|
+
)
|
|
982
|
+
html_parts.append("</div></section>")
|
|
983
|
+
|
|
984
|
+
# Per-Layer Summary Table (Story 5.8)
|
|
985
|
+
if layer_table_html:
|
|
986
|
+
html_parts.append('<section class="layer-summary">')
|
|
987
|
+
html_parts.append("<h2>Layer-by-Layer Analysis</h2>")
|
|
988
|
+
html_parts.append(
|
|
989
|
+
'<p class="section-desc">Click column headers to sort. '
|
|
990
|
+
"Use the search box to filter layers.</p>"
|
|
991
|
+
)
|
|
992
|
+
html_parts.append(layer_table_html)
|
|
993
|
+
html_parts.append("</section>")
|
|
994
|
+
|
|
995
|
+
# Model Details
|
|
996
|
+
html_parts.append('<section class="details">')
|
|
997
|
+
html_parts.append("<h2>Model Details</h2>")
|
|
998
|
+
|
|
999
|
+
# Metadata table
|
|
1000
|
+
if self.metadata:
|
|
1001
|
+
html_parts.append(
|
|
1002
|
+
"""
|
|
1003
|
+
<h3>Metadata</h3>
|
|
1004
|
+
<table>
|
|
1005
|
+
<tr><th>Property</th><th>Value</th></tr>
|
|
1006
|
+
"""
|
|
1007
|
+
)
|
|
1008
|
+
html_parts.append(f"<tr><td>IR Version</td><td>{self.metadata.ir_version}</td></tr>")
|
|
1009
|
+
html_parts.append(
|
|
1010
|
+
f"<tr><td>Producer</td><td>{self.metadata.producer_name} {self.metadata.producer_version}</td></tr>"
|
|
1011
|
+
)
|
|
1012
|
+
opsets = ", ".join(f"{d}:{v}" for d, v in self.metadata.opsets.items())
|
|
1013
|
+
html_parts.append(f"<tr><td>Opsets</td><td>{opsets}</td></tr>")
|
|
1014
|
+
html_parts.append("</table>")
|
|
1015
|
+
|
|
1016
|
+
# Graph summary
|
|
1017
|
+
if self.graph_summary:
|
|
1018
|
+
html_parts.append(
|
|
1019
|
+
"""
|
|
1020
|
+
<h3>Graph Summary</h3>
|
|
1021
|
+
<table>
|
|
1022
|
+
<tr><th>Metric</th><th>Value</th></tr>
|
|
1023
|
+
"""
|
|
1024
|
+
)
|
|
1025
|
+
html_parts.append(f"<tr><td>Nodes</td><td>{self.graph_summary.num_nodes}</td></tr>")
|
|
1026
|
+
html_parts.append(f"<tr><td>Inputs</td><td>{self.graph_summary.num_inputs}</td></tr>")
|
|
1027
|
+
html_parts.append(f"<tr><td>Outputs</td><td>{self.graph_summary.num_outputs}</td></tr>")
|
|
1028
|
+
html_parts.append(
|
|
1029
|
+
f"<tr><td>Initializers</td><td>{self.graph_summary.num_initializers}</td></tr>"
|
|
1030
|
+
)
|
|
1031
|
+
html_parts.append("</table>")
|
|
1032
|
+
|
|
1033
|
+
# I/O shapes
|
|
1034
|
+
if self.graph_summary.input_shapes:
|
|
1035
|
+
html_parts.append("<h4>Inputs</h4><ul>")
|
|
1036
|
+
for name, shape in self.graph_summary.input_shapes.items():
|
|
1037
|
+
html_parts.append(f"<li><code>{name}</code>: {shape}</li>")
|
|
1038
|
+
html_parts.append("</ul>")
|
|
1039
|
+
|
|
1040
|
+
if self.graph_summary.output_shapes:
|
|
1041
|
+
html_parts.append("<h4>Outputs</h4><ul>")
|
|
1042
|
+
for name, shape in self.graph_summary.output_shapes.items():
|
|
1043
|
+
html_parts.append(f"<li><code>{name}</code>: {shape}</li>")
|
|
1044
|
+
html_parts.append("</ul>")
|
|
1045
|
+
|
|
1046
|
+
# Operator Distribution (Task 4.4.1)
|
|
1047
|
+
if self.graph_summary.op_type_counts:
|
|
1048
|
+
html_parts.append("<h3>Operator Distribution</h3>")
|
|
1049
|
+
html_parts.append("<table>")
|
|
1050
|
+
html_parts.append("<tr><th>Operator</th><th>Count</th></tr>")
|
|
1051
|
+
sorted_ops = sorted(self.graph_summary.op_type_counts.items(), key=lambda x: -x[1])
|
|
1052
|
+
for op, count in sorted_ops[:15]:
|
|
1053
|
+
html_parts.append(f"<tr><td>{op}</td><td>{count}</td></tr>")
|
|
1054
|
+
if len(sorted_ops) > 15:
|
|
1055
|
+
html_parts.append(
|
|
1056
|
+
f"<tr><td>...</td><td>({len(sorted_ops) - 15} more)</td></tr>"
|
|
1057
|
+
)
|
|
1058
|
+
html_parts.append("</table>")
|
|
1059
|
+
|
|
1060
|
+
html_parts.append("</section>")
|
|
1061
|
+
|
|
1062
|
+
# Architecture section
|
|
1063
|
+
if self.architecture_type != "unknown" or self.detected_blocks:
|
|
1064
|
+
html_parts.append('<section class="architecture">')
|
|
1065
|
+
html_parts.append("<h2>Architecture</h2>")
|
|
1066
|
+
html_parts.append(
|
|
1067
|
+
f'<p><strong>Detected Type:</strong> <span class="arch-type">{self.architecture_type.upper()}</span></p>'
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
if self.detected_blocks:
|
|
1071
|
+
# Group by block type
|
|
1072
|
+
block_types: dict[str, int] = {}
|
|
1073
|
+
for block in self.detected_blocks:
|
|
1074
|
+
block_types[block.block_type] = block_types.get(block.block_type, 0) + 1
|
|
1075
|
+
|
|
1076
|
+
html_parts.append("<h3>Detected Blocks</h3>")
|
|
1077
|
+
html_parts.append("<table>")
|
|
1078
|
+
html_parts.append("<tr><th>Block Type</th><th>Count</th></tr>")
|
|
1079
|
+
for bt, count in sorted(block_types.items(), key=lambda x: -x[1]):
|
|
1080
|
+
html_parts.append(f"<tr><td>{bt}</td><td>{count}</td></tr>")
|
|
1081
|
+
html_parts.append("</table>")
|
|
1082
|
+
|
|
1083
|
+
# Non-standard residual patterns
|
|
1084
|
+
nonstandard_residuals = [
|
|
1085
|
+
b
|
|
1086
|
+
for b in self.detected_blocks
|
|
1087
|
+
if b.block_type in ("ResidualConcat", "ResidualGate", "ResidualSub")
|
|
1088
|
+
]
|
|
1089
|
+
if nonstandard_residuals:
|
|
1090
|
+
# Group by type
|
|
1091
|
+
by_type: dict[str, list] = {}
|
|
1092
|
+
for block in nonstandard_residuals:
|
|
1093
|
+
if block.block_type not in by_type:
|
|
1094
|
+
by_type[block.block_type] = []
|
|
1095
|
+
by_type[block.block_type].append(block)
|
|
1096
|
+
|
|
1097
|
+
type_labels = {
|
|
1098
|
+
"ResidualConcat": "Concat-based (DenseNet-style)",
|
|
1099
|
+
"ResidualGate": "Gated skip (Highway/attention)",
|
|
1100
|
+
"ResidualSub": "Subtraction-based",
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
html_parts.append('<div class="nonstandard-residuals">')
|
|
1104
|
+
html_parts.append("<h3>Non-Standard Skip Connections</h3>")
|
|
1105
|
+
html_parts.append(
|
|
1106
|
+
f"<p>This model uses {len(nonstandard_residuals)} non-standard skip connection(s):</p>"
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# Create collapsible section for each type
|
|
1110
|
+
for block_type, blocks in by_type.items():
|
|
1111
|
+
label = type_labels.get(block_type, block_type)
|
|
1112
|
+
html_parts.append(f"<details><summary>{label} ({len(blocks)})</summary>")
|
|
1113
|
+
html_parts.append('<div class="skip-connections-grid">')
|
|
1114
|
+
for block in blocks:
|
|
1115
|
+
if block_type == "ResidualConcat":
|
|
1116
|
+
depth_diff = block.attributes.get("depth_diff", "?")
|
|
1117
|
+
html_parts.append(
|
|
1118
|
+
f'<div class="skip-item">{block.name} '
|
|
1119
|
+
f'<span class="skip-detail">depth: {depth_diff}</span></div>'
|
|
1120
|
+
)
|
|
1121
|
+
else:
|
|
1122
|
+
html_parts.append(f'<div class="skip-item">{block.name}</div>')
|
|
1123
|
+
html_parts.append("</div></details>")
|
|
1124
|
+
html_parts.append("</div>")
|
|
1125
|
+
|
|
1126
|
+
html_parts.append("</section>")
|
|
1127
|
+
|
|
1128
|
+
# Dataset Info
|
|
1129
|
+
if self.dataset_info:
|
|
1130
|
+
html_parts.append('<section class="dataset-info">')
|
|
1131
|
+
html_parts.append("<h2>Dataset Info</h2>")
|
|
1132
|
+
if self.dataset_info.task:
|
|
1133
|
+
html_parts.append(f"<p><strong>Task:</strong> {self.dataset_info.task}</p>")
|
|
1134
|
+
if self.dataset_info.num_classes:
|
|
1135
|
+
html_parts.append(
|
|
1136
|
+
f"<p><strong>Number of Classes:</strong> {self.dataset_info.num_classes}</p>"
|
|
1137
|
+
)
|
|
1138
|
+
if self.dataset_info.class_names:
|
|
1139
|
+
num_classes = len(self.dataset_info.class_names)
|
|
1140
|
+
html_parts.append(
|
|
1141
|
+
f'<details class="class-names-details">'
|
|
1142
|
+
f"<summary>Class Names ({num_classes} classes) - click to expand</summary>"
|
|
1143
|
+
)
|
|
1144
|
+
html_parts.append('<div class="class-grid">')
|
|
1145
|
+
for idx, name in enumerate(self.dataset_info.class_names):
|
|
1146
|
+
html_parts.append(f'<div class="class-item"><code>{idx}</code> {name}</div>')
|
|
1147
|
+
html_parts.append("</div></details>")
|
|
1148
|
+
if self.dataset_info.source:
|
|
1149
|
+
html_parts.append(
|
|
1150
|
+
f'<p class="metadata-source"><em>Source: {self.dataset_info.source}</em></p>'
|
|
1151
|
+
)
|
|
1152
|
+
html_parts.append("</section>")
|
|
1153
|
+
|
|
1154
|
+
# Hardware Estimates
|
|
1155
|
+
if self.hardware_estimates and self.hardware_profile:
|
|
1156
|
+
hw = self.hardware_estimates
|
|
1157
|
+
html_parts.append('<section class="hardware">')
|
|
1158
|
+
html_parts.append("<h2>Hardware Estimates</h2>")
|
|
1159
|
+
html_parts.append(f'<p class="device-name">{hw.device}</p>')
|
|
1160
|
+
html_parts.append(
|
|
1161
|
+
f'<p class="precision-info">Precision: {hw.precision} | Batch Size: {hw.batch_size}</p>'
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
html_parts.append(
|
|
1165
|
+
"""
|
|
1166
|
+
<table>
|
|
1167
|
+
<tr><th>Metric</th><th>Value</th></tr>
|
|
1168
|
+
"""
|
|
1169
|
+
)
|
|
1170
|
+
html_parts.append(
|
|
1171
|
+
f"<tr><td>VRAM Required</td><td>{self._format_bytes(hw.vram_required_bytes)}</td></tr>"
|
|
1172
|
+
)
|
|
1173
|
+
html_parts.append(
|
|
1174
|
+
f"<tr><td>Fits in VRAM</td><td>{'Yes' if hw.fits_in_vram else 'No'}</td></tr>"
|
|
1175
|
+
)
|
|
1176
|
+
if hw.fits_in_vram:
|
|
1177
|
+
html_parts.append(
|
|
1178
|
+
f"<tr><td>Theoretical Latency</td><td>{hw.theoretical_latency_ms:.4f} ms</td></tr>"
|
|
1179
|
+
)
|
|
1180
|
+
html_parts.append(f"<tr><td>Bottleneck</td><td>{hw.bottleneck}</td></tr>")
|
|
1181
|
+
html_parts.append(
|
|
1182
|
+
f"<tr><td>Compute Utilization</td><td>{hw.compute_utilization_estimate:.0%}</td></tr>"
|
|
1183
|
+
)
|
|
1184
|
+
html_parts.append(
|
|
1185
|
+
f"<tr><td>GPU Saturation</td><td>{hw.gpu_saturation:.2e} ({hw.gpu_saturation * 100:.4f}%)</td></tr>"
|
|
1186
|
+
)
|
|
1187
|
+
html_parts.append("</table>")
|
|
1188
|
+
|
|
1189
|
+
# Device specs
|
|
1190
|
+
html_parts.append("<h3>Device Specifications</h3>")
|
|
1191
|
+
html_parts.append("<ul>")
|
|
1192
|
+
html_parts.append(
|
|
1193
|
+
f"<li><strong>VRAM:</strong> {self._format_bytes(self.hardware_profile.vram_bytes)}</li>"
|
|
1194
|
+
)
|
|
1195
|
+
html_parts.append(
|
|
1196
|
+
f"<li><strong>FP32 Peak:</strong> {self.hardware_profile.peak_fp32_tflops:.1f} TFLOPS</li>"
|
|
1197
|
+
)
|
|
1198
|
+
html_parts.append(
|
|
1199
|
+
f"<li><strong>FP16 Peak:</strong> {self.hardware_profile.peak_fp16_tflops:.1f} TFLOPS</li>"
|
|
1200
|
+
)
|
|
1201
|
+
if self.hardware_profile.tdp_watts:
|
|
1202
|
+
html_parts.append(
|
|
1203
|
+
f"<li><strong>TDP:</strong> {self.hardware_profile.tdp_watts}W</li>"
|
|
1204
|
+
)
|
|
1205
|
+
html_parts.append("</ul></section>")
|
|
1206
|
+
|
|
1207
|
+
# System Requirements
|
|
1208
|
+
if self.system_requirements:
|
|
1209
|
+
reqs = self.system_requirements
|
|
1210
|
+
html_parts.append('<section class="system-requirements">')
|
|
1211
|
+
html_parts.append("<h2>System Requirements</h2>")
|
|
1212
|
+
html_parts.append("<table>")
|
|
1213
|
+
html_parts.append("<tr><th>Level</th><th>Device</th><th>VRAM</th></tr>")
|
|
1214
|
+
min_name = reqs.minimum_gpu.device if reqs.minimum_gpu else "N/A"
|
|
1215
|
+
rec_name = reqs.recommended_gpu.device if reqs.recommended_gpu else "N/A"
|
|
1216
|
+
opt_name = reqs.optimal_gpu.device if reqs.optimal_gpu else "N/A"
|
|
1217
|
+
min_vram = f"{reqs.minimum_vram_gb} GB" if reqs.minimum_vram_gb is not None else "-"
|
|
1218
|
+
rec_vram = (
|
|
1219
|
+
f"{reqs.recommended_vram_gb} GB" if reqs.recommended_vram_gb is not None else "-"
|
|
1220
|
+
)
|
|
1221
|
+
html_parts.append(f"<tr><td>Minimum</td><td>{min_name}</td><td>{min_vram}</td></tr>")
|
|
1222
|
+
html_parts.append(
|
|
1223
|
+
f"<tr><td>Recommended</td><td>{rec_name}</td><td>{rec_vram}</td></tr>"
|
|
1224
|
+
)
|
|
1225
|
+
html_parts.append(f"<tr><td>Optimal</td><td>{opt_name}</td><td>-</td></tr>")
|
|
1226
|
+
html_parts.append("</table></section>")
|
|
1227
|
+
|
|
1228
|
+
# Batch Size Sweep
|
|
1229
|
+
if self.batch_size_sweep:
|
|
1230
|
+
batch_sweep = self.batch_size_sweep
|
|
1231
|
+
html_parts.append('<section class="batch-scaling">')
|
|
1232
|
+
html_parts.append("<h2>Batch Size Scaling</h2>")
|
|
1233
|
+
html_parts.append(
|
|
1234
|
+
f"<p><strong>Optimal Batch Size:</strong> {batch_sweep.optimal_batch_size}</p>"
|
|
1235
|
+
)
|
|
1236
|
+
html_parts.append("<table>")
|
|
1237
|
+
html_parts.append(
|
|
1238
|
+
"<tr><th>Batch Size</th><th>Latency (ms)</th><th>Throughput (inf/s)</th><th>VRAM (GB)</th></tr>"
|
|
1239
|
+
)
|
|
1240
|
+
for i, bs in enumerate(batch_sweep.batch_sizes):
|
|
1241
|
+
html_parts.append(
|
|
1242
|
+
f"<tr><td>{bs}</td><td>{batch_sweep.latencies[i]:.2f}</td><td>{batch_sweep.throughputs[i]:.1f}</td><td>{batch_sweep.vram_usage_gb[i]:.2f}</td></tr>"
|
|
1243
|
+
)
|
|
1244
|
+
html_parts.append("</table></section>")
|
|
1245
|
+
|
|
1246
|
+
# Resolution Sweep (Story 6.8)
|
|
1247
|
+
if self.resolution_sweep:
|
|
1248
|
+
res_sweep = self.resolution_sweep
|
|
1249
|
+
html_parts.append('<section class="resolution-scaling">')
|
|
1250
|
+
html_parts.append("<h2>Resolution Scaling</h2>")
|
|
1251
|
+
html_parts.append(
|
|
1252
|
+
f"<p><strong>Max Resolution:</strong> {res_sweep.max_resolution} | "
|
|
1253
|
+
f"<strong>Optimal:</strong> {res_sweep.optimal_resolution}</p>"
|
|
1254
|
+
)
|
|
1255
|
+
html_parts.append("<table>")
|
|
1256
|
+
html_parts.append(
|
|
1257
|
+
"<tr><th>Resolution</th><th>FLOPs</th><th>Memory (GB)</th>"
|
|
1258
|
+
"<th>Latency (ms)</th><th>Throughput</th><th>VRAM (GB)</th></tr>"
|
|
1259
|
+
)
|
|
1260
|
+
for i, res in enumerate(res_sweep.resolutions):
|
|
1261
|
+
flops_str = self._format_number(res_sweep.flops[i])
|
|
1262
|
+
lat_str = (
|
|
1263
|
+
f"{res_sweep.latencies[i]:.2f}"
|
|
1264
|
+
if res_sweep.latencies[i] != float("inf")
|
|
1265
|
+
else "OOM"
|
|
1266
|
+
)
|
|
1267
|
+
tput_str = (
|
|
1268
|
+
f"{res_sweep.throughputs[i]:.1f}" if res_sweep.throughputs[i] > 0 else "-"
|
|
1269
|
+
)
|
|
1270
|
+
html_parts.append(
|
|
1271
|
+
f"<tr><td>{res}</td><td>{flops_str}</td><td>{res_sweep.memory_gb[i]:.2f}</td>"
|
|
1272
|
+
f"<td>{lat_str}</td><td>{tput_str}</td><td>{res_sweep.vram_usage_gb[i]:.2f}</td></tr>"
|
|
1273
|
+
)
|
|
1274
|
+
html_parts.append("</table></section>")
|
|
1275
|
+
|
|
1276
|
+
# Risk Signals
|
|
1277
|
+
if self.risk_signals:
|
|
1278
|
+
html_parts.append('<section class="risks">')
|
|
1279
|
+
html_parts.append("<h2>Risk Signals</h2>")
|
|
1280
|
+
for risk in self.risk_signals:
|
|
1281
|
+
severity_class = {
|
|
1282
|
+
"info": "info",
|
|
1283
|
+
"warning": "warning",
|
|
1284
|
+
"high": "high",
|
|
1285
|
+
}.get(risk.severity, "info")
|
|
1286
|
+
html_parts.append(
|
|
1287
|
+
f"""
|
|
1288
|
+
<div class="risk-card {severity_class}">
|
|
1289
|
+
<div class="risk-header">
|
|
1290
|
+
<span class="severity">{risk.severity.upper()}</span>
|
|
1291
|
+
<span class="risk-id">{risk.id}</span>
|
|
1292
|
+
</div>
|
|
1293
|
+
<p>{risk.description}</p>
|
|
1294
|
+
"""
|
|
1295
|
+
)
|
|
1296
|
+
if risk.recommendation:
|
|
1297
|
+
html_parts.append(
|
|
1298
|
+
f'<p class="recommendation"><strong>Recommendation:</strong> {risk.recommendation}</p>'
|
|
1299
|
+
)
|
|
1300
|
+
html_parts.append("</div>")
|
|
1301
|
+
html_parts.append("</section>")
|
|
1302
|
+
|
|
1303
|
+
html_parts.append("</div></body></html>")
|
|
1304
|
+
return "".join(html_parts)
|
|
1305
|
+
|
|
1306
|
+
def _html_head(self, title: str) -> str:
|
|
1307
|
+
"""Generate HTML head with embedded CSS."""
|
|
1308
|
+
return f"""<!DOCTYPE html>
|
|
1309
|
+
<html lang="en">
|
|
1310
|
+
<head>
|
|
1311
|
+
<meta charset="UTF-8">
|
|
1312
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1313
|
+
<title>{title} - HaoLine Report</title>
|
|
1314
|
+
<style>
|
|
1315
|
+
:root {{
|
|
1316
|
+
--bg-primary: #0d1117;
|
|
1317
|
+
--bg-secondary: #161b22;
|
|
1318
|
+
--bg-card: #21262d;
|
|
1319
|
+
--text-primary: #e6edf3;
|
|
1320
|
+
--text-secondary: #8b949e;
|
|
1321
|
+
--accent-cyan: #00d4ff;
|
|
1322
|
+
--accent-coral: #ff6b6b;
|
|
1323
|
+
--accent-green: #3fb950;
|
|
1324
|
+
--accent-yellow: #d29922;
|
|
1325
|
+
--border: #30363d;
|
|
1326
|
+
}}
|
|
1327
|
+
|
|
1328
|
+
* {{
|
|
1329
|
+
margin: 0;
|
|
1330
|
+
padding: 0;
|
|
1331
|
+
box-sizing: border-box;
|
|
1332
|
+
}}
|
|
1333
|
+
|
|
1334
|
+
body {{
|
|
1335
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
1336
|
+
background: var(--bg-primary);
|
|
1337
|
+
color: var(--text-primary);
|
|
1338
|
+
line-height: 1.6;
|
|
1339
|
+
}}
|
|
1340
|
+
|
|
1341
|
+
.container {{
|
|
1342
|
+
max-width: 1200px;
|
|
1343
|
+
margin: 0 auto;
|
|
1344
|
+
padding: 2rem;
|
|
1345
|
+
}}
|
|
1346
|
+
|
|
1347
|
+
header {{
|
|
1348
|
+
text-align: center;
|
|
1349
|
+
margin-bottom: 3rem;
|
|
1350
|
+
padding-bottom: 2rem;
|
|
1351
|
+
border-bottom: 1px solid var(--border);
|
|
1352
|
+
}}
|
|
1353
|
+
|
|
1354
|
+
header h1 {{
|
|
1355
|
+
font-size: 2.5rem;
|
|
1356
|
+
color: var(--accent-cyan);
|
|
1357
|
+
margin-bottom: 0.5rem;
|
|
1358
|
+
}}
|
|
1359
|
+
|
|
1360
|
+
header .subtitle {{
|
|
1361
|
+
font-size: 1.2rem;
|
|
1362
|
+
color: var(--text-secondary);
|
|
1363
|
+
}}
|
|
1364
|
+
|
|
1365
|
+
header .timestamp {{
|
|
1366
|
+
font-size: 0.85rem;
|
|
1367
|
+
color: var(--text-secondary);
|
|
1368
|
+
margin-top: 0.5rem;
|
|
1369
|
+
}}
|
|
1370
|
+
|
|
1371
|
+
section {{
|
|
1372
|
+
margin-bottom: 3rem;
|
|
1373
|
+
}}
|
|
1374
|
+
|
|
1375
|
+
h2 {{
|
|
1376
|
+
font-size: 1.5rem;
|
|
1377
|
+
color: var(--accent-cyan);
|
|
1378
|
+
margin-bottom: 1.5rem;
|
|
1379
|
+
padding-bottom: 0.5rem;
|
|
1380
|
+
border-bottom: 2px solid var(--accent-cyan);
|
|
1381
|
+
}}
|
|
1382
|
+
|
|
1383
|
+
h3 {{
|
|
1384
|
+
font-size: 1.2rem;
|
|
1385
|
+
color: var(--text-primary);
|
|
1386
|
+
margin: 1.5rem 0 1rem;
|
|
1387
|
+
}}
|
|
1388
|
+
|
|
1389
|
+
h4 {{
|
|
1390
|
+
font-size: 1rem;
|
|
1391
|
+
color: var(--text-secondary);
|
|
1392
|
+
margin: 1rem 0 0.5rem;
|
|
1393
|
+
}}
|
|
1394
|
+
|
|
1395
|
+
/* Executive Summary */
|
|
1396
|
+
.executive-summary {{
|
|
1397
|
+
background: linear-gradient(135deg, #1a2a3a 0%, #0d1a26 100%);
|
|
1398
|
+
padding: 2rem;
|
|
1399
|
+
border-radius: 12px;
|
|
1400
|
+
border: 2px solid var(--accent-cyan);
|
|
1401
|
+
margin: 2rem 0;
|
|
1402
|
+
box-shadow: 0 4px 20px rgba(0, 200, 255, 0.15);
|
|
1403
|
+
}}
|
|
1404
|
+
|
|
1405
|
+
.executive-summary .tldr {{
|
|
1406
|
+
font-size: 1.1rem;
|
|
1407
|
+
color: var(--accent-cyan);
|
|
1408
|
+
margin-bottom: 1rem;
|
|
1409
|
+
}}
|
|
1410
|
+
|
|
1411
|
+
.executive-summary .llm-credit {{
|
|
1412
|
+
font-size: 0.8rem;
|
|
1413
|
+
color: var(--text-secondary);
|
|
1414
|
+
margin-top: 1rem;
|
|
1415
|
+
font-style: italic;
|
|
1416
|
+
}}
|
|
1417
|
+
|
|
1418
|
+
/* Metrics Cards */
|
|
1419
|
+
.metrics-cards {{
|
|
1420
|
+
display: grid;
|
|
1421
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
1422
|
+
gap: 1.5rem;
|
|
1423
|
+
margin-bottom: 2rem;
|
|
1424
|
+
}}
|
|
1425
|
+
|
|
1426
|
+
.card {{
|
|
1427
|
+
background: var(--bg-card);
|
|
1428
|
+
padding: 1.5rem;
|
|
1429
|
+
border-radius: 12px;
|
|
1430
|
+
text-align: center;
|
|
1431
|
+
border: 1px solid var(--border);
|
|
1432
|
+
transition: transform 0.2s, border-color 0.2s;
|
|
1433
|
+
}}
|
|
1434
|
+
|
|
1435
|
+
.card:hover {{
|
|
1436
|
+
transform: translateY(-2px);
|
|
1437
|
+
border-color: var(--accent-cyan);
|
|
1438
|
+
}}
|
|
1439
|
+
|
|
1440
|
+
.card-value {{
|
|
1441
|
+
font-size: 2rem;
|
|
1442
|
+
font-weight: bold;
|
|
1443
|
+
color: var(--accent-cyan);
|
|
1444
|
+
}}
|
|
1445
|
+
|
|
1446
|
+
.card-label {{
|
|
1447
|
+
font-size: 0.9rem;
|
|
1448
|
+
color: var(--text-secondary);
|
|
1449
|
+
margin-top: 0.5rem;
|
|
1450
|
+
}}
|
|
1451
|
+
|
|
1452
|
+
/* Visualizations */
|
|
1453
|
+
.chart-grid {{
|
|
1454
|
+
display: grid;
|
|
1455
|
+
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
|
1456
|
+
gap: 1.5rem;
|
|
1457
|
+
}}
|
|
1458
|
+
|
|
1459
|
+
.chart-container {{
|
|
1460
|
+
background: var(--bg-card);
|
|
1461
|
+
padding: 1rem;
|
|
1462
|
+
border-radius: 12px;
|
|
1463
|
+
}}
|
|
1464
|
+
|
|
1465
|
+
/* Section descriptions */
|
|
1466
|
+
.section-desc {{
|
|
1467
|
+
font-size: 0.875rem;
|
|
1468
|
+
color: var(--text-secondary);
|
|
1469
|
+
margin-bottom: 1rem;
|
|
1470
|
+
}}
|
|
1471
|
+
|
|
1472
|
+
/* Interactive Graph Section (Task 5.7.8) */
|
|
1473
|
+
.graph-section {{
|
|
1474
|
+
margin-bottom: 3rem;
|
|
1475
|
+
}}
|
|
1476
|
+
|
|
1477
|
+
.graph-container {{
|
|
1478
|
+
background: var(--bg-card);
|
|
1479
|
+
border: 1px solid var(--border);
|
|
1480
|
+
border-radius: 12px;
|
|
1481
|
+
height: 600px;
|
|
1482
|
+
overflow: hidden;
|
|
1483
|
+
position: relative;
|
|
1484
|
+
}}
|
|
1485
|
+
|
|
1486
|
+
.graph-container iframe {{
|
|
1487
|
+
width: 100%;
|
|
1488
|
+
height: 100%;
|
|
1489
|
+
border: none;
|
|
1490
|
+
}}
|
|
1491
|
+
|
|
1492
|
+
/* Layer Summary Section (Story 5.8) */
|
|
1493
|
+
.layer-summary {{
|
|
1494
|
+
margin-bottom: 3rem;
|
|
1495
|
+
border: 1px solid var(--border);
|
|
1496
|
+
}}
|
|
1497
|
+
|
|
1498
|
+
.chart-container img {{
|
|
1499
|
+
width: 100%;
|
|
1500
|
+
height: auto;
|
|
1501
|
+
border-radius: 8px;
|
|
1502
|
+
}}
|
|
1503
|
+
|
|
1504
|
+
/* Tables */
|
|
1505
|
+
table {{
|
|
1506
|
+
width: 100%;
|
|
1507
|
+
border-collapse: collapse;
|
|
1508
|
+
margin: 1rem 0;
|
|
1509
|
+
background: var(--bg-card);
|
|
1510
|
+
border-radius: 8px;
|
|
1511
|
+
overflow: hidden;
|
|
1512
|
+
}}
|
|
1513
|
+
|
|
1514
|
+
th, td {{
|
|
1515
|
+
padding: 0.75rem 1rem;
|
|
1516
|
+
text-align: left;
|
|
1517
|
+
border-bottom: 1px solid var(--border);
|
|
1518
|
+
}}
|
|
1519
|
+
|
|
1520
|
+
th {{
|
|
1521
|
+
background: var(--bg-secondary);
|
|
1522
|
+
color: var(--accent-cyan);
|
|
1523
|
+
font-weight: 600;
|
|
1524
|
+
}}
|
|
1525
|
+
|
|
1526
|
+
tr:last-child td {{
|
|
1527
|
+
border-bottom: none;
|
|
1528
|
+
}}
|
|
1529
|
+
|
|
1530
|
+
tr:hover {{
|
|
1531
|
+
background: var(--bg-secondary);
|
|
1532
|
+
}}
|
|
1533
|
+
|
|
1534
|
+
/* Lists */
|
|
1535
|
+
ul {{
|
|
1536
|
+
list-style: none;
|
|
1537
|
+
padding-left: 0;
|
|
1538
|
+
}}
|
|
1539
|
+
|
|
1540
|
+
ul li {{
|
|
1541
|
+
padding: 0.5rem 0;
|
|
1542
|
+
padding-left: 1.5rem;
|
|
1543
|
+
position: relative;
|
|
1544
|
+
}}
|
|
1545
|
+
|
|
1546
|
+
ul li::before {{
|
|
1547
|
+
content: "";
|
|
1548
|
+
position: absolute;
|
|
1549
|
+
left: 0;
|
|
1550
|
+
top: 50%;
|
|
1551
|
+
transform: translateY(-50%);
|
|
1552
|
+
width: 6px;
|
|
1553
|
+
height: 6px;
|
|
1554
|
+
background: var(--accent-cyan);
|
|
1555
|
+
border-radius: 50%;
|
|
1556
|
+
}}
|
|
1557
|
+
|
|
1558
|
+
code {{
|
|
1559
|
+
background: var(--bg-secondary);
|
|
1560
|
+
padding: 0.2rem 0.5rem;
|
|
1561
|
+
border-radius: 4px;
|
|
1562
|
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
|
1563
|
+
font-size: 0.9em;
|
|
1564
|
+
color: var(--accent-coral);
|
|
1565
|
+
}}
|
|
1566
|
+
|
|
1567
|
+
/* Collapsible Details */
|
|
1568
|
+
details {{
|
|
1569
|
+
background: var(--bg-card);
|
|
1570
|
+
border-radius: 8px;
|
|
1571
|
+
margin: 1rem 0;
|
|
1572
|
+
border: 1px solid var(--border);
|
|
1573
|
+
}}
|
|
1574
|
+
|
|
1575
|
+
details summary {{
|
|
1576
|
+
padding: 1rem;
|
|
1577
|
+
cursor: pointer;
|
|
1578
|
+
font-weight: 600;
|
|
1579
|
+
color: var(--accent-cyan);
|
|
1580
|
+
user-select: none;
|
|
1581
|
+
transition: background 0.2s;
|
|
1582
|
+
}}
|
|
1583
|
+
|
|
1584
|
+
details summary:hover {{
|
|
1585
|
+
background: var(--bg-secondary);
|
|
1586
|
+
}}
|
|
1587
|
+
|
|
1588
|
+
details[open] summary {{
|
|
1589
|
+
border-bottom: 1px solid var(--border);
|
|
1590
|
+
}}
|
|
1591
|
+
|
|
1592
|
+
details summary::marker {{
|
|
1593
|
+
color: var(--accent-cyan);
|
|
1594
|
+
}}
|
|
1595
|
+
|
|
1596
|
+
/* Class Names Grid */
|
|
1597
|
+
.class-grid {{
|
|
1598
|
+
display: grid;
|
|
1599
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
1600
|
+
gap: 0.5rem;
|
|
1601
|
+
padding: 1rem;
|
|
1602
|
+
max-height: 400px;
|
|
1603
|
+
overflow-y: auto;
|
|
1604
|
+
}}
|
|
1605
|
+
|
|
1606
|
+
.class-item {{
|
|
1607
|
+
padding: 0.4rem 0.6rem;
|
|
1608
|
+
background: var(--bg-secondary);
|
|
1609
|
+
border-radius: 4px;
|
|
1610
|
+
font-size: 0.85rem;
|
|
1611
|
+
white-space: nowrap;
|
|
1612
|
+
overflow: hidden;
|
|
1613
|
+
text-overflow: ellipsis;
|
|
1614
|
+
}}
|
|
1615
|
+
|
|
1616
|
+
.class-item code {{
|
|
1617
|
+
margin-right: 0.3rem;
|
|
1618
|
+
font-size: 0.75rem;
|
|
1619
|
+
}}
|
|
1620
|
+
|
|
1621
|
+
/* KV Cache Section */
|
|
1622
|
+
.kv-cache {{
|
|
1623
|
+
background: var(--bg-secondary);
|
|
1624
|
+
padding: 1.5rem;
|
|
1625
|
+
border-radius: 12px;
|
|
1626
|
+
border-left: 4px solid var(--accent-green);
|
|
1627
|
+
}}
|
|
1628
|
+
|
|
1629
|
+
.kv-cache h2 {{
|
|
1630
|
+
color: var(--accent-green);
|
|
1631
|
+
border-bottom-color: var(--accent-green);
|
|
1632
|
+
}}
|
|
1633
|
+
|
|
1634
|
+
/* Memory Breakdown Section */
|
|
1635
|
+
.memory-breakdown {{
|
|
1636
|
+
background: var(--bg-secondary);
|
|
1637
|
+
padding: 1.5rem;
|
|
1638
|
+
border-radius: 12px;
|
|
1639
|
+
}}
|
|
1640
|
+
|
|
1641
|
+
/* Architecture Section */
|
|
1642
|
+
.architecture .arch-type {{
|
|
1643
|
+
color: var(--accent-cyan);
|
|
1644
|
+
font-weight: bold;
|
|
1645
|
+
}}
|
|
1646
|
+
|
|
1647
|
+
.nonstandard-residuals {{
|
|
1648
|
+
background: var(--bg-card);
|
|
1649
|
+
padding: 1.5rem;
|
|
1650
|
+
border-radius: 8px;
|
|
1651
|
+
margin-top: 1rem;
|
|
1652
|
+
border-left: 4px solid var(--accent-yellow);
|
|
1653
|
+
}}
|
|
1654
|
+
|
|
1655
|
+
.nonstandard-residuals h3 {{
|
|
1656
|
+
color: var(--accent-yellow);
|
|
1657
|
+
margin-top: 0;
|
|
1658
|
+
}}
|
|
1659
|
+
|
|
1660
|
+
.nonstandard-residuals details {{
|
|
1661
|
+
margin: 0.5rem 0;
|
|
1662
|
+
background: var(--bg-primary);
|
|
1663
|
+
}}
|
|
1664
|
+
|
|
1665
|
+
.nonstandard-residuals summary {{
|
|
1666
|
+
padding: 0.75rem 1rem;
|
|
1667
|
+
color: var(--text-primary);
|
|
1668
|
+
}}
|
|
1669
|
+
|
|
1670
|
+
.skip-connections-grid {{
|
|
1671
|
+
display: grid;
|
|
1672
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
1673
|
+
gap: 0.5rem;
|
|
1674
|
+
padding: 1rem;
|
|
1675
|
+
max-height: 300px;
|
|
1676
|
+
overflow-y: auto;
|
|
1677
|
+
}}
|
|
1678
|
+
|
|
1679
|
+
.skip-item {{
|
|
1680
|
+
padding: 0.4rem 0.6rem;
|
|
1681
|
+
background: var(--bg-secondary);
|
|
1682
|
+
border-radius: 4px;
|
|
1683
|
+
font-size: 0.8rem;
|
|
1684
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
1685
|
+
}}
|
|
1686
|
+
|
|
1687
|
+
.skip-detail {{
|
|
1688
|
+
color: var(--text-secondary);
|
|
1689
|
+
font-size: 0.7rem;
|
|
1690
|
+
}}
|
|
1691
|
+
|
|
1692
|
+
/* Hardware Section */
|
|
1693
|
+
.hardware .device-name {{
|
|
1694
|
+
font-size: 1.2rem;
|
|
1695
|
+
color: var(--accent-cyan);
|
|
1696
|
+
margin-bottom: 0.5rem;
|
|
1697
|
+
}}
|
|
1698
|
+
|
|
1699
|
+
.hardware .precision-info {{
|
|
1700
|
+
color: var(--text-secondary);
|
|
1701
|
+
margin-bottom: 1rem;
|
|
1702
|
+
}}
|
|
1703
|
+
|
|
1704
|
+
/* Risk Signals */
|
|
1705
|
+
.risk-card {{
|
|
1706
|
+
background: var(--bg-card);
|
|
1707
|
+
padding: 1.5rem;
|
|
1708
|
+
border-radius: 8px;
|
|
1709
|
+
margin-bottom: 1rem;
|
|
1710
|
+
border-left: 4px solid var(--text-secondary);
|
|
1711
|
+
}}
|
|
1712
|
+
|
|
1713
|
+
.risk-card.info {{
|
|
1714
|
+
border-left-color: var(--accent-cyan);
|
|
1715
|
+
}}
|
|
1716
|
+
|
|
1717
|
+
.risk-card.warning {{
|
|
1718
|
+
border-left-color: var(--accent-yellow);
|
|
1719
|
+
}}
|
|
1720
|
+
|
|
1721
|
+
.risk-card.high {{
|
|
1722
|
+
border-left-color: var(--accent-coral);
|
|
1723
|
+
}}
|
|
1724
|
+
|
|
1725
|
+
.risk-header {{
|
|
1726
|
+
display: flex;
|
|
1727
|
+
gap: 1rem;
|
|
1728
|
+
align-items: center;
|
|
1729
|
+
margin-bottom: 0.75rem;
|
|
1730
|
+
}}
|
|
1731
|
+
|
|
1732
|
+
.severity {{
|
|
1733
|
+
font-size: 0.75rem;
|
|
1734
|
+
font-weight: bold;
|
|
1735
|
+
padding: 0.25rem 0.5rem;
|
|
1736
|
+
border-radius: 4px;
|
|
1737
|
+
background: var(--bg-secondary);
|
|
1738
|
+
}}
|
|
1739
|
+
|
|
1740
|
+
.risk-card.info .severity {{ color: var(--accent-cyan); }}
|
|
1741
|
+
.risk-card.warning .severity {{ color: var(--accent-yellow); }}
|
|
1742
|
+
.risk-card.high .severity {{ color: var(--accent-coral); }}
|
|
1743
|
+
|
|
1744
|
+
.risk-id {{
|
|
1745
|
+
font-weight: 600;
|
|
1746
|
+
}}
|
|
1747
|
+
|
|
1748
|
+
.recommendation {{
|
|
1749
|
+
margin-top: 0.75rem;
|
|
1750
|
+
padding-top: 0.75rem;
|
|
1751
|
+
border-top: 1px solid var(--border);
|
|
1752
|
+
font-size: 0.9rem;
|
|
1753
|
+
}}
|
|
1754
|
+
|
|
1755
|
+
/* Responsive */
|
|
1756
|
+
@media (max-width: 768px) {{
|
|
1757
|
+
.container {{
|
|
1758
|
+
padding: 1rem;
|
|
1759
|
+
}}
|
|
1760
|
+
|
|
1761
|
+
header h1 {{
|
|
1762
|
+
font-size: 1.8rem;
|
|
1763
|
+
}}
|
|
1764
|
+
|
|
1765
|
+
.chart-grid {{
|
|
1766
|
+
grid-template-columns: 1fr;
|
|
1767
|
+
}}
|
|
1768
|
+
|
|
1769
|
+
.metrics-cards {{
|
|
1770
|
+
grid-template-columns: repeat(2, 1fr);
|
|
1771
|
+
}}
|
|
1772
|
+
}}
|
|
1773
|
+
</style>
|
|
1774
|
+
</head>
|
|
1775
|
+
"""
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
class ModelInspector:
|
|
1779
|
+
"""
|
|
1780
|
+
Main orchestrator for ONNX model analysis.
|
|
1781
|
+
|
|
1782
|
+
Coordinates the loader, metrics engine, pattern analyzer, and risk analyzer
|
|
1783
|
+
to produce a comprehensive InspectionReport.
|
|
1784
|
+
|
|
1785
|
+
Example:
|
|
1786
|
+
inspector = ModelInspector()
|
|
1787
|
+
report = inspector.inspect("model.onnx")
|
|
1788
|
+
report.to_json()
|
|
1789
|
+
"""
|
|
1790
|
+
|
|
1791
|
+
def __init__(
|
|
1792
|
+
self,
|
|
1793
|
+
loader: ONNXGraphLoader | None = None,
|
|
1794
|
+
metrics: MetricsEngine | None = None,
|
|
1795
|
+
patterns: PatternAnalyzer | None = None,
|
|
1796
|
+
risks: RiskAnalyzer | None = None,
|
|
1797
|
+
logger: logging.Logger | None = None,
|
|
1798
|
+
):
|
|
1799
|
+
"""
|
|
1800
|
+
Initialize ModelInspector with optional component overrides.
|
|
1801
|
+
|
|
1802
|
+
Args:
|
|
1803
|
+
loader: Custom graph loader. If None, uses default ONNXGraphLoader.
|
|
1804
|
+
metrics: Custom metrics engine. If None, uses default MetricsEngine.
|
|
1805
|
+
patterns: Custom pattern analyzer. If None, uses default PatternAnalyzer.
|
|
1806
|
+
risks: Custom risk analyzer. If None, uses default RiskAnalyzer.
|
|
1807
|
+
logger: Logger for diagnostic output.
|
|
1808
|
+
"""
|
|
1809
|
+
# Defer imports to avoid circular dependencies
|
|
1810
|
+
from .analyzer import MetricsEngine, ONNXGraphLoader
|
|
1811
|
+
from .patterns import PatternAnalyzer
|
|
1812
|
+
from .risks import RiskAnalyzer
|
|
1813
|
+
|
|
1814
|
+
self.loader = loader or ONNXGraphLoader()
|
|
1815
|
+
self.metrics = metrics or MetricsEngine()
|
|
1816
|
+
self.patterns = patterns or PatternAnalyzer()
|
|
1817
|
+
self.risks = risks or RiskAnalyzer()
|
|
1818
|
+
self.logger = logger or logging.getLogger("haoline")
|
|
1819
|
+
|
|
1820
|
+
def inspect(self, model_path: str | pathlib.Path) -> InspectionReport:
|
|
1821
|
+
"""
|
|
1822
|
+
Run full analysis pipeline on an ONNX model.
|
|
1823
|
+
|
|
1824
|
+
Args:
|
|
1825
|
+
model_path: Path to the ONNX model file.
|
|
1826
|
+
|
|
1827
|
+
Returns:
|
|
1828
|
+
InspectionReport with all analysis results.
|
|
1829
|
+
"""
|
|
1830
|
+
model_path = pathlib.Path(model_path)
|
|
1831
|
+
self.logger.info(f"Inspecting model: {model_path}")
|
|
1832
|
+
|
|
1833
|
+
# Load model
|
|
1834
|
+
model, graph_info = self.loader.load(model_path)
|
|
1835
|
+
|
|
1836
|
+
# Extract metadata
|
|
1837
|
+
metadata = self._extract_metadata(model, model_path)
|
|
1838
|
+
|
|
1839
|
+
# Build graph summary
|
|
1840
|
+
graph_summary = self._build_graph_summary(graph_info)
|
|
1841
|
+
|
|
1842
|
+
# Compute metrics
|
|
1843
|
+
self.logger.debug("Computing metrics...")
|
|
1844
|
+
param_counts = self.metrics.count_parameters(graph_info)
|
|
1845
|
+
flop_counts = self.metrics.estimate_flops(graph_info)
|
|
1846
|
+
memory_estimates = self.metrics.estimate_memory(graph_info)
|
|
1847
|
+
|
|
1848
|
+
# Detect patterns
|
|
1849
|
+
self.logger.debug("Detecting patterns...")
|
|
1850
|
+
detected_blocks = self.patterns.group_into_blocks(graph_info)
|
|
1851
|
+
architecture_type = self.patterns.classify_architecture(graph_info, detected_blocks)
|
|
1852
|
+
|
|
1853
|
+
# Analyze risks
|
|
1854
|
+
self.logger.debug("Analyzing risks...")
|
|
1855
|
+
risk_signals = self.risks.analyze(graph_info, detected_blocks)
|
|
1856
|
+
|
|
1857
|
+
# Try to infer num_classes from output shapes
|
|
1858
|
+
dataset_info = infer_num_classes_from_output(graph_info.output_shapes, architecture_type)
|
|
1859
|
+
if dataset_info:
|
|
1860
|
+
self.logger.debug(
|
|
1861
|
+
f"Inferred {dataset_info.task} task with {dataset_info.num_classes} classes from output shape"
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
# Load Universal IR representation (optional, for advanced analysis)
|
|
1865
|
+
universal_graph = None
|
|
1866
|
+
try:
|
|
1867
|
+
from .format_adapters import load_model
|
|
1868
|
+
|
|
1869
|
+
self.logger.debug("Loading Universal IR representation...")
|
|
1870
|
+
universal_graph = load_model(model_path)
|
|
1871
|
+
self.logger.debug(
|
|
1872
|
+
f"Universal IR loaded: {universal_graph.num_nodes} nodes, "
|
|
1873
|
+
f"{universal_graph.total_parameters:,} params"
|
|
1874
|
+
)
|
|
1875
|
+
except Exception as e:
|
|
1876
|
+
self.logger.debug(f"Universal IR loading skipped: {e}")
|
|
1877
|
+
|
|
1878
|
+
report = InspectionReport(
|
|
1879
|
+
metadata=metadata,
|
|
1880
|
+
graph_summary=graph_summary,
|
|
1881
|
+
param_counts=param_counts,
|
|
1882
|
+
flop_counts=flop_counts,
|
|
1883
|
+
memory_estimates=memory_estimates,
|
|
1884
|
+
detected_blocks=detected_blocks,
|
|
1885
|
+
architecture_type=architecture_type,
|
|
1886
|
+
risk_signals=risk_signals,
|
|
1887
|
+
dataset_info=dataset_info,
|
|
1888
|
+
universal_graph=universal_graph,
|
|
1889
|
+
)
|
|
1890
|
+
|
|
1891
|
+
self.logger.info(
|
|
1892
|
+
f"Inspection complete. Found {len(detected_blocks)} blocks, {len(risk_signals)} risks."
|
|
1893
|
+
)
|
|
1894
|
+
return report
|
|
1895
|
+
|
|
1896
|
+
def _extract_metadata(self, model, model_path: pathlib.Path) -> ModelMetadata:
|
|
1897
|
+
"""Extract metadata from ONNX ModelProto."""
|
|
1898
|
+
from .analyzer import get_opsets_imported
|
|
1899
|
+
|
|
1900
|
+
opsets = get_opsets_imported(model)
|
|
1901
|
+
|
|
1902
|
+
return ModelMetadata(
|
|
1903
|
+
path=str(model_path),
|
|
1904
|
+
ir_version=model.ir_version,
|
|
1905
|
+
producer_name=model.producer_name or "unknown",
|
|
1906
|
+
producer_version=model.producer_version or "",
|
|
1907
|
+
domain=model.domain or "",
|
|
1908
|
+
model_version=model.model_version,
|
|
1909
|
+
doc_string=model.doc_string or "",
|
|
1910
|
+
opsets=opsets,
|
|
1911
|
+
)
|
|
1912
|
+
|
|
1913
|
+
def _build_graph_summary(self, graph_info) -> GraphSummary:
|
|
1914
|
+
"""Build summary statistics from GraphInfo."""
|
|
1915
|
+
return GraphSummary(
|
|
1916
|
+
num_nodes=graph_info.num_nodes,
|
|
1917
|
+
num_inputs=len(graph_info.inputs),
|
|
1918
|
+
num_outputs=len(graph_info.outputs),
|
|
1919
|
+
num_initializers=len(graph_info.initializers),
|
|
1920
|
+
input_shapes=graph_info.input_shapes,
|
|
1921
|
+
output_shapes=graph_info.output_shapes,
|
|
1922
|
+
op_type_counts=graph_info.op_type_counts,
|
|
1923
|
+
)
|