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/layer_summary.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
# Copyright (c) 2025 HaoLine Contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Per-Layer Summary Table for HaoLine.
|
|
6
|
+
|
|
7
|
+
Story 5.8: Creates sortable, filterable tables showing per-layer metrics
|
|
8
|
+
(params, FLOPs, latency estimate, memory).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .analyzer import FlopCounts, GraphInfo, MemoryEstimates, ParamCounts
|
|
23
|
+
from .report import InspectionReport
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LayerMetrics:
|
|
28
|
+
"""Metrics for a single layer/node."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
op_type: str
|
|
32
|
+
input_shapes: list[str] = field(default_factory=list)
|
|
33
|
+
output_shapes: list[str] = field(default_factory=list)
|
|
34
|
+
params: int = 0
|
|
35
|
+
flops: int = 0
|
|
36
|
+
memory_bytes: int = 0
|
|
37
|
+
latency_ms: float = 0.0 # Estimated
|
|
38
|
+
pct_params: float = 0.0
|
|
39
|
+
pct_flops: float = 0.0
|
|
40
|
+
depth: int = 0
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
"""Convert to dictionary for JSON export."""
|
|
44
|
+
return {
|
|
45
|
+
"name": self.name,
|
|
46
|
+
"op_type": self.op_type,
|
|
47
|
+
"input_shapes": self.input_shapes,
|
|
48
|
+
"output_shapes": self.output_shapes,
|
|
49
|
+
"params": self.params,
|
|
50
|
+
"flops": self.flops,
|
|
51
|
+
"memory_bytes": self.memory_bytes,
|
|
52
|
+
"latency_ms": self.latency_ms,
|
|
53
|
+
"pct_params": self.pct_params,
|
|
54
|
+
"pct_flops": self.pct_flops,
|
|
55
|
+
"depth": self.depth,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class LayerSummary:
|
|
61
|
+
"""Complete layer summary for a model."""
|
|
62
|
+
|
|
63
|
+
layers: list[LayerMetrics] = field(default_factory=list)
|
|
64
|
+
total_params: int = 0
|
|
65
|
+
total_flops: int = 0
|
|
66
|
+
total_memory: int = 0
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
"""Convert to dictionary."""
|
|
70
|
+
return {
|
|
71
|
+
"layers": [layer.to_dict() for layer in self.layers],
|
|
72
|
+
"total_params": self.total_params,
|
|
73
|
+
"total_flops": self.total_flops,
|
|
74
|
+
"total_memory": self.total_memory,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def to_json(self, indent: int = 2) -> str:
|
|
78
|
+
"""Export to JSON string."""
|
|
79
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
80
|
+
|
|
81
|
+
def to_csv(self) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Export to CSV format.
|
|
84
|
+
|
|
85
|
+
Task 5.8.4: Export table as CSV.
|
|
86
|
+
"""
|
|
87
|
+
output = io.StringIO()
|
|
88
|
+
writer = csv.writer(output)
|
|
89
|
+
|
|
90
|
+
# Header
|
|
91
|
+
writer.writerow(
|
|
92
|
+
[
|
|
93
|
+
"Layer Name",
|
|
94
|
+
"Op Type",
|
|
95
|
+
"Input Shape",
|
|
96
|
+
"Output Shape",
|
|
97
|
+
"Parameters",
|
|
98
|
+
"FLOPs",
|
|
99
|
+
"Memory (bytes)",
|
|
100
|
+
"Latency (ms)",
|
|
101
|
+
"% Params",
|
|
102
|
+
"% FLOPs",
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Data rows
|
|
107
|
+
for layer in self.layers:
|
|
108
|
+
writer.writerow(
|
|
109
|
+
[
|
|
110
|
+
layer.name,
|
|
111
|
+
layer.op_type,
|
|
112
|
+
"; ".join(layer.input_shapes),
|
|
113
|
+
"; ".join(layer.output_shapes),
|
|
114
|
+
layer.params,
|
|
115
|
+
layer.flops,
|
|
116
|
+
layer.memory_bytes,
|
|
117
|
+
f"{layer.latency_ms:.4f}",
|
|
118
|
+
f"{layer.pct_params:.2f}",
|
|
119
|
+
f"{layer.pct_flops:.2f}",
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return output.getvalue()
|
|
124
|
+
|
|
125
|
+
def save_csv(self, path: Path | str) -> None:
|
|
126
|
+
"""Save to CSV file."""
|
|
127
|
+
path = Path(path)
|
|
128
|
+
path.write_text(self.to_csv(), encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
def filter_by_op_type(self, op_types: list[str]) -> LayerSummary:
|
|
131
|
+
"""Return a new summary filtered by op type."""
|
|
132
|
+
filtered = [layer for layer in self.layers if layer.op_type in op_types]
|
|
133
|
+
return LayerSummary(
|
|
134
|
+
layers=filtered,
|
|
135
|
+
total_params=self.total_params,
|
|
136
|
+
total_flops=self.total_flops,
|
|
137
|
+
total_memory=self.total_memory,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def filter_by_threshold(
|
|
141
|
+
self,
|
|
142
|
+
min_params: int = 0,
|
|
143
|
+
min_flops: int = 0,
|
|
144
|
+
min_pct_params: float = 0.0,
|
|
145
|
+
min_pct_flops: float = 0.0,
|
|
146
|
+
) -> LayerSummary:
|
|
147
|
+
"""Return a new summary filtered by thresholds."""
|
|
148
|
+
filtered = [
|
|
149
|
+
layer
|
|
150
|
+
for layer in self.layers
|
|
151
|
+
if layer.params >= min_params
|
|
152
|
+
and layer.flops >= min_flops
|
|
153
|
+
and layer.pct_params >= min_pct_params
|
|
154
|
+
and layer.pct_flops >= min_pct_flops
|
|
155
|
+
]
|
|
156
|
+
return LayerSummary(
|
|
157
|
+
layers=filtered,
|
|
158
|
+
total_params=self.total_params,
|
|
159
|
+
total_flops=self.total_flops,
|
|
160
|
+
total_memory=self.total_memory,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def sort_by(self, key: str, descending: bool = True) -> LayerSummary:
|
|
164
|
+
"""
|
|
165
|
+
Return a new summary sorted by the specified key.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
key: One of 'name', 'op_type', 'params', 'flops', 'memory_bytes',
|
|
169
|
+
'latency_ms', 'pct_params', 'pct_flops', 'depth'
|
|
170
|
+
descending: Sort in descending order if True
|
|
171
|
+
"""
|
|
172
|
+
valid_keys = {
|
|
173
|
+
"name",
|
|
174
|
+
"op_type",
|
|
175
|
+
"params",
|
|
176
|
+
"flops",
|
|
177
|
+
"memory_bytes",
|
|
178
|
+
"latency_ms",
|
|
179
|
+
"pct_params",
|
|
180
|
+
"pct_flops",
|
|
181
|
+
"depth",
|
|
182
|
+
"input_shapes",
|
|
183
|
+
"output_shapes",
|
|
184
|
+
}
|
|
185
|
+
key_normalized = key.replace("-", "_")
|
|
186
|
+
if key_normalized not in valid_keys:
|
|
187
|
+
raise ValueError(f"Invalid sort key: {key}")
|
|
188
|
+
|
|
189
|
+
sorted_layers = sorted(
|
|
190
|
+
self.layers,
|
|
191
|
+
key=lambda layer: getattr(layer, key_normalized),
|
|
192
|
+
reverse=descending,
|
|
193
|
+
)
|
|
194
|
+
return LayerSummary(
|
|
195
|
+
layers=sorted_layers,
|
|
196
|
+
total_params=self.total_params,
|
|
197
|
+
total_flops=self.total_flops,
|
|
198
|
+
total_memory=self.total_memory,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def top_n(self, n: int, key: str = "flops") -> LayerSummary:
|
|
202
|
+
"""Get top N layers by the specified metric."""
|
|
203
|
+
sorted_summary = self.sort_by(key, descending=True)
|
|
204
|
+
return LayerSummary(
|
|
205
|
+
layers=sorted_summary.layers[:n],
|
|
206
|
+
total_params=self.total_params,
|
|
207
|
+
total_flops=self.total_flops,
|
|
208
|
+
total_memory=self.total_memory,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class LayerSummaryBuilder:
|
|
213
|
+
"""
|
|
214
|
+
Build per-layer summary from ONNX graph analysis.
|
|
215
|
+
|
|
216
|
+
Task 5.8.1: Create per-layer summary table.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, logger: logging.Logger | None = None):
|
|
220
|
+
self.logger = logger or logging.getLogger("haoline.layer_summary")
|
|
221
|
+
|
|
222
|
+
def build(
|
|
223
|
+
self,
|
|
224
|
+
graph_info: GraphInfo,
|
|
225
|
+
param_counts: ParamCounts | None = None,
|
|
226
|
+
flop_counts: FlopCounts | None = None,
|
|
227
|
+
memory_estimates: MemoryEstimates | None = None,
|
|
228
|
+
) -> LayerSummary:
|
|
229
|
+
"""
|
|
230
|
+
Build layer summary from graph analysis.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
graph_info: Parsed ONNX graph.
|
|
234
|
+
param_counts: Parameter counts from analysis.
|
|
235
|
+
flop_counts: FLOPs from analysis.
|
|
236
|
+
memory_estimates: Memory estimates from analysis.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
LayerSummary with per-layer metrics.
|
|
240
|
+
"""
|
|
241
|
+
layers = []
|
|
242
|
+
|
|
243
|
+
# Calculate totals for percentages
|
|
244
|
+
total_params = param_counts.total if param_counts else 0
|
|
245
|
+
total_flops = flop_counts.total if flop_counts else 0
|
|
246
|
+
total_memory = memory_estimates.model_size_bytes if memory_estimates else 0
|
|
247
|
+
|
|
248
|
+
# Build tensor shape map for input/output shapes
|
|
249
|
+
tensor_shapes: dict[str, str] = {}
|
|
250
|
+
for name, shape in graph_info.input_shapes.items():
|
|
251
|
+
tensor_shapes[name] = str(shape)
|
|
252
|
+
for name, shape in graph_info.output_shapes.items():
|
|
253
|
+
tensor_shapes[name] = str(shape)
|
|
254
|
+
|
|
255
|
+
# Process each node
|
|
256
|
+
for idx, node in enumerate(graph_info.nodes):
|
|
257
|
+
# Get metrics for this node
|
|
258
|
+
node_params = 0
|
|
259
|
+
node_flops = 0
|
|
260
|
+
|
|
261
|
+
if param_counts and param_counts.by_node:
|
|
262
|
+
node_params = int(param_counts.by_node.get(node.name, 0))
|
|
263
|
+
|
|
264
|
+
if flop_counts and flop_counts.by_node:
|
|
265
|
+
node_flops = flop_counts.by_node.get(node.name, 0)
|
|
266
|
+
|
|
267
|
+
# Get shapes
|
|
268
|
+
input_shapes = []
|
|
269
|
+
output_shapes = []
|
|
270
|
+
|
|
271
|
+
for inp in node.inputs:
|
|
272
|
+
if inp in tensor_shapes:
|
|
273
|
+
input_shapes.append(tensor_shapes[inp])
|
|
274
|
+
elif inp in graph_info.initializers:
|
|
275
|
+
init = graph_info.initializers[inp]
|
|
276
|
+
if hasattr(init, "dims"):
|
|
277
|
+
input_shapes.append(str(list(init.dims)))
|
|
278
|
+
|
|
279
|
+
for out in node.outputs:
|
|
280
|
+
if out in tensor_shapes:
|
|
281
|
+
output_shapes.append(tensor_shapes[out])
|
|
282
|
+
|
|
283
|
+
# Calculate percentages
|
|
284
|
+
pct_params = (node_params / total_params * 100) if total_params > 0 else 0.0
|
|
285
|
+
pct_flops = (node_flops / total_flops * 100) if total_flops > 0 else 0.0
|
|
286
|
+
|
|
287
|
+
# Estimate memory for this layer
|
|
288
|
+
node_memory = 0
|
|
289
|
+
if memory_estimates and memory_estimates.breakdown:
|
|
290
|
+
# Use weight memory by op type as approximation
|
|
291
|
+
op_weights = memory_estimates.breakdown.weights_by_op_type
|
|
292
|
+
if op_weights and node.op_type in op_weights:
|
|
293
|
+
# Distribute among nodes of same type
|
|
294
|
+
nodes_of_type = sum(1 for n in graph_info.nodes if n.op_type == node.op_type)
|
|
295
|
+
if nodes_of_type > 0:
|
|
296
|
+
node_memory = op_weights[node.op_type] // nodes_of_type
|
|
297
|
+
|
|
298
|
+
# Estimate latency (rough: assume linear with FLOPs)
|
|
299
|
+
# This is a placeholder - real latency requires profiling
|
|
300
|
+
latency_ms = node_flops / 1e9 * 0.01 if node_flops > 0 else 0.0
|
|
301
|
+
|
|
302
|
+
layer = LayerMetrics(
|
|
303
|
+
name=node.name,
|
|
304
|
+
op_type=node.op_type,
|
|
305
|
+
input_shapes=input_shapes,
|
|
306
|
+
output_shapes=output_shapes,
|
|
307
|
+
params=node_params,
|
|
308
|
+
flops=node_flops,
|
|
309
|
+
memory_bytes=node_memory,
|
|
310
|
+
latency_ms=latency_ms,
|
|
311
|
+
pct_params=pct_params,
|
|
312
|
+
pct_flops=pct_flops,
|
|
313
|
+
depth=idx,
|
|
314
|
+
)
|
|
315
|
+
layers.append(layer)
|
|
316
|
+
|
|
317
|
+
return LayerSummary(
|
|
318
|
+
layers=layers,
|
|
319
|
+
total_params=total_params,
|
|
320
|
+
total_flops=total_flops,
|
|
321
|
+
total_memory=total_memory,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def build_from_report(self, report: InspectionReport) -> LayerSummary:
|
|
325
|
+
"""Build layer summary from an inspection report."""
|
|
326
|
+
# We need the graph_info which isn't stored in the report
|
|
327
|
+
# This method requires re-loading the model
|
|
328
|
+
raise NotImplementedError(
|
|
329
|
+
"build_from_report requires model path. Use build() with graph_info instead."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def generate_html_table(summary: LayerSummary, include_js: bool = True) -> str:
|
|
334
|
+
"""
|
|
335
|
+
Generate HTML table with sortable columns.
|
|
336
|
+
|
|
337
|
+
Task 5.8.2: Add sortable/filterable table to HTML report.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
summary: Layer summary to render.
|
|
341
|
+
include_js: Include JavaScript for sorting/filtering.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
HTML string for the table.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def format_number(n: int | float) -> str:
|
|
348
|
+
if isinstance(n, float):
|
|
349
|
+
if n >= 1e9:
|
|
350
|
+
return f"{n / 1e9:.2f}B"
|
|
351
|
+
if n >= 1e6:
|
|
352
|
+
return f"{n / 1e6:.2f}M"
|
|
353
|
+
if n >= 1e3:
|
|
354
|
+
return f"{n / 1e3:.2f}K"
|
|
355
|
+
return f"{n:.2f}"
|
|
356
|
+
else:
|
|
357
|
+
if n >= 1e9:
|
|
358
|
+
return f"{n / 1e9:.2f}B"
|
|
359
|
+
if n >= 1e6:
|
|
360
|
+
return f"{n / 1e6:.2f}M"
|
|
361
|
+
if n >= 1e3:
|
|
362
|
+
return f"{n / 1e3:.2f}K"
|
|
363
|
+
return str(n)
|
|
364
|
+
|
|
365
|
+
def format_bytes(b: int) -> str:
|
|
366
|
+
if b >= 1e9:
|
|
367
|
+
return f"{b / 1e9:.2f} GB"
|
|
368
|
+
if b >= 1e6:
|
|
369
|
+
return f"{b / 1e6:.2f} MB"
|
|
370
|
+
if b >= 1e3:
|
|
371
|
+
return f"{b / 1e3:.2f} KB"
|
|
372
|
+
return f"{b} B"
|
|
373
|
+
|
|
374
|
+
html_parts = []
|
|
375
|
+
|
|
376
|
+
# CSS styles
|
|
377
|
+
html_parts.append(
|
|
378
|
+
"""
|
|
379
|
+
<style>
|
|
380
|
+
.layer-table-container {
|
|
381
|
+
margin: 20px 0;
|
|
382
|
+
overflow-x: auto;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.layer-controls {
|
|
386
|
+
display: flex;
|
|
387
|
+
gap: 16px;
|
|
388
|
+
margin-bottom: 12px;
|
|
389
|
+
flex-wrap: wrap;
|
|
390
|
+
align-items: center;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.layer-search {
|
|
394
|
+
padding: 8px 12px;
|
|
395
|
+
border: 1px solid var(--border, #30363d);
|
|
396
|
+
border-radius: 6px;
|
|
397
|
+
background: var(--bg-card, #21262d);
|
|
398
|
+
color: var(--text-primary, #e6edf3);
|
|
399
|
+
font-size: 0.875rem;
|
|
400
|
+
min-width: 200px;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.layer-filter {
|
|
404
|
+
padding: 8px 12px;
|
|
405
|
+
border: 1px solid var(--border, #30363d);
|
|
406
|
+
border-radius: 6px;
|
|
407
|
+
background: var(--bg-card, #21262d);
|
|
408
|
+
color: var(--text-primary, #e6edf3);
|
|
409
|
+
font-size: 0.875rem;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.layer-export-btn {
|
|
413
|
+
padding: 8px 16px;
|
|
414
|
+
border: 1px solid var(--accent-cyan, #00d4ff);
|
|
415
|
+
border-radius: 6px;
|
|
416
|
+
background: transparent;
|
|
417
|
+
color: var(--accent-cyan, #00d4ff);
|
|
418
|
+
cursor: pointer;
|
|
419
|
+
font-size: 0.875rem;
|
|
420
|
+
transition: all 0.2s;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.layer-export-btn:hover {
|
|
424
|
+
background: var(--accent-cyan, #00d4ff);
|
|
425
|
+
color: white;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.layer-table {
|
|
429
|
+
width: 100%;
|
|
430
|
+
border-collapse: collapse;
|
|
431
|
+
font-size: 0.8125rem;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.layer-table th {
|
|
435
|
+
background: var(--bg-secondary, #161b22);
|
|
436
|
+
color: var(--accent-cyan, #00d4ff);
|
|
437
|
+
padding: 10px 12px;
|
|
438
|
+
text-align: left;
|
|
439
|
+
cursor: pointer;
|
|
440
|
+
user-select: none;
|
|
441
|
+
white-space: nowrap;
|
|
442
|
+
border-bottom: 2px solid var(--border, #30363d);
|
|
443
|
+
position: sticky;
|
|
444
|
+
top: 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.layer-table th:hover {
|
|
448
|
+
background: var(--bg-card, #21262d);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.layer-table th .sort-icon {
|
|
452
|
+
margin-left: 4px;
|
|
453
|
+
opacity: 0.5;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.layer-table th.sorted .sort-icon {
|
|
457
|
+
opacity: 1;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.layer-table td {
|
|
461
|
+
padding: 8px 12px;
|
|
462
|
+
border-bottom: 1px solid var(--border, #30363d);
|
|
463
|
+
vertical-align: top;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.layer-table tr:hover {
|
|
467
|
+
background: var(--bg-secondary, #161b22);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.layer-table tr.highlight {
|
|
471
|
+
background: rgba(0, 212, 255, 0.1);
|
|
472
|
+
outline: 1px solid var(--accent-cyan, #00d4ff);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.layer-table .op-type {
|
|
476
|
+
display: inline-block;
|
|
477
|
+
padding: 2px 8px;
|
|
478
|
+
border-radius: 4px;
|
|
479
|
+
font-size: 0.75rem;
|
|
480
|
+
font-weight: 500;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.layer-table .op-type.conv { background: rgba(74, 144, 217, 0.2); color: #4A90D9; }
|
|
484
|
+
.layer-table .op-type.linear { background: rgba(191, 90, 242, 0.2); color: #BF5AF2; }
|
|
485
|
+
.layer-table .op-type.norm { background: rgba(100, 210, 255, 0.2); color: #64D2FF; }
|
|
486
|
+
.layer-table .op-type.activation { background: rgba(255, 214, 10, 0.2); color: #FFD60A; }
|
|
487
|
+
.layer-table .op-type.pool { background: rgba(48, 209, 88, 0.2); color: #30D158; }
|
|
488
|
+
.layer-table .op-type.reshape { background: rgba(94, 92, 230, 0.2); color: #5E5CE6; }
|
|
489
|
+
.layer-table .op-type.elementwise { background: rgba(255, 100, 130, 0.2); color: #FF6482; }
|
|
490
|
+
.layer-table .op-type.default { background: rgba(99, 99, 102, 0.2); color: #636366; }
|
|
491
|
+
|
|
492
|
+
.layer-table .shape {
|
|
493
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
494
|
+
font-size: 0.7rem;
|
|
495
|
+
color: var(--text-secondary, #8b949e);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.layer-table .pct-bar {
|
|
499
|
+
display: inline-block;
|
|
500
|
+
height: 4px;
|
|
501
|
+
background: var(--accent-cyan, #00d4ff);
|
|
502
|
+
border-radius: 2px;
|
|
503
|
+
margin-right: 6px;
|
|
504
|
+
vertical-align: middle;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.layer-count {
|
|
508
|
+
font-size: 0.75rem;
|
|
509
|
+
color: var(--text-secondary, #8b949e);
|
|
510
|
+
margin-top: 8px;
|
|
511
|
+
}
|
|
512
|
+
</style>
|
|
513
|
+
"""
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Controls
|
|
517
|
+
html_parts.append(
|
|
518
|
+
"""
|
|
519
|
+
<div class="layer-table-container">
|
|
520
|
+
<div class="layer-controls">
|
|
521
|
+
<input type="text" class="layer-search" id="layerSearch"
|
|
522
|
+
placeholder="Search layers..." oninput="filterLayers()">
|
|
523
|
+
<select class="layer-filter" id="opFilter" onchange="filterLayers()">
|
|
524
|
+
<option value="">All Op Types</option>
|
|
525
|
+
"""
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
# Get unique op types
|
|
529
|
+
op_types = sorted({layer.op_type for layer in summary.layers})
|
|
530
|
+
for op_type in op_types:
|
|
531
|
+
html_parts.append(f' <option value="{op_type}">{op_type}</option>\n')
|
|
532
|
+
|
|
533
|
+
html_parts.append(
|
|
534
|
+
"""
|
|
535
|
+
</select>
|
|
536
|
+
<button class="layer-export-btn" onclick="exportLayersCSV()">Export CSV</button>
|
|
537
|
+
</div>
|
|
538
|
+
"""
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
# Table
|
|
542
|
+
html_parts.append(
|
|
543
|
+
"""
|
|
544
|
+
<table class="layer-table" id="layerTable">
|
|
545
|
+
<thead>
|
|
546
|
+
<tr>
|
|
547
|
+
<th onclick="sortTable(0)" data-col="name">Layer Name <span class="sort-icon">^v</span></th>
|
|
548
|
+
<th onclick="sortTable(1)" data-col="op_type">Op Type <span class="sort-icon">^v</span></th>
|
|
549
|
+
<th onclick="sortTable(2)" data-col="input_shape">Input Shape</th>
|
|
550
|
+
<th onclick="sortTable(3)" data-col="output_shape">Output Shape</th>
|
|
551
|
+
<th onclick="sortTable(4)" data-col="params">Parameters <span class="sort-icon">^v</span></th>
|
|
552
|
+
<th onclick="sortTable(5)" data-col="flops">FLOPs <span class="sort-icon">^v</span></th>
|
|
553
|
+
<th onclick="sortTable(6)" data-col="memory">Memory <span class="sort-icon">^v</span></th>
|
|
554
|
+
<th onclick="sortTable(7)" data-col="pct_flops">% Compute <span class="sort-icon">^v</span></th>
|
|
555
|
+
</tr>
|
|
556
|
+
</thead>
|
|
557
|
+
<tbody>
|
|
558
|
+
"""
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Get op type category for styling
|
|
562
|
+
def get_op_category(op_type: str) -> str:
|
|
563
|
+
op = op_type.lower()
|
|
564
|
+
if "conv" in op:
|
|
565
|
+
return "conv"
|
|
566
|
+
if "matmul" in op or "gemm" in op:
|
|
567
|
+
return "linear"
|
|
568
|
+
if "norm" in op:
|
|
569
|
+
return "norm"
|
|
570
|
+
if any(x in op for x in ["relu", "gelu", "softmax", "sigmoid", "silu", "tanh"]):
|
|
571
|
+
return "activation"
|
|
572
|
+
if "pool" in op:
|
|
573
|
+
return "pool"
|
|
574
|
+
if any(x in op for x in ["reshape", "transpose", "flatten", "squeeze"]):
|
|
575
|
+
return "reshape"
|
|
576
|
+
if any(x in op for x in ["add", "mul", "sub", "div", "concat"]):
|
|
577
|
+
return "elementwise"
|
|
578
|
+
return "default"
|
|
579
|
+
|
|
580
|
+
# Data rows
|
|
581
|
+
for layer in summary.layers:
|
|
582
|
+
op_cat = get_op_category(layer.op_type)
|
|
583
|
+
input_str = "; ".join(layer.input_shapes) if layer.input_shapes else "-"
|
|
584
|
+
output_str = "; ".join(layer.output_shapes) if layer.output_shapes else "-"
|
|
585
|
+
|
|
586
|
+
# Calculate bar width for percentage
|
|
587
|
+
bar_width = min(100, layer.pct_flops * 3) # Scale for visibility
|
|
588
|
+
|
|
589
|
+
html_parts.append(
|
|
590
|
+
f"""
|
|
591
|
+
<tr data-name="{layer.name}" data-op="{layer.op_type}"
|
|
592
|
+
data-params="{layer.params}" data-flops="{layer.flops}">
|
|
593
|
+
<td><code>{layer.name}</code></td>
|
|
594
|
+
<td><span class="op-type {op_cat}">{layer.op_type}</span></td>
|
|
595
|
+
<td class="shape">{input_str}</td>
|
|
596
|
+
<td class="shape">{output_str}</td>
|
|
597
|
+
<td>{format_number(layer.params)}</td>
|
|
598
|
+
<td>{format_number(layer.flops)}</td>
|
|
599
|
+
<td>{format_bytes(layer.memory_bytes)}</td>
|
|
600
|
+
<td>
|
|
601
|
+
<span class="pct-bar" style="width: {bar_width}px"></span>
|
|
602
|
+
{layer.pct_flops:.1f}%
|
|
603
|
+
</td>
|
|
604
|
+
</tr>
|
|
605
|
+
"""
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
html_parts.append(
|
|
609
|
+
"""
|
|
610
|
+
</tbody>
|
|
611
|
+
</table>
|
|
612
|
+
<div class="layer-count" id="layerCount">
|
|
613
|
+
"""
|
|
614
|
+
)
|
|
615
|
+
html_parts.append(
|
|
616
|
+
f' Showing <span id="visibleCount">{len(summary.layers)}</span> of {len(summary.layers)} layers'
|
|
617
|
+
)
|
|
618
|
+
html_parts.append(
|
|
619
|
+
"""
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
"""
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# JavaScript for sorting and filtering
|
|
626
|
+
if include_js:
|
|
627
|
+
# Build CSV data for export
|
|
628
|
+
csv_data = summary.to_csv().replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$")
|
|
629
|
+
|
|
630
|
+
html_parts.append(
|
|
631
|
+
f"""
|
|
632
|
+
<script>
|
|
633
|
+
const layerCSVData = `{csv_data}`;
|
|
634
|
+
|
|
635
|
+
let sortColumn = -1;
|
|
636
|
+
let sortAsc = true;
|
|
637
|
+
|
|
638
|
+
function sortTable(col) {{
|
|
639
|
+
const table = document.getElementById('layerTable');
|
|
640
|
+
const tbody = table.querySelector('tbody');
|
|
641
|
+
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
642
|
+
const headers = table.querySelectorAll('th');
|
|
643
|
+
|
|
644
|
+
// Toggle sort direction if same column
|
|
645
|
+
if (sortColumn === col) {{
|
|
646
|
+
sortAsc = !sortAsc;
|
|
647
|
+
}} else {{
|
|
648
|
+
sortAsc = false; // Default descending for numeric columns
|
|
649
|
+
sortColumn = col;
|
|
650
|
+
}}
|
|
651
|
+
|
|
652
|
+
// Update header styling
|
|
653
|
+
headers.forEach((h, i) => {{
|
|
654
|
+
h.classList.remove('sorted');
|
|
655
|
+
if (i === col) h.classList.add('sorted');
|
|
656
|
+
}});
|
|
657
|
+
|
|
658
|
+
// Sort rows
|
|
659
|
+
rows.sort((a, b) => {{
|
|
660
|
+
let aVal = a.cells[col].textContent.trim();
|
|
661
|
+
let bVal = b.cells[col].textContent.trim();
|
|
662
|
+
|
|
663
|
+
// Try numeric comparison for columns 4-7
|
|
664
|
+
if (col >= 4) {{
|
|
665
|
+
aVal = parseFloat(aVal.replace(/[^0-9.-]/g, '')) || 0;
|
|
666
|
+
bVal = parseFloat(bVal.replace(/[^0-9.-]/g, '')) || 0;
|
|
667
|
+
return sortAsc ? aVal - bVal : bVal - aVal;
|
|
668
|
+
}}
|
|
669
|
+
|
|
670
|
+
// String comparison
|
|
671
|
+
return sortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
672
|
+
}});
|
|
673
|
+
|
|
674
|
+
// Re-add rows
|
|
675
|
+
rows.forEach(row => tbody.appendChild(row));
|
|
676
|
+
}}
|
|
677
|
+
|
|
678
|
+
function filterLayers() {{
|
|
679
|
+
const searchTerm = document.getElementById('layerSearch').value.toLowerCase();
|
|
680
|
+
const opFilter = document.getElementById('opFilter').value;
|
|
681
|
+
const table = document.getElementById('layerTable');
|
|
682
|
+
const rows = table.querySelectorAll('tbody tr');
|
|
683
|
+
|
|
684
|
+
let visibleCount = 0;
|
|
685
|
+
rows.forEach(row => {{
|
|
686
|
+
const name = row.dataset.name.toLowerCase();
|
|
687
|
+
const op = row.dataset.op;
|
|
688
|
+
|
|
689
|
+
const matchesSearch = !searchTerm || name.includes(searchTerm);
|
|
690
|
+
const matchesOp = !opFilter || op === opFilter;
|
|
691
|
+
|
|
692
|
+
if (matchesSearch && matchesOp) {{
|
|
693
|
+
row.style.display = '';
|
|
694
|
+
visibleCount++;
|
|
695
|
+
}} else {{
|
|
696
|
+
row.style.display = 'none';
|
|
697
|
+
}}
|
|
698
|
+
}});
|
|
699
|
+
|
|
700
|
+
document.getElementById('visibleCount').textContent = visibleCount;
|
|
701
|
+
}}
|
|
702
|
+
|
|
703
|
+
function exportLayersCSV() {{
|
|
704
|
+
const blob = new Blob([layerCSVData], {{ type: 'text/csv;charset=utf-8;' }});
|
|
705
|
+
const link = document.createElement('a');
|
|
706
|
+
link.href = URL.createObjectURL(blob);
|
|
707
|
+
link.download = 'layer_summary.csv';
|
|
708
|
+
link.click();
|
|
709
|
+
}}
|
|
710
|
+
|
|
711
|
+
// Highlight row when clicked (for graph integration)
|
|
712
|
+
document.querySelectorAll('#layerTable tbody tr').forEach(row => {{
|
|
713
|
+
row.addEventListener('click', () => {{
|
|
714
|
+
document.querySelectorAll('#layerTable tbody tr').forEach(r => r.classList.remove('highlight'));
|
|
715
|
+
row.classList.add('highlight');
|
|
716
|
+
|
|
717
|
+
// Dispatch event for graph integration (Task 5.8.3)
|
|
718
|
+
const event = new CustomEvent('layer-selected', {{
|
|
719
|
+
detail: {{ name: row.dataset.name, op: row.dataset.op }}
|
|
720
|
+
}});
|
|
721
|
+
document.dispatchEvent(event);
|
|
722
|
+
}});
|
|
723
|
+
}});
|
|
724
|
+
</script>
|
|
725
|
+
"""
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
return "".join(html_parts)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def generate_markdown_table(summary: LayerSummary, max_rows: int = 50) -> str:
|
|
732
|
+
"""Generate Markdown table for layer summary."""
|
|
733
|
+
|
|
734
|
+
def format_number(n: int | float) -> str:
|
|
735
|
+
if isinstance(n, float):
|
|
736
|
+
if n >= 1e9:
|
|
737
|
+
return f"{n / 1e9:.1f}B"
|
|
738
|
+
if n >= 1e6:
|
|
739
|
+
return f"{n / 1e6:.1f}M"
|
|
740
|
+
if n >= 1e3:
|
|
741
|
+
return f"{n / 1e3:.1f}K"
|
|
742
|
+
return f"{n:.2f}"
|
|
743
|
+
else:
|
|
744
|
+
if n >= 1e9:
|
|
745
|
+
return f"{n / 1e9:.1f}B"
|
|
746
|
+
if n >= 1e6:
|
|
747
|
+
return f"{n / 1e6:.1f}M"
|
|
748
|
+
if n >= 1e3:
|
|
749
|
+
return f"{n / 1e3:.1f}K"
|
|
750
|
+
return str(n)
|
|
751
|
+
|
|
752
|
+
lines = []
|
|
753
|
+
lines.append("| Layer | Op Type | Params | FLOPs | % Compute |")
|
|
754
|
+
lines.append("|-------|---------|--------|-------|-----------|")
|
|
755
|
+
|
|
756
|
+
# Sort by FLOPs descending to show most important first
|
|
757
|
+
sorted_layers = sorted(summary.layers, key=lambda layer: layer.flops, reverse=True)
|
|
758
|
+
|
|
759
|
+
for layer in sorted_layers[:max_rows]:
|
|
760
|
+
lines.append(
|
|
761
|
+
f"| `{layer.name}` | {layer.op_type} | "
|
|
762
|
+
f"{format_number(layer.params)} | {format_number(layer.flops)} | "
|
|
763
|
+
f"{layer.pct_flops:.1f}% |"
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
if len(summary.layers) > max_rows:
|
|
767
|
+
lines.append(f"| ... | ({len(summary.layers) - max_rows} more layers) | ... | ... | ... |")
|
|
768
|
+
|
|
769
|
+
return "\n".join(lines)
|